devservices 1.0.3__tar.gz → 1.0.5__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.
Files changed (56) hide show
  1. {devservices-1.0.3 → devservices-1.0.5}/PKG-INFO +1 -1
  2. {devservices-1.0.3 → devservices-1.0.5}/README.md +1 -1
  3. {devservices-1.0.3 → devservices-1.0.5}/devservices/commands/down.py +14 -6
  4. {devservices-1.0.3 → devservices-1.0.5}/devservices/commands/list_dependencies.py +5 -2
  5. {devservices-1.0.3 → devservices-1.0.5}/devservices/commands/list_services.py +2 -0
  6. {devservices-1.0.3 → devservices-1.0.5}/devservices/commands/logs.py +5 -2
  7. {devservices-1.0.3 → devservices-1.0.5}/devservices/commands/status.py +5 -2
  8. {devservices-1.0.3 → devservices-1.0.5}/devservices/commands/up.py +29 -6
  9. {devservices-1.0.3 → devservices-1.0.5}/devservices/utils/dependencies.py +114 -6
  10. {devservices-1.0.3 → devservices-1.0.5}/devservices/utils/devenv.py +1 -1
  11. {devservices-1.0.3 → devservices-1.0.5}/devservices/utils/docker_compose.py +2 -3
  12. {devservices-1.0.3 → devservices-1.0.5}/devservices/utils/state.py +20 -11
  13. {devservices-1.0.3 → devservices-1.0.5}/devservices.egg-info/PKG-INFO +1 -1
  14. {devservices-1.0.3 → devservices-1.0.5}/devservices.egg-info/SOURCES.txt +3 -0
  15. {devservices-1.0.3 → devservices-1.0.5}/pyproject.toml +1 -1
  16. {devservices-1.0.3 → devservices-1.0.5}/tests/commands/test_down.py +35 -3
  17. devservices-1.0.5/tests/commands/test_list_dependencies.py +111 -0
  18. {devservices-1.0.3 → devservices-1.0.5}/tests/commands/test_list_services.py +4 -4
  19. {devservices-1.0.3 → devservices-1.0.5}/tests/commands/test_logs.py +33 -0
  20. {devservices-1.0.3 → devservices-1.0.5}/tests/commands/test_purge.py +3 -3
  21. devservices-1.0.5/tests/commands/test_status.py +171 -0
  22. devservices-1.0.5/tests/commands/test_up.py +596 -0
  23. {devservices-1.0.3 → devservices-1.0.5}/tests/utils/test_dependencies.py +577 -8
  24. {devservices-1.0.3 → devservices-1.0.5}/tests/utils/test_docker_compose.py +127 -22
  25. devservices-1.0.5/tests/utils/test_services.py +102 -0
  26. {devservices-1.0.3 → devservices-1.0.5}/tests/utils/test_state.py +10 -10
  27. devservices-1.0.3/tests/commands/test_up.py +0 -338
  28. {devservices-1.0.3 → devservices-1.0.5}/LICENSE.md +0 -0
  29. {devservices-1.0.3 → devservices-1.0.5}/devservices/__init__.py +0 -0
  30. {devservices-1.0.3 → devservices-1.0.5}/devservices/commands/__init__.py +0 -0
  31. {devservices-1.0.3 → devservices-1.0.5}/devservices/commands/check_for_update.py +0 -0
  32. {devservices-1.0.3 → devservices-1.0.5}/devservices/commands/purge.py +0 -0
  33. {devservices-1.0.3 → devservices-1.0.5}/devservices/commands/update.py +0 -0
  34. {devservices-1.0.3 → devservices-1.0.5}/devservices/configs/service_config.py +0 -0
  35. {devservices-1.0.3 → devservices-1.0.5}/devservices/constants.py +0 -0
  36. {devservices-1.0.3 → devservices-1.0.5}/devservices/exceptions.py +0 -0
  37. {devservices-1.0.3 → devservices-1.0.5}/devservices/main.py +0 -0
  38. {devservices-1.0.3 → devservices-1.0.5}/devservices/utils/__init__.py +0 -0
  39. {devservices-1.0.3 → devservices-1.0.5}/devservices/utils/console.py +0 -0
  40. {devservices-1.0.3 → devservices-1.0.5}/devservices/utils/docker.py +0 -0
  41. {devservices-1.0.3 → devservices-1.0.5}/devservices/utils/file_lock.py +0 -0
  42. {devservices-1.0.3 → devservices-1.0.5}/devservices/utils/install_binary.py +0 -0
  43. {devservices-1.0.3 → devservices-1.0.5}/devservices/utils/services.py +0 -0
  44. {devservices-1.0.3 → devservices-1.0.5}/devservices.egg-info/dependency_links.txt +0 -0
  45. {devservices-1.0.3 → devservices-1.0.5}/devservices.egg-info/entry_points.txt +0 -0
  46. {devservices-1.0.3 → devservices-1.0.5}/devservices.egg-info/requires.txt +0 -0
  47. {devservices-1.0.3 → devservices-1.0.5}/devservices.egg-info/top_level.txt +0 -0
  48. {devservices-1.0.3 → devservices-1.0.5}/setup.cfg +0 -0
  49. {devservices-1.0.3 → devservices-1.0.5}/testing/__init__.py +0 -0
  50. {devservices-1.0.3 → devservices-1.0.5}/testing/utils.py +0 -0
  51. {devservices-1.0.3 → devservices-1.0.5}/tests/__init__.py +0 -0
  52. {devservices-1.0.3 → devservices-1.0.5}/tests/commands/test_update.py +0 -0
  53. {devservices-1.0.3 → devservices-1.0.5}/tests/configs/test_service_config.py +0 -0
  54. {devservices-1.0.3 → devservices-1.0.5}/tests/conftest.py +0 -0
  55. {devservices-1.0.3 → devservices-1.0.5}/tests/utils/test_docker.py +0 -0
  56. {devservices-1.0.3 → devservices-1.0.5}/tests/utils/test_install_binary.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: devservices
3
- Version: 1.0.3
3
+ Version: 1.0.5
4
4
  Requires-Python: >=3.10
5
5
  License-File: LICENSE.md
6
6
  Requires-Dist: pyyaml
@@ -11,7 +11,7 @@ A standalone cli tool used to manage dependencies for services. It simplifies th
11
11
  The recommended way to install devservices is through a virtualenv in the requirements.txt.
12
12
 
13
13
  ```
14
- devservices==1.0.3
14
+ devservices==1.0.5
15
15
  ```
16
16
 
17
17
 
@@ -56,10 +56,13 @@ def down(args: Namespace) -> None:
56
56
  service_name = args.service_name
57
57
  try:
58
58
  service = find_matching_service(service_name)
59
- except (ConfigError, ServiceNotFoundError) as e:
59
+ except ConfigError as e:
60
60
  capture_exception(e)
61
61
  console.failure(str(e))
62
62
  exit(1)
63
+ except ServiceNotFoundError as e:
64
+ console.failure(str(e))
65
+ exit(1)
63
66
 
64
67
  modes = service.config.modes
65
68
 
@@ -69,15 +72,20 @@ def down(args: Namespace) -> None:
69
72
  console.warning(f"{service.name} is not running")
70
73
  exit(0)
71
74
 
72
- mode = state.get_mode_for_service(service.name) or "default"
73
- mode_dependencies = modes[mode]
75
+ active_modes = state.get_active_modes_for_service(service.name)
76
+ mode_dependencies = set()
77
+ for active_mode in active_modes:
78
+ active_mode_dependencies = modes.get(active_mode, [])
79
+ mode_dependencies.update(active_mode_dependencies)
74
80
 
75
81
  with Status(
76
82
  lambda: console.warning(f"Stopping {service.name}"),
77
83
  lambda: console.success(f"{service.name} stopped"),
78
84
  ) as status:
79
85
  try:
80
- remote_dependencies = install_and_verify_dependencies(service, mode=mode)
86
+ remote_dependencies = install_and_verify_dependencies(
87
+ service, modes=active_modes
88
+ )
81
89
  except DependencyError as de:
82
90
  capture_exception(de)
83
91
  status.failure(str(de))
@@ -86,7 +94,7 @@ def down(args: Namespace) -> None:
86
94
  service, remote_dependencies
87
95
  )
88
96
  try:
89
- _down(service, remote_dependencies, mode_dependencies, status)
97
+ _down(service, remote_dependencies, list(mode_dependencies), status)
90
98
  except DockerComposeError as dce:
91
99
  capture_exception(dce)
92
100
  status.failure(f"Failed to stop {service.name}: {dce.stderr}")
@@ -126,7 +134,7 @@ def _down(
126
134
  ] = relative_local_dependency_directory
127
135
  docker_compose_commands = get_docker_compose_commands_to_run(
128
136
  service=service,
129
- remote_dependencies=remote_dependencies,
137
+ remote_dependencies=list(remote_dependencies),
130
138
  current_env=current_env,
131
139
  command="down",
132
140
  options=[],
@@ -32,10 +32,13 @@ def list_dependencies(args: Namespace) -> None:
32
32
 
33
33
  try:
34
34
  service = find_matching_service(service_name)
35
- except (ConfigError, ServiceNotFoundError) as e:
35
+ except ConfigError as e:
36
36
  capture_exception(e)
37
37
  console.failure(str(e))
38
38
  exit(1)
39
+ except ServiceNotFoundError as e:
40
+ console.failure(str(e))
41
+ exit(1)
39
42
 
40
43
  dependencies = service.config.dependencies
41
44
 
@@ -45,4 +48,4 @@ def list_dependencies(args: Namespace) -> None:
45
48
 
46
49
  console.info(f"Dependencies of {service.name}:")
47
50
  for dependency_key, dependency_info in dependencies.items():
48
- console.info("-" + dependency_key + ":" + dependency_info.description)
51
+ console.info("- " + dependency_key + ": " + dependency_info.description)
@@ -47,7 +47,9 @@ def list_services(args: Namespace) -> None:
47
47
 
48
48
  for service in services_to_show:
49
49
  status = "running" if service.name in running_services else "stopped"
50
+ active_modes = state.get_active_modes_for_service(service.name)
50
51
  console.info(f"- {service.name}")
52
+ console.info(f" modes: {active_modes}")
51
53
  console.info(f" status: {status}")
52
54
  console.info(f" location: {service.repo_path}")
53
55
 
@@ -46,10 +46,13 @@ def logs(args: Namespace) -> None:
46
46
  service_name = args.service_name
47
47
  try:
48
48
  service = find_matching_service(service_name)
49
- except (ConfigError, ServiceNotFoundError) as e:
49
+ except ConfigError as e:
50
50
  capture_exception(e)
51
51
  console.failure(str(e))
52
52
  exit(1)
53
+ except ServiceNotFoundError as e:
54
+ console.failure(str(e))
55
+ exit(1)
53
56
 
54
57
  modes = service.config.modes
55
58
  # TODO: allow custom modes to be used
@@ -99,7 +102,7 @@ def _logs(
99
102
  ] = relative_local_dependency_directory
100
103
  docker_compose_commands = get_docker_compose_commands_to_run(
101
104
  service=service,
102
- remote_dependencies=remote_dependencies,
105
+ remote_dependencies=list(remote_dependencies),
103
106
  current_env=current_env,
104
107
  command="logs",
105
108
  options=["-n", MAX_LOG_LINES],
@@ -81,10 +81,13 @@ def status(args: Namespace) -> None:
81
81
  service_name = args.service_name
82
82
  try:
83
83
  service = find_matching_service(service_name)
84
- except (ConfigError, ServiceNotFoundError) as e:
84
+ except ConfigError as e:
85
85
  capture_exception(e)
86
86
  console.failure(str(e))
87
87
  exit(1)
88
+ except ServiceNotFoundError as e:
89
+ console.failure(str(e))
90
+ exit(1)
88
91
 
89
92
  modes = service.config.modes
90
93
  # TODO: allow custom modes to be used
@@ -137,7 +140,7 @@ def _status(
137
140
  ] = relative_local_dependency_directory
138
141
  docker_compose_commands = get_docker_compose_commands_to_run(
139
142
  service=service,
140
- remote_dependencies=remote_dependencies,
143
+ remote_dependencies=list(remote_dependencies),
141
144
  current_env=current_env,
142
145
  command="ps",
143
146
  options=["--format", "json"],
@@ -21,6 +21,7 @@ from devservices.exceptions import ModeDoesNotExistError
21
21
  from devservices.exceptions import ServiceNotFoundError
22
22
  from devservices.utils.console import Console
23
23
  from devservices.utils.console import Status
24
+ from devservices.utils.dependencies import construct_dependency_graph
24
25
  from devservices.utils.dependencies import install_and_verify_dependencies
25
26
  from devservices.utils.dependencies import InstalledRemoteDependency
26
27
  from devservices.utils.docker_compose import get_docker_compose_commands_to_run
@@ -55,22 +56,25 @@ def up(args: Namespace) -> None:
55
56
  service_name = args.service_name
56
57
  try:
57
58
  service = find_matching_service(service_name)
58
- except (ConfigError, ServiceNotFoundError) as e:
59
+ except ConfigError as e:
59
60
  capture_exception(e)
60
61
  console.failure(str(e))
61
62
  exit(1)
63
+ except ServiceNotFoundError as e:
64
+ console.failure(str(e))
65
+ exit(1)
62
66
 
63
67
  modes = service.config.modes
64
68
  mode = args.mode
65
69
 
66
70
  with Status(
67
- lambda: console.warning(f"Starting {service.name}"),
71
+ lambda: console.warning(f"Starting '{service.name}' in mode: '{mode}'"),
68
72
  lambda: console.success(f"{service.name} started"),
69
73
  ) as status:
70
74
  try:
71
75
  status.info("Retrieving dependencies")
72
76
  remote_dependencies = install_and_verify_dependencies(
73
- service, force_update_dependencies=True, mode=mode
77
+ service, force_update_dependencies=True, modes=[mode]
74
78
  )
75
79
  except DependencyError as de:
76
80
  capture_exception(de)
@@ -79,16 +83,21 @@ def up(args: Namespace) -> None:
79
83
  except ModeDoesNotExistError as mde:
80
84
  status.failure(str(mde))
81
85
  exit(1)
86
+ try:
87
+ _create_devservices_network()
88
+ except subprocess.CalledProcessError:
89
+ # Network already exists, ignore the error
90
+ pass
82
91
  try:
83
92
  mode_dependencies = modes[mode]
84
- _up(service, remote_dependencies, mode_dependencies, status)
93
+ _up(service, [mode], remote_dependencies, mode_dependencies, status)
85
94
  except DockerComposeError as dce:
86
95
  capture_exception(dce)
87
96
  status.failure(f"Failed to start {service.name}: {dce.stderr}")
88
97
  exit(1)
89
98
  # TODO: We should factor in healthchecks here before marking service as running
90
99
  state = State()
91
- state.add_started_service(service.name, mode)
100
+ state.update_started_service(service.name, mode)
92
101
 
93
102
 
94
103
  def _bring_up_dependency(
@@ -102,6 +111,7 @@ def _bring_up_dependency(
102
111
 
103
112
  def _up(
104
113
  service: Service,
114
+ modes: list[str],
105
115
  remote_dependencies: set[InstalledRemoteDependency],
106
116
  mode_dependencies: list[str],
107
117
  status: Status,
@@ -119,9 +129,14 @@ def _up(
119
129
  DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY
120
130
  ] = relative_local_dependency_directory
121
131
  options = ["-d"]
132
+ dependency_graph = construct_dependency_graph(service, modes=modes)
133
+ starting_order = dependency_graph.get_starting_order()
134
+ sorted_remote_dependencies = sorted(
135
+ remote_dependencies, key=lambda dep: starting_order.index(dep.service_name)
136
+ )
122
137
  docker_compose_commands = get_docker_compose_commands_to_run(
123
138
  service=service,
124
- remote_dependencies=remote_dependencies,
139
+ remote_dependencies=sorted_remote_dependencies,
125
140
  current_env=current_env,
126
141
  command="up",
127
142
  options=options,
@@ -131,3 +146,11 @@ def _up(
131
146
 
132
147
  for cmd in docker_compose_commands:
133
148
  _bring_up_dependency(cmd, current_env, status, len(options))
149
+
150
+
151
+ def _create_devservices_network() -> None:
152
+ subprocess.run(
153
+ ["docker", "network", "create", "devservices"],
154
+ stdout=subprocess.DEVNULL,
155
+ stderr=subprocess.DEVNULL,
156
+ )
@@ -5,6 +5,7 @@ import os
5
5
  import shutil
6
6
  import subprocess
7
7
  import tempfile
8
+ from collections import deque
8
9
  from concurrent.futures import as_completed
9
10
  from concurrent.futures import ThreadPoolExecutor
10
11
  from dataclasses import dataclass
@@ -14,6 +15,7 @@ from typing import TypeGuard
14
15
  from devservices.configs.service_config import Dependency
15
16
  from devservices.configs.service_config import load_service_config_from_file
16
17
  from devservices.configs.service_config import RemoteConfig
18
+ from devservices.configs.service_config import ServiceConfig
17
19
  from devservices.constants import CONFIG_FILE_NAME
18
20
  from devservices.constants import DEPENDENCY_CONFIG_VERSION
19
21
  from devservices.constants import DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS
@@ -35,6 +37,62 @@ from devservices.utils.services import Service
35
37
  from devservices.utils.state import State
36
38
 
37
39
 
40
+ class DependencyGraph:
41
+ def __init__(self) -> None:
42
+ self.graph: dict[str, set[str]] = dict()
43
+
44
+ def add_dependency(self, service_name: str) -> None:
45
+ if service_name not in self.graph:
46
+ self.graph[service_name] = set()
47
+
48
+ def add_edge(self, service_name: str, dependency_name: str) -> None:
49
+ # TODO: We should rename services that depend on themselves
50
+ if service_name == dependency_name:
51
+ return
52
+ if service_name not in self.graph:
53
+ self.add_dependency(service_name)
54
+ if dependency_name not in self.graph:
55
+ self.add_dependency(dependency_name)
56
+
57
+ # TODO: Should we check for cycles here?
58
+
59
+ self.graph[service_name].add(dependency_name)
60
+
61
+ def topological_sort(self) -> list[str]:
62
+ in_degree = {service_name: 0 for service_name in self.graph}
63
+
64
+ for service_name in self.graph.keys():
65
+ for dependency in self.graph[service_name]:
66
+ in_degree[dependency] += 1
67
+
68
+ queue = deque(
69
+ [
70
+ service_name
71
+ for service_name in self.graph
72
+ if in_degree[service_name] == 0
73
+ ]
74
+ )
75
+ topological_order = list()
76
+
77
+ while queue:
78
+ service_name = queue.popleft()
79
+ topological_order.append(service_name)
80
+
81
+ for dependency in self.graph[service_name]:
82
+ in_degree[dependency] -= 1
83
+ if in_degree[dependency] == 0:
84
+ queue.append(dependency)
85
+
86
+ if len(topological_order) != len(self.graph):
87
+ # TODO: Add a better exception
88
+ raise ValueError("Cycle detected in the dependency graph")
89
+
90
+ return topological_order
91
+
92
+ def get_starting_order(self) -> list[str]:
93
+ return list(reversed(self.topological_sort()))
94
+
95
+
38
96
  @dataclass(frozen=True)
39
97
  class InstalledRemoteDependency:
40
98
  service_name: str
@@ -102,11 +160,20 @@ class GitConfigManager:
102
160
 
103
161
 
104
162
  def install_and_verify_dependencies(
105
- service: Service, force_update_dependencies: bool = False, mode: str = "default"
163
+ service: Service,
164
+ force_update_dependencies: bool = False,
165
+ modes: list[str] | None = None,
106
166
  ) -> set[InstalledRemoteDependency]:
107
- if mode not in service.config.modes:
108
- raise ModeDoesNotExistError(service_name=service.name, mode=mode)
109
- mode_dependencies = set(service.config.modes[mode])
167
+ """
168
+ Install and verify dependencies for a service
169
+ """
170
+ if modes is None:
171
+ modes = ["default"]
172
+ mode_dependencies = set()
173
+ for mode in modes:
174
+ if mode not in service.config.modes:
175
+ raise ModeDoesNotExistError(service_name=service.name, mode=mode)
176
+ mode_dependencies.update(service.config.modes[mode])
110
177
  matching_dependencies = [
111
178
  dependency
112
179
  for dependency_key, dependency in list(service.config.dependencies.items())
@@ -288,8 +355,18 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
288
355
  branch=dependency.branch,
289
356
  ) from e
290
357
 
291
- nested_dependencies = list(installed_config.dependencies.values())
292
- nested_remote_configs = _get_remote_configs(nested_dependencies)
358
+ if dependency.mode not in installed_config.modes:
359
+ raise ModeDoesNotExistError(
360
+ service_name=installed_config.service_name,
361
+ mode=dependency.mode,
362
+ )
363
+
364
+ active_nested_dependencies = [
365
+ nested_dependency
366
+ for nested_dependency_name, nested_dependency in installed_config.dependencies.items()
367
+ if nested_dependency_name in installed_config.modes[dependency.mode]
368
+ ]
369
+ nested_remote_configs = _get_remote_configs(active_nested_dependencies)
293
370
 
294
371
  installed_dependencies: set[InstalledRemoteDependency] = set(
295
372
  [
@@ -457,3 +534,34 @@ def _run_command(
457
534
  logger = logging.getLogger(LOGGER_NAME)
458
535
  logger.debug(f"Running command: {' '.join(cmd)} in {cwd}")
459
536
  subprocess.run(cmd, cwd=cwd, check=True, stdout=stdout, stderr=subprocess.DEVNULL)
537
+
538
+
539
+ def get_remote_dependency_config(remote_config: RemoteConfig) -> ServiceConfig:
540
+ dependency_repo_dir = os.path.join(
541
+ DEVSERVICES_DEPENDENCIES_CACHE_DIR,
542
+ DEPENDENCY_CONFIG_VERSION,
543
+ remote_config.repo_name,
544
+ )
545
+ return load_service_config_from_file(dependency_repo_dir)
546
+
547
+
548
+ def construct_dependency_graph(service: Service, modes: list[str]) -> DependencyGraph:
549
+ dependency_graph = DependencyGraph()
550
+
551
+ def _construct_dependency_graph(
552
+ service_config: ServiceConfig, modes: list[str]
553
+ ) -> None:
554
+ service_mode_dependencies = set()
555
+ for mode in modes:
556
+ service_mode_dependencies.update(service_config.modes.get(mode, []))
557
+ for dependency_name, dependency in service_config.dependencies.items():
558
+ # Skip the dependency if it's not in the modes (since it may not be installed and we don't care about it)
559
+ if dependency_name not in service_mode_dependencies:
560
+ continue
561
+ dependency_graph.add_edge(service_config.service_name, dependency_name)
562
+ if _has_remote_config(dependency.remote):
563
+ dependency_config = get_remote_dependency_config(dependency.remote)
564
+ _construct_dependency_graph(dependency_config, [dependency.remote.mode])
565
+
566
+ _construct_dependency_graph(service.config, modes)
567
+ return dependency_graph
@@ -13,7 +13,7 @@ def get_coderoot() -> str:
13
13
  config_path = os.path.join(home, ".config", "sentry-devenv", "config.ini")
14
14
  try:
15
15
  devenv_config: ConfigParser = read_config(config_path)
16
- return devenv_config.get("devenv", "coderoot", fallback="")
16
+ return os.path.expanduser(devenv_config.get("devenv", "coderoot", fallback=""))
17
17
  except (FileNotFoundError, NoSectionError, NoOptionError):
18
18
  # TODO: Handle the case where there is no config file or the coderoot is not set
19
19
  raise Exception("Failed to read code root from config")
@@ -163,7 +163,7 @@ def _get_non_remote_services(
163
163
 
164
164
  def get_docker_compose_commands_to_run(
165
165
  service: Service,
166
- remote_dependencies: set[InstalledRemoteDependency],
166
+ remote_dependencies: list[InstalledRemoteDependency],
167
167
  current_env: dict[str, str],
168
168
  command: str,
169
169
  options: list[str],
@@ -184,8 +184,7 @@ def get_docker_compose_commands_to_run(
184
184
  + sorted(list(services_to_use)) # Sort the services to prevent flaky tests
185
185
  + options
186
186
  )
187
- # Sort the remote dependencies by service name to ensure a deterministic order
188
- for dependency in sorted(remote_dependencies, key=lambda x: x.service_name):
187
+ for dependency in remote_dependencies:
189
188
  # TODO: Consider passing in service config in InstalledRemoteDependency instead of loading it here
190
189
  dependency_service_config = load_service_config_from_file(dependency.repo_path)
191
190
  dependency_config_path = os.path.join(
@@ -35,17 +35,26 @@ class State:
35
35
  )
36
36
  self.conn.commit()
37
37
 
38
- def add_started_service(self, service_name: str, mode: str) -> None:
38
+ def update_started_service(self, service_name: str, mode: str) -> None:
39
39
  cursor = self.conn.cursor()
40
40
  started_services = self.get_started_services()
41
- if service_name in started_services:
41
+ active_modes = self.get_active_modes_for_service(service_name)
42
+ if service_name in started_services and mode in active_modes:
42
43
  return
43
- cursor.execute(
44
- """
45
- INSERT INTO started_services (service_name, mode) VALUES (?, ?)
46
- """,
47
- (service_name, mode),
48
- )
44
+ if service_name in started_services:
45
+ cursor.execute(
46
+ """
47
+ UPDATE started_services SET mode = ? WHERE service_name = ?
48
+ """,
49
+ (",".join(active_modes + [mode]), service_name),
50
+ )
51
+ else:
52
+ cursor.execute(
53
+ """
54
+ INSERT INTO started_services (service_name, mode) VALUES (?, ?)
55
+ """,
56
+ (service_name, ",".join(active_modes + [mode])),
57
+ )
49
58
  self.conn.commit()
50
59
 
51
60
  def remove_started_service(self, service_name: str) -> None:
@@ -67,7 +76,7 @@ class State:
67
76
  )
68
77
  return [row[0] for row in cursor.fetchall()]
69
78
 
70
- def get_mode_for_service(self, service_name: str) -> str | None:
79
+ def get_active_modes_for_service(self, service_name: str) -> list[str]:
71
80
  cursor = self.conn.cursor()
72
81
  cursor.execute(
73
82
  """
@@ -77,8 +86,8 @@ class State:
77
86
  )
78
87
  result = cursor.fetchone()
79
88
  if result is None:
80
- return None
81
- return str(result[0])
89
+ return []
90
+ return str(result[0]).split(",")
82
91
 
83
92
  def clear_state(self) -> None:
84
93
  cursor = self.conn.cursor()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: devservices
3
- Version: 1.0.3
3
+ Version: 1.0.5
4
4
  Requires-Python: >=3.10
5
5
  License-File: LICENSE.md
6
6
  Requires-Dist: pyyaml
@@ -38,9 +38,11 @@ testing/utils.py
38
38
  tests/__init__.py
39
39
  tests/conftest.py
40
40
  tests/commands/test_down.py
41
+ tests/commands/test_list_dependencies.py
41
42
  tests/commands/test_list_services.py
42
43
  tests/commands/test_logs.py
43
44
  tests/commands/test_purge.py
45
+ tests/commands/test_status.py
44
46
  tests/commands/test_up.py
45
47
  tests/commands/test_update.py
46
48
  tests/configs/test_service_config.py
@@ -48,4 +50,5 @@ tests/utils/test_dependencies.py
48
50
  tests/utils/test_docker.py
49
51
  tests/utils/test_docker_compose.py
50
52
  tests/utils/test_install_binary.py
53
+ tests/utils/test_services.py
51
54
  tests/utils/test_state.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devservices"
7
- version = "1.0.3"
7
+ version = "1.0.5"
8
8
  # 3.10 is just for internal pypi compat
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -13,6 +13,8 @@ from devservices.constants import CONFIG_FILE_NAME
13
13
  from devservices.constants import DEPENDENCY_CONFIG_VERSION
14
14
  from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY
15
15
  from devservices.constants import DEVSERVICES_DIR_NAME
16
+ from devservices.exceptions import ConfigError
17
+ from devservices.exceptions import ServiceNotFoundError
16
18
  from devservices.utils.state import State
17
19
  from testing.utils import create_config_file
18
20
 
@@ -64,7 +66,7 @@ def test_down_simple(
64
66
  "devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")
65
67
  ):
66
68
  state = State()
67
- state.add_started_service("example-service", "default")
69
+ state.update_started_service("example-service", "default")
68
70
  down(args)
69
71
 
70
72
  # Ensure the DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY is set and is relative
@@ -135,7 +137,7 @@ def test_down_error(
135
137
 
136
138
  with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")):
137
139
  state = State()
138
- state.add_started_service("example-service", "default")
140
+ state.update_started_service("example-service", "default")
139
141
  with pytest.raises(SystemExit):
140
142
  down(args)
141
143
 
@@ -200,7 +202,7 @@ def test_down_mode_simple(
200
202
  "devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")
201
203
  ):
202
204
  state = State()
203
- state.add_started_service("example-service", "test")
205
+ state.update_started_service("example-service", "test")
204
206
  down(args)
205
207
 
206
208
  # Ensure the DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY is set and is relative
@@ -231,3 +233,33 @@ def test_down_mode_simple(
231
233
 
232
234
  captured = capsys.readouterr()
233
235
  assert "Stopping redis" in captured.out.strip()
236
+
237
+
238
+ @mock.patch("devservices.commands.down.find_matching_service")
239
+ def test_down_config_error(
240
+ find_matching_service_mock: mock.Mock, capsys: pytest.CaptureFixture[str]
241
+ ) -> None:
242
+ find_matching_service_mock.side_effect = ConfigError("Config error")
243
+ args = Namespace(service_name="example-service", debug=False)
244
+
245
+ with pytest.raises(SystemExit):
246
+ down(args)
247
+
248
+ find_matching_service_mock.assert_called_once_with("example-service")
249
+ captured = capsys.readouterr()
250
+ assert "Config error" in captured.out.strip()
251
+
252
+
253
+ @mock.patch("devservices.commands.down.find_matching_service")
254
+ def test_down_service_not_found_error(
255
+ find_matching_service_mock: mock.Mock, capsys: pytest.CaptureFixture[str]
256
+ ) -> None:
257
+ find_matching_service_mock.side_effect = ServiceNotFoundError("Service not found")
258
+ args = Namespace(service_name="example-service", debug=False)
259
+
260
+ with pytest.raises(SystemExit):
261
+ down(args)
262
+
263
+ find_matching_service_mock.assert_called_once_with("example-service")
264
+ captured = capsys.readouterr()
265
+ assert "Service not found" in captured.out.strip()