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" },
]