subcompose 1.0.0.dev0__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.
subcompose/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ # Copyright (C) 2026 Jose Hernandez
2
+ #
3
+ # This file is part of subcompose.
4
+ #
5
+ # subcompose is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # subcompose is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with subcompose. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+
19
+ from subcompose.parsing import get_groups_from_data
20
+ from subcompose.validation import (
21
+ validate_groups,
22
+ validate_volumes,
23
+ check_service_extension_chain,
24
+ )
25
+ from subcompose.substitution import (
26
+ substitute_environment_variables,
27
+ substitute_image_tags,
28
+ )
29
+ from subcompose.filtering import (
30
+ filter_by_image_tag,
31
+ remove_dependencies_from_filtered_containers,
32
+ )
33
+ from subcompose.utils import represent_none, remove_subcompose_keys
34
+ from subcompose.cli import main
35
+
36
+ __all__ = [
37
+ "get_groups_from_data",
38
+ "validate_groups",
39
+ "validate_volumes",
40
+ "check_service_extension_chain",
41
+ "substitute_environment_variables",
42
+ "substitute_image_tags",
43
+ "filter_by_image_tag",
44
+ "remove_dependencies_from_filtered_containers",
45
+ "represent_none",
46
+ "remove_subcompose_keys",
47
+ "main",
48
+ ]
subcompose/__main__.py ADDED
@@ -0,0 +1,24 @@
1
+ # Copyright (C) 2026 Jose Hernandez
2
+ #
3
+ # This file is part of subcompose.
4
+ #
5
+ # subcompose is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # subcompose is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with subcompose. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+
19
+ """Entry point for `python -m subcompose`."""
20
+
21
+ from subcompose.cli import main
22
+
23
+ if __name__ == "__main__":
24
+ main()
subcompose/cli.py ADDED
@@ -0,0 +1,430 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Copyright (C) 2026 Jose Hernandez
4
+ #
5
+ # This file is part of subcompose.
6
+ #
7
+ # subcompose is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+ #
12
+ # subcompose is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU General Public License for more details.
16
+ #
17
+ # You should have received a copy of the GNU General Public License
18
+ # along with subcompose. If not, see <https://www.gnu.org/licenses/>.
19
+
20
+ """
21
+ 🐳 subcompose: a command line utility to manage subsets of services in Docker compose.yaml files.
22
+
23
+ Usage:
24
+ subcompose ( -h | --help | -? )
25
+ subcompose ( -v | --version )
26
+ subcompose ( -l | --list ) [--compose-file=<filename>]
27
+ subcompose delete-containers [--debug] [--interpolate] [--all] [--unmanaged] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
28
+ subcompose delete-images [--debug] [--interpolate] [--unmanaged] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
29
+ subcompose preview [--debug] [--interpolate] [--unmanaged] [--var-file=<filename>] [--src-tag=<src_tag>] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
30
+ subcompose pull [--interpolate] [--unmanaged] [--var-file=<filename>] [--src-tag=<src_tag>] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
31
+ subcompose push [--interpolate] [--unmanaged] [--var-file=<filename>] [--src-tag=<src_tag>] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
32
+ subcompose run [--debug] [--interpolate] [--unmanaged] [--var-file=<filename>] [--src-tag=<src_tag>] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
33
+ subcompose stop [--debug] [--interpolate] [--unmanaged] [--service=<service_tag>]... [--group=<group_tag>]... [--compose-file=<filename>]
34
+ subcompose tag [--interpolate] [--unmanaged] [--var-file=<filename>] [--src-tag=<src_tag>] [--service=<service_tag>]... [--group=<group_tag>]... --registry=<registry> --dst-tag=<dst_tag> [--compose-file=<filename>]
35
+ subcompose validate [--debug] [--fix] [--compose-file=<filename>]
36
+
37
+ Examples:
38
+ subcompose run --group=core
39
+ subcompose run --group=minimal:1.0.0-SNAPSHOT --group=elk:1.0.0-SNAPSHOT --service=elasticsearch:latest --service=kibana:latest
40
+ subcompose run --group=standalone
41
+ subcompose stop --group=standalone
42
+ subcompose --list
43
+ subcompose preview --group=minimal --service=gateway
44
+
45
+ Please note that it is also possible to launch the docker stack by passing the output of preview to docker compose. e.g.:
46
+
47
+ subcompose preview --group=all | docker compose up
48
+ subcompose preview --group=core --group=elk | docker compose up
49
+
50
+ Options:
51
+ -E --env-var=<variable> Set ENV variable ( -E AWS_SECRET_ACCESS_KEY=<some_value> -E AWS_DEFAULT_REGION=<some_value> ... )
52
+ -T --dst-tag=<dst_tag> Set TAG and repo for destination (-T 127.0.0.1:5000:mytag). Uses for tag option only
53
+ -c --compose-file=<filename> Specify an alternate compose file [default: compose.yaml]
54
+ -d --debug Display debug messages in console
55
+ -f --var-file=<filename> Load variables from file
56
+ -g --group=<group_tag> Group of services to use (-g group1:tag1 -g group2:tag2 ...)
57
+ -h -? --help Show this screen.
58
+ -i --interpolate Enable variable interpolation
59
+ -l --list List of available groups and services
60
+ -r --registry=<registry> Set registry URL
61
+ -s --service=<service_tag> Service to use (-s service1:tag1 -s service2:tag2 ...)
62
+ -t --src-tag=<src_tag> Default image tag
63
+ -u --unmanaged Exclude managed services
64
+ -v --version Show version.
65
+
66
+ Commands:
67
+ 🗑️ delete-containers Removes specified containers using the auto-generated compose.yaml configuration or all system containers with --all
68
+ 🖼️ delete-images Removes specified images using the auto-generated compose.yaml configuration
69
+ 👁️ preview Prints the auto-generated compose.yaml configuration to the console
70
+ ⬇️ pull Pulls Docker images to the registry using an auto-generated compose.yaml to obtain image links
71
+ ⬆️ push Pushes Docker images to the registry using an auto-generated compose.yaml to obtain image links
72
+ 🐳 run Runs the compose project using an auto-generated compose.yaml
73
+ ⏹️ stop Stops specified containers using the auto-generated compose.yaml configuration
74
+ 🏷️ tag Adds tag to images for services and nested services that are in the script
75
+ 🛡️ validate Validates the groups and dependencies in compose.yaml
76
+ """
77
+
78
+ import logging
79
+ import re
80
+ import subprocess
81
+ import sys
82
+ from importlib.metadata import version as _pkg_version
83
+ from pathlib import Path
84
+ from typing import Any, Dict
85
+
86
+ import yaml
87
+ from docopt import docopt
88
+
89
+ from subcompose.constants import (
90
+ ARG_DEBUG,
91
+ ARG_GROUPS,
92
+ ARG_SERVICES,
93
+ ARG_SRC_TAG,
94
+ ARG_UNMANAGED,
95
+ COMPOSE_COMMAND,
96
+ )
97
+ from subcompose.filtering import (
98
+ filter_by_image_tag,
99
+ remove_dependencies_from_filtered_containers,
100
+ )
101
+ from subcompose.logger import configure_logging
102
+ from subcompose.parsing import get_groups_from_data
103
+ from subcompose.substitution import (
104
+ substitute_environment_variables,
105
+ substitute_image_tags,
106
+ )
107
+ from subcompose.utils import remove_subcompose_keys, represent_none
108
+ from subcompose.validation import validate_groups, validate_volumes
109
+
110
+ BOLD = '\033[1m'
111
+ RESET = '\033[0m'
112
+
113
+ # Function to bold 'subcompose' in any string
114
+ def bold_subcompose(text: str) -> str:
115
+ return re.sub(r'(subcompose)', f'{BOLD}\1{RESET}', text, flags=re.IGNORECASE)
116
+
117
+
118
+ def main() -> None:
119
+ # Print banner at the top of every command output
120
+ banner_path = Path(__file__).parent.parent / "banner.txt"
121
+ if banner_path.exists():
122
+ banner = banner_path.read_text()
123
+ print(bold_subcompose(banner))
124
+
125
+ try:
126
+ _version = _pkg_version("subcompose")
127
+ arguments: Dict[str, Any] = docopt(__doc__, version=_version)
128
+ except Exception as e:
129
+ print(e)
130
+ sys.exit(1)
131
+
132
+ compose_file = "compose.yaml"
133
+ # Pre-scan arguments for compose file to load groups
134
+ # Note: docopt handles arguments, but for dynamic help generation dependent on file content
135
+ # we might need to parse manually or rely on default if available.
136
+ # However, logic in original script was iterating sys.argv manually before docopt.
137
+ # We can keep that logic.
138
+ for i, arg in enumerate(sys.argv):
139
+ if arg.startswith("--compose-file="):
140
+ compose_file = arg.split("=", 1)[1]
141
+ elif (arg == "-c" or arg == "--compose-file") and i + 1 < len(sys.argv):
142
+ compose_file = sys.argv[i + 1]
143
+
144
+ content = ""
145
+ data: Dict[str, Any] = {}
146
+ try:
147
+ content = Path(compose_file).read_text()
148
+ data = yaml.safe_load(content) or {}
149
+ except Exception as e:
150
+ logging.debug(f"Could not load groups: {e}")
151
+ pass
152
+
153
+ groups = get_groups_from_data(data)
154
+
155
+ project_name = data.get("name", "project_name")
156
+ help_groups = f"# {project_name} subcompose groups and services:\n"
157
+ for group_name in sorted(groups.keys()):
158
+ help_groups += f"\n[{group_name}]\n"
159
+ for service in sorted(groups[group_name]):
160
+ help_groups += f"{service}\n"
161
+
162
+ if arguments["--list"]:
163
+ print(help_groups)
164
+ return
165
+
166
+ debug = arguments[ARG_DEBUG]
167
+ configure_logging(debug)
168
+
169
+ logging.debug("Docker subcompose utility started...")
170
+
171
+ if arguments[ARG_SERVICES]:
172
+ arguments[ARG_SERVICES] = str(arguments[ARG_SERVICES][0]).split(",")
173
+
174
+ logging.debug(f"\nCommand-line arguments:\n{arguments}")
175
+
176
+ yaml.add_representer(type(None), represent_none)
177
+
178
+ try:
179
+ # If the generated Docker compose file is to be run in interpolation mode then the registry URL is required as
180
+ # part of the image names, allowing the server to know where to pull the images from. Otherwise, if running
181
+ # locally then no registry reference is required as part of the image names.
182
+ if arguments["--interpolate"]:
183
+ content = content.replace("${REGISTRY_URL}/", "")
184
+ data = yaml.safe_load(content) or {}
185
+
186
+ project_name = data.get("name", "default")
187
+
188
+ required_services = []
189
+ if arguments[ARG_SERVICES]:
190
+ for service in arguments[ARG_SERVICES]:
191
+ service_name = service.split(":")[0]
192
+ if service_name not in groups["all"]:
193
+ print(
194
+ f"\nERROR: unknown service '{service_name}' found in command line.\n"
195
+ )
196
+ sys.exit(1)
197
+ required_services.append(service_name)
198
+
199
+ if arguments[ARG_GROUPS]:
200
+ for group in arguments[ARG_GROUPS]:
201
+ group_name = group.split(":")[0]
202
+ if group_name not in groups:
203
+ print(f"\nERROR: unknown group '{group}' found in command line.\n")
204
+ sys.exit(1)
205
+ required_services.extend(groups[group_name])
206
+
207
+ # Add dependencies if required
208
+ if arguments["run"] or arguments["preview"]:
209
+ for service in list(required_services):
210
+ if "depends_on" in data["services"][service]:
211
+ dependencies = data["services"][service]["depends_on"]
212
+ for dependency in dependencies:
213
+ if dependency not in groups["all"]:
214
+ print(
215
+ f"\nERROR: unknown dependency '{dependency}' listed for service '{service}' in compose.yaml."
216
+ )
217
+ sys.exit(1)
218
+ required_services.append(dependency)
219
+
220
+ # Remove duplicates
221
+ required_services = list(set(required_services))
222
+
223
+ # Filter out managed services if --unmanaged is specified
224
+ managed_services_set = set()
225
+ if arguments[ARG_UNMANAGED]:
226
+ managed_services_set = {
227
+ s
228
+ for s in required_services
229
+ if data["services"][s].get("x-subcompose-managed", False)
230
+ }
231
+ required_services = [
232
+ s for s in required_services if s not in managed_services_set
233
+ ]
234
+
235
+ # Stop or deletes all services if no groups or services specified
236
+ if not required_services and (
237
+ arguments["stop"]
238
+ or arguments["delete-containers"]
239
+ or arguments["delete-images"]
240
+ ):
241
+ for group in groups.values():
242
+ required_services.extend(group)
243
+ required_services = list(set(required_services))
244
+
245
+ logging.debug(f"\nRequired services: {sorted(required_services)}")
246
+
247
+ # Copy data but replace services with only those required
248
+ required_data = {
249
+ **data,
250
+ "services": {
251
+ s: c
252
+ for s, c in data.get("services", {}).items()
253
+ if s in required_services
254
+ },
255
+ "volumes": data.get("volumes", {}),
256
+ }
257
+
258
+ if arguments["validate"]:
259
+ fix = arguments["--fix"]
260
+ issues_groups, fixed_groups = validate_groups(groups, data, fix=fix)
261
+ issues_volumes, fixed_volumes = validate_volumes(data, fix=fix)
262
+
263
+ if fix and (fixed_groups or fixed_volumes):
264
+ with open("compose.yaml", "w") as f:
265
+ yaml.dump(
266
+ data,
267
+ f,
268
+ default_flow_style=False,
269
+ sort_keys=False,
270
+ allow_unicode=True,
271
+ )
272
+ logging.info("Fixed validation warnings and updated compose.yaml.")
273
+ elif not (issues_groups or issues_volumes):
274
+ logging.info("Validation successful. No issues found.")
275
+ return
276
+
277
+ # Extract variables to ignore during substitution
278
+ ignored_vars = []
279
+ if not arguments["--interpolate"]:
280
+ # Find all variables in the content to ignore them
281
+ # Matches ${VAR} or $VAR or ${VAR:-default}
282
+ matches = re.findall(
283
+ r"\$\{?([a-zA-Z_][a-zA-Z0-9_]*)(?:[:?].*?)?}?", content
284
+ )
285
+ ignored_vars = list(set(matches))
286
+
287
+ # Substitute environment variables
288
+ logging.debug("\nSubstituting environment variables...")
289
+ substituted_data = substitute_environment_variables(
290
+ required_data,
291
+ no_interpolate=not bool(arguments["--interpolate"]),
292
+ ignored_vars=ignored_vars,
293
+ )
294
+
295
+ # Identify default image tag
296
+ service_image_tags = {}
297
+ if arguments[ARG_SRC_TAG]:
298
+ service_image_tags = {s: arguments[ARG_SRC_TAG] for s in required_services}
299
+
300
+ # Identify group-specific image tags
301
+ if arguments[ARG_GROUPS]:
302
+ for group in arguments[ARG_GROUPS]:
303
+ if ":" in group:
304
+ group_name, image_tag = group.split(":")
305
+ for service_name in groups[group_name]:
306
+ service_image_tags[service_name] = image_tag
307
+ else:
308
+ for service_name in groups[group]:
309
+ if service_name not in service_image_tags:
310
+ service_image_tags[service_name] = None
311
+
312
+ # Identify service-specific image tags
313
+ if arguments[ARG_SERVICES]:
314
+ for service in arguments[ARG_SERVICES]:
315
+ if ":" in service:
316
+ service_name, image_tag = service.split(":")
317
+ service_image_tags[service_name] = image_tag
318
+ else:
319
+ if service not in service_image_tags:
320
+ service_image_tags[service] = None
321
+
322
+ # Substitute all image tags
323
+ if service_image_tags:
324
+ logging.debug("Substituting image tags...")
325
+ substituted_data = substitute_image_tags(
326
+ substituted_data, service_image_tags
327
+ )
328
+ # Only stop specified services if they are running
329
+ if (
330
+ arguments["stop"]
331
+ or arguments["delete-containers"]
332
+ or arguments["delete-images"]
333
+ ):
334
+ logging.debug("Filtering running containers by image tags...")
335
+ substituted_data = filter_by_image_tag(
336
+ substituted_data, service_image_tags
337
+ )
338
+
339
+ # Remove dependencies for stopped containers so they can be stopped in isolation from their dependencies.
340
+ if (
341
+ arguments["stop"]
342
+ or arguments["delete-containers"]
343
+ or arguments["delete-images"]
344
+ or arguments[ARG_UNMANAGED]
345
+ ):
346
+ logging.debug("Removing dependencies from filtered containers...")
347
+ substituted_data = remove_dependencies_from_filtered_containers(
348
+ substituted_data,
349
+ groups,
350
+ only_managed=arguments[ARG_UNMANAGED],
351
+ managed_services_set=managed_services_set,
352
+ )
353
+
354
+ substituted_data = remove_subcompose_keys(substituted_data)
355
+
356
+ effective_docker_compose = yaml.dump(
357
+ substituted_data,
358
+ default_flow_style=False,
359
+ sort_keys=False,
360
+ allow_unicode=True,
361
+ )
362
+
363
+ if arguments["preview"] or debug:
364
+ print(bold_subcompose(f"\n{effective_docker_compose}"))
365
+ if arguments["preview"]:
366
+ return
367
+
368
+ if arguments["run"]:
369
+ logging.debug(bold_subcompose("\nRunning 'docker compose up' using generated YAML..."))
370
+ cmd_str = f"{COMPOSE_COMMAND} --project-name {project_name} -f - up -d"
371
+ subprocess.run(
372
+ cmd_str,
373
+ input=effective_docker_compose,
374
+ shell=True,
375
+ check=True,
376
+ text=True,
377
+ )
378
+ return
379
+
380
+ if arguments["stop"]:
381
+ logging.debug(bold_subcompose("\nRunning 'docker compose stop' using generated YAML..."))
382
+ cmd_str = f"{COMPOSE_COMMAND} --project-name {project_name} -f - stop"
383
+ subprocess.run(
384
+ cmd_str,
385
+ input=effective_docker_compose,
386
+ shell=True,
387
+ check=True,
388
+ text=True,
389
+ )
390
+ return
391
+
392
+ if arguments["delete-containers"] or arguments["delete-images"]:
393
+ if arguments["delete-containers"] and arguments["--all"]:
394
+ logging.debug(bold_subcompose("\nDeleting ALL containers on the system..."))
395
+ cmd_str = (
396
+ "docker stop $(docker ps -a -q) && docker rm -f $(docker ps -a -q)"
397
+ )
398
+ subprocess.run(cmd_str, shell=True, check=False)
399
+ return
400
+
401
+ logging.debug(bold_subcompose("\nRunning 'docker compose rm' using generated YAML..."))
402
+ cmd_str = f"{COMPOSE_COMMAND} --project-name {project_name} -f - rm --force --stop"
403
+ subprocess.run(
404
+ cmd_str,
405
+ input=effective_docker_compose,
406
+ shell=True,
407
+ check=True,
408
+ text=True,
409
+ )
410
+
411
+ if arguments["delete-images"]:
412
+ logging.debug(bold_subcompose("\nRunning 'docker rmi' against Docker images in generated YAML..."))
413
+ tagged_images = [
414
+ params["image"]
415
+ for service, params in substituted_data["services"].items()
416
+ ]
417
+ if tagged_images:
418
+ cmd_str = f"docker rmi --force {' '.join(tagged_images)}"
419
+ subprocess.run(cmd_str, shell=True, check=False)
420
+ return
421
+
422
+ except yaml.YAMLError as exc:
423
+ print(bold_subcompose(str(exc)))
424
+ except subprocess.CalledProcessError as exc:
425
+ print(bold_subcompose(f"Error running command: {exc}"))
426
+ sys.exit(1)
427
+
428
+
429
+ if __name__ == "__main__":
430
+ main()
@@ -0,0 +1,33 @@
1
+ # Copyright (C) 2026 Jose Hernandez
2
+ #
3
+ # This file is part of subcompose.
4
+ #
5
+ # subcompose is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # subcompose is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with subcompose. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+
19
+ """Constants for subcompose."""
20
+
21
+ import logging
22
+
23
+ ARG_SERVICES = "--service"
24
+ ARG_GROUPS = "--group"
25
+ ARG_SRC_TAG = "--src-tag"
26
+ ARG_DEBUG = "--debug"
27
+ ARG_COMPOSE_FILE = "--compose-file"
28
+ ARG_UNMANAGED = "--unmanaged"
29
+
30
+ COMPOSE_COMMAND = "docker compose"
31
+ MAX_LEVELNAME_LEN = max(
32
+ (len(str(name)) for name in logging.getLevelNamesMapping().keys()), default=0
33
+ )
@@ -0,0 +1,97 @@
1
+ # Copyright (C) 2026 Jose Hernandez
2
+ #
3
+ # This file is part of subcompose.
4
+ #
5
+ # subcompose is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # subcompose is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with subcompose. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ """Filtering logic for subcompose."""
19
+
20
+ import subprocess
21
+ from typing import Any, Dict, List, Optional
22
+
23
+
24
+ def filter_by_image_tag(
25
+ parent: Dict[str, Any], service_image_tags: Dict[str, Optional[str]]
26
+ ) -> Dict[str, Any]:
27
+ """
28
+ Filters services to only include those that are currently running with the specified image tags.
29
+
30
+ Args:
31
+ parent (Dict[str, Any]): The compose data.
32
+ service_image_tags (Dict[str, Optional[str]]): A map of service names to image tags.
33
+
34
+ Returns:
35
+ Dict[str, Any]: The filtered compose data.
36
+ """
37
+ # Filter running containers by container name.
38
+ name_filters = '$" -f "name='.join(service_image_tags.keys())
39
+ cmd = f'docker ps -f "name={name_filters}$" --format "{{{{.Names}}}}"'
40
+ result = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, check=False)
41
+ running_containers = result.stdout.decode("utf-8").strip().split("\n")
42
+
43
+ parent["services"] = {
44
+ name: params
45
+ for name, params in parent["services"].items()
46
+ if parent["services"][name].get("container_name") in running_containers
47
+ or service_image_tags[name] is None
48
+ }
49
+ return parent
50
+
51
+
52
+ def remove_dependencies_from_filtered_containers(
53
+ parent: Dict[str, Any],
54
+ groups: Dict[str, List[str]],
55
+ only_managed: bool = False,
56
+ managed_services_set: Optional[set[str]] = None,
57
+ ) -> Dict[str, Any]:
58
+ """
59
+ Removes dependencies from services that are not in the managed groups.
60
+
61
+ Args:
62
+ parent (Dict[str, Any]): The compose data.
63
+ groups (Dict[str, List[str]]): The group definitions.
64
+ only_managed (bool): If True, only removes dependencies that are managed externally depending on the deployment.
65
+ managed_services_set (Optional[set[str]]): Set of managed services to remove from dependencies.
66
+
67
+ Returns:
68
+ Dict[str, Any]: The updated compose data.
69
+ """
70
+ if managed_services_set is None:
71
+ managed_services_set = {
72
+ s
73
+ for s, c in parent["services"].items()
74
+ if c.get("x-subcompose-managed", False)
75
+ }
76
+
77
+ for service_config in parent["services"].values():
78
+ if not only_managed:
79
+ service_config.pop("depends_on", None)
80
+ continue
81
+
82
+ depends_on = service_config.get("depends_on")
83
+ if not depends_on:
84
+ continue
85
+
86
+ if isinstance(depends_on, list):
87
+ service_config["depends_on"] = [
88
+ s for s in depends_on if s not in managed_services_set
89
+ ]
90
+ elif isinstance(depends_on, dict):
91
+ for s in managed_services_set:
92
+ depends_on.pop(s, None)
93
+
94
+ if not service_config["depends_on"]:
95
+ del service_config["depends_on"]
96
+
97
+ return parent
subcompose/logger.py ADDED
@@ -0,0 +1,48 @@
1
+ # Copyright (C) 2026 Jose Hernandez
2
+ #
3
+ # This file is part of subcompose.
4
+ #
5
+ # subcompose is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # subcompose is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with subcompose. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+
19
+ """Logging configuration for subcompose."""
20
+
21
+ import logging
22
+ import sys
23
+
24
+ from subcompose.constants import MAX_LEVELNAME_LEN
25
+
26
+
27
+ def configure_logging(debug: bool) -> None:
28
+ """
29
+ Configure logging for the application.
30
+
31
+ Args:
32
+ debug (bool): Whether to enable debug logging.
33
+ """
34
+ root_logger = logging.getLogger()
35
+ root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
36
+
37
+ # Remove existing handlers to avoid duplication or wrong configuration
38
+ if root_logger.handlers:
39
+ for handler in root_logger.handlers:
40
+ root_logger.removeHandler(handler)
41
+
42
+ handler = logging.StreamHandler(sys.stdout)
43
+ handler.setLevel(logging.DEBUG if debug else logging.INFO)
44
+ formatter = logging.Formatter(
45
+ f"[%(levelname)-{MAX_LEVELNAME_LEN}s] %(name)s: %(message)s"
46
+ )
47
+ handler.setFormatter(formatter)
48
+ root_logger.addHandler(handler)