devservices 0.0.1__tar.gz

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,12 @@
1
+ Metadata-Version: 2.1
2
+ Name: devservices
3
+ Version: 0.0.1
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: pyyaml
6
+ Requires-Dist: sentry-devenv
7
+ Provides-Extra: dev
8
+ Requires-Dist: black; extra == "dev"
9
+ Requires-Dist: mypy; extra == "dev"
10
+ Requires-Dist: pre-commit; extra == "dev"
11
+ Requires-Dist: pytest; extra == "dev"
12
+ Requires-Dist: types-PyYAML; extra == "dev"
@@ -0,0 +1 @@
1
+ # devservices
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "devservices"
7
+ version = "0.0.1"
8
+ requires-python = ">=3.12"
9
+ dependencies = [
10
+ "pyyaml",
11
+ "sentry-devenv",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "black",
17
+ "mypy",
18
+ "pre-commit",
19
+ "pytest",
20
+ "types-PyYAML",
21
+ ]
22
+
23
+ [project.scripts]
24
+ devservices = "main:main"
25
+
26
+ [tool.setuptools]
27
+ package-dir = {"" = "src"}
28
+ packages = {find = {where = ["src"]}}
29
+ include-package-data = true
30
+
31
+ [tool.mypy]
32
+ python_version = "3.12"
33
+ strict = true
34
+ ignore_missing_imports = true
35
+
36
+ [[tool.mypy.overrides]]
37
+ module = "yaml.*"
38
+ ignore_missing_imports = true
@@ -0,0 +1,9 @@
1
+ [flake8]
2
+ max-line-length = 100
3
+ extend-ignore =
4
+ E501
5
+
6
+ [egg_info]
7
+ tag_build =
8
+ tag_date = 0
9
+
File without changes
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from argparse import _SubParsersAction
4
+ from argparse import ArgumentParser
5
+ from argparse import Namespace
6
+
7
+ from utils.services import find_matching_service
8
+
9
+
10
+ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
11
+ parser = subparsers.add_parser(
12
+ "list-dependencies", help="List the dependencies of a service"
13
+ )
14
+ parser.add_argument(
15
+ "service_name",
16
+ help="Name of the service to list the dependencies of",
17
+ nargs="?",
18
+ default=None,
19
+ )
20
+ parser.set_defaults(func=list_dependencies)
21
+
22
+
23
+ def list_dependencies(args: Namespace) -> None:
24
+ """List the dependencies of a service."""
25
+ service_name = args.service_name
26
+
27
+ try:
28
+ service = find_matching_service(service_name)
29
+ except Exception as e:
30
+ print(e)
31
+ exit(1)
32
+
33
+ dependencies = service.config.dependencies
34
+
35
+ if not dependencies:
36
+ print(f"No dependencies found for {service.name}")
37
+ return
38
+
39
+ print(f"Dependencies of {service.name}:")
40
+ for dependency_key, dependency_info in dependencies.items():
41
+ print("-", dependency_key, ":", dependency_info.description)
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from argparse import _SubParsersAction
4
+ from argparse import ArgumentParser
5
+ from argparse import Namespace
6
+
7
+ from utils.devenv import get_coderoot
8
+ from utils.services import get_local_services
9
+
10
+
11
+ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
12
+ parser = subparsers.add_parser(
13
+ "list-services", help="List the services installed locally"
14
+ )
15
+ parser.set_defaults(func=list_services)
16
+
17
+
18
+ def list_services(args: Namespace) -> None:
19
+ """List the services installed locally."""
20
+
21
+ # Get all of the services installed locally
22
+ coderoot = get_coderoot()
23
+ services = get_local_services(coderoot)
24
+
25
+ if not services:
26
+ print("No services found")
27
+ return
28
+
29
+ print("Services installed locally:")
30
+ for service in services:
31
+ print("-", service.name, f"({service.repo_path})")
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from argparse import _SubParsersAction
6
+ from argparse import ArgumentParser
7
+ from argparse import Namespace
8
+
9
+ from constants import DEVSERVICES_DIR_NAME
10
+ from constants import DOCKER_COMPOSE_FILE_NAME
11
+ from exceptions import DockerComposeError
12
+ from utils.docker_compose import run_docker_compose_command
13
+ from utils.services import find_matching_service
14
+
15
+
16
+ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
17
+ parser = subparsers.add_parser("logs", help="View logs for a service")
18
+ parser.add_argument(
19
+ "service_name",
20
+ help="Name of the service to view logs for",
21
+ nargs="?",
22
+ default=None,
23
+ )
24
+ parser.set_defaults(func=logs)
25
+
26
+
27
+ def logs(args: Namespace) -> None:
28
+ """View the logs for a specified service."""
29
+ service_name = args.service_name
30
+ try:
31
+ service = find_matching_service(service_name)
32
+ except Exception as e:
33
+ print(e)
34
+ exit(1)
35
+ modes = service.config.modes
36
+ # TODO: allow custom modes to be used
37
+ mode_to_use = "default"
38
+ mode_dependencies = " ".join(modes[mode_to_use])
39
+ service_config_file_path = os.path.join(
40
+ service.repo_path, DEVSERVICES_DIR_NAME, DOCKER_COMPOSE_FILE_NAME
41
+ )
42
+ try:
43
+ logs = run_docker_compose_command(
44
+ f"-f {service_config_file_path} logs {mode_dependencies}"
45
+ )
46
+ except DockerComposeError as dce:
47
+ print(f"Failed to get logs for {service.name}: {dce.stderr}")
48
+ exit(1)
49
+ sys.stdout.write(logs.stdout)
50
+ sys.stdout.flush()
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from argparse import _SubParsersAction
5
+ from argparse import ArgumentParser
6
+ from argparse import Namespace
7
+
8
+ from constants import DEVSERVICES_DIR_NAME
9
+ from constants import DOCKER_COMPOSE_FILE_NAME
10
+ from exceptions import DockerComposeError
11
+ from utils.console import Status
12
+ from utils.docker_compose import run_docker_compose_command
13
+ from utils.services import find_matching_service
14
+
15
+
16
+ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
17
+ parser = subparsers.add_parser("start", help="Start a service and its dependencies")
18
+ parser.add_argument(
19
+ "service_name", help="Name of the service to start", nargs="?", default=None
20
+ )
21
+ parser.set_defaults(func=start)
22
+
23
+
24
+ def start(args: Namespace) -> None:
25
+ """Start a service and its dependencies."""
26
+ service_name = args.service_name
27
+ try:
28
+ service = find_matching_service(service_name)
29
+ except Exception as e:
30
+ print(e)
31
+ exit(1)
32
+ modes = service.config.modes
33
+ # TODO: allow custom modes to be used
34
+ mode_to_start = "default"
35
+ mode_dependencies = " ".join(modes[mode_to_start])
36
+ service_config_file_path = os.path.join(
37
+ service.repo_path, DEVSERVICES_DIR_NAME, DOCKER_COMPOSE_FILE_NAME
38
+ )
39
+ with Status(f"Starting {service.name}", f"{service.name} started") as status:
40
+ try:
41
+ run_docker_compose_command(
42
+ f"-f {service_config_file_path} up -d {mode_dependencies}"
43
+ )
44
+ except DockerComposeError as dce:
45
+ status.print(f"Failed to start {service.name}: {dce.stderr}")
46
+ exit(1)
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from argparse import _SubParsersAction
7
+ from argparse import ArgumentParser
8
+ from argparse import Namespace
9
+
10
+ from constants import DEVSERVICES_DIR_NAME
11
+ from constants import DOCKER_COMPOSE_FILE_NAME
12
+ from exceptions import DockerComposeError
13
+ from utils.docker_compose import run_docker_compose_command
14
+ from utils.services import find_matching_service
15
+
16
+ LINE_LENGTH = 40
17
+
18
+
19
+ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
20
+ parser = subparsers.add_parser("status", help="View status of a service")
21
+ parser.add_argument(
22
+ "service_name",
23
+ help="Name of the service to view status for",
24
+ nargs="?",
25
+ default=None,
26
+ )
27
+ parser.set_defaults(func=status)
28
+
29
+
30
+ def format_status_output(status_json: str) -> str:
31
+ # Docker compose ps is line delimited json, so this constructs this into an array we can use
32
+ service_statuses = status_json.split("\n")[:-1]
33
+ output = []
34
+ output.append("-" * LINE_LENGTH)
35
+ for service_status in service_statuses:
36
+ service = json.loads(service_status)
37
+ name = service["Service"]
38
+ state = service["State"]
39
+ health = service.get("Health", "N/A")
40
+ ports = service.get("Publishers", [])
41
+ running_for = service.get("RunningFor", "N/A")
42
+
43
+ output.append(f"{name}")
44
+ output.append(f"Status: {state}")
45
+ output.append(f"Health: {health}")
46
+ output.append(f"Uptime: {running_for}")
47
+
48
+ if ports:
49
+ output.append("Ports:")
50
+ for port in ports:
51
+ output.append(
52
+ f" {port['PublishedPort']} -> {port['TargetPort']}/{port['Protocol']}"
53
+ )
54
+ else:
55
+ output.append("No ports exposed")
56
+
57
+ output.append("") # Empty line for readability
58
+
59
+ return "\n".join(output)
60
+
61
+
62
+ def status(args: Namespace) -> None:
63
+ """Start a service and its dependencies."""
64
+ service_name = args.service_name
65
+ try:
66
+ service = find_matching_service(service_name)
67
+ except Exception as e:
68
+ print(e)
69
+ exit(1)
70
+ modes = service.config.modes
71
+ # TODO: allow custom modes to be used
72
+ mode_to_view = "default"
73
+ mode_dependencies = modes[mode_to_view]
74
+ service_config_file_path = os.path.join(
75
+ service.repo_path, DEVSERVICES_DIR_NAME, DOCKER_COMPOSE_FILE_NAME
76
+ )
77
+ mode_dependencies = " ".join(modes[mode_to_view])
78
+ try:
79
+ status_json = run_docker_compose_command(
80
+ f"-f {service_config_file_path} ps {mode_dependencies} --format json"
81
+ ).stdout
82
+ except DockerComposeError as dce:
83
+ print(f"Failed to get status for {service.name}: {dce.stderr}")
84
+ exit(1)
85
+ # If the service is not running, the status_json will be empty
86
+ if not status_json:
87
+ print(f"{service.name} is not running")
88
+ return
89
+ output = f"Service: {service.name}\n\n"
90
+ output += format_status_output(status_json)
91
+ output += "=" * LINE_LENGTH
92
+ sys.stdout.write(output + "\n")
93
+ sys.stdout.flush()
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from argparse import _SubParsersAction
5
+ from argparse import ArgumentParser
6
+ from argparse import Namespace
7
+
8
+ from constants import DEVSERVICES_DIR_NAME
9
+ from constants import DOCKER_COMPOSE_FILE_NAME
10
+ from exceptions import DockerComposeError
11
+ from utils.console import Status
12
+ from utils.docker_compose import run_docker_compose_command
13
+ from utils.services import find_matching_service
14
+
15
+
16
+ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
17
+ parser = subparsers.add_parser("stop", help="Stop a service and its dependencies")
18
+ parser.add_argument(
19
+ "service_name", help="Name of the service to stop", nargs="?", default=None
20
+ )
21
+ parser.set_defaults(func=stop)
22
+
23
+
24
+ def stop(args: Namespace) -> None:
25
+ """Stop a service and its dependencies."""
26
+ service_name = args.service_name
27
+ try:
28
+ service = find_matching_service(service_name)
29
+ except Exception as e:
30
+ print(e)
31
+ exit(1)
32
+ modes = service.config.modes
33
+ # TODO: allow custom modes to be used
34
+ mode_to_stop = "default"
35
+ mode_dependencies = " ".join(modes[mode_to_stop])
36
+ service_config_file_path = os.path.join(
37
+ service.repo_path, DEVSERVICES_DIR_NAME, DOCKER_COMPOSE_FILE_NAME
38
+ )
39
+ with Status(f"Stopping {service.name}", f"{service.name} stopped") as status:
40
+ try:
41
+ run_docker_compose_command(
42
+ f"-f {service_config_file_path} down {mode_dependencies}"
43
+ )
44
+ except DockerComposeError as dce:
45
+ status.print(f"Failed to stop {service.name}: {dce.stderr}")
46
+ exit(1)
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ import yaml
7
+ from constants import DEVSERVICES_DIR_NAME
8
+ from constants import DOCKER_COMPOSE_FILE_NAME
9
+ from exceptions import ConfigNotFoundError
10
+ from exceptions import ConfigParseError
11
+ from exceptions import ConfigValidationError
12
+
13
+
14
+ VALID_VERSIONS = [0.1]
15
+
16
+
17
+ @dataclass
18
+ class Dependency:
19
+ description: str
20
+ link: str | None = None
21
+
22
+
23
+ @dataclass
24
+ class ServiceConfig:
25
+ version: float
26
+ service_name: str
27
+ dependencies: dict[str, Dependency]
28
+ modes: dict[str, list[str]]
29
+
30
+ def __post_init__(self) -> None:
31
+ self._validate()
32
+
33
+ def _validate(self) -> None:
34
+ if not self.version:
35
+ raise ConfigValidationError("Version is required in service config")
36
+
37
+ if self.version not in VALID_VERSIONS:
38
+ raise ConfigValidationError(
39
+ f"Invalid version '{self.version}' in service config"
40
+ )
41
+
42
+ if not self.service_name:
43
+ raise ConfigValidationError("Service name is required in service config")
44
+
45
+ if "default" not in self.modes:
46
+ raise ConfigValidationError("Default mode is required in service config")
47
+
48
+ for mode, services in self.modes.items():
49
+ if not isinstance(services, list):
50
+ raise ConfigValidationError(f"Services in mode '{mode}' must be a list")
51
+ for service in services:
52
+ if service not in self.dependencies:
53
+ raise ConfigValidationError(
54
+ f"Service '{service}' in mode '{mode}' is not defined in dependencies"
55
+ )
56
+
57
+
58
+ def load_service_config_from_file(repo_path: str) -> ServiceConfig:
59
+ config_path = os.path.join(
60
+ repo_path, DEVSERVICES_DIR_NAME, DOCKER_COMPOSE_FILE_NAME
61
+ )
62
+ if not os.path.exists(config_path):
63
+ raise ConfigNotFoundError(f"Config file not found in directory: {config_path}")
64
+ with open(config_path, "r") as stream:
65
+ try:
66
+ config = yaml.safe_load(stream)
67
+ except yaml.YAMLError as yml_error:
68
+ raise ConfigParseError(
69
+ f"Error parsing config file: {yml_error}"
70
+ ) from yml_error
71
+
72
+ if "x-sentry-service-config" not in config:
73
+ raise ConfigParseError(
74
+ "Config file does not contain 'x-sentry-service-config' key"
75
+ )
76
+ service_config_data = config.get("x-sentry-service-config")
77
+
78
+ try:
79
+ dependencies = {
80
+ key: Dependency(**value)
81
+ for key, value in service_config_data.get("dependencies", {}).items()
82
+ }
83
+ except TypeError as type_error:
84
+ raise ConfigParseError(
85
+ f"Error parsing service dependencies: {type_error}"
86
+ ) from type_error
87
+
88
+ service_config = ServiceConfig(
89
+ version=service_config_data.get("version"),
90
+ service_name=service_config_data.get("service_name"),
91
+ dependencies=dependencies,
92
+ modes=service_config_data.get("modes", {}),
93
+ )
94
+
95
+ return service_config
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.1
2
+ Name: devservices
3
+ Version: 0.0.1
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: pyyaml
6
+ Requires-Dist: sentry-devenv
7
+ Provides-Extra: dev
8
+ Requires-Dist: black; extra == "dev"
9
+ Requires-Dist: mypy; extra == "dev"
10
+ Requires-Dist: pre-commit; extra == "dev"
11
+ Requires-Dist: pytest; extra == "dev"
12
+ Requires-Dist: types-PyYAML; extra == "dev"
@@ -0,0 +1,23 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.cfg
4
+ src/commands/__init__.py
5
+ src/commands/list_dependencies.py
6
+ src/commands/list_services.py
7
+ src/commands/logs.py
8
+ src/commands/start.py
9
+ src/commands/status.py
10
+ src/commands/stop.py
11
+ src/configs/service_config.py
12
+ src/devservices.egg-info/PKG-INFO
13
+ src/devservices.egg-info/SOURCES.txt
14
+ src/devservices.egg-info/dependency_links.txt
15
+ src/devservices.egg-info/entry_points.txt
16
+ src/devservices.egg-info/requires.txt
17
+ src/devservices.egg-info/top_level.txt
18
+ src/utils/__init__.py
19
+ src/utils/console.py
20
+ src/utils/devenv.py
21
+ src/utils/docker_compose.py
22
+ src/utils/services.py
23
+ tests/testutils.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ devservices = main:main
@@ -0,0 +1,9 @@
1
+ pyyaml
2
+ sentry-devenv
3
+
4
+ [dev]
5
+ black
6
+ mypy
7
+ pre-commit
8
+ pytest
9
+ types-PyYAML
@@ -0,0 +1,3 @@
1
+ commands
2
+ configs
3
+ utils
File without changes
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import threading
5
+ import time
6
+ from types import TracebackType
7
+
8
+
9
+ ANIMATION_FRAMES = ("⠟", "⠯", "⠷", "⠾", "⠽", "⠻")
10
+
11
+
12
+ class Status:
13
+ """Shows loading status in the terminal."""
14
+
15
+ def __init__(
16
+ self, start_message: str | None = None, end_message: str | None = None
17
+ ) -> None:
18
+ self.start_message = start_message
19
+ self.end_message = end_message
20
+ self._stop_loading = threading.Event()
21
+ self._loading_thread = threading.Thread(target=self._loading_animation)
22
+ self._exception_occured = False
23
+
24
+ def print(self, message: str) -> None:
25
+ sys.stdout.write("\r" + message + "\n")
26
+ sys.stdout.flush()
27
+
28
+ def start(self) -> None:
29
+ if self.start_message:
30
+ print(self.start_message)
31
+ self._loading_thread.start()
32
+
33
+ def stop(self) -> None:
34
+ self._stop_loading.set()
35
+ self._loading_thread.join()
36
+ sys.stdout.write("\r")
37
+ sys.stdout.flush()
38
+ if self.end_message and not self._exception_occured:
39
+ print(self.end_message)
40
+
41
+ def _loading_animation(self) -> None:
42
+ idx = 0
43
+ while not self._stop_loading.is_set():
44
+ sys.stdout.write("\r" + ANIMATION_FRAMES[idx % len(ANIMATION_FRAMES)] + " ")
45
+ sys.stdout.flush()
46
+ idx += 1
47
+ time.sleep(0.1)
48
+
49
+ def __enter__(self) -> Status:
50
+ self.start()
51
+ return self
52
+
53
+ def __exit__(
54
+ self,
55
+ exc_type: type[BaseException] | None,
56
+ exc_inst: BaseException | None,
57
+ exc_tb: TracebackType | None,
58
+ ) -> bool:
59
+ self._exception_occured = exc_type is not None
60
+ self.stop()
61
+ if exc_type:
62
+ if exc_type in (KeyboardInterrupt,):
63
+ # Don't print anything if the user interrupts the process
64
+ return True
65
+ else:
66
+ return False
67
+ return False
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from configparser import ConfigParser
5
+ from configparser import NoOptionError
6
+ from configparser import NoSectionError
7
+
8
+ from devenv.constants import home
9
+ from devenv.lib.config import read_config
10
+
11
+
12
+ def get_coderoot() -> str:
13
+ config_path = os.path.join(home, ".config", "sentry-devenv", "config.ini")
14
+ try:
15
+ devenv_config: ConfigParser = read_config(config_path)
16
+ return devenv_config.get("devenv", "coderoot", fallback="")
17
+ except (FileNotFoundError, NoSectionError, NoOptionError):
18
+ # TODO: Handle the case where there is no config file or the coderoot is not set
19
+ raise Exception("Failed to read code root from config")
20
+ except Exception as e:
21
+ raise Exception(f"Failed to read config: {e}")
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+
5
+ from exceptions import DockerComposeError
6
+
7
+
8
+ def run_docker_compose_command(command: str) -> subprocess.CompletedProcess[str]:
9
+ cmd = ["docker", "compose"] + command.split()
10
+ try:
11
+ return subprocess.run(cmd, check=True, capture_output=True, text=True)
12
+ except subprocess.CalledProcessError as e:
13
+ raise DockerComposeError(
14
+ command=command,
15
+ returncode=e.returncode,
16
+ stdout=e.stdout,
17
+ stderr=e.stderr,
18
+ )
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ from configs.service_config import ServiceConfig
7
+ from exceptions import ConfigNotFoundError
8
+ from exceptions import ConfigParseError
9
+ from exceptions import ConfigValidationError
10
+ from exceptions import ServiceNotFoundError
11
+ from utils.devenv import get_coderoot
12
+
13
+
14
+ @dataclass
15
+ class Service:
16
+ name: str
17
+ repo_path: str
18
+ config: ServiceConfig
19
+
20
+
21
+ def get_local_services(coderoot: str) -> list[Service]:
22
+ """Get a list of services in the coderoot."""
23
+ from configs.service_config import load_service_config_from_file
24
+
25
+ services = []
26
+ for repo in os.listdir(coderoot):
27
+ repo_path = os.path.join(coderoot, repo)
28
+ try:
29
+ service_config = load_service_config_from_file(repo_path)
30
+ except (ConfigNotFoundError, ConfigParseError, ConfigValidationError):
31
+ continue
32
+ service_name = service_config.service_name
33
+ services.append(
34
+ Service(
35
+ name=service_name,
36
+ repo_path=repo_path,
37
+ config=service_config,
38
+ )
39
+ )
40
+ return services
41
+
42
+
43
+ def find_matching_service(service_name: str | None = None) -> Service:
44
+ """Find a service with the given name."""
45
+ if service_name is None:
46
+ from configs.service_config import load_service_config_from_file
47
+
48
+ repo_path = os.getcwd()
49
+ service_config = load_service_config_from_file(repo_path)
50
+
51
+ return Service(
52
+ name=service_config.service_name,
53
+ repo_path=repo_path,
54
+ config=service_config,
55
+ )
56
+ coderoot = get_coderoot()
57
+ services = get_local_services(coderoot)
58
+ for service in services:
59
+ if service.name.lower() == service_name.lower():
60
+ return service
61
+ raise ServiceNotFoundError(f'Service "{service_name}" not found')
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+
7
+
8
+ def create_config_file(
9
+ tmp_path: Path, config: dict[str, object] | dict[str, dict[str, object]]
10
+ ) -> None:
11
+ devservices_dir = Path(tmp_path, "devservices")
12
+ devservices_dir.mkdir(parents=True, exist_ok=True)
13
+ tmp_file = Path(devservices_dir, "docker-compose.yml")
14
+ with tmp_file.open("w") as f:
15
+ yaml.dump(config, f, sort_keys=False, default_flow_style=False)