diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..de60c0b --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..244a3c0 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compose_gen.iml b/.idea/compose_gen.iml new file mode 100644 index 0000000..ac26cc3 --- /dev/null +++ b/.idea/compose_gen.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml new file mode 100644 index 0000000..ae1d885 --- /dev/null +++ b/.idea/dictionaries/project.xml @@ -0,0 +1,12 @@ + + + + ccamper + certresolver + exts + stryten + traefik + websecure + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..25ce8b7 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..401fa09 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..71fbeb2 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..0faa797 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.python-version b/.python-version old mode 100644 new mode 100755 diff --git a/.vscode/settings.json b/.vscode/settings.json old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/docs/workflow.md b/docs/workflow.md old mode 100644 new mode 100755 diff --git a/pyproject.toml b/pyproject.toml old mode 100644 new mode 100755 index 7ea0b1a..9be42a0 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,23 @@ [project] -name = "compose" +name = "docker_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", + "basedpyright>=1.37.0", "pyyaml>=6.0.3", - "ruff>=0.14.9", + "ruff>=0.14.10", + "ty>=0.0.10", ] [project.scripts] -compose = "compose:main" +docker_compose = "docker_compose:main" [build-system] requires = ["uv_build>=0.9.17,<0.10.0"] build-backend = "uv_build" + +[tool.pyright] +#reportExplicitAny = false diff --git a/src/compose/__init__.py b/src/compose/__init__.py deleted file mode 100644 index 1329af5..0000000 --- a/src/compose/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -from collections.abc import Iterable, Iterator - -from compose.cfg import CFG_ROOT, TRAEFIK_PATH -from compose.cfg.factory import cfg_data_factory -from compose.compose.factory import compose_factory -from compose.rendered.entity import Rendered -from compose.rendered.factory import rendered_factory -from compose.rendered.util import write -from compose.src_path.entity import src_paths_factory -from compose.template.factory import template_factory - - -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__": - renders = render_all() - src_paths = src_paths_factory(TRAEFIK_PATH) - cfg_data = cfg_data_factory(src_paths) - traefik = compose_factory(cfg_data) - for template in template_factory(traefik): - rendered = rendered_factory(template) - write(rendered) diff --git a/src/compose/cfg/__init__.py b/src/compose/cfg/__init__.py deleted file mode 100644 index f91132f..0000000 --- a/src/compose/cfg/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -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/compose/cfg/entity.py b/src/compose/cfg/entity.py deleted file mode 100644 index 3132da0..0000000 --- a/src/compose/cfg/entity.py +++ /dev/null @@ -1,33 +0,0 @@ -from dataclasses import dataclass -from pathlib import Path -from typing import NotRequired, TypedDict, final - -from compose.src_path.entity import SrcPaths - - -class OrgDataYaml(TypedDict): - org: str - url: NotRequired[str] - - -class CfgDataYaml(TypedDict): - services: list[str] - volumes: NotRequired[list[str]] - orgs: list[OrgDataYaml] - - -@final -@dataclass(frozen=True, slots=True) -class OrgData: - org: str - url: str | None - - -@final -@dataclass(frozen=True, slots=True) -class CfgData: - src_paths: SrcPaths - name: str - services: frozenset[Path] - volumes: frozenset[Path] | None - orgs: frozenset[OrgData] diff --git a/src/compose/cfg/factory.py b/src/compose/cfg/factory.py deleted file mode 100644 index 2358042..0000000 --- a/src/compose/cfg/factory.py +++ /dev/null @@ -1,30 +0,0 @@ -from pathlib import Path -from typing import cast - -from compose.cfg.entity import CfgData, CfgDataYaml, OrgDataYaml -from compose.cfg.get import cfg_get_orgs -from compose.src_path.entity import SrcPaths -from compose.src_path.get import src_path_get_services, src_path_get_volumes -from compose.util import read_yml, validate_typed_dict - - -def cfg_data_yml_factory(file: Path) -> CfgDataYaml: - data = cast(CfgDataYaml, read_yml(file)) - validate_typed_dict(CfgDataYaml, data, file) - - orgs_key = "orgs" - for org in data[orgs_key]: - validate_typed_dict(OrgDataYaml, org, file, (orgs_key,)) - return data - - -def cfg_data_factory(src_paths: SrcPaths) -> CfgData: - data = cfg_data_yml_factory(src_paths.cfg_file) - vols = frozenset(src_path_get_volumes(src_paths, data)) - return CfgData( - src_paths, - src_paths.cfg_dir.name, - frozenset(src_path_get_services(src_paths, data)), - vols if vols else None, - frozenset(cfg_get_orgs(data)), - ) diff --git a/src/compose/cfg/get.py b/src/compose/cfg/get.py deleted file mode 100644 index 34fb454..0000000 --- a/src/compose/cfg/get.py +++ /dev/null @@ -1,83 +0,0 @@ -from collections.abc import Iterator -from typing import cast - -from compose.cfg.entity import CfgData, CfgDataYaml, OrgData -from compose.compose.entity import VolYaml -from compose.service.entity import Service, T_Compose -from compose.service.factory import services_yaml_factory -from compose.util import get_replace_name, read_yml - - -def cfg_get_orgs(data: CfgDataYaml) -> Iterator[OrgData]: - for org_data in data["orgs"]: - yield OrgData( - org_data["org"], - org_data.get("url"), - ) - - -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) - - -def cfg_get_services(cfg_data: CfgData) -> Iterator[tuple[str, Service]]: - for path in cfg_data.services: - data = services_yaml_factory(path) - # yield path.stem, Service.from_dict(data) - # @classmethod - command = data.get("command") - volumes = data.get("volumes") - entry = data.get("entrypoint") - - service = Service( - tuple(command) if command else None, - get_replace_name("org_name"), - tuple(entry) if entry else None, - data.get("environment"), - data["image"], - _get_labels(data), - None, - Service.get_nets(data), - "unless-stopped", - _get_sec_opts(data), - data.get("user"), - frozenset(volumes) if volumes else None, - ) - yield path.stem, service - - -def cfg_get_volumes(cfg_data: CfgData) -> Iterator[tuple[str, VolYaml]]: - vols = cfg_data.volumes - if vols is None: - return - for path in vols: - yield path.stem, cast(VolYaml, read_yml(path)) diff --git a/src/compose/compose/entity.py b/src/compose/compose/entity.py deleted file mode 100644 index c27e4a3..0000000 --- a/src/compose/compose/entity.py +++ /dev/null @@ -1,66 +0,0 @@ -from dataclasses import asdict, dataclass -from typing import Literal, NotRequired, TypedDict, final - -from compose.cfg import T_YamlDict -from compose.cfg.entity import CfgData -from compose.net.entities import Net, NetTraefik, NetYaml -from compose.service.entity import Service, ServiceYaml, TraefikService -from compose.util import to_yaml - -type VolYaml = dict[str, T_YamlDict] - - -class ComposeYaml(TypedDict): - name: str - services: dict[str, ServiceYaml] - networks: NotRequired[NetYaml] - volumes: NotRequired[dict[str, T_YamlDict]] - - -@final -@dataclass(frozen=True, slots=True) -class Compose: - cfg: CfgData - services: dict[str, Service] - networks: Net | None - volumes: VolYaml | None - - # @classmethod - # def from_dict(cls, cfg: CfgData, data: ComposeYaml) -> Self: - # # services = dict[str, ComposeService]() - # services = dict(_get_services_dict(data)) - # # vols = frozenset(_get_volumes_dict(data)) - # return cls( - # cfg, - # services, - # services_get_networks(services.values()), - # data.get("volumes"), - # ) - - 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) - - -# def _get_volumes_dict(data: ComposeYaml) -> Iterator[VolYaml]: -# vols = data.get("volumes") -# if vols is None: -# return -# for k, v in vols.items(): -# yield {k: v} - - -@final -@dataclass(frozen=True, slots=True) -class TraefikCompose: - cfg: CfgData - services: dict[Literal["traefik"], TraefikService] - networks: NetTraefik - volumes: None - - def as_yaml(self) -> str: - return to_yaml(asdict(self)) diff --git a/src/compose/compose/factory.py b/src/compose/compose/factory.py deleted file mode 100644 index f7afd06..0000000 --- a/src/compose/compose/factory.py +++ /dev/null @@ -1,38 +0,0 @@ -from compose.cfg.entity import CfgData -from compose.cfg.get import cfg_get_services, cfg_get_volumes -from compose.compose.entity import Compose, VolYaml - -# from compose.service.factory import get_traefik_service -from compose.service.get import services_get_networks - - -def compose_factory(cfg_data: CfgData) -> Compose: - services = dict(cfg_get_services(cfg_data)) - vols: VolYaml | None = dict(cfg_get_volumes(cfg_data)) - - return Compose( - cfg_data, - services, - services_get_networks(services.values()), - vols if vols else None, - ) - - -# def traefik_compose_factory(renders: Iterable[Rendered]) -> TraefikCompose: -# src_paths = src_paths_factory(TRAEFIK_PATH) -# cfg = cfg_data_factory(src_paths) -# # cfg = CfgData( -# # src_paths, -# # 'traefik', -# # frozenset((TRAEFIK_PATH.joinpath('traefik'),)), -# # None, -# # ) -# 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/compose/get.py b/src/compose/compose/get.py deleted file mode 100644 index 521b037..0000000 --- a/src/compose/compose/get.py +++ /dev/null @@ -1,26 +0,0 @@ -from collections.abc import Iterator - -from compose.compose.entity import Compose, TraefikCompose -from compose.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/dest_path/entity.py b/src/compose/dest_path/entity.py deleted file mode 100644 index e885d7d..0000000 --- a/src/compose/dest_path/entity.py +++ /dev/null @@ -1,11 +0,0 @@ -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/compose/dest_path/factory.py b/src/compose/dest_path/factory.py deleted file mode 100644 index b7f96a1..0000000 --- a/src/compose/dest_path/factory.py +++ /dev/null @@ -1,17 +0,0 @@ -from compose.cfg import DATA_ROOT -from compose.dest_path.entity import DestPaths -from compose.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/compose/net/entities.py b/src/compose/net/entities.py deleted file mode 100644 index 108703a..0000000 --- a/src/compose/net/entities.py +++ /dev/null @@ -1,45 +0,0 @@ -from dataclasses import dataclass -from typing import NotRequired, 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_dict(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/compose/net/factory.py b/src/compose/net/factory.py deleted file mode 100644 index 5850725..0000000 --- a/src/compose/net/factory.py +++ /dev/null @@ -1,21 +0,0 @@ -from compose.net.entities import Net, NetArgs -from compose.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/compose/rendered/entity.py b/src/compose/rendered/entity.py deleted file mode 100644 index 133424f..0000000 --- a/src/compose/rendered/entity.py +++ /dev/null @@ -1,18 +0,0 @@ -from dataclasses import dataclass -from pathlib import Path -from typing import final - -from compose.dest_path.entity import DestPaths -from compose.src_path.entity import SrcPaths -from compose.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/compose/rendered/factory.py b/src/compose/rendered/factory.py deleted file mode 100644 index d2f981b..0000000 --- a/src/compose/rendered/factory.py +++ /dev/null @@ -1,26 +0,0 @@ -from functools import reduce - -from compose.dest_path.factory import dest_paths_factory -from compose.rendered.entity import Rendered -from compose.template.entity import Template -from compose.template.get import template_get_proxy, template_get_vols -from compose.template.util import replace - - -def rendered_factory(template: Template) -> Rendered: - yml = template.compose.as_yaml() - if template.replace_args is not None: - yml = reduce( - lambda s, f: replace(f, 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/compose/rendered/get.py b/src/compose/rendered/get.py deleted file mode 100644 index 3494c97..0000000 --- a/src/compose/rendered/get.py +++ /dev/null @@ -1,13 +0,0 @@ -from collections.abc import Iterable, Iterator - -from compose.net.entities import NetArgs -from compose.net.factory import net_args_factory -from compose.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/compose/rendered/util.py b/src/compose/rendered/util.py deleted file mode 100644 index 9adee6c..0000000 --- a/src/compose/rendered/util.py +++ /dev/null @@ -1,38 +0,0 @@ -from pathlib import Path -from shutil import copyfile - -from compose.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/compose/service/entity.py b/src/compose/service/entity.py deleted file mode 100644 index 5a47acc..0000000 --- a/src/compose/service/entity.py +++ /dev/null @@ -1,128 +0,0 @@ -from abc import ABCMeta, abstractmethod -from dataclasses import dataclass -from typing import Literal, NotRequired, TypedDict, TypeVar, overload, override - -from compose.cfg import T_Primitive - -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: NotRequired[str] - entrypoint: NotRequired[list[str]] - environment: NotRequired[dict[str, T_Primitive]] - image: str - labels: NotRequired[list[str]] - logging: NotRequired[dict[str, str]] - networks: NotRequired[list[T_net]] - restart: NotRequired[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](metaclass=ABCMeta): - command: tuple[str, ...] | None - container_name: str - entrypoint: tuple[str, ...] | None - 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") - # entry = data.get("entrypoint") - # name = data.get("container_name") - # if name is None: - # raise KeyError - # return cls( - # tuple(command) if command else None, - # name, - # tuple(entry) if entry else None, - # 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 - - @staticmethod - @abstractmethod - 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) diff --git a/src/compose/service/factory.py b/src/compose/service/factory.py deleted file mode 100644 index 78d1dd6..0000000 --- a/src/compose/service/factory.py +++ /dev/null @@ -1,11 +0,0 @@ -from pathlib import Path -from typing import cast - -from compose.service.entity import ServiceYaml -from compose.util import read_yml, validate_typed_dict - - -def services_yaml_factory(path: Path): - data = cast(ServiceYaml, read_yml(path)) - validate_typed_dict(ServiceYaml, data, path) - return data diff --git a/src/compose/service/get.py b/src/compose/service/get.py deleted file mode 100644 index c0469f1..0000000 --- a/src/compose/service/get.py +++ /dev/null @@ -1,26 +0,0 @@ -from collections.abc import Iterable, Iterator -from typing import Literal - -from compose.net.entities import Net -from compose.net.factory import net_factory_re -from compose.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/compose/src_path/entity.py b/src/compose/src_path/entity.py deleted file mode 100644 index 115a12c..0000000 --- a/src/compose/src_path/entity.py +++ /dev/null @@ -1,19 +0,0 @@ -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/compose/src_path/get.py b/src/compose/src_path/get.py deleted file mode 100644 index 27ebac6..0000000 --- a/src/compose/src_path/get.py +++ /dev/null @@ -1,15 +0,0 @@ -from compose.cfg.entity import CfgDataYaml -from compose.src_path.entity import SrcPaths - - -def src_path_get_services(src_paths: SrcPaths, data: CfgDataYaml): - for path in data["services"]: - yield src_paths.cfg_dir.joinpath(path) - - -def src_path_get_volumes(src_paths: SrcPaths, data: CfgDataYaml): - vols = data.get("volumes") - if vols is None: - return - for path in vols: - yield src_paths.cfg_dir.joinpath(path) diff --git a/src/compose/template/entity.py b/src/compose/template/entity.py deleted file mode 100644 index c596cfb..0000000 --- a/src/compose/template/entity.py +++ /dev/null @@ -1,40 +0,0 @@ -from collections.abc import Iterator -from dataclasses import dataclass -from typing import final - -from compose.compose.entity import Compose, TraefikCompose -from compose.template.val_obj import ( - DataDir, - NameVal, - OrgVal, - RecordCls, - T_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) -> Iterator[T_RecordCls]: - 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/compose/template/factory.py b/src/compose/template/factory.py deleted file mode 100644 index 5b99cef..0000000 --- a/src/compose/template/factory.py +++ /dev/null @@ -1,44 +0,0 @@ -from collections.abc import Iterator - -from compose.cfg import DATA_ROOT -from compose.cfg.entity import CfgData, OrgData -from compose.compose.entity import Compose, TraefikCompose -from compose.compose.get import compose_get_volumes -from compose.template.entity import ReplaceArgs, Template -from compose.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 - - for org_data in cfg_data.orgs: - args = replace_args_factory( - cfg_data, - org_data, - ) - - yield Template( - compose, - args, - vols, - ) diff --git a/src/compose/template/get.py b/src/compose/template/get.py deleted file mode 100644 index f6ab888..0000000 --- a/src/compose/template/get.py +++ /dev/null @@ -1,38 +0,0 @@ -from collections.abc import Iterable -from functools import partial -from pathlib import Path - -from compose.net.entities import Net -from compose.template.entity import Template -from compose.template.util import replace - - -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 partial(replace, r_args.data) - 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 replace(r_args.name, net) diff --git a/src/compose/template/util.py b/src/compose/template/util.py deleted file mode 100644 index d32269b..0000000 --- a/src/compose/template/util.py +++ /dev/null @@ -1,5 +0,0 @@ -from compose.template.val_obj import T_RecordCls - - -def replace(record: T_RecordCls, string: str) -> str: - return str.replace(string, record.name, record.val.to_str()) diff --git a/src/compose/template/val_obj.py b/src/compose/template/val_obj.py deleted file mode 100644 index 6e4a35b..0000000 --- a/src/compose/template/val_obj.py +++ /dev/null @@ -1,100 +0,0 @@ -from dataclasses import dataclass -from pathlib import Path -from typing import Protocol, final - -from compose.util import get_replace_name - -# class RecordVal(ABC): -# @abstractmethod -# def to_str(self) -> str: -# pass - - -@final -class RecordVal(Protocol): - def to_str(self) -> str: ... - - -# TCo_RecordVal = TypeVar( -# "TCo_RecordVal", -# bound=RecordVal, -# covariant=True, -# ) -# TCon_RecordVal = TypeVar( -# "TCon_RecordVal", -# bound=RecordVal, -# contravariant=True, -# ) -@final -class T_RecordCls(Protocol): - # name: str - # val: RecordVal - - @property - def name(self) -> str: ... - - @property - def val(self) -> RecordVal: ... - - -@final -@dataclass(frozen=True, slots=True) -class RecordCls[T: RecordVal]: - name: str - val: T - - # @final - # class RecordClsProto(Protocol): - # name:str - # val: RecordVal - - -# def replace(self:RecordCls[TCo_RecordVal], string: str) -> str: -# return str.replace(string, self.name, self.val.to_str()) - - -@final -@dataclass(frozen=True, slots=True) -class OrgVal: - val: str | None - - def to_str(self) -> str: - if self.val is None: - return "personal" - return self.val - - def is_valid(self) -> bool: - return self.val is not None - - -@final -@dataclass(frozen=True, slots=True) -class NameVal: - val: str - - def to_str(self) -> str: - return self.val - - -@final -@dataclass(frozen=True, slots=True) -class DataDir: - path: Path - - def to_str(self) -> str: - return str(self.path) - - -@final -@dataclass(frozen=True, slots=True) -class Url: - sub_url: str | None - - def to_str(self) -> str: - if self.sub_url is None: - return "" - return ".".join([self.sub_url, "ccamper7", "net"]) - - -def Record[T: RecordVal](name: str, val: T) -> RecordCls[T]: - return RecordCls(get_replace_name(name), val) diff --git a/src/compose/util.py b/src/compose/util.py deleted file mode 100644 index 0bd9c85..0000000 --- a/src/compose/util.py +++ /dev/null @@ -1,77 +0,0 @@ -import re -from collections.abc import KeysView, Mapping -from pathlib import Path -from typing import Any, ClassVar, Protocol, cast, override - -import yaml - -from compose.cfg import T_PrimDict, T_Primitive, T_PrimVal, T_YamlDict - - -class VerboseSafeDumper(yaml.SafeDumper): - @override - def ignore_aliases(self, data: Any) -> bool: # pyright: ignore[reportExplicitAny, reportAny] - return True - - -def merge_dicts[T: Mapping[Any, Any]](dict1: T, dict2: T) -> T: - def _merge_dicts(dict1: T_PrimDict, dict2: T_PrimDict): - s1 = frozenset(dict1.keys()) - s2 = frozenset(dict2.keys()) - for k in s1.difference(s2): - yield k, dict1[k] - for k in s2.difference(s1): - yield k, dict2[k] - for k in s1.intersection(s2): - v1 = dict1[k] - v2 = dict2[k] - if isinstance(v1, dict) and isinstance(v2, dict): - yield k, dict[T_Primitive, T_PrimVal](_merge_dicts(v1, v2)) - continue - if isinstance(v1, list) and isinstance(v2, list): - 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) -> T_YamlDict: - with path.open("rt") as f: - return cast(T_YamlDict, yaml.safe_load(f)) - - -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()}}}" - - -class T_TypedDict(Protocol): - __required_keys__: ClassVar[frozenset[str]] - - def keys(self) -> KeysView[str]: ... - - -def validate_typed_dict( - typed_dict: type[T_TypedDict], - data: T_TypedDict, - path: Path | None = None, - pre: tuple[str, ...] | None = None, -) -> None: - req = typed_dict.__required_keys__.difference(data.keys()) - if not req: - return - if pre is None: - keys = (f'"{key}"' for key in req) - else: - key_pre = ".".join(pre) - keys = (f'"{key_pre}.{key}"' for key in req) - msg = f"key(s) ({', '.join(keys)}) not found" - if path is not None: - msg = f"{msg} in file {path!s}" - print(msg) - raise KeyError diff --git a/src/docker_compose/__init__.py b/src/docker_compose/__init__.py new file mode 100644 index 0000000..87ee1a3 --- /dev/null +++ b/src/docker_compose/__init__.py @@ -0,0 +1,36 @@ +from collections.abc import Iterator + +from docker_compose.cfg import CFG_ROOT, TRAEFIK_PATH +from docker_compose.compose.net_yaml import NetArgsYaml +from docker_compose.compose.rendered import Rendered +from docker_compose.util.yaml_util import to_yaml + + +def load_all() -> Iterator[Rendered]: + for path in CFG_ROOT.iterdir(): + if path == TRAEFIK_PATH: + continue + yield from Rendered.from_path(path) + + +def render_all() -> Iterator[str]: + for rendered in load_all(): + rendered.write() + rendered.write_bind_vols() + rendered.mk_bind_vols() + yield from rendered.proxy_nets + + +if __name__ == "__main__": + # renders = render_all() + nets = frozenset(render_all()) + traefik = next(Rendered.from_path(TRAEFIK_PATH)) + data = traefik.as_dict + data["networks"] = {net: NetArgsYaml(name=f"{net}_proxy") for net in nets} + cfg = traefik.cfg + data["services"]["traefik"]["networks"] = nets + template = cfg.pre_render(to_yaml(data)) + cfg.src_paths.compose_file.write(template) + cfg.dest_paths.compose_file.write(cfg.render(template)) + traefik.write_bind_vols() + traefik.mk_bind_vols() diff --git a/src/docker_compose/cfg/__init__.py b/src/docker_compose/cfg/__init__.py new file mode 100644 index 0000000..84d0e80 --- /dev/null +++ b/src/docker_compose/cfg/__init__.py @@ -0,0 +1,7 @@ +from pathlib import Path + +ROOT = Path("/nas") +DATA_ROOT = ROOT.joinpath("apps") +CFG_ROOT = ROOT.joinpath("cfg/templates") +TRAEFIK_PATH = CFG_ROOT.joinpath("traefik") +# TEMPLATE_DIR = CFG_ROOT.joinpath("templates") diff --git a/src/docker_compose/cfg/cfg_paths.py b/src/docker_compose/cfg/cfg_paths.py new file mode 100644 index 0000000..f191c5e --- /dev/null +++ b/src/docker_compose/cfg/cfg_paths.py @@ -0,0 +1,62 @@ +from collections.abc import Iterator +from dataclasses import dataclass +from itertools import chain +from shutil import copyfile +from typing import Self, final + +from docker_compose.cfg.compose_paths import ComposePaths +from docker_compose.cfg.dest_path import DestPaths +from docker_compose.cfg.org import OrgData +from docker_compose.cfg.src_path import SrcPaths + + +@final +@dataclass(frozen=True, slots=True) +class CfgData: + src_paths: SrcPaths + org_data: OrgData + compose_paths: ComposePaths + dest_paths: DestPaths + + @classmethod + def from_src_paths(cls, src_paths: SrcPaths) -> Iterator[Self]: + for org, args in src_paths.org_file.as_dict.items(): + org = OrgData.from_dict(src_paths.app, org, args) + dest = DestPaths.from_org(org) + yield cls( + src_paths, + org, + ComposePaths.from_iters( + src_paths.service_dir.files, + src_paths.vol_dir.files, + ), + dest, + ) + + def pre_render(self, data: str) -> str: + for func in chain( + self.org_data.pre_render_funcs, self.dest_paths.pre_render_funcs + ): + data = func(data) + return data + + def render(self, data: str) -> str: + for func in chain( + self.org_data.render_funcs, + self.dest_paths.render_funcs, + self.compose_paths.render_funcs, + ): + data = func(data) + return data + + def render_all(self, data: str) -> str: + for func in (self.pre_render, self.render): + # noinspection PyArgumentList + data = func(data) + return data + + def mk_compose_env(self) -> None: + src = self.src_paths.env_file + dest = self.dest_paths.env_file + if src.exists() and not dest.exists(): + _ = copyfile(src, dest) diff --git a/src/docker_compose/cfg/compose_paths.py b/src/docker_compose/cfg/compose_paths.py new file mode 100644 index 0000000..1ef48da --- /dev/null +++ b/src/docker_compose/cfg/compose_paths.py @@ -0,0 +1,112 @@ +from collections.abc import Callable, Iterable, Iterator, MutableMapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Self, cast, final, override + +import yaml + +from docker_compose.cfg.org import OrgData +from docker_compose.cfg.record import Record, RecordCls, RecordName +from docker_compose.compose.services_yaml import ServiceYamlRead +from docker_compose.compose.volume_yaml import VolYaml +from docker_compose.util.Ts import T_YamlRW +from docker_compose.util.yaml_util import path_to_typed, read_yaml + + +@final +@dataclass(frozen=True, slots=True) +class ServiceValNew: + val: Path + + @override + def __str__(self) -> str: + return str(RecordName(self.val.stem)) + + @property + def replace(self) -> Record[Self, str]: + return Record(self, self.val.stem) + + +@final +@dataclass(frozen=True, slots=True) +class ServiceVal(RecordCls[ServiceValNew]): + old = RecordName("service") + + @property + def stage_two(self): + return self.new.replace + + +@final +@dataclass(frozen=True, slots=True) +class ServicePath: + fqdn = Record[RecordName, str]( + RecordName("fqdn"), + f"{OrgData.org_app.old!s}_{ServiceVal.old!s}", + ) + + path: Path + replace: ServiceVal = field(init=False) + + def __post_init__(self): + setter = super(ServicePath, self).__setattr__ + setter("replace", ServiceVal(ServiceValNew(self.path))) + + @property + def as_dict(self) -> ServiceYamlRead: + with self.path.open("rt") as f: + data_str = f.read() + for func in self.pre_render_funcs: + data_str = func(data_str) + data_dict: T_YamlRW = yaml.safe_load(data_str) # pyright: ignore[reportAny] + if not isinstance(data_dict, MutableMapping): + raise TypeError + path_to_typed(ServiceYamlRead, data_dict, self.path) + return data_dict # pyright: ignore[reportReturnType] + + @property + def pre_render_funcs(self) -> Iterator[Callable[[str], str]]: + yield self.fqdn + yield self.replace + + @property + def render_funcs(self) -> Iterator[Callable[[str], str]]: + yield self.replace.stage_two + + +@final +@dataclass(frozen=True, slots=True) +class VolumePath: + path: Path + + @property + def as_k_v(self) -> tuple[str, VolYaml]: + return self.path.stem, self.as_dict + + @property + def as_dict(self) -> VolYaml: + return cast(VolYaml, cast(object, read_yaml(self.path))) + + +@final +@dataclass(frozen=True, slots=True) +class ComposePaths: + services: frozenset[ServicePath] + volumes: frozenset[VolumePath] + + @classmethod + def from_iters(cls, services: Iterable[ServicePath], volumes: Iterable[VolumePath]): + return cls( + frozenset(services), + frozenset(volumes), + ) + + @property + def volumes_k_v(self) -> Iterator[tuple[str, VolYaml]]: + for path in self.volumes: + yield path.as_k_v + + @property + def render_funcs(self) -> Iterator[Callable[[str], str]]: + for path in self.services: + yield from path.render_funcs diff --git a/src/docker_compose/cfg/dest_path.py b/src/docker_compose/cfg/dest_path.py new file mode 100644 index 0000000..54fd07b --- /dev/null +++ b/src/docker_compose/cfg/dest_path.py @@ -0,0 +1,81 @@ +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from os import sep +from pathlib import Path +from typing import Self, final + +from docker_compose.cfg import DATA_ROOT +from docker_compose.cfg.org import AppVal, OrgData, OrgVal +from docker_compose.cfg.record import Record, RecordName + + +@final +@dataclass(frozen=True, slots=True) +class ComposeFileRendered: + path: Path + + def write(self, data: str) -> None: + print(self.path) + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open("wt") as f: + _ = f.write(data) + + +# @final +# @dataclass(frozen=True, slots=True) +# class DataDirReplace(RecordCls[Path]): +# old = RecordName("data") +# +# +# @final +# @dataclass(frozen=True, slots=True) +# class DataDir: +# val: Path +# replace: DataDirReplace = field(init=False) +# +# def __post_init__(self) -> None: +# setter = super().__setattr__ +# setter("replace", DataDirReplace(self.val)) +# +# @classmethod +# def from_org(cls, org: OrgData) -> Self: +# cls(DATA_ROOT.joinpath(org.org.val, org.app.val)) +@final +@dataclass(frozen=True, slots=True) +class DestPaths: + data_root = Record[RecordName, Path](RecordName("data_root"), DATA_ROOT) + data_path = Record[RecordName, str]( + RecordName("data"), + sep.join((str(data_root.old), str(OrgVal.old), str(AppVal.old))), + ) + data_dir: Path + env_file: Path = field(init=False) + compose_file: ComposeFileRendered = field(init=False) + + def __post_init__(self) -> None: + setter = super(DestPaths, self).__setattr__ + path_join = self.data_dir.joinpath + setter("env_file", path_join(".env")) + setter("compose_file", ComposeFileRendered(path_join("docker-compose.yml"))) + + @classmethod + def from_org(cls, org: OrgData) -> Self: + return cls.from_path(DATA_ROOT.joinpath(org.org.val, org.app.val)) + + @classmethod + def from_path(cls, path: Path) -> Self: + return cls(path) + + # def mk_compose_dir(self) -> None: + # folder = self.data_dir + # if folder.exists(): + # return + # folder.mkdir(parents=True) + + @property + def pre_render_funcs(self) -> Iterator[Callable[[str], str]]: + yield self.data_path + + @property + def render_funcs(self) -> Iterator[Callable[[str], str]]: + yield self.data_root diff --git a/src/docker_compose/cfg/org.py b/src/docker_compose/cfg/org.py new file mode 100644 index 0000000..6b7ce5c --- /dev/null +++ b/src/docker_compose/cfg/org.py @@ -0,0 +1,104 @@ +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Callable, ClassVar, Self, final, override + +from docker_compose.cfg.org_yaml import OrgDataYaml +from docker_compose.cfg.record import Record, RecordCls, RecordName + + +@final +@dataclass(frozen=True, slots=True) +class OrgVal(RecordCls[str]): + old: ClassVar[RecordName] = RecordName("org") + + +@final +@dataclass(frozen=True, slots=True) +class Org: + val: str + replace: OrgVal = field(init=False) + + def __post_init__(self) -> None: + setter = super(Org, self).__setattr__ + setter("replace", OrgVal(self.val)) + + +@final +@dataclass(frozen=True, slots=True) +class AppVal(RecordCls[str]): + old: ClassVar[RecordName] = RecordName("name") + + +@final +@dataclass(frozen=True, slots=True) +class App: + val: str + replace: AppVal = field(init=False) + + def __post_init__(self) -> None: + setter = super(App, self).__setattr__ + setter("replace", AppVal(self.val)) + + +@final +@dataclass(frozen=True, slots=True) +class UrlValNew: + val: str | None + + @override + def __str__(self) -> str: + if not self.val: + return "" + return ".".join((self.val, "ccamper7", "net")) + + +@final +@dataclass(frozen=True, slots=True) +class UrlVal(RecordCls[UrlValNew]): + old = RecordName("url") + + +@final +@dataclass(frozen=True, slots=True) +class Url: + val: str | None + replace: UrlVal = field(init=False) + + def __post_init__(self) -> None: + setter = super(Url, self).__setattr__ + setter("replace", UrlVal(UrlValNew(self.val))) + + +@final +@dataclass(frozen=True, slots=True) +class OrgData: + org_app = Record[RecordName, str]( + RecordName(f"{OrgVal.old.val}_{AppVal.old.val}"), + f"{OrgVal.old!s}_{AppVal.old!s}", + ) + app: App + org: Org + url: Url + + @classmethod + def from_dict(cls, app: str, org: str, data: OrgDataYaml) -> Self: + return cls( + App(app), + Org(org), + Url(data.get("url")), + ) + + @property + def render_funcs(self) -> Iterator[Callable[[str], str]]: + yield self.app.replace + yield self.org.replace + yield self.url.replace + + @property + def pre_render_funcs(self) -> Iterator[Callable[[str], str]]: + yield self.org_app + + # def render_yaml(self, yaml: str) -> str: + # for func in self.render_funcs: + # yaml = func(yaml) + # return yaml diff --git a/src/docker_compose/cfg/org_yaml.py b/src/docker_compose/cfg/org_yaml.py new file mode 100644 index 0000000..3634301 --- /dev/null +++ b/src/docker_compose/cfg/org_yaml.py @@ -0,0 +1,9 @@ +from typing import Literal, NotRequired, TypedDict + + +class OrgDataYaml(TypedDict): + # org: str + url: NotRequired[str] + + +type OrgYaml = dict[Literal["ccamper7", "c4", "stryten"], OrgDataYaml] diff --git a/src/docker_compose/cfg/record.py b/src/docker_compose/cfg/record.py new file mode 100644 index 0000000..d3b3a02 --- /dev/null +++ b/src/docker_compose/cfg/record.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from typing import ClassVar, Protocol, final, override + + +class String(Protocol): + @override + def __str__(self) -> str: ... + + +@final +@dataclass(frozen=True, slots=True) +class RecordName: + val: str + + @override + def __str__(self) -> str: + return f"${{_{str(self.val).upper()}}}" + + # def replace(self, string: str) -> str: + # return string.replace(str(self), str(self)) + + +@dataclass(frozen=True, slots=True) +class RecordCls[T_New: String]: + old: ClassVar[String] + new: T_New + + def __call__(self, string: str) -> str: + return string.replace(str(self.old), str(self.new)) + + +@dataclass(frozen=True, slots=True) +class Record[T_Old: String, T_New: String]: + old: T_Old + new: T_New + + def __call__(self, string: str) -> str: + return string.replace(str(self.old), str(self.new)) diff --git a/src/docker_compose/cfg/src_path.py b/src/docker_compose/cfg/src_path.py new file mode 100644 index 0000000..9c4a57a --- /dev/null +++ b/src/docker_compose/cfg/src_path.py @@ -0,0 +1,101 @@ +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Self, cast, final + +from docker_compose.cfg.compose_paths import ServicePath, VolumePath +from docker_compose.cfg.org_yaml import OrgYaml +from docker_compose.util.Ts import T_YamlDict, T_YamlRW +from docker_compose.util.yaml_util import read_yaml, write_yaml + +YAML_EXTS = frozenset((".yml", ".yaml")) + + +class ComposeFileTemplate(Path): + def write_dict(self, data: T_YamlDict) -> None: + write_yaml(data, self) + + def write(self, data: str) -> None: + with self.open("wt") as f: + _ = f.write(data) + + +class OrgFile(Path): + @property + def as_dict(self) -> OrgYaml: + return cast(OrgYaml, cast(object, read_yaml(self))) + + +class YamlDir(Path): + @property + def yaml_files(self) -> Iterator[Path]: + if not self: + raise FileNotFoundError(self) + for service in self.iterdir(): + if service.suffix not in YAML_EXTS: + continue + yield service + + def __bool__(self) -> bool: + return self.exists() + + +class CfgDir(YamlDir): + @property + def cfg_file(self) -> OrgFile: + for file in self.yaml_files: + if file.stem != "cfg": + continue + return OrgFile(file) + raise FileNotFoundError(self.joinpath("cfg.y(a)ml")) + + +class ServiceDir(YamlDir): + @property + def files(self) -> Iterator[ServicePath]: + for file in self.yaml_files: + yield ServicePath(file) + + +class VolumesDir(YamlDir): + @property + def files(self) -> Iterator[VolumePath]: + try: + for file in self.yaml_files: + yield VolumePath(file) + except FileNotFoundError: + return + + +class VolumeData(Path): + def write(self, data: T_YamlRW) -> None: + write_yaml(data, self) + + +@final +@dataclass(frozen=True, slots=True) +class SrcPaths: + cfg_dir: CfgDir + org_file: OrgFile + env_file: Path + service_dir: ServiceDir + vol_dir: VolumesDir + compose_file: ComposeFileTemplate + volume_data: VolumeData + + @classmethod + def from_path(cls, src: Path) -> Self: + cfg_dir = CfgDir(src) + return cls( + cfg_dir, + cfg_dir.cfg_file, + src.joinpath(".env"), + ServiceDir(src.joinpath("services")), + VolumesDir(src.joinpath("volumes")), + ComposeFileTemplate(src.joinpath("docker-compose.yml")), + VolumeData(src.joinpath("volume_paths.yml")), + ) + + @property + def app(self) -> str: + return self.cfg_dir.stem diff --git a/src/docker_compose/compose/__init__.py b/src/docker_compose/compose/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/src/docker_compose/compose/compose.py b/src/docker_compose/compose/compose.py new file mode 100644 index 0000000..bc24cb7 --- /dev/null +++ b/src/docker_compose/compose/compose.py @@ -0,0 +1,69 @@ +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Self + +from docker_compose.cfg.cfg_paths import CfgData +from docker_compose.cfg.org import OrgData +from docker_compose.cfg.src_path import SrcPaths +from docker_compose.compose.compose_yaml import ComposeYaml +from docker_compose.compose.net import Net +from docker_compose.compose.services import Service +from docker_compose.compose.volume_yaml import VolYaml +from docker_compose.util.yaml_util import to_yaml + + +@dataclass(slots=True) +class Compose: + cfg: CfgData + services: frozenset[Service] + networks: Net + volumes: dict[str, VolYaml] + # replace_args: ReplaceArgs + + @classmethod + def from_path(cls, path: Path) -> Iterator[Self]: + return cls.from_src_path(SrcPaths.from_path(path)) + + @classmethod + def from_src_path(cls, src_paths: SrcPaths) -> Iterator[Self]: + for cfg in CfgData.from_src_paths(src_paths): + yield cls.from_cfg(cfg) + + @classmethod + def from_cfg(cls, cfg_data: CfgData) -> Self: + # services = { + # path.stem: Service.from_dict(path, data) + # for path, data in cfg_data.compose_paths.services_k_v + # } + services = frozenset( + Service.from_path(path) for path in cfg_data.compose_paths.services + ) + return cls( + cfg_data, + services, + Net.from_service_list(services), + dict(cfg_data.compose_paths.volumes_k_v), + ) + + # @property + # def app(self) -> str: + # return self.cfg.src_paths.app + + @property + def as_dict(self) -> ComposeYaml: + return ComposeYaml( + name=str(OrgData.org_app.old), + services={ + service.service_name: service.as_dict for service in self.services + }, + networks=self.networks.as_dict, + volumes=self.volumes, + ) + + @property + def as_template(self) -> str: + return self.cfg.pre_render(to_yaml(self.as_dict)) + + def write_template(self): + self.cfg.src_paths.compose_file.write(self.as_template) diff --git a/src/docker_compose/compose/compose_yaml.py b/src/docker_compose/compose/compose_yaml.py new file mode 100644 index 0000000..6f95b4b --- /dev/null +++ b/src/docker_compose/compose/compose_yaml.py @@ -0,0 +1,12 @@ +from typing import TypedDict + +from docker_compose.compose.net_yaml import NetYaml +from docker_compose.compose.services_yaml import ServiceYamlWrite +from docker_compose.compose.volume_yaml import VolYaml + + +class ComposeYaml(TypedDict): + name: str + services: dict[str, ServiceYamlWrite] + networks: NetYaml | None + volumes: dict[str, VolYaml] | None diff --git a/src/docker_compose/compose/net.py b/src/docker_compose/compose/net.py new file mode 100644 index 0000000..81e3393 --- /dev/null +++ b/src/docker_compose/compose/net.py @@ -0,0 +1,61 @@ +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from typing import Self, final, override + +from docker_compose.cfg.org import OrgData +from docker_compose.compose.net_yaml import NetArgsYaml, NetYaml +from docker_compose.compose.services import Service + + +@final +@dataclass(frozen=True, slots=True) +class NetArgs: + name: str + + @property + def as_dict(self) -> NetArgsYaml: + yaml_dict = NetArgsYaml( + name=str(self), + ) + if self.external: + yaml_dict["external"] = self.external + return yaml_dict + + # @property + # def as_key_dict(self) -> tuple[str, NetArgsYaml]: + # return str(self), self.as_dict + + @override + def __str__(self) -> str: + return f"{OrgData.org_app.old!s}_{self.name}" + + @property + def external(self) -> bool: + return self.name == "proxy" + + +@final +@dataclass +class Net: + data: frozenset[NetArgs] + + @classmethod + def from_service_list(cls, args: Iterable[Service]) -> Self: + return cls.from_list( + frozenset(net for service in args for net in service.networks) + ) + + @classmethod + def from_list(cls, args: frozenset[str]) -> Self: + return cls(frozenset(NetArgs(arg) for arg in args)) + + @property + def as_dict(self) -> NetYaml: + return {net.name: net.as_dict for net in self.data} + + @property + def proxys(self) -> Iterator[str]: + for net in self.data: + if not net.external: + continue + yield str(net)[:-6] diff --git a/src/docker_compose/compose/net_yaml.py b/src/docker_compose/compose/net_yaml.py new file mode 100644 index 0000000..fe18fc3 --- /dev/null +++ b/src/docker_compose/compose/net_yaml.py @@ -0,0 +1,9 @@ +from typing import NotRequired, TypedDict + + +class NetArgsYaml(TypedDict): + name: str + external: NotRequired[bool] + + +type NetYaml = dict[str, NetArgsYaml] diff --git a/src/docker_compose/compose/rendered.py b/src/docker_compose/compose/rendered.py new file mode 100644 index 0000000..4253b84 --- /dev/null +++ b/src/docker_compose/compose/rendered.py @@ -0,0 +1,54 @@ +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import final + +from docker_compose.cfg import ROOT +from docker_compose.compose.compose import Compose + + +@final +@dataclass(slots=True) +class Rendered(Compose): + # @property + # def org(self) -> str: + # return self.cfg.org_data.org + + @property + def bind_vols(self) -> Iterator[Path]: + root = str(ROOT) + for app in self.services: + for vol in app.volumes: + path = self.cfg.render_all(vol.split(":", 1)[0]) + if not path.startswith(root): + continue + path = Path(path) + if path.is_file(): + continue + yield path + + # @property + # def missing_bind_vols(self) -> Iterator[Path]: + # for path in self.bind_vols: + # if path.exists(): + # continue + # yield path + + def mk_bind_vols(self) -> None: + for path in self.bind_vols: + path.mkdir(parents=True, exist_ok=True) + + def write_bind_vols(self) -> None: + self.cfg.src_paths.volume_data.write(map(str, self.bind_vols)) + + @property + def proxy_nets(self) -> Iterator[str]: + for net in self.networks.proxys: + yield self.cfg.render_all(net) + + @property + def as_rendered(self) -> str: + return self.cfg.render(self.as_template) + + def write(self) -> None: + self.cfg.dest_paths.compose_file.write(self.as_rendered) diff --git a/src/docker_compose/compose/services.py b/src/docker_compose/compose/services.py new file mode 100644 index 0000000..9052998 --- /dev/null +++ b/src/docker_compose/compose/services.py @@ -0,0 +1,102 @@ +from dataclasses import dataclass +from typing import Self, final, override + +from docker_compose.cfg.compose_paths import ServicePath, ServiceVal +from docker_compose.cfg.org import OrgData, UrlVal +from docker_compose.compose.services_yaml import ( + HealthCheck, + ServiceYamlRead, + ServiceYamlWrite, +) +from docker_compose.util.Ts import T_Primitive + + +@final +@dataclass(frozen=True, slots=True) +class Service: + _traefik_labels = frozenset( + ( + f"traefik.http.routers.{OrgData.org_app.old!s}.rule=Host(`{UrlVal.old!s}`)", + f"traefik.http.routers.{OrgData.org_app.old!s}.entrypoints=websecure", + f"traefik.docker.network={OrgData.org_app.old!s}_proxy", + f"traefik.http.routers.{OrgData.org_app.old!s}.tls.certresolver=le", + ) + ) + + @override + def __hash__(self) -> int: + return hash(self.service_name) + + @property + def service_name(self) -> str: + return self.service_val.new.val.stem + + _sec_opts = frozenset(("no-new-privileges:true",)) + # service_name: str + service_val: ServiceVal + command: tuple[str, ...] + entrypoint: tuple[str, ...] + environment: dict[str, T_Primitive] + image: str + labels: frozenset[str] + logging: dict[str, str] + networks: frozenset[str] + restart: str + security_opt: frozenset[str] + user: str | None + volumes: frozenset[str] + shm_size: str | None + depends_on: frozenset[str] + healthcheck: HealthCheck | None + ports: frozenset[str] + + @classmethod + def from_path(cls, path: ServicePath) -> Self: + return cls.from_dict(path.replace, path.as_dict) + + @classmethod + def from_dict(cls, service_val: ServiceVal, data: ServiceYamlRead) -> Self: + # helper = ServiceYamlProps(data) + labels = frozenset(data.get("labels", ())) + # ports = (f'"{p}"' for p in data.get("ports", ())) + return cls( + service_val, + tuple(data.get("command", ())), + tuple(data.get("entrypoint", ())), + data.get("environment", {}), + data["image"], + cls._traefik_labels.union(labels) + if "traefik.enable=true" in labels + else labels, + data.get("logging", {}), + frozenset(data.get("networks", ())), + "unless-stopped", + cls._sec_opts.union(data.get("security_opt", [])), + data.get("user"), + frozenset(data.get("volumes", ())), + data.get("shm_size"), + frozenset(data.get("depends_on", ())), + data.get("healthcheck"), + frozenset(data.get("ports", ())), + ) + + @property + def as_dict(self) -> ServiceYamlWrite: + return ServiceYamlWrite( + command=self.command, + entrypoint=self.entrypoint, + environment=self.environment, + image=self.image, + labels=self.labels, + logging=self.logging, + networks=self.networks, + security_opt=self.security_opt, + user=self.user, + volumes=self.volumes, + container_name=self.service_val(ServicePath.fqdn.new), + restart=self.restart, + shm_size=self.shm_size, + depends_on=self.depends_on, + healthcheck=self.healthcheck, + ports=self.ports, + ) diff --git a/src/docker_compose/compose/services_yaml.py b/src/docker_compose/compose/services_yaml.py new file mode 100644 index 0000000..0f0d17c --- /dev/null +++ b/src/docker_compose/compose/services_yaml.py @@ -0,0 +1,47 @@ +from typing import NotRequired, TypedDict + +from docker_compose.util.Ts import T_Primitive + + +class HealthCheck(TypedDict): + test: list[str] | str + interval: NotRequired[str] + timeout: NotRequired[str] + retries: NotRequired[int] + start_period: NotRequired[str] + + +class ServiceYamlRead(TypedDict): + command: NotRequired[list[str]] + entrypoint: NotRequired[list[str]] + environment: NotRequired[dict[str, T_Primitive]] + image: str + labels: NotRequired[list[str]] + logging: NotRequired[dict[str, str]] + networks: NotRequired[list[str]] + security_opt: NotRequired[list[str]] + user: NotRequired[str] + volumes: NotRequired[list[str]] + shm_size: NotRequired[str] + depends_on: NotRequired[list[str]] + healthcheck: NotRequired[HealthCheck] + ports: NotRequired[list[str]] + + +class ServiceYamlWrite(TypedDict): + command: tuple[str, ...] + entrypoint: tuple[str, ...] + environment: dict[str, T_Primitive] + image: str + labels: frozenset[str] + logging: dict[str, str] + networks: frozenset[str] + security_opt: frozenset[str] + user: str | None + volumes: frozenset[str] + container_name: str + restart: str + shm_size: str | None + depends_on: frozenset[str] + healthcheck: HealthCheck | None + ports: frozenset[str] diff --git a/src/docker_compose/compose/volume_yaml.py b/src/docker_compose/compose/volume_yaml.py new file mode 100644 index 0000000..33974ec --- /dev/null +++ b/src/docker_compose/compose/volume_yaml.py @@ -0,0 +1,3 @@ +from docker_compose.util.Ts import T_YamlDict + +type VolYaml = dict[str, T_YamlDict] diff --git a/src/docker_compose/compose/volumes.py b/src/docker_compose/compose/volumes.py new file mode 100644 index 0000000..473a0f4 diff --git a/src/docker_compose/util/Ts.py b/src/docker_compose/util/Ts.py new file mode 100644 index 0000000..73cc698 --- /dev/null +++ b/src/docker_compose/util/Ts.py @@ -0,0 +1,99 @@ +from collections.abc import ItemsView, Iterator, KeysView, MutableMapping, Set +from types import GenericAlias, UnionType +from typing import ( + ClassVar, + Never, + Protocol, + TypeAliasType, + cast, + get_args, + get_origin, + overload, +) + + +class TypedYamlDict[K: object, V: object](Protocol): + def __getitem__(self, key: str | K, /) -> V: ... + # def __setitem__(self, key: str, value: V, /) -> V: ... + def __delitem__(self, key: Never | K, /) -> None: ... + def __contains__(self, key: K, /) -> bool: ... + def __iter__(self) -> Iterator[K]: ... + def __len__(self) -> int: ... + def keys(self) -> KeysView[K]: ... + def items(self) -> ItemsView[K, V]: ... + def pop(self, key: Never | K, /) -> V: ... + + # def popitem(self) -> tuple[K, V]: ... + + # def clear(self) -> None: ... + + __required_keys__: ClassVar[frozenset[str]] + __optional_keys__: ClassVar[frozenset[str]] + + +# class Test(TypedDict): +# var: str +# +# +# x = Test(var="test") +# +# +# def is_typed_dict_test(obj: TypedYamlDict[object, object]) -> None: +# print(obj) +# pass +# +# +# is_typed_dict_test(x) + +type T_Primitive = None | bool | int | str + +type T_PrimIters = tuple[T_Prim, ...] | list[T_Prim] | Set[T_Prim] | Iterator[T_Prim] +type T_PrimDict = MutableMapping[T_Primitive, T_Prim] +type T_Prim = T_Primitive | T_PrimIters | T_PrimDict + +type T_YamlIters = tuple[T_Yaml, ...] | list[T_Yaml] | Set[T_Yaml] | Iterator[T_Yaml] +type T_YamlDict = MutableMapping[str, T_Yaml] +type T_YamlRW = T_YamlIters | T_YamlDict +type T_Yaml = T_Primitive | T_YamlRW + + +type T_YamlPostDict = TypedYamlDict[str, T_YamlPost] +type T_YamlPostRes = tuple[T_YamlPost, ...] | T_YamlPostDict +type T_YamlPost = T_Primitive | T_YamlPostRes + + +def get_union_types(annotations: UnionType) -> Iterator[type]: + for annotation in get_args(annotations): # pyright: ignore[reportAny] + if isinstance(annotation, TypeAliasType): + annotation = annotation.__value__ # pyright: ignore[reportAny] + if isinstance(annotation, UnionType): + yield from get_union_types(annotation) + continue + yield get_types(annotation) # pyright: ignore[reportAny] + + +@overload +def get_types(annotation: UnionType) -> tuple[type]: + pass + + +@overload +def get_types(annotation: GenericAlias) -> type: + pass + + +@overload +def get_types(annotation: TypeAliasType) -> type | tuple[type, ...]: + pass + + +def get_types( + annotation: TypeAliasType | GenericAlias | UnionType, +) -> type | tuple[type, ...]: + if isinstance(annotation, TypeAliasType): + annotation = annotation.__value__ # pyright: ignore[reportAny] + if isinstance(annotation, GenericAlias): + return get_origin(annotation) + if isinstance(annotation, UnionType): + return tuple(get_union_types(annotation)) + return cast(type, annotation) # pyright: ignore[reportInvalidCast] diff --git a/src/docker_compose/util/__init__.py b/src/docker_compose/util/__init__.py new file mode 100644 index 0000000..fbbdd3b --- /dev/null +++ b/src/docker_compose/util/__init__.py @@ -0,0 +1,29 @@ +# from collections.abc import Iterator, Mapping +# from typing import Any, cast +# +# from docker_compose.util.Ts import T_PrimDict, T_Primitive, T_PrimVal +# +# +# def merge_dicts[T: Mapping[Any, Any]](dict1: T, dict2: T) -> T: +# def _merge_dicts( +# _dict1: T_PrimDict, _dict2: T_PrimDict +# ) -> Iterator[tuple[T_Primitive, T_PrimVal]]: +# s1 = frozenset(_dict1.keys()) +# s2 = frozenset(_dict2.keys()) +# for k in s1.difference(s2): +# yield k, _dict1[k] +# for k in s2.difference(s1): +# yield k, _dict2[k] +# for k in s1.intersection(s2): +# v1 = _dict1[k] +# v2 = _dict2[k] +# if isinstance(v1, dict) and isinstance(v2, dict): +# yield k, dict[T_Primitive, T_PrimVal](_merge_dicts(v1, v2)) +# continue +# if isinstance(v1, list) and isinstance(v2, list): +# yield k, list(frozenset(v1).union(v2)) +# continue +# raise Exception("merge error") +# +# return cast(T, dict(_merge_dicts(dict1, dict2))) +# diff --git a/src/docker_compose/util/yaml_util.py b/src/docker_compose/util/yaml_util.py new file mode 100644 index 0000000..35efe20 --- /dev/null +++ b/src/docker_compose/util/yaml_util.py @@ -0,0 +1,157 @@ +import re +from collections.abc import Iterator, MutableMapping, Set +from pathlib import Path +from typing import cast, get_type_hints, is_typeddict, override + +import yaml + +from docker_compose.util.Ts import ( + T_YamlDict, + T_YamlIters, + T_YamlPost, + T_YamlPostDict, + T_YamlPostRes, + T_YamlRW, + TypedYamlDict, + get_types, +) + +# class TypedYamlDict[K: object, V: object](Protocol): +# def __getitem__(self, key: K, /) -> V: ... +# # def __setitem__(self, key: K, value: V, /) -> V: ... +# def __delitem__(self, key: K, /) -> V: ... +# def __contains__(self, key: K, /) -> bool: ... +# def __iter__(self) -> Iterator[K]: ... +# def __len__(self) -> int: ... +# def keys(self) -> KeysView[K]: ... +# def items(self) -> ItemsView[K, V]: ... +# def pop(self, key: K, /) -> V: ... +# +# # def popitem(self) -> tuple[K, V]: ... +# +# # def clear(self) -> None: ... +# +# __required_keys__: ClassVar[frozenset[str]] +# __optional_keys__: ClassVar[frozenset[str]] + + +class VerboseSafeDumper(yaml.SafeDumper): + @override + def ignore_aliases(self, data: object) -> bool: + return True + + +def yaml_prep(data: T_YamlRW) -> T_YamlPostRes: + if isinstance(data, MutableMapping): + return dict_prep(data) + if isinstance(data, (tuple, list)): + return tuple(list_prep(data)) + res = tuple(list_prep(data)) + try: + return tuple(sorted(res)) # pyright: ignore[reportArgumentType, reportUnknownArgumentType, reportUnknownVariableType] + except TypeError: + return res + + +def list_prep(data: T_YamlIters) -> Iterator[T_YamlPost]: + for v in data: + if isinstance(v, (MutableMapping, tuple, list, Set, Iterator)): + yield yaml_prep(v) + continue + if v: + yield v + continue + if isinstance(v, bool): + yield v + continue + + +def dict_prep(data: T_YamlDict) -> T_YamlPostDict: + keys = tuple(data.keys()) + for k in keys: + v = data[k] + if isinstance(v, (MutableMapping, tuple, list, Set, Iterator)): + data[k] = v = yaml_prep(v) # pyright: ignore[reportArgumentType] + + if v: + continue + if isinstance(v, bool): + continue + del data[k] + return cast(T_YamlPostDict, cast(object, data)) + + +def to_yaml(data: T_YamlRW) -> str: + dict_ = yaml_prep(data) + res = yaml.dump(dict_, Dumper=VerboseSafeDumper) + res = re.sub(r"(^\s?-)", r" \g<1>", res, flags=re.MULTILINE) + return re.sub(r"(\W*?)(\d+:\d+)", r'\g<1>"\g<2>"', res, flags=re.MULTILINE) + + +def write_yaml( + data: T_YamlRW, + path: Path, +) -> None: + with path.open("wt") as f: + _ = f.write(to_yaml(data)) + + +def read_yaml(path: Path) -> T_YamlPostRes: + with path.open("rt") as f: + return yaml.safe_load(f) # pyright: ignore[reportAny] + + +def read_typed_yaml[T: TypedYamlDict[object, object]]( + type_: type[T], + path: Path, +) -> T: + with path.open("rt") as f: + data: T_YamlDict = yaml.safe_load(f) # pyright: ignore[reportAny] + path_to_typed(type_, data, path) + return cast(T, data) # pyright: ignore[reportInvalidCast] + + +def path_to_typed( + type_: type[TypedYamlDict[object, object]], + data: T_YamlDict, + path: Path, +) -> None: + try: + validate_typed_dict(type_, data) + except (KeyError, TypeError) as e: + e.add_note(f"path: {path!s}") + raise e + + +def validate_typed_dict( + t: type[TypedYamlDict[object, object]], + data: T_YamlDict, +) -> None: + keys = frozenset(data.keys()) + missing = t.__required_keys__.difference(keys) + if missing: + raise KeyError(f"missing required key(s): {', '.join(missing)}") + extra = keys.difference(t.__required_keys__, t.__optional_keys__) + if extra: + raise KeyError(f"extra key(s): {', '.join(map(str, extra))}") + hints = get_type_hints(t) + for key, val in data.items(): + t2 = hints[key] # pyright: ignore[reportAny] + if is_typeddict(t2): # pyright: ignore[reportAny] + validate_typed_dict(t2, cast(T_YamlDict, val)) # pyright: ignore[reportAny] + continue + + # try: + if not isinstance(val, get_types(t2)): # pyright: ignore[reportAny] + raise TypeError( + f"key: {key} expected *{type(t2).__name__}*, got *{type(val).__name__}*" # pyright: ignore[reportAny] + ) + + # valid = isinstance(val, get_types(t2)) + # except TypeError: + # valid = isinstance(val, get_origin(t2)) + # if not valid: + # raise TypeError( + # f"key: {key} expected *{type(t2).__name__}*, got *{type(val).__name__}*" + # ) + # yield key, val diff --git a/uv.lock b/uv.lock old mode 100644 new mode 100755 index 6ae6dac..83d7a83 --- a/uv.lock +++ b/uv.lock @@ -4,31 +4,33 @@ requires-python = ">=3.13" [[package]] name = "basedpyright" -version = "1.36.1" +version = "1.37.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/60/d7/9476af6f45a70e8d23045ec59d99c2698513b7395283cadc75caeeea2b83/basedpyright-1.37.0.tar.gz", hash = "sha256:affbffced97a04a08bfc44aef2da43951a5ab5e2e55921a144ed786c4fd2c6ad", size = 22837441, upload-time = "2026-01-04T09:59:32.652Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/e8/63/753918f0bad07a1b24755a540b64bca1388322615025d4c954e3740fcdbe/basedpyright-1.37.0-py3-none-any.whl", hash = "sha256:261a02a8732a19f3f585e2940582147560058626a062a2320724de84fb2dc41b", size = 11884509, upload-time = "2026-01-04T09:59:35.997Z" }, ] [[package]] -name = "compose" +name = "docker-compose" version = "0.1.0" source = { editable = "." } dependencies = [ { name = "basedpyright" }, { name = "pyyaml" }, { name = "ruff" }, + { name = "ty" }, ] [package.metadata] requires-dist = [ - { name = "basedpyright", specifier = ">=1.36.1" }, + { name = "basedpyright", specifier = ">=1.37.0" }, { name = "pyyaml", specifier = ">=6.0.3" }, - { name = "ruff", specifier = ">=0.14.9" }, + { name = "ruff", specifier = ">=0.14.10" }, + { name = "ty", specifier = ">=0.0.10" }, ] [[package]] @@ -85,26 +87,50 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.9" +version = "0.14.10" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + +[[package]] +name = "ty" +version = "0.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/85/97b5276baa217e05db2fe3d5c61e4dfd35d1d3d0ec95bfca1986820114e0/ty-0.0.10.tar.gz", hash = "sha256:0a1f9f7577e56cd508a8f93d0be2a502fdf33de6a7d65a328a4c80b784f4ac5f", size = 4892892, upload-time = "2026-01-07T23:00:23.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/7a/5a7147ce5231c3ccc55d6f945dabd7412e233e755d28093bfdec988ba595/ty-0.0.10-py3-none-linux_armv6l.whl", hash = "sha256:406a8ea4e648551f885629b75dc3f070427de6ed099af45e52051d4c68224829", size = 9835881, upload-time = "2026-01-07T22:08:17.492Z" }, + { url = "https://files.pythonhosted.org/packages/3e/7d/89f4d2277c938332d047237b47b11b82a330dbff4fff0de8574cba992128/ty-0.0.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d6e0a733e3d6d3bce56d6766bc61923e8b130241088dc2c05e3c549487190096", size = 9696404, upload-time = "2026-01-07T22:08:37.965Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cd/9dd49e6d40e54d4b7d563f9e2a432c4ec002c0673a81266e269c4bc194ce/ty-0.0.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e4832f8879cb95fc725f7e7fcab4f22be0cf2550f3a50641d5f4409ee04176d4", size = 9181195, upload-time = "2026-01-07T22:59:07.187Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b8/3e7c556654ba0569ed5207138d318faf8633d87e194760fc030543817c26/ty-0.0.10-py3-none-manylinux_2_24_aarch64.whl", hash = "sha256:6b58cc78e5865bc908f053559a80bb77cab0dc168aaad2e88f2b47955694b138", size = 9665002, upload-time = "2026-01-07T22:08:30.782Z" }, + { url = "https://files.pythonhosted.org/packages/98/96/410a483321406c932c4e3aa1581d1072b72cdcde3ae83cd0664a65c7b254/ty-0.0.10-py3-none-manylinux_2_24_armv7l.whl", hash = "sha256:83c6a514bb86f05005fa93e3b173ae3fde94d291d994bed6fe1f1d2e5c7331cf", size = 9664948, upload-time = "2026-01-07T23:04:14.655Z" }, + { url = "https://files.pythonhosted.org/packages/1f/5d/cba2ab3e2f660763a72ad12620d0739db012e047eaa0ceaa252bf5e94ebb/ty-0.0.10-py3-none-manylinux_2_24_i686.whl", hash = "sha256:2e43f71e357f8a4f7fc75e4753b37beb2d0f297498055b1673a9306aa3e21897", size = 10125401, upload-time = "2026-01-07T22:08:28.171Z" }, + { url = "https://files.pythonhosted.org/packages/a7/67/29536e0d97f204a2933122239298e754db4564f4ed7f34e2153012b954be/ty-0.0.10-py3-none-manylinux_2_24_ppc64le.whl", hash = "sha256:18be3c679965c23944c8e574be0635504398c64c55f3f0c46259464e10c0a1c7", size = 10714052, upload-time = "2026-01-07T22:08:20.098Z" }, + { url = "https://files.pythonhosted.org/packages/63/c8/82ac83b79a71c940c5dcacb644f526f0c8fdf4b5e9664065ab7ee7c0e4ec/ty-0.0.10-py3-none-manylinux_2_24_s390x.whl", hash = "sha256:5477981681440a35acdf9b95c3097410c547abaa32b893f61553dbc3b0096fff", size = 10395924, upload-time = "2026-01-07T22:08:22.839Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4c/2f9ac5edbd0e67bf82f5cd04275c4e87cbbf69a78f43e5dcf90c1573d44e/ty-0.0.10-py3-none-manylinux_2_24_x86_64.whl", hash = "sha256:e206a23bd887574302138b33383ae1edfcc39d33a06a12a5a00803b3f0287a45", size = 10220096, upload-time = "2026-01-07T22:08:13.171Z" }, + { url = "https://files.pythonhosted.org/packages/04/13/3be2b7bfd53b9952b39b6f2c2ef55edeb1a2fea3bf0285962736ee26731c/ty-0.0.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4e09ddb0d3396bd59f645b85eab20f9a72989aa8b736b34338dcb5ffecfe77b6", size = 9649120, upload-time = "2026-01-07T22:08:34.003Z" }, + { url = "https://files.pythonhosted.org/packages/93/e3/edd58547d9fd01e4e584cec9dced4f6f283506b422cdd953e946f6a8e9f0/ty-0.0.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:139d2a741579ad86a044233b5d7e189bb81f427eebce3464202f49c3ec0eba3b", size = 9686033, upload-time = "2026-01-07T22:08:40.967Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bc/9d2f5fec925977446d577fb9b322d0e7b1b1758709f23a6cfc10231e9b84/ty-0.0.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6bae10420c0abfe4601fbbc6ce637b67d0b87a44fa520283131a26da98f2e74c", size = 9841905, upload-time = "2026-01-07T23:04:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b8/5acd3492b6a4ef255ace24fcff0d4b1471a05b7f3758d8910a681543f899/ty-0.0.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7358bbc5d037b9c59c3a48895206058bcd583985316c4125a74dd87fd1767adb", size = 10320058, upload-time = "2026-01-07T22:08:25.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/67/5b6906fccef654c7e801d6ac8dcbe0d493e1f04c38127f82a5e6d7e0aa0e/ty-0.0.10-py3-none-win32.whl", hash = "sha256:f51b6fd485bc695d0fdf555e69e6a87d1c50f14daef6cb980c9c941e12d6bcba", size = 9271806, upload-time = "2026-01-07T22:08:10.08Z" }, + { url = "https://files.pythonhosted.org/packages/42/36/82e66b9753a76964d26fd9bc3514ea0abce0a5ba5ad7d5f084070c6981da/ty-0.0.10-py3-none-win_amd64.whl", hash = "sha256:16deb77a72cf93b89b4d29577829613eda535fbe030513dfd9fba70fe38bc9f5", size = 10130520, upload-time = "2026-01-07T23:04:11.759Z" }, + { url = "https://files.pythonhosted.org/packages/63/52/89da123f370e80b587d2db8551ff31562c882d87b32b0e92b59504b709ae/ty-0.0.10-py3-none-win_arm64.whl", hash = "sha256:7495288bca7afba9a4488c9906466d648ffd3ccb6902bc3578a6dbd91a8f05f0", size = 9626026, upload-time = "2026-01-07T23:04:17.91Z" }, ]