Compare commits

...

2 Commits

Author SHA256 Message Date
6aa6c9e4dd sync 2025-12-18 00:00:53 -06:00
fa4339768f sync 2025-12-18 00:00:05 -06:00
60 changed files with 930 additions and 1029 deletions

5
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

11
.idea/compose_gen_uv.iml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (compose_gen_uv)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

7
.idea/dictionaries/project.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>traefik</w>
</words>
</dictionary>
</component>

View File

@@ -0,0 +1,10 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyInconsistentReturnsInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="PyMissingTypeHintsInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="m_onlyWhenTypesAreKnown" value="false" />
</inspection_tool>
<inspection_tool class="PyUnnecessaryCastInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.13 (compose_gen_uv)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (compose_gen_uv)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/compose_gen_uv.iml" filepath="$PROJECT_DIR$/.idea/compose_gen_uv.iml" />
</modules>
</component>
</project>

9
.idea/ruff.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RuffConfigService">
<option name="globalRuffExecutablePath" value="/opt/pycharm_venv/bin/ruff" />
<option name="runRuffOnSave" value="true" />
<option name="useRuffImportOptimizer" value="true" />
<option name="useRuffServer" value="true" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -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)

View File

@@ -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]

View File

@@ -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]

View File

@@ -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)),
)

View File

@@ -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))

View File

@@ -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))

View File

@@ -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)},
# )

View File

@@ -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

View File

@@ -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

View File

@@ -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"),
)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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"),
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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())

View File

@@ -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)

View File

@@ -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

8
src/docker_compose/Ts.py Normal file
View File

@@ -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]

View File

@@ -0,0 +1,21 @@
from collections.abc import Iterable, Iterator
from docker_compose.cfg import CFG_ROOT, TRAEFIK_PATH
from docker_compose.compose.render import Rendered
def load_all() -> Iterable[Rendered]:
for _dir in CFG_ROOT.iterdir():
yield Rendered.from_path(_dir)
def render_all() -> Iterator[str]:
for rendered in load_all():
rendered.write_all()
yield from rendered.proxy_nets
if __name__ == "__main__":
# renders = render_all()
nets = frozenset(render_all())
traefik = Rendered.from_path(TRAEFIK_PATH)

View File

@@ -0,0 +1,5 @@
from pathlib import Path
CFG_ROOT = Path("/data/cfg")
DATA_ROOT = Path("/data")
TRAEFIK_PATH = Path("/data/traefik")

View File

@@ -0,0 +1,30 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Self, final
from docker_compose.cfg.cfg_paths_yaml import CfgYaml
from docker_compose.cfg.org_data import OrgData
from docker_compose.cfg.src_path import SrcPaths
@final
@dataclass(frozen=True, slots=True)
class CfgData:
src_paths: SrcPaths
name: str
services: frozenset[Path]
volumes: frozenset[Path] | None
org_data: frozenset[OrgData]
@classmethod
def from_src_paths(cls, src_paths: SrcPaths) -> Self:
cfg_yaml = CfgYaml.from_src_paths(src_paths)
cfg_root = src_paths.cfg_dir.joinpath
vols = cfg_yaml.data.get("volumes")
return cls(
src_paths,
src_paths.cfg_dir.name,
frozenset(cfg_root(path) for path in cfg_yaml.data["services"]),
frozenset(cfg_root(path) for path in vols) if vols else None,
frozenset(cfg_yaml.orgs_data),
)

View File

@@ -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)

View File

@@ -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"))

View File

@@ -0,0 +1,6 @@
from typing import NotRequired, TypedDict
class OrgDataYaml(TypedDict):
org: str
url: NotRequired[str]

View File

@@ -0,0 +1,30 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Self, final
@final
@dataclass(frozen=True, slots=True)
class SrcPaths:
cfg_dir: Path
cfg_file: Path
env_file: Path
@classmethod
def from_path(cls, src: Path) -> Self:
return cls(
cfg_dir=src,
cfg_file=src.joinpath("cfg.yml"),
env_file=src.joinpath(".env"),
)
# 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)

View File

@@ -0,0 +1,75 @@
from abc import ABCMeta
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.src_path import SrcPaths
from docker_compose.compose.compose_yaml import ComposeYaml, ComposeYamlData
from docker_compose.compose.net import Net
from docker_compose.compose.replace_args import ReplaceArgs
from docker_compose.compose.service import Service
from docker_compose.compose.volumes_yaml import VolYaml
@dataclass(frozen=True, slots=True)
class Compose(metaclass=ABCMeta):
cfg: CfgData
services: dict[str, Service]
networks: Net | None
volumes: dict[str, VolYaml] | None
replace_args: frozenset[ReplaceArgs]
@classmethod
def from_path(cls, path: Path):
return cls.from_src_path(SrcPaths.from_path(path))
@classmethod
def from_src_path(cls, src_paths: SrcPaths) -> Self:
return cls.from_cfg(CfgData.from_src_paths(src_paths))
@classmethod
def from_cfg(cls, cfg_data: CfgData) -> Self:
services = {path.stem: Service.from_path(path) for path in cfg_data.services}
vols = {path.stem: VolYaml.from_path(path) for path in cfg_data.services}
nets = Net.from_service_list(services.values())
return cls(
cfg_data,
services,
nets if nets else None,
vols if vols else None,
frozenset(
ReplaceArgs.from_cfg_data(cfg_data, org) for org in cfg_data.org_data
),
)
@property
def as_dict(self) -> ComposeYaml:
data = ComposeYamlData(
name=self.cfg.name,
services={k: v.as_dict.data for k, v in self.services.items()},
)
if self.networks:
data["networks"] = self.networks.as_dict
if self.volumes:
data["volumes"] = {k: v.data for k, v in self.volumes.items()}
return ComposeYaml(data)
@property
def as_yaml(self) -> str:
return self.as_dict.as_yaml
@property
def proxys(self) -> Iterator[str]:
proxy = self.networks
if proxy is None:
return
for net in proxy.data.values():
if not net.is_proxy:
return
yield net.name
# nets = frozenset(net.name for net in proxy.data.values() if net.is_proxy)
# return nets if nets else None

View File

@@ -0,0 +1,20 @@
from dataclasses import dataclass
from typing import NotRequired, TypedDict, final
from docker_compose.compose.net_yaml import NetYaml
from docker_compose.compose.service_yaml_write import ServiceYamlWriteData
from docker_compose.compose.volumes_yaml import VolYamlData
from docker_compose.yaml import YamlWrapper
class ComposeYamlData(TypedDict):
name: str
services: dict[str, ServiceYamlWriteData]
networks: NotRequired[NetYaml]
volumes: NotRequired[dict[str, VolYamlData]]
@final
@dataclass(frozen=True, slots=True)
class ComposeYaml(YamlWrapper[ComposeYamlData]):
pass

View File

@@ -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)

View File

@@ -0,0 +1,46 @@
from collections.abc import Iterable
from dataclasses import dataclass
from typing import final
from docker_compose.compose.net_args import NetArgs
from docker_compose.compose.net_yaml import NetYaml
from docker_compose.compose.service import Service
# @final
# @dataclass(frozen=True, slots=True)
# class Net:
# internal: NetArgs | None
# proxy: NetArgs | None
# @property
# def as_dict(self) -> NetYaml:
# yaml_dict = NetYaml()
# if self.internal is not None:
# yaml_dict["internal"] = self.internal.as_dict
# if self.proxy is not None:
# yaml_dict["proxy"] = self.proxy.as_dict
# return yaml_dict
@final
@dataclass
class Net:
data: dict[str, NetArgs]
@classmethod
def from_service_list(cls, args: Iterable[Service]):
return cls.from_list(
{net for service in args if service.networks for net in service.networks}
)
@classmethod
def from_list(cls, args: Iterable[str]):
return cls({net: NetArgs(net, NetArgs.is_proxy_check(net)) for net in args})
@property
def as_dict(self) -> NetYaml:
return {name: net_args.as_dict for name, net_args in self.data.items()}
def __bool__(self) -> bool:
return bool(self.data)

View File

@@ -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)

View File

@@ -0,0 +1,6 @@
from typing import NotRequired, TypedDict
class NetArgsYaml(TypedDict):
name: str
external: NotRequired[bool]

View File

@@ -0,0 +1,8 @@
from docker_compose.compose.net_args_yaml import NetArgsYaml
# class NetYaml(TypedDict):
# internal: NotRequired[NetArgsYaml]
# proxy: NotRequired[NetArgsYaml]
type NetYaml = dict[str, NetArgsYaml]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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"])

View File

@@ -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))

View File

@@ -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

View File

@@ -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)