diff --git a/.idea/compose_gen_uv.iml b/.idea/compose_gen_uv.iml
new file mode 100644
index 0000000..5ecfebe
--- /dev/null
+++ b/.idea/compose_gen_uv.iml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..d3af53a
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/ruff.xml b/.idea/ruff.xml
new file mode 100644
index 0000000..0dad89a
--- /dev/null
+++ b/.idea/ruff.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/docker_compose/Ts.py b/src/docker_compose/Ts.py
new file mode 100644
index 0000000..8154bb0
--- /dev/null
+++ b/src/docker_compose/Ts.py
@@ -0,0 +1,8 @@
+from collections.abc import Mapping
+
+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]
diff --git a/src/docker_compose/cfg/cfg_paths_yaml.py b/src/docker_compose/cfg/cfg_paths_yaml.py
new file mode 100644
index 0000000..9786513
--- /dev/null
+++ b/src/docker_compose/cfg/cfg_paths_yaml.py
@@ -0,0 +1,27 @@
+from collections.abc import Iterator
+from dataclasses import dataclass
+from typing import NotRequired, Self, TypedDict, final
+
+from docker_compose.cfg.org_data import OrgData
+from docker_compose.cfg.org_data_yaml import OrgDataYaml
+from docker_compose.cfg.src_path import SrcPaths
+from docker_compose.yaml import YamlWrapper
+
+
+class CfgYamlData(TypedDict):
+ services: list[str]
+ volumes: NotRequired[list[str]]
+ orgs: list[OrgDataYaml]
+
+
+@final
+@dataclass(frozen=True, slots=True)
+class CfgYaml(YamlWrapper[CfgYamlData]):
+ @classmethod
+ def from_src_paths(cls, src_paths: SrcPaths) -> Self:
+ return cls.from_path(src_paths.cfg_file)
+
+ @property
+ def orgs_data(self) -> Iterator[OrgData]:
+ for org in self.data["orgs"]:
+ yield OrgData.from_dict(org)
diff --git a/src/docker_compose/cfg/org_data.py b/src/docker_compose/cfg/org_data.py
new file mode 100644
index 0000000..bca6b26
--- /dev/null
+++ b/src/docker_compose/cfg/org_data.py
@@ -0,0 +1,15 @@
+from dataclasses import dataclass
+from typing import Self, final
+
+from docker_compose.cfg.org_data_yaml import OrgDataYaml
+
+
+@final
+@dataclass(frozen=True, slots=True)
+class OrgData:
+ org: str
+ url: str | None
+
+ @classmethod
+ def from_dict(cls, data: OrgDataYaml) -> Self:
+ return cls(data["org"], data.get("url"))
diff --git a/src/docker_compose/cfg/org_data_yaml.py b/src/docker_compose/cfg/org_data_yaml.py
new file mode 100644
index 0000000..b9a6a9f
--- /dev/null
+++ b/src/docker_compose/cfg/org_data_yaml.py
@@ -0,0 +1,6 @@
+from typing import NotRequired, TypedDict
+
+
+class OrgDataYaml(TypedDict):
+ org: str
+ url: NotRequired[str]
diff --git a/src/docker_compose/compose/dest_path.py b/src/docker_compose/compose/dest_path.py
new file mode 100644
index 0000000..ae51df2
--- /dev/null
+++ b/src/docker_compose/compose/dest_path.py
@@ -0,0 +1,35 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Self, final
+
+
+@final
+@dataclass(frozen=True, slots=True)
+class DestPaths:
+ data_dir: Path
+ env_file: Path
+ compose_file: Path
+
+ @classmethod
+ def from_path(cls, path: Path) -> Self:
+ return cls(
+ path,
+ path.joinpath(".env"),
+ path.joinpath("docker-docker_compose.yml"),
+ )
+
+ # @staticmethod
+ # def _mk_dir(path: Path) -> None:
+ # if path.exists():
+ # return
+ # path.mkdir(parents=True)
+
+ def mk_compose_dir(self) -> None:
+ if self.data_dir.exists():
+ return
+ self.data_dir.mkdir(parents=True)
+ # vols = self.bind_vols
+ # if vols is None:
+ # return
+ # for vol in vols:
+ # _mk_dir(vol)
diff --git a/src/docker_compose/compose/net_args.py b/src/docker_compose/compose/net_args.py
new file mode 100644
index 0000000..7085d34
--- /dev/null
+++ b/src/docker_compose/compose/net_args.py
@@ -0,0 +1,28 @@
+from dataclasses import dataclass
+from typing import final
+
+from docker_compose.compose.net_args_yaml import NetArgsYaml
+
+
+@final
+@dataclass(frozen=True, slots=True)
+class NetArgs:
+ name: str
+ external: bool | None
+
+ @property
+ def as_dict(self) -> NetArgsYaml:
+ yaml_dict = NetArgsYaml(
+ name=self.name,
+ )
+ if self.external is not None:
+ yaml_dict["external"] = self.external
+ return yaml_dict
+
+ @staticmethod
+ def is_proxy_check(name: str) -> bool:
+ return name.endswith("proxy")
+
+ @property
+ def is_proxy(self) -> bool:
+ return self.is_proxy_check(self.name)
diff --git a/src/docker_compose/compose/net_args_yaml.py b/src/docker_compose/compose/net_args_yaml.py
new file mode 100644
index 0000000..1309476
--- /dev/null
+++ b/src/docker_compose/compose/net_args_yaml.py
@@ -0,0 +1,6 @@
+from typing import NotRequired, TypedDict
+
+
+class NetArgsYaml(TypedDict):
+ name: str
+ external: NotRequired[bool]
diff --git a/src/docker_compose/compose/render.py b/src/docker_compose/compose/render.py
new file mode 100644
index 0000000..b59c85c
--- /dev/null
+++ b/src/docker_compose/compose/render.py
@@ -0,0 +1,39 @@
+from collections.abc import Iterator
+from dataclasses import dataclass
+from pathlib import Path
+from typing import final
+
+from docker_compose.compose.compose import Compose
+from docker_compose.compose.replace_args import ReplaceArgs
+
+
+@final
+@dataclass(frozen=True, slots=True)
+class Rendered(Compose):
+ def mk_bind_vols(self) -> None:
+ for app_data in self.services.values():
+ if app_data.volumes is None:
+ continue
+ for vol in app_data.volumes:
+ for arg in self.replace_args:
+ path = arg.render_yaml(vol.split(":", 1)[0])
+ if not path.startswith("/"):
+ continue
+ path = Path(path)
+ if path.exists():
+ continue
+ path.mkdir(parents=True)
+
+ @property
+ def proxy_nets(self) -> Iterator[str]:
+ for net in self.proxys:
+ for re in self.replace_args:
+ yield re.org_name.replace(net)
+
+ def write_all(self) -> None:
+ self.mk_bind_vols()
+ for arg in self.replace_args:
+ arg.write_yaml(self.as_yaml)
+
+ def write(self, args: ReplaceArgs) -> None:
+ args.write_yaml(self.as_yaml)
diff --git a/src/docker_compose/compose/replace_args.py b/src/docker_compose/compose/replace_args.py
new file mode 100644
index 0000000..1aa4705
--- /dev/null
+++ b/src/docker_compose/compose/replace_args.py
@@ -0,0 +1,73 @@
+from dataclasses import dataclass
+from functools import reduce
+from shutil import copyfile
+from typing import Self, final
+
+from docker_compose.cfg import DATA_ROOT
+from docker_compose.cfg.cfg_paths import CfgData
+from docker_compose.cfg.org_data import OrgData
+from docker_compose.compose.dest_path import DestPaths
+from docker_compose.compose.val_obj import (
+ DataDir,
+ NameVal,
+ OrgVal,
+ Record,
+ Url,
+)
+
+
+@final
+@dataclass(frozen=True, slots=True)
+class ReplaceArgs:
+ cfg: CfgData
+ org: Record[OrgVal]
+ name: Record[NameVal]
+ org_name: Record[NameVal]
+ data: Record[DataDir]
+ url: Record[Url]
+ dest_paths: DestPaths
+
+ # noinspection PyMissingTypeHints
+ def __iter__(self):
+ yield self.org
+ yield self.name
+ yield self.org_name
+ yield self.data
+ yield self.url
+
+ @classmethod
+ def from_cfg_data(cls, cfg_data: CfgData, org_data: OrgData) -> Self:
+ _org = OrgVal(org_data.org)
+ _name = NameVal(cfg_data.name)
+ org_name = NameVal(f"{_org.str}_{_name.str}") if _org.is_valid() else _name
+ data_dir = DATA_ROOT.joinpath(_org.str, _name.str)
+
+ return cls(
+ cfg_data,
+ Record("org", _org),
+ Record("name", _name),
+ Record("org_name", org_name),
+ Record("data", DataDir(data_dir)),
+ Record("url", Url(org_data.url)),
+ DestPaths.from_path(data_dir),
+ )
+
+ def mk_compose_env(self) -> None:
+ src = self.cfg.src_paths.env_file
+ dest = self.dest_paths.env_file
+ if src.exists() and not dest.exists():
+ _ = copyfile(src, dest)
+
+ def render_yaml(self, yaml: str) -> str:
+ return reduce(lambda s, f: f.replace(s), self, yaml)
+
+ def write_yaml(self, yaml: str) -> None:
+ self.dest_paths.mk_compose_dir()
+ with self.dest_paths.compose_file.open("wt") as f:
+ _ = f.write(self.render_yaml(yaml))
+
+ # def mk_vol_dir(self, path: str):
+ # p = Path(self.render_yaml(path))
+ # if p.exists():
+ # return
+ # p.mkdir(parents=True)
diff --git a/src/docker_compose/compose/service.py b/src/docker_compose/compose/service.py
new file mode 100644
index 0000000..f06edd2
--- /dev/null
+++ b/src/docker_compose/compose/service.py
@@ -0,0 +1,80 @@
+from abc import ABCMeta
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Self, final
+
+from docker_compose.compose.service_yaml_read import (
+ ServiceYamlRead,
+)
+from docker_compose.compose.service_yaml_write import (
+ ServiceYamlWrite,
+ ServiceYamlWriteData,
+)
+from docker_compose.compose.val_obj import Record
+from docker_compose.Ts import T_Primitive
+
+
+@final
+@dataclass(frozen=True, slots=True)
+class Service(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[str] | None
+ restart: str
+ security_opt: frozenset[str]
+ user: str | None
+ volumes: frozenset[str] | None
+
+ @classmethod
+ def from_path(cls, path: Path) -> Self:
+ return cls.from_dict(ServiceYamlRead.from_path(path))
+
+ @classmethod
+ def from_dict(cls, data: ServiceYamlRead):
+ command = data.data.get("command")
+ entrypoint = data.data.get("entrypoint")
+ volumes = data.data.get("volumes")
+ nets = data.data.get("networks")
+ return cls(
+ None if not command else tuple(command),
+ Record.get_replace_name("org_name"),
+ tuple(entrypoint) if entrypoint else None,
+ data.data.get("environment"),
+ data.data["image"],
+ data.labels,
+ data.data.get("logging"),
+ frozenset(nets) if nets else None,
+ "unless-stopped",
+ data.sec_opts,
+ data.data.get("user"),
+ frozenset(volumes) if volumes else None,
+ )
+
+ @property
+ def as_dict(self) -> ServiceYamlWrite:
+ data = ServiceYamlWriteData(
+ container_name=self.container_name,
+ image=self.image,
+ restart=self.restart,
+ security_opt=sorted(self.security_opt),
+ )
+ if self.command is not None:
+ data["command"] = list(self.command)
+ if self.entrypoint is not None:
+ data["entrypoint"] = list(self.entrypoint)
+ if self.environment is not None:
+ data["environment"] = self.environment
+ if self.labels is not None:
+ data["labels"] = sorted(self.labels)
+ if self.logging is not None:
+ data["logging"] = self.logging
+ if self.user is not None:
+ data["user"] = self.user
+ if self.volumes is not None:
+ data["volumes"] = sorted(self.volumes)
+ return ServiceYamlWrite(data)
diff --git a/src/docker_compose/compose/service_yaml_read.py b/src/docker_compose/compose/service_yaml_read.py
new file mode 100644
index 0000000..bb73ed1
--- /dev/null
+++ b/src/docker_compose/compose/service_yaml_read.py
@@ -0,0 +1,60 @@
+from dataclasses import dataclass
+from typing import Literal, NotRequired, TypedDict
+
+from docker_compose.compose.val_obj import Record
+from docker_compose.Ts import T_Primitive
+from docker_compose.yaml import YamlWrapper
+
+type T_NetAbc = str | Literal["proxy", "internal"]
+
+
+class ServiceYamlReadData(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]]
+
+
+@dataclass(frozen=True, slots=True)
+class ServiceYamlRead(YamlWrapper[ServiceYamlReadData]):
+ @property
+ def sec_opts(self) -> frozenset[str]:
+ sec_opts = frozenset(
+ "no-new-privileges:true",
+ )
+ sec = self.data.get("security_opt")
+ if not sec:
+ return sec_opts
+ return sec_opts.union(sec)
+
+ @property
+ def labels(self) -> frozenset[str] | None:
+ org_name = Record.get_replace_name("org_name")
+ url = Record.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 = self.data.get("labels")
+ if not labels:
+ return
+ if "traefik.enable=true" not in labels:
+ return frozenset(labels)
+ return traefik_labels.union(labels)
+
+ @property
+ def nets(self) -> frozenset[str] | None:
+ nets = self.data.get("networks")
+ if nets is None:
+ return
+ return frozenset(nets)
diff --git a/src/docker_compose/compose/service_yaml_write.py b/src/docker_compose/compose/service_yaml_write.py
new file mode 100644
index 0000000..e0c5dc4
--- /dev/null
+++ b/src/docker_compose/compose/service_yaml_write.py
@@ -0,0 +1,15 @@
+from dataclasses import dataclass
+from typing import NotRequired
+
+from docker_compose.compose.service_yaml_read import ServiceYamlReadData
+from docker_compose.yaml import YamlWrapper
+
+
+class ServiceYamlWriteData(ServiceYamlReadData):
+ container_name: NotRequired[str]
+ restart: NotRequired[str]
+
+
+@dataclass(frozen=True, slots=True)
+class ServiceYamlWrite(YamlWrapper[ServiceYamlWriteData]):
+ pass
diff --git a/src/docker_compose/compose/val_obj.py b/src/docker_compose/compose/val_obj.py
new file mode 100644
index 0000000..7875628
--- /dev/null
+++ b/src/docker_compose/compose/val_obj.py
@@ -0,0 +1,81 @@
+from abc import ABCMeta, abstractmethod
+from dataclasses import dataclass
+from pathlib import Path
+from typing import final, override
+
+
+@dataclass(frozen=True, slots=True)
+class RecordVal(metaclass=ABCMeta):
+ @property
+ @abstractmethod
+ def str(self) -> str:
+ pass
+
+
+@final
+@dataclass(frozen=True, slots=True)
+class Record[T: RecordVal]:
+ name: str
+ val: T
+
+ def replace(self, string: str) -> str:
+ return string.replace(self.replace_name, self.val.str)
+
+ @property
+ def replace_name(self) -> str:
+ return self.get_replace_name(self.name)
+
+ @staticmethod
+ def get_replace_name(string: str) -> str:
+ return f"${{_{string.upper()}}}"
+
+
+@final
+@dataclass(frozen=True, slots=True)
+class OrgVal(RecordVal):
+ val: str | None
+
+ @property
+ @override
+ def 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(RecordVal):
+ val: str
+
+ @property
+ @override
+ def str(self) -> str:
+ return self.val
+
+
+@final
+@dataclass(frozen=True, slots=True)
+class DataDir(RecordVal):
+ path: Path
+
+ @property
+ @override
+ def str(self) -> str:
+ return str(self.path)
+
+
+@final
+@dataclass(frozen=True, slots=True)
+class Url(RecordVal):
+ sub_url: str | None
+
+ @property
+ @override
+ def str(self) -> str:
+ if self.sub_url is None:
+ return ""
+ return ".".join([self.sub_url, "ccamper7", "net"])
diff --git a/src/docker_compose/compose/volumes_yaml.py b/src/docker_compose/compose/volumes_yaml.py
new file mode 100644
index 0000000..ed39dc8
--- /dev/null
+++ b/src/docker_compose/compose/volumes_yaml.py
@@ -0,0 +1,25 @@
+from dataclasses import dataclass
+from typing import final
+
+from docker_compose.Ts import T_YamlDict
+from docker_compose.yaml import YamlWrapper
+
+type VolYamlData = dict[str, T_YamlDict]
+
+
+@final
+@dataclass(frozen=True, slots=True)
+class VolYaml(YamlWrapper[VolYamlData]):
+ pass
+
+
+# def vols_from_path(path: Path) -> VolYamlData:
+# return cast(VolYamlData, read_yml(path))
+
+
+# def vols_yaml_factory(self) -> Iterator[tuple[str, VolDataYaml]]:
+# vols = self.volumes
+# if vols is None:
+# return
+# for path in vols:
+# yield path.stem, cast(VolDataYaml, read_yml(path))
diff --git a/src/docker_compose/util.py b/src/docker_compose/util.py
new file mode 100644
index 0000000..6637d48
--- /dev/null
+++ b/src/docker_compose/util.py
@@ -0,0 +1,86 @@
+from collections.abc import Mapping
+from typing import Any, cast
+
+from docker_compose.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):
+ 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)))
+
+
+# class T_TypedDict(Protocol):
+# __required_keys__: ClassVar[frozenset[str]]
+
+# def keys(self) -> KeysView[str]: ...
+
+
+# def read_yml(path: Path):
+# with path.open("rt") as f:
+# return 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()}}}"
+
+
+# def validate_typed_dict(
+# # typed_dict: type[T_TypedDict],
+# data: T_TypedDict,
+# path: Path | None = None,
+# pre: tuple[str, ...] | None = None,
+# ) -> None:
+# req = type(data).__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
+
+
+# def to_typed_dict[T:T_TypedDict](typed_dict:type[T] ,data: Mapping[str, Any]) -> T:
+# missing = typed_dict.__required_keys__.difference(data)
+# if missing:
+# msg = f"key(s) ({', '.join(map("{}".format, missing))}) not found"
+# raise KeyError(msg)
+# _dict = typed_dict()
+# for key in typed_dict.__required_keys__:
+# val = data[key]
+# if not isinstance(val, typed_dict.__annotations__[key]):
+# msg = f'invalid type for {type(data).__name__}[{key}]\nexpected {typed_dict.__annotations__[key]} got {type(val).__name__}'
+# raise TypeError()
+# _dict[key] = val
+# for key, key_type in BackupData.__annotations__.items():
+# if key not in data:
+# raise ValueError(f"Key: {key} is not available in data.")
+# result[key] = key_type(data[key])
+# return result
diff --git a/src/docker_compose/yaml.py b/src/docker_compose/yaml.py
new file mode 100644
index 0000000..369fe90
--- /dev/null
+++ b/src/docker_compose/yaml.py
@@ -0,0 +1,42 @@
+import re
+from collections.abc import ItemsView, Iterator, KeysView
+from dataclasses import dataclass
+from pathlib import Path
+from typing import ClassVar, Protocol, cast, override
+
+import yaml
+
+
+class ProtoMapping[K, V: object](Protocol):
+ def __getitem__(self, key: K, /) -> V: ...
+ def __iter__(self) -> Iterator[K]: ...
+ def __len__(self) -> int: ...
+ def __contains__(self, key: object, /) -> bool: ...
+ def keys(self) -> KeysView[K]: ...
+ def items(self) -> ItemsView[K, V]: ...
+
+
+class TTypedyamldict(ProtoMapping[str, object], Protocol):
+ __required_keys__: ClassVar[frozenset[str]]
+ __optional_keys__: ClassVar[frozenset[str]]
+
+
+class VerboseSafeDumper(yaml.SafeDumper):
+ @override
+ def ignore_aliases(self, data: object) -> bool:
+ return True
+
+
+@dataclass(frozen=True, slots=True)
+class YamlWrapper[T: ProtoMapping[str, object]]:
+ data: T
+
+ @classmethod
+ def from_path(cls, path: Path):
+ with path.open("rt") as f:
+ return cls(cast(T, yaml.safe_load(f)))
+
+ @property
+ def as_yaml(self) -> str:
+ _yaml = yaml.dump(self.data, Dumper=VerboseSafeDumper)
+ return re.sub(r"(^\s*-)", r" \g<1>", _yaml, flags=re.MULTILINE)