dockercomposefile 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ """Common types and validators shared across all compose models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+ from pydantic_core import PydanticCustomError
9
+
10
+
11
+ def _parse_list_or_dict(value: Any) -> dict[str, str | None]:
12
+ """Parse a value that can be either a dict or a list of KEY=VALUE strings."""
13
+ if value is None:
14
+ return {}
15
+ if isinstance(value, dict):
16
+ return {str(k): str(v) if v is not None else None for k, v in value.items()}
17
+ if isinstance(value, list):
18
+ result: dict[str, str | None] = {}
19
+ for item in value:
20
+ if isinstance(item, str):
21
+ if "=" in item:
22
+ k, v = item.split("=", 1)
23
+ result[k] = v
24
+ else:
25
+ result[item] = None
26
+ return result
27
+ raise PydanticCustomError("list_or_dict", "Value must be a dict or list of strings")
28
+
29
+
30
+ def _parse_string_or_list(value: Any) -> list[str]:
31
+ """Parse a value that can be either a string or a list of strings."""
32
+ if value is None:
33
+ return []
34
+ if isinstance(value, str):
35
+ return [value]
36
+ if isinstance(value, list):
37
+ return [str(item) for item in value]
38
+ raise PydanticCustomError("string_or_list", "Value must be a string or list of strings")
39
+
40
+
41
+ def _parse_command(value: Any) -> str | list[str] | None:
42
+ """Parse a command that can be a string, a list of strings, or None."""
43
+ if value is None:
44
+ return None
45
+ if isinstance(value, str):
46
+ return value
47
+ if isinstance(value, list):
48
+ return [str(item) for item in value]
49
+ raise PydanticCustomError("command", "Command must be a string or list of strings")
50
+
51
+
52
+ def validate_duration(value: Any) -> str | None:
53
+ """Validate a duration string.
54
+
55
+ Accepts formats like ``10s``, ``1m30s``, ``1h5m30s20ms``,
56
+ or plain integers (microseconds).
57
+ """
58
+ if value is None:
59
+ return None
60
+ if not isinstance(value, str):
61
+ raise PydanticCustomError("duration", "Duration must be a string")
62
+ # Plain integers are treated as microseconds
63
+ if value.isdigit():
64
+ return value
65
+
66
+ import re
67
+
68
+ _DURATION_RE = re.compile(r"^(\d+(?:\.\d+)?)(us|ms|s|m|h)$")
69
+ remaining = value
70
+ while remaining:
71
+ m = _DURATION_RE.match(remaining)
72
+ if not m:
73
+ raise PydanticCustomError(
74
+ "duration",
75
+ f"Invalid duration format: {value!r}",
76
+ )
77
+ remaining = remaining[m.end() :]
78
+ return value
79
+
80
+
81
+ def validate_byte_value(value: Any) -> str | int | None:
82
+ """Validate a byte value string or integer.
83
+
84
+ Accepts formats like ``1gb``, ``300m``, ``1024kb``,
85
+ or plain integers (bytes).
86
+ """
87
+ if value is None:
88
+ return None
89
+ if isinstance(value, int):
90
+ return value
91
+ if isinstance(value, str):
92
+ if value.isdigit():
93
+ return int(value)
94
+ import re
95
+
96
+ _BYTE_VALUE_RE = re.compile(
97
+ r"^(\d+(?:\.\d+)?)\s*([kmgt]?b)$", re.IGNORECASE
98
+ )
99
+ _BYTE_VALUE_RE_2 = re.compile(r"^(\d+(?:\.\d+)?)\s*([kmgt])$", re.IGNORECASE)
100
+ m = _BYTE_VALUE_RE.match(value)
101
+ if not m:
102
+ m = _BYTE_VALUE_RE_2.match(value)
103
+ if not m:
104
+ raise PydanticCustomError(
105
+ "byte_value",
106
+ f"Invalid byte value format: {value!r}",
107
+ )
108
+ return value
109
+ raise PydanticCustomError("byte_value", "Byte value must be a string or integer")
110
+
111
+
112
+ def _parse_port(value: Any) -> dict[str, Any]:
113
+ """Parse a port specification from short or long form."""
114
+ if isinstance(value, dict):
115
+ return dict(value)
116
+ if not isinstance(value, str):
117
+ raise PydanticCustomError("port", "Port must be a string or dict")
118
+
119
+ s = value.strip()
120
+
121
+ # Extract protocol
122
+ protocol: str | None = None
123
+ if "/" in s:
124
+ s, protocol = s.split("/", 1)
125
+
126
+ # Handle bracketed IPv6: [::1]:6000:6000
127
+ if s.startswith("["):
128
+ bracket_end = s.find("]")
129
+ if bracket_end == -1:
130
+ raise PydanticCustomError("port", f"Invalid port format: {value!r}")
131
+ host_ip = s[1:bracket_end]
132
+ rest = s[bracket_end + 1 :]
133
+ if rest.startswith(":"):
134
+ rest = rest[1:]
135
+ parts = rest.split(":")
136
+ if len(parts) == 2 and parts[0] and parts[1]:
137
+ return {
138
+ "host_ip": host_ip,
139
+ "published": parts[0],
140
+ "target": int(parts[1]),
141
+ **({"protocol": protocol} if protocol else {}),
142
+ }
143
+ if len(parts) == 1 and parts[0]:
144
+ return {
145
+ "host_ip": host_ip,
146
+ "target": int(parts[0]),
147
+ **({"protocol": protocol} if protocol else {}),
148
+ }
149
+ raise PydanticCustomError("port", f"Invalid port format: {value!r}")
150
+
151
+ parts = s.split(":")
152
+
153
+ if len(parts) == 1:
154
+ # Just container port
155
+ return {
156
+ "target": int(parts[0]),
157
+ **({"protocol": protocol} if protocol else {}),
158
+ }
159
+
160
+ if len(parts) == 2:
161
+ # HOST:CONTAINER or IP:CONTAINER
162
+ first, second = parts[0], parts[1]
163
+ if "." in first:
164
+ # IPv4 address without host port
165
+ return {
166
+ "host_ip": first,
167
+ "target": int(second),
168
+ **({"protocol": protocol} if protocol else {}),
169
+ }
170
+ return {
171
+ "published": first,
172
+ "target": int(second),
173
+ **({"protocol": protocol} if protocol else {}),
174
+ }
175
+
176
+ if len(parts) == 3:
177
+ # IP:HOST:CONTAINER
178
+ return {
179
+ "host_ip": parts[0],
180
+ "published": parts[1],
181
+ "target": int(parts[2]),
182
+ **({"protocol": protocol} if protocol else {}),
183
+ }
184
+
185
+ raise PydanticCustomError("port", f"Invalid port format: {value!r}")
186
+
187
+
188
+ # Volume mount parsing (short syntax)
189
+ _VOLUME_MOUNT_RE = __import__("re").compile(
190
+ r"^(?P<source>[^:]+):(?P<target>[^:]+)(?::(?P<mode>[^:]*))?$"
191
+ )
192
+
193
+
194
+ def _parse_service_volume(value: Any) -> dict[str, Any]:
195
+ """Parse a service volume mount from short or long form."""
196
+ if isinstance(value, dict):
197
+ return dict(value)
198
+ if not isinstance(value, str):
199
+ raise PydanticCustomError("volume", "Volume mount must be a string or dict")
200
+ m = _VOLUME_MOUNT_RE.match(value)
201
+ if not m:
202
+ # Could be a named volume without source (just target)
203
+ return {"type": "volume", "target": value}
204
+ source = m.group("source")
205
+ target = m.group("target")
206
+ mode = m.group("mode")
207
+ result: dict[str, Any] = {"target": target}
208
+ if source.startswith(".") or source.startswith("/") or source.startswith("~"):
209
+ result["type"] = "bind"
210
+ result["source"] = source
211
+ elif source.startswith("\\"):
212
+ result["type"] = "npipe"
213
+ result["source"] = source
214
+ else:
215
+ result["type"] = "volume"
216
+ result["source"] = source
217
+ if mode:
218
+ modes = mode.split(",")
219
+ if "ro" in modes:
220
+ result["read_only"] = True
221
+ if "z" in modes:
222
+ result.setdefault("bind", {})
223
+ result["bind"]["selinux"] = "z"
224
+ if "Z" in modes:
225
+ result.setdefault("bind", {})
226
+ result["bind"]["selinux"] = "Z"
227
+ return result
228
+
229
+
230
+ def _parse_env_file(value: Any) -> list[dict[str, Any]] | None:
231
+ """Parse env_file which can be a string, list of strings, or list of objects."""
232
+ if value is None:
233
+ return None
234
+ if isinstance(value, str):
235
+ return [{"path": value, "required": True}]
236
+ if isinstance(value, list):
237
+ result: list[dict[str, Any]] = []
238
+ for item in value:
239
+ if isinstance(item, str):
240
+ result.append({"path": item, "required": True})
241
+ elif isinstance(item, dict):
242
+ result.append(dict(item))
243
+ return result
244
+ raise PydanticCustomError("env_file", "env_file must be a string or list")
245
+
246
+
247
+ def _parse_external(value: Any) -> dict[str, Any] | bool:
248
+ """Parse external which can be a boolean or a dict with a name."""
249
+ if isinstance(value, bool):
250
+ return value
251
+ if isinstance(value, dict):
252
+ return dict(value)
253
+ raise PydanticCustomError("external", "external must be a boolean or dict")
254
+
255
+
256
+ def _parse_extra_hosts(value: Any) -> dict[str, str] | None:
257
+ """Parse extra_hosts which can be a dict or list of HOST=IP strings."""
258
+ if value is None:
259
+ return None
260
+ if isinstance(value, dict):
261
+ return {str(k): str(v) for k, v in value.items()}
262
+ if isinstance(value, list):
263
+ result: dict[str, str] = {}
264
+ for item in value:
265
+ if isinstance(item, str):
266
+ sep = "=" if "=" in item else ":"
267
+ parts = item.split(sep, 1)
268
+ if len(parts) == 2:
269
+ result[parts[0]] = parts[1]
270
+ return result
271
+ raise PydanticCustomError("extra_hosts", "extra_hosts must be a dict or list")
272
+
273
+
274
+ def _parse_ulimits(value: Any) -> dict[str, Any] | None:
275
+ """Parse ulimits which can be a dict of int or dict with soft/hard."""
276
+ if value is None:
277
+ return None
278
+ if isinstance(value, dict):
279
+ return dict(value)
280
+ raise PydanticCustomError("ulimits", "ulimits must be a dict")
281
+
282
+
283
+ def _parse_blkio_config(value: Any) -> dict[str, Any] | None:
284
+ """Parse blkio_config from input."""
285
+ if value is None:
286
+ return None
287
+ if isinstance(value, dict):
288
+ return dict(value)
289
+ raise PydanticCustomError("blkio_config", "blkio_config must be a dict")
290
+
291
+
292
+ def _parse_depends_on(value: Any) -> dict[str, Any] | list[str] | None:
293
+ """Parse depends_on which can be a list of strings or a dict."""
294
+ if value is None:
295
+ return None
296
+ if isinstance(value, list):
297
+ return [str(item) for item in value]
298
+ if isinstance(value, dict):
299
+ return dict(value)
300
+ raise PydanticCustomError("depends_on", "depends_on must be a list or dict")
301
+
302
+
303
+ def _parse_tmpfs(value: Any) -> list[str] | None:
304
+ """Parse tmpfs which can be a string or list of strings."""
305
+ if value is None:
306
+ return None
307
+ if isinstance(value, str):
308
+ return [value]
309
+ if isinstance(value, list):
310
+ return [str(item) for item in value]
311
+ raise PydanticCustomError("tmpfs", "tmpfs must be a string or list")
312
+
313
+
314
+ def _parse_volumes_from(value: Any) -> list[str] | None:
315
+ """Parse volumes_from which is a list of strings."""
316
+ if value is None:
317
+ return None
318
+ if isinstance(value, str):
319
+ return [value]
320
+ if isinstance(value, list):
321
+ return [str(item) for item in value]
322
+ raise PydanticCustomError("volumes_from", "volumes_from must be a string or list")
323
+
324
+
325
+ def _parse_annotations(value: Any) -> dict[str, str] | None:
326
+ """Parse annotations which can be a dict or list of KEY=VALUE strings."""
327
+ if value is None:
328
+ return None
329
+ return _parse_list_or_dict(value)
330
+
331
+
332
+ def _parse_labels(value: Any) -> dict[str, str] | None:
333
+ """Parse labels which can be a dict or list of KEY=VALUE strings."""
334
+ if value is None:
335
+ return None
336
+ return _parse_list_or_dict(value)
337
+
338
+
339
+ def _parse_environment(value: Any) -> dict[str, str | None] | None:
340
+ """Parse environment which can be a dict or list of KEY=VALUE strings."""
341
+ if value is None:
342
+ return None
343
+ return _parse_list_or_dict(value)
344
+
345
+
346
+ def _parse_devices(value: Any) -> list[str] | None:
347
+ """Parse devices which is a list of strings."""
348
+ if value is None:
349
+ return None
350
+ if isinstance(value, str):
351
+ return [value]
352
+ if isinstance(value, list):
353
+ return [str(item) for item in value]
354
+ raise PydanticCustomError("devices", "devices must be a string or list")
355
+
356
+
357
+ def _parse_dns(value: Any) -> str | list[str] | None:
358
+ """Parse dns which can be a string or list of strings."""
359
+ if value is None:
360
+ return None
361
+ if isinstance(value, str):
362
+ return value
363
+ if isinstance(value, list):
364
+ return [str(item) for item in value]
365
+ raise PydanticCustomError("dns", "dns must be a string or list")
366
+
367
+
368
+ def _parse_configs_or_secrets(value: Any) -> list[dict[str, Any]] | list[str] | None:
369
+ """Parse configs/secrets which can be a list of strings or list of dicts."""
370
+ if value is None:
371
+ return None
372
+ if isinstance(value, list):
373
+ result: list[dict[str, Any] | str] = []
374
+ for item in value:
375
+ if isinstance(item, str):
376
+ result.append(item)
377
+ elif isinstance(item, dict):
378
+ result.append(dict(item))
379
+ return result
380
+ raise PydanticCustomError("configs_secrets", "Must be a list")
381
+
382
+
383
+ def _parse_additional_contexts(value: Any) -> dict[str, str] | list[str] | None:
384
+ """Parse additional_contexts which can be a dict or list of NAME=VALUE strings."""
385
+ if value is None:
386
+ return None
387
+ if isinstance(value, dict):
388
+ return {str(k): str(v) for k, v in value.items()}
389
+ if isinstance(value, list):
390
+ result: dict[str, str] = {}
391
+ for item in value:
392
+ if isinstance(item, str) and "=" in item:
393
+ k, v = item.split("=", 1)
394
+ result[k] = v
395
+ return result
396
+ raise PydanticCustomError("additional_contexts", "Must be a dict or list")
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # Model base class shared by all compose models
401
+ # ---------------------------------------------------------------------------
402
+
403
+ class ComposeBaseModel(BaseModel):
404
+ """Base model for all compose file models.
405
+
406
+ Allows extra fields for ``x-*`` extensions and unknown fields.
407
+ """
408
+
409
+ model_config = ConfigDict(
410
+ extra="allow",
411
+ populate_by_name=True,
412
+ )
@@ -0,0 +1,58 @@
1
+ """Top-level DockerComposeFile model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import model_validator
8
+
9
+ from .common import ComposeBaseModel
10
+ from .config import Config
11
+ from .model import Model
12
+ from .network import Network
13
+ from .secret import Secret
14
+ from .service import Service
15
+ from .volume import Volume
16
+
17
+
18
+ class Include(ComposeBaseModel):
19
+ """Include configuration for composing multiple files."""
20
+
21
+ path: list[str] | str | None = None
22
+ project_directory: str | None = None
23
+ env_file: list[str] | str | None = None
24
+
25
+
26
+ class DockerComposeFile(ComposeBaseModel):
27
+ """Top-level Docker Compose file model."""
28
+
29
+ version: str | None = None
30
+ name: str | None = None
31
+ include: list[Include] | None = None
32
+ services: dict[str, Service] | None = None
33
+ networks: dict[str, Network] | None = None
34
+ volumes: dict[str, Volume] | None = None
35
+ configs: dict[str, Config] | None = None
36
+ secrets: dict[str, Secret] | None = None
37
+ models: dict[str, Model] | None = None
38
+
39
+ @model_validator(mode="before")
40
+ @staticmethod
41
+ def _normalize_none_values(data: Any) -> Any:
42
+ """Replace None values in resource dicts with empty dicts.
43
+
44
+ Docker Compose allows declaring resources with no attributes
45
+ (e.g. ``volumes:\n frontend_build:``), which YAML parses as
46
+ ``None``. Convert these to empty dicts for Pydantic validation.
47
+ """
48
+ if not isinstance(data, dict):
49
+ return data
50
+ for key in ("networks", "volumes", "configs", "secrets"):
51
+ if key not in data or data[key] is None:
52
+ continue
53
+ resource = data[key]
54
+ if isinstance(resource, dict):
55
+ for name, value in list(resource.items()):
56
+ if value is None:
57
+ resource[name] = {}
58
+ return data
@@ -0,0 +1,45 @@
1
+ """Config models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import Field, field_validator
6
+
7
+ from .common import ComposeBaseModel, _parse_external, _parse_list_or_dict
8
+
9
+
10
+ class Config(ComposeBaseModel):
11
+ """Top-level config definition."""
12
+
13
+ file: str | None = None
14
+ environment: str | None = None
15
+ content: str | None = None
16
+ external: bool | dict[str, str] | None = Field(
17
+ default=None, validate_default=True
18
+ )
19
+ name: str | None = None
20
+ labels: dict[str, str] | list[str] | None = Field(
21
+ default=None, validate_default=True
22
+ )
23
+ driver: str | None = None
24
+ driver_opts: dict[str, str | int] | None = None
25
+ template_driver: str | None = None
26
+
27
+ @field_validator("labels", mode="before")
28
+ @staticmethod
29
+ def _labels(v):
30
+ return _parse_list_or_dict(v) if v is not None else None
31
+
32
+ @field_validator("external", mode="before")
33
+ @staticmethod
34
+ def _external(v):
35
+ return _parse_external(v) if v is not None else None
36
+
37
+
38
+ class ServiceConfig(ComposeBaseModel):
39
+ """Service-level config reference."""
40
+
41
+ source: str
42
+ target: str | None = None
43
+ uid: str | None = None
44
+ gid: str | None = None
45
+ mode: str | None = None
@@ -0,0 +1,117 @@
1
+ """Deploy configuration models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field, field_validator
8
+
9
+ from .common import ComposeBaseModel, validate_byte_value, validate_duration
10
+
11
+
12
+ class PlacementPreference(BaseModel):
13
+ """Placement preference."""
14
+
15
+ spread: str | None = None
16
+
17
+
18
+ class Placement(ComposeBaseModel):
19
+ """Placement constraints and preferences."""
20
+
21
+ constraints: list[str] | None = None
22
+ preferences: list[PlacementPreference] | None = None
23
+ max_replicas_per_node: int | None = None
24
+
25
+
26
+ class ResourceLimits(ComposeBaseModel):
27
+ """Resource limits."""
28
+
29
+ cpus: str | None = None
30
+ memory: str | None = Field(default=None, validate_default=True)
31
+ pids: int | None = None
32
+
33
+ @field_validator("memory", mode="before")
34
+ @staticmethod
35
+ def _memory(v: Any) -> str | None:
36
+ return validate_byte_value(v)
37
+
38
+
39
+ class ResourceReservations(ComposeBaseModel):
40
+ """Resource reservations."""
41
+
42
+ cpus: str | None = None
43
+ memory: str | None = Field(default=None, validate_default=True)
44
+ pids: int | None = None
45
+ devices: list[dict[str, Any]] | None = None
46
+
47
+ @field_validator("memory", mode="before")
48
+ @staticmethod
49
+ def _memory(v: Any) -> str | None:
50
+ return validate_byte_value(v)
51
+
52
+
53
+ class Resources(ComposeBaseModel):
54
+ """Resource configuration."""
55
+
56
+ limits: ResourceLimits | None = None
57
+ reservations: ResourceReservations | None = None
58
+
59
+
60
+ class RestartPolicy(ComposeBaseModel):
61
+ """Restart policy for deploy."""
62
+
63
+ condition: str | None = None
64
+ delay: str | None = Field(default=None, validate_default=True)
65
+ max_attempts: int | None = None
66
+ window: str | None = Field(default=None, validate_default=True)
67
+
68
+ @field_validator("delay", "window", mode="before")
69
+ @staticmethod
70
+ def _duration(v: Any) -> str | None:
71
+ return validate_duration(v)
72
+
73
+
74
+ class UpdateConfig(ComposeBaseModel):
75
+ """Update configuration."""
76
+
77
+ parallelism: int | None = None
78
+ delay: str | None = Field(default=None, validate_default=True)
79
+ failure_action: str | None = None
80
+ monitor: str | None = Field(default=None, validate_default=True)
81
+ max_failure_ratio: float | None = None
82
+ order: str | None = None
83
+
84
+ @field_validator("delay", "monitor", mode="before")
85
+ @staticmethod
86
+ def _duration(v: Any) -> str | None:
87
+ return validate_duration(v)
88
+
89
+
90
+ class RollbackConfig(ComposeBaseModel):
91
+ """Rollback configuration."""
92
+
93
+ parallelism: int | None = None
94
+ delay: str | None = Field(default=None, validate_default=True)
95
+ failure_action: str | None = None
96
+ monitor: str | None = Field(default=None, validate_default=True)
97
+ max_failure_ratio: float | None = None
98
+ order: str | None = None
99
+
100
+ @field_validator("delay", "monitor", mode="before")
101
+ @staticmethod
102
+ def _duration(v: Any) -> str | None:
103
+ return validate_duration(v)
104
+
105
+
106
+ class DeployConfig(ComposeBaseModel):
107
+ """Deploy configuration for a service."""
108
+
109
+ endpoint_mode: str | None = None
110
+ labels: dict[str, str] | list[str] | None = None
111
+ mode: str | None = None
112
+ replicas: int | None = None
113
+ resources: Resources | None = None
114
+ placement: Placement | None = None
115
+ restart_policy: RestartPolicy | None = None
116
+ rollback_config: RollbackConfig | None = None
117
+ update_config: UpdateConfig | None = None
@@ -0,0 +1,33 @@
1
+ """Development configuration models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .common import ComposeBaseModel
6
+
7
+
8
+ class WatchExec(ComposeBaseModel):
9
+ """Exec configuration for watch sync+exec action."""
10
+
11
+ command: str | list[str]
12
+ user: str | None = None
13
+ privileged: bool | None = None
14
+ working_dir: str | None = None
15
+ environment: dict[str, str] | list[str] | None = None
16
+
17
+
18
+ class WatchRule(ComposeBaseModel):
19
+ """Watch rule for development."""
20
+
21
+ path: str
22
+ action: str
23
+ target: str | None = None
24
+ ignore: list[str] | None = None
25
+ include: list[str] | None = None
26
+ initial_sync: bool | None = None
27
+ exec: WatchExec | None = None
28
+
29
+
30
+ class DevelopConfig(ComposeBaseModel):
31
+ """Development configuration for a service."""
32
+
33
+ watch: list[WatchRule] | None = None
@@ -0,0 +1,20 @@
1
+ """AI model models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .common import ComposeBaseModel
6
+
7
+
8
+ class Model(ComposeBaseModel):
9
+ """Top-level AI model definition."""
10
+
11
+ model: str
12
+ context_size: int | None = None
13
+ runtime_flags: list[str] | None = None
14
+
15
+
16
+ class ServiceModelConfig(ComposeBaseModel):
17
+ """Service-level model reference (long syntax)."""
18
+
19
+ endpoint_var: str | None = None
20
+ model_var: str | None = None