This commit is contained in:
2025-12-14 18:14:07 -06:00
parent 9d45d5db88
commit a0b6e00a31
38 changed files with 969 additions and 932 deletions

174
.gitignore vendored
View File

@@ -1,176 +1,10 @@
# ---> Python # Python-generated files
# Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[oc]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/ build/
develop-eggs/
dist/ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/ wheels/
share/python-wheels/ *.egg-info
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller # Virtual environments
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv .venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

20
.vscode/settings.json vendored
View File

@@ -1,16 +1,16 @@
{ {
"python.languageServer": "None", "python.languageServer": "None",
"cSpell.words": [
"traefik"
],
"editor.formatOnSave": true,
"[python]": { "[python]": {
"editor.formatOnType": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports": "explicit", "source.fixAll.ruff": "explicit",
"source.unusedImports": "explicit" "source.organizeImports.ruff": "explicit"
} },
}, "editor.defaultFormatter": "charliermarsh.ruff"
"terminal.integrated.env.linux": {
"PYTHONPATH": "${workspaceFolder}/src"
}, },
"cSpell.words": [
"certresolver",
"traefik",
"websecure"
]
} }

View File

@@ -1,2 +0,0 @@
# compose_gen

13
docs/workflow.md Normal file
View File

@@ -0,0 +1,13 @@
```mermaid
flowchart TD
read --> src_paths
src_paths --> cfg
cfg --> parsed
cfg --> template_args
template_args --> dest_paths
src_paths --> template
parsed --> template
dest_paths --> template
template --> write
```

19
pyproject.toml Normal file
View File

@@ -0,0 +1,19 @@
[project]
name = "compose"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [{ name = "Christian Camper", email = "ccamper7@gmail.com" }]
requires-python = ">=3.13"
dependencies = [
"basedpyright>=1.36.1",
"pyyaml>=6.0.3",
"ruff>=0.14.9",
]
[project.scripts]
compose = "compose:main"
[build-system]
requires = ["uv_build>=0.9.17,<0.10.0"]
build-backend = "uv_build"

26
src/cfg/__init__.py Normal file
View File

@@ -0,0 +1,26 @@
from collections.abc import Mapping
from pathlib import Path
type nested_list = list[str | nested_list]
type T_Primitive = bool | int | str
type T_PrimVal = T_Primitive | list[T_Primitive] | T_PrimDict
type T_PrimDict = Mapping[T_Primitive, T_PrimVal]
type T_YamlVals = T_Primitive | list[T_Primitive | T_YamlDict] | T_YamlDict
type T_YamlDict = Mapping[str, T_YamlVals]
CFG_ROOT = Path("/data/cfg")
DATA_ROOT = Path("/data")
TRAEFIK_PATH = Path("/data/traefik")
# TCo_YamlVals = TypeVar(
# "TCo_YamlVals",
# bound=T_Primitive | list[T_Primitive | T_YamlDict] | T_YamlDict,
# covariant=True,
# )
# type TCo_YamlDict = dict[str, TCo_YamlVals]
# TCo_YamlDict = TypeVar("TCo_YamlDict", bound=dict[str, T_YamlVals], covariant=True)
# class HasServices(TypedDict):
# services: dict[str, ComposeService]

32
src/cfg/entity.py Normal file
View File

@@ -0,0 +1,32 @@
from dataclasses import dataclass
from pathlib import Path
from typing import NotRequired, TypedDict, final
from src_path.entity import SrcPaths
class OrgDataYaml(TypedDict):
org: NotRequired[str]
url: NotRequired[str]
class CfgDataYaml(TypedDict):
name: str
files: list[str]
orgs: list[OrgDataYaml]
@final
@dataclass(frozen=True, slots=True)
class OrgData:
org: str | None
url: str | None
@final
@dataclass(frozen=True, slots=True)
class CfgData:
src_paths: SrcPaths
name: str
files: frozenset[Path]
orgs: frozenset[OrgData] | None

18
src/cfg/factory.py Normal file
View File

@@ -0,0 +1,18 @@
from typing import cast
from cfg.entity import CfgData, CfgDataYaml
from cfg.get import cfg_get_orgs
from src_path.entity import SrcPaths
from src_path.get import src_path_get_files
from util import read_yml
def cfg_data_factory(cfg_dir: SrcPaths) -> CfgData:
data = cast(CfgDataYaml, read_yml(cfg_dir.cfg_file))
return CfgData(
cfg_dir,
data["name"],
frozenset(src_path_get_files(cfg_dir, data)),
frozenset(cfg_get_orgs(data)),
)

20
src/cfg/get.py Normal file
View File

@@ -0,0 +1,20 @@
from collections.abc import Iterator
from typing import cast
from cfg.entity import CfgData, CfgDataYaml, OrgData
from service.entity import Service, ServiceYaml
from util import read_yml
def cfg_get_orgs(data: CfgDataYaml) -> Iterator[OrgData]:
for org_data in data["orgs"]:
yield OrgData(
org_data.get("org"),
org_data.get("url"),
)
def cfg_get_services(cfg_data: CfgData) -> Iterator[tuple[str, Service]]:
for path in cfg_data.files:
_dict = cast(ServiceYaml, read_yml(path))
yield path.stem, Service.from_dict(_dict)

View File

@@ -1,250 +0,0 @@
from collections.abc import Collection, Iterator
from dataclasses import dataclass
from functools import reduce
from pathlib import Path
from shutil import copyfile
from typing import Literal, NotRequired, Protocol, cast, final
from Ts import (
CfgData,
Compose,
ComposeNet,
ComposeNetArgs,
HasServices,
OrgData,
TraefikComposeDict,
TraefikNet,
TraefikNetName,
)
from util import merge_dicts, read_yml, to_yaml
from val_objs import RecordCls
# CFG_ROOT = Path("/data/cfg")
# def replace(rec: RecordCls, string: str) -> str:
# return str.replace(string, rec.name, rec.val.to_str())
# @final
# @dataclass(frozen=True, slots=True)
# class ComposeBuild:
# root_path: Path
# # def __init__(self, path: str) -> None:
# # self.env_path = self.path.joinpath(".env")
# def compose_build_factory(root_path: str):
# path = Path("/data/cfg").joinpath(root_path)
# return ComposeBuild(path)
# CFG_PATH = Path("/data/cfg")
def compose_treafik(proxy_data: Iterator[tuple[str, str]]):
root_path = CFG_PATH.joinpath("treafik")
cfg_data = CfgData(
name="treafik",
files=["treafik.yml"],
orgs=[OrgData(org="util", url="treafik")],
)
services = build_compose(root_path, cfg_data)["services"]
networks: TraefikNet = dict()
for name, proxy in proxy_data:
networks[name] = TraefikNetName(name=proxy)
traefik_compose = TraefikComposeDict(
name="traefik",
services=services,
networks=networks,
)
mk_volumes = tuple(get_volumes(traefik_compose))
og_yaml = to_yaml(traefik_compose)
org = cfg_data["orgs"][0]
# def build_compose(compose_rw: ComposeRWData, cfg_data: CfgData) -> Compose:
# compose_dict = get_compose_dict(compose_rw, cfg_data)
# insert_defaults(compose_dict)
# insert_traefik_labels(compose_dict)
# return compose_dict
# def write_compose(write_data: ComposeData):
# write(write_data)
# mk_compose_dir(write_data)
# mk_compose_env(write_data.rw)
# def gen_compose(path: str) -> Iterator[tuple[str, str]]:
# # compose_build = compose_build_factory(path)
# root_path = CFG_PATH.joinpath(path)
# cfg_data = get_config_data(root_path)
# compose_dict = build_compose(root_path, cfg_data)
# mk_volumes = tuple(get_volumes(compose_dict))
# proxy_net = set_networks(compose_dict)
# og_yaml = to_yaml(compose_dict)
# for _dict in cfg_data["orgs"]:
# compose_rw = compose_rw_factory(
# _dict.get("org"), cfg_data["name"], _dict["url"]
# )
# write(compose_rw, og_yaml)
# mk_compose_dir(compose_rw, mk_volumes)
# mk_compose_env(root_path, compose_rw.data_dir.to_path())
# if proxy_net is None:
# continue
# for net in proxy_net:
# def sub():
# for kv in net:
# yield replace(compose_rw.org_name, kv)
# yield tuple[str, str](sub())
# def get_config_data(cfg_dir: Path) -> CfgData:
# cfg_path = cfg_dir.joinpath("cfg.yml")
# return cast(CfgData, read_yml(cfg_path))
# def get_compose_dict(compose_rw: ComposeRWData, cfg_data: CfgData) -> Compose:
# def sub():
# for file in cfg_data["files"]:
# path = compose_rw.data_dir.joinpath(file)
# yield cast(Compose, read_yml(path))
# return reduce(merge_dicts, sub(), cast(Compose, {})) # pyright: ignore[reportInvalidCast]
# @final
# class TraefikBuild:
# def insert_defaults(data: Compose) -> None:
# data["name"] = "${_ORG_NAME}"
# for app_data in data["services"].values():
# app_data["restart"] = "unless-stopped"
# sec_opts = {"no-new-privileges:true"}
# app_data["security_opt"] = list(
# sec_opts.union(app_data.get("security_opt", set()))
# )
# def insert_traefik_labels(data: Compose) -> None:
# for app_data in data["services"].values():
# traefik_labels = {
# "traefik.http.routers.${_ORG_NAME}.rule=Host(`${_URL}`)",
# "traefik.http.routers.${_ORG_NAME}.entrypoints=websecure",
# "traefik.docker.network=${_ORG_NAME}_proxy",
# "traefik.http.routers.${_ORG_NAME}.tls.certresolver=le",
# }
# if "labels" not in app_data:
# continue
# if "traefik.enable=true" not in app_data["labels"]:
# continue
# app_data["labels"] = list(traefik_labels.union(app_data["labels"]))
# def get_volumes(data: HasServices) -> Iterator[str]:
# for app_data in data["services"].values():
# if "volumes" not in app_data:
# return
# for vol in app_data["volumes"]:
# if not vol.startswith(r"${_DATA}"):
# continue
# yield vol.split(":", 1)[0]
# def set_networks(data: Compose) -> tuple[str, str] | None:
# def sub() -> Iterator[Literal["proxy", "internal"]]:
# for app_data in data["services"].values():
# if "networks" not in app_data:
# continue
# yield from app_data["networks"]
# nets = set(sub())
# net = ComposeNet()
# if "proxy" in nets:
# proxy = ComposeNetArgs(name="${_ORG_NAME}_proxy", external=True)
# net["proxy"] = proxy
# ret = "${_ORG_NAME}", "${_ORG_NAME}_proxy"
# else:
# ret = None
# if "internal" in nets:
# net["internal"] = ComposeNetArgs(name="${_ORG_NAME}")
# if not net:
# return
# data["networks"] = net
# return ret
# def compose_rw_factory(org_raw: str | None, name_raw: str, sub_url: str):
# _org = OrgVal(org_raw)
# _name = NameVal(name_raw)
# # org = Record("ORG", _org)
# # name = Record("NAME", _name)
# org_name = (
# NameVal(f"{_org.to_str()}_{_name.to_str()}") if _org.is_valid() else _name
# )
# # org_name = Record("ORG_NAME", org_name)
# data_dir = Path("/data").joinpath(_org.to_str(), _name.to_str())
# # cfg_dir = CFG_PATH.joinpath(org_name.val.to_str())
# # data = Record("DATA", DataDir(data_dir))
# # url = Record("URL", Url(sub_url))
# # Path("/data").joinpath(_org.to_str(), _name.to_str())
# # compose_path = data_dir.to_path().joinpath("docker-compose.yml")
# # env_path = data_dir.to_path().joinpath(".env")
# return ComposeRWData(
# Record("ORG", _org),
# Record("NAME", _name),
# Record("ORG_NAME", org_name),
# Record("DATA", DataDir(data_dir)),
# Record("URL", Url(sub_url)),
# data_dir,
# CFG_PATH.joinpath(org_name.to_str()),
# )
# def __init__(self, org: str | None, name: str, sub_url: str) -> None:
# super().__init__()
# self._org = OrgVal(org)
# self._name = NameVal(name)
# self.org = Record("ORG", self._org)
# self.name = Record("NAME", self._name)
# org_name = (
# NameVal(f"{self.org.val.to_str()}_{self.name.val.to_str()}")
# if self._org.is_valid()
# else self._name
# )
# self.org_name = Record("ORG_NAME", org_name)
# self.data_dir = DataDir(
# self._org,
# self._name,
# )
# self.data = Record("DATA", self.data_dir)
# self.url = Record("URL", Url(sub_url))
# self.compose_path = self.data_dir.to_path().joinpath("docker-compose.yml")
# self.env_path = self.data_dir.to_path().joinpath(".env")
# def mk_compose_dir(compose_data: ComposeData) -> None:
# for vol in compose_data.volumes:
# mk_path = Path(replace(compose_data.rw.data, vol))
# if mk_path.exists():
# continue
# mk_path.mkdir(parents=True)
# def mk_compose_env(compose_data: ComposeRWData) -> None:
# root_env_path = compose_data.cfg_dir.joinpath(".env")
# dest_env_path = compose_data.data_dir.joinpath(".env")
# if root_env_path.exists() and not dest_env_path.exists():
# _ = copyfile(root_env_path, dest_env_path)
# def write(compose_data: ComposeData) -> None:
# string = reduce(lambda s, rec: replace(rec, s), compose_data.rw, compose_data.yaml)
# compose_path = compose_data.rw.data_dir.joinpath("docker-compose.yml")
# if not compose_path.exists():
# compose_path.parent.mkdir(parents=True)
# with compose_path.open("wt") as f:
# _ = f.write(string)

View File

@@ -1,163 +0,0 @@
from collections.abc import Mapping
from dataclasses import dataclass, asdict
from typing import Literal, NotRequired, Self, TypedDict, final
from util import to_yaml
type nested_list = list[str | nested_list]
type T_Primitive = bool | int | str
type T_PrimVal = T_Primitive | list[T_Primitive] | T_PrimDict
type T_PrimDict = Mapping[T_Primitive, T_PrimVal]
type T_YamlVals = T_Primitive | list[T_Primitive | T_YamlDict] | T_YamlDict
type T_YamlDict = dict[str, T_YamlVals]
class ComposeNetArgsYaml(TypedDict):
name: str
external: NotRequired[bool]
@final
@dataclass(frozen=True, slots=True)
class ComposeNetArgs:
name: str
external: bool | None
@classmethod
def from_dict(cls, data: ComposeNetArgsYaml) -> Self:
return cls(
data["name"],
data.get("external"),
)
class ComposeNetYaml(TypedDict):
internal: NotRequired[ComposeNetArgsYaml]
proxy: NotRequired[ComposeNetArgsYaml]
@final
@dataclass(frozen=True, slots=True)
class ComposeNet:
internal: ComposeNetArgs | None
proxy: ComposeNetArgs | None
@classmethod
def from_class(cls, data: ComposeNetYaml) -> Self:
internal = data.get("internal")
if internal is not None:
internal = ComposeNetArgs.from_dict(internal)
proxy = data.get("proxy")
if proxy is not None:
proxy = ComposeNetArgs.from_dict(proxy)
return cls(internal, proxy)
class ComposeServiceYaml(TypedDict):
command: NotRequired[list[str]]
container_name: str
entrypoint: list[str]
environment: NotRequired[dict[str, T_Primitive]]
image: str
labels: NotRequired[list[str]]
logging: dict[str, str]
networks: NotRequired[list[Literal["proxy", "internal"]]]
restart: str
security_opt: NotRequired[list[str]]
user: NotRequired[str]
volumes: NotRequired[list[str]]
@final
@dataclass(slots=True)
class ComposeService:
command: list[str] | None
container_name: str | None
entrypoint: list[str]
environment: dict[str, T_Primitive] | None
image: str
labels: set[str] | None
logging: dict[str, str] | None
networks: set[Literal["proxy", "internal"]] | None
restart: str | None
security_opt: set[str] | None
user: str | None
volumes: set[str] | None
@classmethod
def from_dict(cls, data: ComposeServiceYaml) -> Self:
labels = data.get("labels")
if labels is not None:
labels = set(labels)
sec = data.get("security_opt")
if sec is not None:
sec = set(sec)
vols = data.get("volumes")
if vols is not None:
vols = set(vols)
return cls(
data.get("command"),
None, # data['container_name'],
data["entrypoint"],
data.get("environment"),
data["image"],
labels,
None,
data.get("netwoks"),
None,
sec,
data.get("user"),
vols,
)
def is_valid(self) -> bool:
attrs = (self.container_name, self.restart, self.logging)
for attr in attrs:
if attr is None:
return False
return True
class ComposeYaml(TypedDict):
name: str
networks: NotRequired[ComposeNetYaml]
services: dict[str, ComposeServiceYaml]
@final
@dataclass(slots=True)
class Compose:
name: str
networks: ComposeNet | None
services: dict[str, ComposeService]
@classmethod
def from_dict(cls, data: ComposeYaml) -> Self:
services = dict[str, ComposeService]()
for k, v in data["services"].items():
services[k] = ComposeService.from_dict(v)
return cls(
data["name"],
None,
services,
)
def as_yaml(self) -> str:
return to_yaml(asdict(self))
class TraefikNetName(TypedDict):
name: str
type TraefikNet = dict[str, TraefikNetName]
class TraefikComposeDict(TypedDict):
name: str
networks: TraefikNet
services: dict[str, ComposeServiceYaml]
class HasServices(TypedDict):
services: dict[str, ComposeServiceYaml]

View File

@@ -1,265 +0,0 @@
from collections.abc import Iterator
from .compose_struct import Compose, ComposeService, ComposeServiceYaml, ComposeYaml
from dataclasses import dataclass
from functools import reduce
from pathlib import Path
from typing import Literal, NotRequired, Self, TypedDict, cast, final
from util import merge_dicts, read_yml, to_yaml
from .compose_struct import ComposeNet, ComposeNetArgs, HasServices
from .val_objs import DataDir, NameVal, OrgVal, Record, RecordCls, RecordVal, Url
def replace(rec: RecordCls[RecordVal], string: str) -> str:
return str.replace(string, rec.name, rec.val.to_str())
@final
@dataclass(frozen=True, slots=True)
class SrcPaths:
data_dir: Path
cfg_file: Path
env_file: Path
def src_path_factory(src: str) -> SrcPaths:
root = Path("/data/cfg")
dir = root.joinpath(src)
return SrcPaths(
data_dir=dir,
cfg_file=dir.joinpath("cfg.yml"),
env_file=dir.joinpath(".env"),
)
class OrgDataYaml(TypedDict):
org: NotRequired[str]
url: NotRequired[str]
class CfgDataYaml(TypedDict):
name: str
files: list[str]
orgs: list[OrgDataYaml]
@final
@dataclass(frozen=True, slots=True)
class OrgData:
org: str | None
url: str | None
@final
@dataclass(frozen=True, slots=True)
class CfgData:
name: str
files: tuple[Path, ...]
orgs: tuple[OrgData, ...]
def config_data_factory(cfg_dir: SrcPaths) -> CfgData:
data = cast(CfgDataYaml, read_yml(cfg_dir.cfg_file))
def sub_files():
for path in data["files"]:
yield cfg_dir.data_dir.joinpath(path)
def sub_orgs():
for org_data in data["orgs"]:
yield OrgData(
org_data.get("org"),
org_data.get("url"),
)
return CfgData(
data["name"],
tuple(sub_files()),
tuple(sub_orgs()),
)
@final
@dataclass(frozen=True, slots=True)
class ComposeTemplate:
org: RecordCls[OrgVal]
name: RecordCls[NameVal]
org_name: RecordCls[NameVal]
data: RecordCls[DataDir]
url: RecordCls[Url]
def __iter__(self):
yield self.org
yield self.name
yield self.org_name
yield self.data
yield self.url
def compose_template_factory(cfg_data: CfgData) -> Iterator[ComposeTemplate]:
# def sub():
for org_data in cfg_data.orgs:
_org = OrgVal(org_data.org)
_name = NameVal(cfg_data.name)
org_name = (
NameVal(f"{_org.to_str()}_{_name.to_str()}") if _org.is_valid() else _name
)
data_dir = Path("/data").joinpath(_org.to_str(), _name.to_str())
yield ComposeTemplate(
Record("ORG", _org),
Record("NAME", _name),
Record("ORG_NAME", org_name),
Record("DATA", DataDir(data_dir)),
Record("URL", Url(org_data.url)),
# data_dir,
# CFG_PATH.joinpath(org_name.to_str()),
)
# return tuple(sub())
@final
@dataclass(frozen=True, slots=True)
class DestPaths:
data_dir: Path
env_file: Path
compose_file: Path
def dest_paths_factory(compose_template: ComposeTemplate) -> DestPaths:
data_dir = compose_template.data.val.path
return DestPaths(
data_dir, data_dir.joinpath(".env"), data_dir.joinpath("docker-compose.yml")
)
@final
@dataclass(frozen=True, slots=True)
class ParsedCompose:
@final
@dataclass(frozen=True, slots=True)
class DestData:
paths: DestPaths
templates: ComposeTemplate
volumes: tuple[Path, ...]
def get_volumes_raw(data: HasServices) -> Iterator[str]:
for app_data in data["services"].values():
if "volumes" not in app_data:
return
for vol in app_data["volumes"]:
if not vol.startswith(r"${_DATA}"):
continue
yield vol.split(":", 1)[0]
def dest_data_factory(cfg_data: CfgData, compose: Compose) -> Iterator[DestData]:
# def sub():
vols_raw = tuple(get_volumes_raw(compose))
# templates = tuple(compose_template_factory(cfg_data))
# for template in templates:
# for vol in vols_raw:
for template in compose_template_factory(cfg_data):
paths = dest_paths_factory(template)
def vol_sub():
for vol in vols_raw:
yield Path(replace(template.data.val, vol))
yield DestData(
paths,
template,
tuple(vol_sub()),
)
# return tuple(sub())
@final
@dataclass(frozen=True, slots=True)
class ComposeData:
src_paths: SrcPaths
dest_data: tuple[DestData, ...]
Compose: Compose
proxy_net: tuple[str, str] | None
yaml: str
def get_compose(cfg_data: CfgData) -> Compose:
def insert_defaults(data: Compose) -> None:
sec_opts = "no-new-privileges:true"
data.name = "${_ORG_NAME}"
for app_data in data.services.values():
app_data.restart = "unless-stopped"
if app_data.security_opt is None:
app_data.security_opt = {sec_opts}
else:
app_data.security_opt.add(sec_opts)
def insert_traefik_labels(data: Compose) -> None:
for app_data in data.services.values():
traefik_labels = {
"traefik.http.routers.${_ORG_NAME}.rule=Host(`${_URL}`)",
"traefik.http.routers.${_ORG_NAME}.entrypoints=websecure",
"traefik.docker.network=${_ORG_NAME}_proxy",
"traefik.http.routers.${_ORG_NAME}.tls.certresolver=le",
}
if app_data.labels is None:
continue
if "traefik.enable=true" not in app_data.labels:
continue
app_data.labels.update(traefik_labels)
def sub():
for path in cfg_data.files:
_dict = cast(ComposeServiceYaml, read_yml(path))
yield ComposeService.from_dict(_dict)
compose = reduce(merge_dicts, sub(), cast(Compose, {})) # pyright: ignore[reportInvalidCast]
defaults = (insert_defaults, insert_traefik_labels)
for func in defaults:
func(compose)
return compose
def set_networks(data: Compose) -> tuple[str, str] | None:
def sub() -> Iterator[Literal["proxy", "internal"]]:
for app_data in data["services"].values():
if "networks" not in app_data:
continue
yield from app_data["networks"]
nets = set(sub())
net = ComposeNet()
if "proxy" in nets:
proxy = ComposeNetArgs(name="${_ORG_NAME}_proxy", external=True)
net["proxy"] = proxy
ret = "${_ORG_NAME}", "${_ORG_NAME}_proxy"
else:
ret = None
if "internal" in nets:
net["internal"] = ComposeNetArgs(name="${_ORG_NAME}")
if not net:
return
data["networks"] = net
return ret
def compose_data_factory(src: str):
src_path = src_path_factory(src)
cfg_data = config_data_factory(src_path)
compose = get_compose(cfg_data)
dest_data = dest_data_factory(cfg_data, compose)
return ComposeData(
src_path,
tuple(dest_data),
compose,
set_networks(compose),
to_yaml(compose),
)

52
src/compose/entity.py Normal file
View File

@@ -0,0 +1,52 @@
from dataclasses import asdict, dataclass
from typing import Literal, NotRequired, Self, TypedDict, final
from cfg.entity import CfgData
from net.entities import Net, NetTraefik, NetYaml
from service.entity import Service, ServiceYaml, TraefikService
from service.get import services_get_networks
from util import to_yaml
class ComposeYaml(TypedDict):
name: str
networks: NotRequired[NetYaml]
services: dict[str, ServiceYaml]
@final
@dataclass(frozen=True, slots=True)
class Compose:
cfg: CfgData
networks: Net | None
services: dict[str, Service]
@classmethod
def from_dict(cls, cfg: CfgData, data: ComposeYaml) -> Self:
# services = dict[str, ComposeService]()
services = dict(get_services_dict(data))
return cls(
cfg,
services_get_networks(services.values()),
services,
)
def as_yaml(self) -> str:
return to_yaml(asdict(self))
def get_services_dict(data: ComposeYaml):
for k, v in data["services"].items():
yield k, Service.from_dict(v)
@final
@dataclass(frozen=True, slots=True)
class TraefikCompose:
cfg: CfgData
networks: NetTraefik
services: dict[Literal["traefik"], TraefikService]
def as_yaml(self) -> str:
return to_yaml(asdict(self))

37
src/compose/factory.py Normal file
View File

@@ -0,0 +1,37 @@
from collections.abc import Iterable
from cfg import TRAEFIK_PATH
from cfg.entity import CfgData
from cfg.factory import cfg_data_factory
from cfg.get import cfg_get_services
from compose.entity import Compose, TraefikCompose
from net.entities import NetTraefik
from rendered.entity import Rendered
from rendered.get import rendered_get_nets
from service.entity import TraefikService
from service.factory import get_traefik_service
from service.get import services_get_networks
from src_path.entity import src_paths_factory
def compose_factory(cfg_data: CfgData) -> Compose:
services = dict(cfg_get_services(cfg_data))
return Compose(
cfg_data,
services_get_networks(services.values()),
services,
)
def traefik_compose_factory(renders: Iterable[Rendered]) -> TraefikCompose:
src_paths = src_paths_factory(TRAEFIK_PATH)
cfg = cfg_data_factory(src_paths)
service = get_traefik_service()
nets: NetTraefik = dict(rendered_get_nets(renders))
service["networks"] = list(nets.keys())
return TraefikCompose(
cfg,
nets,
{"traefik": TraefikService.from_dict(service)},
)

26
src/compose/get.py Normal file
View File

@@ -0,0 +1,26 @@
from collections.abc import Iterator
from compose.entity import Compose, TraefikCompose
from util import get_replace_name
def compose_get_volumes(compose: Compose | TraefikCompose) -> Iterator[str]:
if isinstance(compose, TraefikCompose):
return
f = get_replace_name("DATA")
for app_data in compose.services.values():
if app_data.volumes is None:
return
for vol in app_data.volumes:
if not vol.startswith(f):
continue
yield vol.split(":", 1)[0]
# def compose_get_volumes(compose: Compose | TraefikCompose):
# return _volumes_sub(compose)
# # vols = set(_volumes_sub(compose))
# # if not vols:
# # return None
# # return vols

View File

@@ -1,35 +0,0 @@
from functools import reduce
from pathlib import Path
from shutil import copyfile
from .entities import ComposeData, replace
def mk_dir(path: Path):
if path.exists():
return
path.mkdir(parents=True)
def mk_compose_dir(compose_data: ComposeData) -> None:
for dest in compose_data.dest_data:
mk_dir(dest.paths.data_dir)
for vol in dest.volumes:
mk_dir(vol)
def mk_compose_env(compose_data: ComposeData) -> None:
src = compose_data.src_paths.env_file
for dest_data in compose_data.dest_data:
dest = dest_data.paths.env_file
if src.exists() and not dest.exists():
_ = copyfile(src, dest)
def write(compose_data: ComposeData) -> None:
for dest_data in compose_data.dest_data:
string = reduce(
lambda s, rec: replace(rec, s), dest_data.templates, compose_data.yaml
)
with dest_data.paths.compose_file.open("wt") as f:
_ = f.write(string)

11
src/dest_path/entity.py Normal file
View File

@@ -0,0 +1,11 @@
from dataclasses import dataclass
from pathlib import Path
from typing import final
@final
@dataclass(frozen=True, slots=True)
class DestPaths:
data_dir: Path
env_file: Path
compose_file: Path

17
src/dest_path/factory.py Normal file
View File

@@ -0,0 +1,17 @@
from cfg import DATA_ROOT
from dest_path.entity import DestPaths
from template.entity import Template
def dest_paths_factory(template: Template) -> DestPaths:
r_args = template.replace_args
path = (
DATA_ROOT.joinpath(template.compose.cfg.name)
if r_args is None
else r_args.data.val.path
)
return DestPaths(
path,
path.joinpath(".env"),
path.joinpath("docker-compose.yml"),
)

View File

@@ -1,23 +1,33 @@
from typing import Any, Generator from collections.abc import Iterable, Iterator
from cfg import CFG_ROOT
from cfg.factory import cfg_data_factory
from compose.factory import compose_factory, traefik_compose_factory
from rendered.entity import Rendered
from rendered.factory import rendered_factory
from rendered.util import write
from src_path.entity import src_paths_factory
from template.factory import template_factory
from collections.abc import Iterator def load_all() -> Iterable[Rendered]:
from Ts import TraefikComposeDict, TraefikNet, TraefikNetName for dir in CFG_ROOT.iterdir():
from compose import gen_compose paths = src_paths_factory(dir)
cfg = cfg_data_factory(paths)
parsed = compose_factory(cfg)
for template in template_factory(parsed):
yield rendered_factory(template)
def render_all() -> Iterator[Rendered]:
for rendered in load_all():
write(rendered)
yield rendered
if __name__ == "__main__": if __name__ == "__main__":
paths: tuple[str, ...] = ("gitea", "opencloud", "jellyfin", "immich") renders = render_all()
traefik = traefik_compose_factory(renders)
def sub() -> Iterator[tuple[str, str]]: for template in template_factory(traefik):
for path in paths: rendered = rendered_factory(template)
yield from gen_compose(path) write(rendered)
# yield name, TraefikNetName(name=proxy)
networks: TraefikNet = dict(sub())
traefik = ComposeBuild("traefik").build()
traefik_compose = TraefikComposeDict(
name="traefik",
networks=networks,
)

45
src/net/entities.py Normal file
View File

@@ -0,0 +1,45 @@
from dataclasses import dataclass
from typing import NotRequired, Self, TypedDict, final
type NetTraefik = dict[str, NetArgs]
class NetArgsYaml(TypedDict):
name: str
external: NotRequired[bool]
@final
@dataclass(frozen=True, slots=True)
class NetArgs:
name: str
external: bool | None
@classmethod
def from_dict(cls, data: NetArgsYaml) -> Self:
return cls(
data["name"],
data.get("external"),
)
class NetYaml(TypedDict):
internal: NotRequired[NetArgsYaml]
proxy: NotRequired[NetArgsYaml]
@final
@dataclass(frozen=True, slots=True)
class Net:
internal: NetArgs | None
proxy: NetArgs | None
@classmethod
def from_class(cls, data: NetYaml) -> Self:
internal = data.get("internal")
if internal is not None:
internal = NetArgs.from_dict(internal)
proxy = data.get("proxy")
if proxy is not None:
proxy = NetArgs.from_dict(proxy)
return cls(internal, proxy)

21
src/net/factory.py Normal file
View File

@@ -0,0 +1,21 @@
from net.entities import Net, NetArgs
from util import get_replace_name
def net_args_factory(name: str, external: bool | None = None) -> NetArgs:
return NetArgs(name, external if external else None)
def net_factory(name: str, _internal: bool, _proxy: bool) -> Net:
return Net(
net_args_factory(name, _internal) if _internal else None,
net_args_factory(f"{name}_proxy", _proxy) if _proxy else None,
)
def net_factory_re(_internal: bool, _proxy: bool) -> Net:
return net_factory(
get_replace_name("name"),
_internal,
_proxy,
)

18
src/rendered/entity.py Normal file
View File

@@ -0,0 +1,18 @@
from dataclasses import dataclass
from pathlib import Path
from typing import final
from dest_path.entity import DestPaths
from src_path.entity import SrcPaths
from template.entity import Template
@final
@dataclass(frozen=True, slots=True)
class Rendered:
template: Template
src_paths: SrcPaths
dest_paths: DestPaths
volumes: frozenset[Path] | None
proxy_net: str | None
data: str

25
src/rendered/factory.py Normal file
View File

@@ -0,0 +1,25 @@
from functools import reduce
from dest_path.factory import dest_paths_factory
from rendered.entity import Rendered
from template.entity import Template
from template.get import template_get_proxy, template_get_vols
def rendered_factory(template: Template) -> Rendered:
yml = template.compose.as_yaml()
if template.replace_args is not None:
yml = reduce(
lambda s, f: f.replace(s),
template.replace_args,
yml,
)
vols = frozenset(template_get_vols(template))
return Rendered(
template,
template.compose.cfg.src_paths,
dest_paths_factory(template),
vols if vols else None,
template_get_proxy(template),
yml,
)

13
src/rendered/get.py Normal file
View File

@@ -0,0 +1,13 @@
from collections.abc import Iterable, Iterator
from net.entities import NetArgs
from net.factory import net_args_factory
from rendered.entity import Rendered
def rendered_get_nets(renders: Iterable[Rendered]) -> Iterator[tuple[str, NetArgs]]:
for render in renders:
net = render.proxy_net
if net is None:
continue
yield net, net_args_factory(f"{net}_proxy")

38
src/rendered/util.py Normal file
View File

@@ -0,0 +1,38 @@
from pathlib import Path
from shutil import copyfile
from rendered.entity import Rendered
def _mk_dir(path: Path) -> None:
if path.exists():
return
path.mkdir(parents=True)
def _mk_compose_dir(rendered: Rendered) -> None:
_mk_dir(rendered.dest_paths.data_dir)
vols = rendered.volumes
if vols is None:
return
for vol in vols:
_mk_dir(vol)
def _mk_compose_env(rendered: Rendered) -> None:
src = rendered.src_paths.env_file
dest = rendered.dest_paths.env_file
if src.exists() and not dest.exists():
_ = copyfile(src, dest)
def write_raw(path: Path, data: str) -> None:
with path.open("wt") as f:
_ = f.write(data)
def write(rendered: Rendered) -> None:
funcs = (_mk_compose_dir, _mk_compose_env)
for func in funcs:
func(rendered)
write_raw(rendered.dest_paths.compose_file, rendered.data)

158
src/service/entity.py Normal file
View File

@@ -0,0 +1,158 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Literal, NotRequired, Self, TypedDict, TypeVar, overload, override
from cfg import T_Primitive
from util import get_replace_name
type T_NetAbc = str | Literal["proxy", "internal"]
TCo_NetABC = TypeVar("TCo_NetABC", bound=T_NetAbc, covariant=True)
class ServiceYamlAbc[T_net: T_NetAbc](TypedDict):
command: NotRequired[list[str]]
container_name: str
entrypoint: list[str]
environment: NotRequired[dict[str, T_Primitive]]
image: str
labels: NotRequired[list[str]]
logging: dict[str, str]
networks: NotRequired[list[T_net]]
restart: str
security_opt: NotRequired[list[str]]
user: NotRequired[str]
volumes: NotRequired[list[str]]
TCo_ServiceYaml = TypeVar(
"TCo_ServiceYaml",
bound=ServiceYamlAbc[T_NetAbc],
covariant=True,
)
class ServiceYaml(ServiceYamlAbc[Literal["proxy", "internal"]]):
pass
class TraefikServiceYaml(ServiceYamlAbc[str]):
pass
type T_Compose = ServiceYaml | TraefikServiceYaml
@dataclass(frozen=True, slots=True)
class ServiceAbc[T_net: T_NetAbc, T_Yaml: T_Compose](ABC):
command: tuple[str, ...] | None
container_name: str
entrypoint: tuple[str, ...]
environment: dict[str, T_Primitive] | None
image: str
labels: frozenset[str] | None
logging: dict[str, str] | None
networks: frozenset[T_net] | None
restart: str
security_opt: frozenset[str]
user: str | None
volumes: frozenset[str] | None
@classmethod
def from_dict(cls, data: T_Yaml) -> Self:
command = data.get("command")
volumes = data.get("volumes")
return cls(
tuple(command) if command else None,
data["container_name"],
tuple(data["entrypoint"]),
data.get("environment"),
data["image"],
_get_labels(data),
None,
cls.get_nets(data),
"unless-stopped",
_get_sec_opts(data),
data.get("user"),
frozenset(volumes) if volumes else None,
)
def is_valid(self) -> bool:
attrs = (self.container_name, self.restart, self.logging)
for attr in attrs:
if attr is None:
return False
return True
@abstractmethod
@staticmethod
def get_nets(data: T_Yaml) -> frozenset[T_net] | None:
pass
# return self._get_nets(data)
class Service(ServiceAbc[Literal["proxy", "internal"], ServiceYaml]):
@override
@staticmethod
def get_nets(data: ServiceYaml) -> frozenset[Literal["proxy", "internal"]] | None:
return _get_nets(data)
class TraefikService(ServiceAbc[str, TraefikServiceYaml]):
@override
@staticmethod
def get_nets(data: TraefikServiceYaml) -> frozenset[str] | None:
return _get_nets(data)
@overload
def _get_nets(
data: ServiceYaml,
) -> frozenset[Literal["proxy", "internal"]] | None:
pass
@overload
def _get_nets(data: TraefikServiceYaml) -> frozenset[str] | None:
pass
def _get_nets(
data: ServiceYaml | TraefikServiceYaml,
) -> frozenset[str] | frozenset[Literal["proxy", "internal"]] | None:
nets = data.get("networks")
if nets is None:
return
return frozenset(nets)
def _get_sec_opts(
data: T_Compose,
) -> frozenset[str]:
sec_opts = frozenset(
"no-new-privileges:true",
)
sec = data.get("security_opt")
if not sec:
return sec_opts
return sec_opts.union(sec)
def _get_labels(
data: T_Compose,
) -> frozenset[str] | None:
org_name = get_replace_name("org_name")
url = get_replace_name("url")
traefik_labels = frozenset(
(
f"traefik.http.routers.{org_name}.rule=Host(`{url}`)",
f"traefik.http.routers.{org_name}.entrypoints=websecure",
f"traefik.docker.network={org_name}_proxy",
f"traefik.http.routers.{org_name}.tls.certresolver=le",
)
)
labels = data.get("labels")
if not labels:
return
if "traefik.enable=true" not in labels:
return frozenset(labels)
return traefik_labels.union(labels)

10
src/service/factory.py Normal file
View File

@@ -0,0 +1,10 @@
from typing import cast
from cfg import TRAEFIK_PATH
from service.entity import TraefikServiceYaml
from util import read_yml
def get_traefik_service():
path = TRAEFIK_PATH.joinpath("service.yml")
return cast(TraefikServiceYaml, read_yml(path))

26
src/service/get.py Normal file
View File

@@ -0,0 +1,26 @@
from collections.abc import Iterable, Iterator
from typing import Literal
from net.entities import Net
from net.factory import net_factory_re
from service.entity import Service
def _networks_sub(
services: Iterable[Service],
) -> Iterator[Literal["proxy", "internal"]]:
for app_data in services:
networks = app_data.networks
if networks is None:
continue
yield from networks
def services_get_networks(services: Iterable[Service]) -> Net | None:
networks = frozenset(_networks_sub(services))
if not networks:
return None
return net_factory_re(
"internal" in networks,
"proxy" in networks,
)

19
src/src_path/entity.py Normal file
View File

@@ -0,0 +1,19 @@
from dataclasses import dataclass
from pathlib import Path
from typing import final
@final
@dataclass(frozen=True, slots=True)
class SrcPaths:
cfg_dir: Path
cfg_file: Path
env_file: Path
def src_paths_factory(src: Path) -> SrcPaths:
return SrcPaths(
cfg_dir=src,
cfg_file=src.joinpath("cfg.yml"),
env_file=src.joinpath(".env"),
)

7
src/src_path/get.py Normal file
View File

@@ -0,0 +1,7 @@
from cfg.entity import CfgDataYaml
from src_path.entity import SrcPaths
def src_path_get_files(src_paths: SrcPaths, data: CfgDataYaml):
for path in data["files"]:
yield src_paths.cfg_dir.joinpath(path)

32
src/template/entity.py Normal file
View File

@@ -0,0 +1,32 @@
from dataclasses import dataclass
from typing import final
from compose.entity import Compose, TraefikCompose
from template.val_obj import DataDir, NameVal, OrgVal, RecordCls, Url
@final
@dataclass(frozen=True, slots=True)
class ReplaceArgs:
org: RecordCls[OrgVal]
name: RecordCls[NameVal]
org_name: RecordCls[NameVal]
data: RecordCls[DataDir]
url: RecordCls[Url]
def __iter__(self):
yield self.org
yield self.name
yield self.org_name
yield self.data
yield self.url
@final
@dataclass(frozen=True, slots=True)
class Template:
compose: Compose | TraefikCompose
replace_args: ReplaceArgs | None
# dest_paths: DestPaths
volumes: frozenset[str] | None
# proxy_net: str | None

49
src/template/factory.py Normal file
View File

@@ -0,0 +1,49 @@
from collections.abc import Iterator
from cfg import DATA_ROOT
from cfg.entity import CfgData, OrgData
from compose.entity import Compose, TraefikCompose
from compose.get import compose_get_volumes
from template.entity import ReplaceArgs, Template
from template.val_obj import DataDir, NameVal, OrgVal, Record, Url
def replace_args_factory(cfg_data: CfgData, org_data: OrgData) -> ReplaceArgs:
_org = OrgVal(org_data.org)
_name = NameVal(cfg_data.name)
org_name = (
NameVal(f"{_org.to_str()}_{_name.to_str()}") if _org.is_valid() else _name
)
data_dir = DATA_ROOT.joinpath(_org.to_str(), _name.to_str())
return ReplaceArgs(
Record("org", _org),
Record("name", _name),
Record("org_name", org_name),
Record("data", DataDir(data_dir)),
Record("url", Url(org_data.url)),
)
def template_factory(compose: Compose | TraefikCompose) -> Iterator[Template]:
cfg_data = compose.cfg
vols = frozenset(compose_get_volumes(compose))
if not vols:
vols = None
orgs = cfg_data.orgs
if orgs is None:
yield Template(compose, None, vols)
return
for org_data in orgs:
args = replace_args_factory(
cfg_data,
org_data,
)
yield Template(
compose,
args,
vols,
)

36
src/template/get.py Normal file
View File

@@ -0,0 +1,36 @@
from collections.abc import Iterable
from pathlib import Path
from net.entities import Net
from template.entity import Template
def template_get_vols(template: Template) -> Iterable[Path]:
def _lambda(x: str) -> str:
return x
vols = template.volumes
if not vols:
return
r_args = template.replace_args
re = _lambda if r_args is None else r_args.data.replace
for vol in vols:
yield Path(re(vol))
def template_get_proxy(template: Template) -> str | None:
proxy = template.compose.networks
if proxy is None:
return
if not isinstance(proxy, Net):
return
if proxy.proxy is None:
return
if proxy.internal is None:
net = proxy.proxy.name.replace("_proxy", "")
else:
net = proxy.internal.name
r_args = template.replace_args
if r_args is None:
return net
return r_args.name.replace(net)

View File

@@ -1,10 +1,27 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Protocol, final from typing import TypeVar, final, override
from util import get_replace_name
class RecordVal(Protocol): class RecordVal(ABC):
def to_str(self) -> str: ... @abstractmethod
def to_str(self) -> str:
pass
TCo_RecordVal = TypeVar(
"TCo_RecordVal",
bound=RecordVal,
covariant=True,
)
TCon_RecordVal = TypeVar(
"TCon_RecordVal",
bound=RecordVal,
contravariant=True,
)
@final @final
@@ -13,12 +30,21 @@ class RecordCls[T: RecordVal]:
name: str name: str
val: T val: T
# @final
# class RecordClsProto(Protocol):
# name:str
# val: RecordVal
def replace(self, string: str) -> str:
return str.replace(string, self.name, self.val.to_str())
@final @final
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class OrgVal: class OrgVal(RecordVal):
val: str | None val: str | None
@override
def to_str(self) -> str: def to_str(self) -> str:
if self.val is None: if self.val is None:
return "personal" return "personal"
@@ -30,36 +56,35 @@ class OrgVal:
@final @final
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class NameVal: class NameVal(RecordVal):
val: str val: str
@override
def to_str(self) -> str: def to_str(self) -> str:
return self.val return self.val
@final @final
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class DataDir: class DataDir(RecordVal):
path: Path path: Path
@override
def to_str(self) -> str: def to_str(self) -> str:
return str(self.path) return str(self.path)
@final @final
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class Url: class Url(RecordVal):
sub_url: str | None sub_url: str | None
@override
def to_str(self) -> str: def to_str(self) -> str:
if self.sub_url is None: if self.sub_url is None:
return "" return ""
return ".".join([self.sub_url, "ccamper7", "net"]) return ".".join([self.sub_url, "ccamper7", "net"])
# def get_replace_var(string: str) -> str:
# return f"${{_{name}}}"
def Record[T: RecordVal](name: str, val: T) -> RecordCls[T]: def Record[T: RecordVal](name: str, val: T) -> RecordCls[T]:
return RecordCls(f"${{_{name}}}", val) return RecordCls(get_replace_name(name), val)

View File

View File

@@ -5,7 +5,7 @@ from typing import Any, cast, override
import yaml import yaml
from compose.compose_struct import T_PrimDict, T_PrimVal, T_Primitive from cfg import T_PrimDict, T_Primitive, T_PrimVal, T_YamlDict
class VerboseSafeDumper(yaml.SafeDumper): class VerboseSafeDumper(yaml.SafeDumper):
@@ -16,8 +16,8 @@ class VerboseSafeDumper(yaml.SafeDumper):
def merge_dicts[T: Mapping[Any, Any]](dict1: T, dict2: T) -> T: def merge_dicts[T: Mapping[Any, Any]](dict1: T, dict2: T) -> T:
def _merge_dicts(dict1: T_PrimDict, dict2: T_PrimDict): def _merge_dicts(dict1: T_PrimDict, dict2: T_PrimDict):
s1 = set(dict1.keys()) s1 = frozenset(dict1.keys())
s2 = set(dict2.keys()) s2 = frozenset(dict2.keys())
for k in s1.difference(s2): for k in s1.difference(s2):
yield k, dict1[k] yield k, dict1[k]
for k in s2.difference(s1): for k in s2.difference(s1):
@@ -29,18 +29,22 @@ def merge_dicts[T: Mapping[Any, Any]](dict1: T, dict2: T) -> T:
yield k, dict[T_Primitive, T_PrimVal](_merge_dicts(v1, v2)) yield k, dict[T_Primitive, T_PrimVal](_merge_dicts(v1, v2))
continue continue
if isinstance(v1, list) and isinstance(v2, list): if isinstance(v1, list) and isinstance(v2, list):
yield k, list(set(v1).union(v2)) yield k, list(frozenset(v1).union(v2))
continue continue
raise Exception("merge error") raise Exception("merge error")
return cast(T, dict(_merge_dicts(dict1, dict2))) return cast(T, dict(_merge_dicts(dict1, dict2)))
def read_yml(path: Path) -> Mapping[Any, Any]: # pyright: ignore[reportExplicitAny] def read_yml(path: Path) -> T_YamlDict:
with path.open("rt") as f: with path.open("rt") as f:
return yaml.safe_load(f) # pyright: ignore[reportAny] return cast(T_YamlDict, yaml.safe_load(f))
def to_yaml(data: Mapping[Any, Any]) -> str: # pyright: ignore[reportExplicitAny] def to_yaml(data: T_YamlDict) -> str:
_yaml = yaml.dump(data, Dumper=VerboseSafeDumper) _yaml = yaml.dump(data, Dumper=VerboseSafeDumper)
return re.sub(r"(^\s*-)", r" \g<1>", _yaml, flags=re.MULTILINE) return re.sub(r"(^\s*-)", r" \g<1>", _yaml, flags=re.MULTILINE)
def get_replace_name(name: str) -> str:
return f"${{_{name.upper()}}}"

110
uv.lock generated Normal file
View File

@@ -0,0 +1,110 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "basedpyright"
version = "1.36.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/29/d42d543a1637e692ac557bfc6d6fcf50e9a7061c1cb4da403378d6a70453/basedpyright-1.36.1.tar.gz", hash = "sha256:20c9a24e2a4c95d5b6d46c78a6b6c7e3dc7cbba227125256431d47c595b15fd4", size = 22834851, upload-time = "2025-12-11T14:55:47.463Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/7f/f0133313bffa303d32aa74468981eb6b2da7fadda6247c9aa0aeab8391b1/basedpyright-1.36.1-py3-none-any.whl", hash = "sha256:3d738484fe9681cdfe35dd98261f30a9a7aec64208bc91f8773a9aaa9b89dd16", size = 11881725, upload-time = "2025-12-11T14:55:43.805Z" },
]
[[package]]
name = "compose"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "basedpyright" },
{ name = "pyyaml" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "basedpyright", specifier = ">=1.36.1" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "ruff", specifier = ">=0.14.9" },
]
[[package]]
name = "nodejs-wheel-binaries"
version = "24.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/35/d806c2ca66072e36dc340ccdbeb2af7e4f1b5bcc33f1481f00ceed476708/nodejs_wheel_binaries-24.12.0.tar.gz", hash = "sha256:f1b50aa25375e264697dec04b232474906b997c2630c8f499f4caf3692938435", size = 8058, upload-time = "2025-12-11T21:12:26.856Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/3b/9d6f044319cd5b1e98f07c41e2465b58cadc1c9c04a74c891578f3be6cb5/nodejs_wheel_binaries-24.12.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:7564ddea0a87eff34e9b3ef71764cc2a476a8f09a5cccfddc4691148b0a47338", size = 55125859, upload-time = "2025-12-11T21:11:58.132Z" },
{ url = "https://files.pythonhosted.org/packages/48/a5/f5722bf15c014e2f476d7c76bce3d55c341d19122d8a5d86454db32a61a4/nodejs_wheel_binaries-24.12.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:8ff929c4669e64613ceb07f5bbd758d528c3563820c75d5de3249eb452c0c0ab", size = 55309035, upload-time = "2025-12-11T21:12:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/a9/61/68d39a6f1b5df67805969fd2829ba7e80696c9af19537856ec912050a2be/nodejs_wheel_binaries-24.12.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6ebacefa8891bc456ad3655e6bce0af7e20ba08662f79d9109986faeb703fd6f", size = 59661017, upload-time = "2025-12-11T21:12:05.268Z" },
{ url = "https://files.pythonhosted.org/packages/16/a1/31aad16f55a5e44ca7ea62d1367fc69f4b6e1dba67f58a0a41d0ed854540/nodejs_wheel_binaries-24.12.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3292649a03682ccbfa47f7b04d3e4240e8c46ef04dc941b708f20e4e6a764f75", size = 60159770, upload-time = "2025-12-11T21:12:08.696Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5e/b7c569aa1862690ca4d4daf3a64cafa1ea6ce667a9e3ae3918c56e127d9b/nodejs_wheel_binaries-24.12.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7fb83df312955ea355ba7f8cbd7055c477249a131d3cb43b60e4aeb8f8c730b1", size = 61653561, upload-time = "2025-12-11T21:12:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/71/87/567f58d7ba69ff0208be849b37be0f2c2e99c69e49334edd45ff44f00043/nodejs_wheel_binaries-24.12.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2473c819448fedd7b036dde236b09f3c8bbf39fbbd0c1068790a0498800f498b", size = 62238331, upload-time = "2025-12-11T21:12:16.143Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9d/c6492188ce8de90093c6755a4a63bb6b2b4efb17094cb4f9a9a49c73ed3b/nodejs_wheel_binaries-24.12.0-py2.py3-none-win_amd64.whl", hash = "sha256:2090d59f75a68079fabc9b86b14df8238b9aecb9577966dc142ce2a23a32e9bb", size = 41342076, upload-time = "2025-12-11T21:12:20.618Z" },
{ url = "https://files.pythonhosted.org/packages/df/af/cd3290a647df567645353feed451ef4feaf5844496ced69c4dcb84295ff4/nodejs_wheel_binaries-24.12.0-py2.py3-none-win_arm64.whl", hash = "sha256:d0c2273b667dd7e3f55e369c0085957b702144b1b04bfceb7ce2411e58333757", size = 39048104, upload-time = "2025-12-11T21:12:23.495Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.14.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/1b/ab712a9d5044435be8e9a2beb17cbfa4c241aa9b5e4413febac2a8b79ef2/ruff-0.14.9.tar.gz", hash = "sha256:35f85b25dd586381c0cc053f48826109384c81c00ad7ef1bd977bfcc28119d5b", size = 5809165, upload-time = "2025-12-11T21:39:47.381Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/1c/d1b1bba22cffec02351c78ab9ed4f7d7391876e12720298448b29b7229c1/ruff-0.14.9-py3-none-linux_armv6l.whl", hash = "sha256:f1ec5de1ce150ca6e43691f4a9ef5c04574ad9ca35c8b3b0e18877314aba7e75", size = 13576541, upload-time = "2025-12-11T21:39:14.806Z" },
{ url = "https://files.pythonhosted.org/packages/94/ab/ffe580e6ea1fca67f6337b0af59fc7e683344a43642d2d55d251ff83ceae/ruff-0.14.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ed9d7417a299fc6030b4f26333bf1117ed82a61ea91238558c0268c14e00d0c2", size = 13779363, upload-time = "2025-12-11T21:39:20.29Z" },
{ url = "https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c", size = 12925292, upload-time = "2025-12-11T21:39:38.757Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697", size = 13362894, upload-time = "2025-12-11T21:39:02.524Z" },
{ url = "https://files.pythonhosted.org/packages/31/1c/5b4e8e7750613ef43390bb58658eaf1d862c0cc3352d139cd718a2cea164/ruff-0.14.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa733093d1f9d88a5d98988d8834ef5d6f9828d03743bf5e338bf980a19fce27", size = 13311482, upload-time = "2025-12-11T21:39:17.51Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3a/459dce7a8cb35ba1ea3e9c88f19077667a7977234f3b5ab197fad240b404/ruff-0.14.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a1cfb04eda979b20c8c19550c8b5f498df64ff8da151283311ce3199e8b3648", size = 14016100, upload-time = "2025-12-11T21:39:41.948Z" },
{ url = "https://files.pythonhosted.org/packages/a6/31/f064f4ec32524f9956a0890fc6a944e5cf06c63c554e39957d208c0ffc45/ruff-0.14.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e5cb521e5ccf0008bd74d5595a4580313844a42b9103b7388eca5a12c970743", size = 15477729, upload-time = "2025-12-11T21:39:23.279Z" },
{ url = "https://files.pythonhosted.org/packages/7a/6d/f364252aad36ccd443494bc5f02e41bf677f964b58902a17c0b16c53d890/ruff-0.14.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd429a8926be6bba4befa8cdcf3f4dd2591c413ea5066b1e99155ed245ae42bb", size = 15122386, upload-time = "2025-12-11T21:39:33.125Z" },
{ url = "https://files.pythonhosted.org/packages/20/02/e848787912d16209aba2799a4d5a1775660b6a3d0ab3944a4ccc13e64a02/ruff-0.14.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab208c1b7a492e37caeaf290b1378148f75e13c2225af5d44628b95fd7834273", size = 14497124, upload-time = "2025-12-11T21:38:59.33Z" },
{ url = "https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a", size = 14195343, upload-time = "2025-12-11T21:39:44.866Z" },
{ url = "https://files.pythonhosted.org/packages/f6/53/3bb8d2fa73e4c2f80acc65213ee0830fa0c49c6479313f7a68a00f39e208/ruff-0.14.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:712ff04f44663f1b90a1195f51525836e3413c8a773574a7b7775554269c30ed", size = 14346425, upload-time = "2025-12-11T21:39:05.927Z" },
{ url = "https://files.pythonhosted.org/packages/ad/04/bdb1d0ab876372da3e983896481760867fc84f969c5c09d428e8f01b557f/ruff-0.14.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a111fee1db6f1d5d5810245295527cda1d367c5aa8f42e0fca9a78ede9b4498b", size = 13258768, upload-time = "2025-12-11T21:39:08.691Z" },
{ url = "https://files.pythonhosted.org/packages/40/d9/8bf8e1e41a311afd2abc8ad12be1b6c6c8b925506d9069b67bb5e9a04af3/ruff-0.14.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8769efc71558fecc25eb295ddec7d1030d41a51e9dcf127cbd63ec517f22d567", size = 13326939, upload-time = "2025-12-11T21:39:53.842Z" },
{ url = "https://files.pythonhosted.org/packages/f4/56/a213fa9edb6dd849f1cfbc236206ead10913693c72a67fb7ddc1833bf95d/ruff-0.14.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:347e3bf16197e8a2de17940cd75fd6491e25c0aa7edf7d61aa03f146a1aa885a", size = 13578888, upload-time = "2025-12-11T21:39:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/33/09/6a4a67ffa4abae6bf44c972a4521337ffce9cbc7808faadede754ef7a79c/ruff-0.14.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7715d14e5bccf5b660f54516558aa94781d3eb0838f8e706fb60e3ff6eff03a8", size = 14314473, upload-time = "2025-12-11T21:39:50.78Z" },
{ url = "https://files.pythonhosted.org/packages/12/0d/15cc82da5d83f27a3c6b04f3a232d61bc8c50d38a6cd8da79228e5f8b8d6/ruff-0.14.9-py3-none-win32.whl", hash = "sha256:df0937f30aaabe83da172adaf8937003ff28172f59ca9f17883b4213783df197", size = 13202651, upload-time = "2025-12-11T21:39:26.628Z" },
{ url = "https://files.pythonhosted.org/packages/32/f7/c78b060388eefe0304d9d42e68fab8cffd049128ec466456cef9b8d4f06f/ruff-0.14.9-py3-none-win_amd64.whl", hash = "sha256:c0b53a10e61df15a42ed711ec0bda0c582039cf6c754c49c020084c55b5b0bc2", size = 14702079, upload-time = "2025-12-11T21:39:11.954Z" },
{ url = "https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl", hash = "sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size = 13744730, upload-time = "2025-12-11T21:39:29.659Z" },
]