diff --git a/.gitignore b/.gitignore index 3996e81..e48c1b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,176 +1,10 @@ -# ---> Python -# Byte-compiled / optimized / DLL files +# Python-generated files __pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python +*.py[oc] build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST +*.egg-info -# PyInstaller -# 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 +# Virtual environments .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 - diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..689be22 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/.vscode/settings.json b/.vscode/settings.json index 20562e0..89a9eef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,16 +1,16 @@ { "python.languageServer": "None", - "cSpell.words": [ - "traefik" - ], - "editor.formatOnSave": true, "[python]": { + "editor.formatOnType": true, "editor.codeActionsOnSave": { - "source.organizeImports": "explicit", - "source.unusedImports": "explicit" - } - }, - "terminal.integrated.env.linux": { - "PYTHONPATH": "${workspaceFolder}/src" + "source.fixAll.ruff": "explicit", + "source.organizeImports.ruff": "explicit" + }, + "editor.defaultFormatter": "charliermarsh.ruff" }, + "cSpell.words": [ + "certresolver", + "traefik", + "websecure" + ] } \ No newline at end of file diff --git a/README.md b/README.md index 4a97c2d..473a0f4 100644 --- a/README.md +++ b/README.md @@ -1,2 +0,0 @@ -# compose_gen - diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 0000000..85a7065 --- /dev/null +++ b/docs/workflow.md @@ -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 + +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7ea0b1a --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/cfg/__init__.py b/src/cfg/__init__.py new file mode 100644 index 0000000..f91132f --- /dev/null +++ b/src/cfg/__init__.py @@ -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] diff --git a/src/cfg/entity.py b/src/cfg/entity.py new file mode 100644 index 0000000..abe635e --- /dev/null +++ b/src/cfg/entity.py @@ -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 diff --git a/src/cfg/factory.py b/src/cfg/factory.py new file mode 100644 index 0000000..9f9fa8a --- /dev/null +++ b/src/cfg/factory.py @@ -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)), + ) diff --git a/src/cfg/get.py b/src/cfg/get.py new file mode 100644 index 0000000..13e8371 --- /dev/null +++ b/src/cfg/get.py @@ -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) diff --git a/src/compose.py b/src/compose.py deleted file mode 100644 index 172aa39..0000000 --- a/src/compose.py +++ /dev/null @@ -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) diff --git a/src/compose/compose_struct.py b/src/compose/compose_struct.py deleted file mode 100644 index 455031b..0000000 --- a/src/compose/compose_struct.py +++ /dev/null @@ -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] diff --git a/src/compose/entities.py b/src/compose/entities.py deleted file mode 100644 index e3c260e..0000000 --- a/src/compose/entities.py +++ /dev/null @@ -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), - ) diff --git a/src/compose/entity.py b/src/compose/entity.py new file mode 100644 index 0000000..8e23cef --- /dev/null +++ b/src/compose/entity.py @@ -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)) diff --git a/src/compose/factory.py b/src/compose/factory.py new file mode 100644 index 0000000..d998fa9 --- /dev/null +++ b/src/compose/factory.py @@ -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)}, + ) diff --git a/src/compose/get.py b/src/compose/get.py new file mode 100644 index 0000000..73edcb5 --- /dev/null +++ b/src/compose/get.py @@ -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 diff --git a/src/compose/service.py b/src/compose/service.py deleted file mode 100644 index 6cddb4a..0000000 --- a/src/compose/service.py +++ /dev/null @@ -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) diff --git a/src/dest_path/entity.py b/src/dest_path/entity.py new file mode 100644 index 0000000..e885d7d --- /dev/null +++ b/src/dest_path/entity.py @@ -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 diff --git a/src/dest_path/factory.py b/src/dest_path/factory.py new file mode 100644 index 0000000..b873dab --- /dev/null +++ b/src/dest_path/factory.py @@ -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"), + ) diff --git a/src/main.py b/src/main.py index e2f1a49..76e145b 100644 --- a/src/main.py +++ b/src/main.py @@ -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 -from Ts import TraefikComposeDict, TraefikNet, TraefikNetName -from compose import gen_compose +def load_all() -> Iterable[Rendered]: + for dir in CFG_ROOT.iterdir(): + 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__": - paths: tuple[str, ...] = ("gitea", "opencloud", "jellyfin", "immich") - - def sub() -> Iterator[tuple[str, str]]: - for path in paths: - yield from gen_compose(path) - - # yield name, TraefikNetName(name=proxy) - - networks: TraefikNet = dict(sub()) - traefik = ComposeBuild("traefik").build() - traefik_compose = TraefikComposeDict( - name="traefik", - networks=networks, - ) + renders = render_all() + traefik = traefik_compose_factory(renders) + for template in template_factory(traefik): + rendered = rendered_factory(template) + write(rendered) diff --git a/src/net/entities.py b/src/net/entities.py new file mode 100644 index 0000000..015eefa --- /dev/null +++ b/src/net/entities.py @@ -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) diff --git a/src/net/factory.py b/src/net/factory.py new file mode 100644 index 0000000..26e7e29 --- /dev/null +++ b/src/net/factory.py @@ -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, + ) diff --git a/src/rendered/entity.py b/src/rendered/entity.py new file mode 100644 index 0000000..55225a5 --- /dev/null +++ b/src/rendered/entity.py @@ -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 diff --git a/src/rendered/factory.py b/src/rendered/factory.py new file mode 100644 index 0000000..599f511 --- /dev/null +++ b/src/rendered/factory.py @@ -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, + ) diff --git a/src/rendered/get.py b/src/rendered/get.py new file mode 100644 index 0000000..5e2fa82 --- /dev/null +++ b/src/rendered/get.py @@ -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") diff --git a/src/rendered/util.py b/src/rendered/util.py new file mode 100644 index 0000000..f961b8a --- /dev/null +++ b/src/rendered/util.py @@ -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) diff --git a/src/service/entity.py b/src/service/entity.py new file mode 100644 index 0000000..2b61c12 --- /dev/null +++ b/src/service/entity.py @@ -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) diff --git a/src/service/factory.py b/src/service/factory.py new file mode 100644 index 0000000..0cddff1 --- /dev/null +++ b/src/service/factory.py @@ -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)) diff --git a/src/service/get.py b/src/service/get.py new file mode 100644 index 0000000..52b08e2 --- /dev/null +++ b/src/service/get.py @@ -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, + ) diff --git a/src/src_path/entity.py b/src/src_path/entity.py new file mode 100644 index 0000000..115a12c --- /dev/null +++ b/src/src_path/entity.py @@ -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"), + ) diff --git a/src/src_path/get.py b/src/src_path/get.py new file mode 100644 index 0000000..0bb0d58 --- /dev/null +++ b/src/src_path/get.py @@ -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) diff --git a/src/template/entity.py b/src/template/entity.py new file mode 100644 index 0000000..980a360 --- /dev/null +++ b/src/template/entity.py @@ -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 diff --git a/src/template/factory.py b/src/template/factory.py new file mode 100644 index 0000000..72c1c22 --- /dev/null +++ b/src/template/factory.py @@ -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, + ) diff --git a/src/template/get.py b/src/template/get.py new file mode 100644 index 0000000..61bc903 --- /dev/null +++ b/src/template/get.py @@ -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) diff --git a/src/compose/val_objs.py b/src/template/val_obj.py similarity index 53% rename from src/compose/val_objs.py rename to src/template/val_obj.py index 9a6fcea..ff2a291 100644 --- a/src/compose/val_objs.py +++ b/src/template/val_obj.py @@ -1,10 +1,27 @@ +from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path -from typing import Protocol, final +from typing import TypeVar, final, override + +from util import get_replace_name -class RecordVal(Protocol): - def to_str(self) -> str: ... +class RecordVal(ABC): + @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 @@ -13,12 +30,21 @@ class RecordCls[T: RecordVal]: name: str 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 @dataclass(frozen=True, slots=True) -class OrgVal: +class OrgVal(RecordVal): val: str | None + @override def to_str(self) -> str: if self.val is None: return "personal" @@ -30,36 +56,35 @@ class OrgVal: @final @dataclass(frozen=True, slots=True) -class NameVal: +class NameVal(RecordVal): val: str + @override def to_str(self) -> str: return self.val @final @dataclass(frozen=True, slots=True) -class DataDir: +class DataDir(RecordVal): path: Path + @override def to_str(self) -> str: return str(self.path) @final @dataclass(frozen=True, slots=True) -class Url: +class Url(RecordVal): sub_url: str | None + @override def to_str(self) -> str: if self.sub_url is None: return "" 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]: - return RecordCls(f"${{_{name}}}", val) + return RecordCls(get_replace_name(name), val) diff --git a/src/treafik.py b/src/treafik.py deleted file mode 100644 index 473a0f4..0000000 diff --git a/src/util.py b/src/util.py index abe339d..402ca15 100644 --- a/src/util.py +++ b/src/util.py @@ -5,7 +5,7 @@ from typing import Any, cast, override 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): @@ -16,8 +16,8 @@ class VerboseSafeDumper(yaml.SafeDumper): def merge_dicts[T: Mapping[Any, Any]](dict1: T, dict2: T) -> T: def _merge_dicts(dict1: T_PrimDict, dict2: T_PrimDict): - s1 = set(dict1.keys()) - s2 = set(dict2.keys()) + s1 = frozenset(dict1.keys()) + s2 = frozenset(dict2.keys()) for k in s1.difference(s2): yield k, dict1[k] 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)) continue if isinstance(v1, list) and isinstance(v2, list): - yield k, list(set(v1).union(v2)) + yield k, list(frozenset(v1).union(v2)) continue raise Exception("merge error") 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: - 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) return re.sub(r"(^\s*-)", r" \g<1>", _yaml, flags=re.MULTILINE) + + +def get_replace_name(name: str) -> str: + return f"${{_{name.upper()}}}" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6ae6dac --- /dev/null +++ b/uv.lock @@ -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" }, +]