devservices 1.0.3__tar.gz → 1.0.4__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 (52) hide show
  1. {devservices-1.0.3 → devservices-1.0.4}/PKG-INFO +1 -1
  2. {devservices-1.0.3 → devservices-1.0.4}/README.md +1 -1
  3. {devservices-1.0.3 → devservices-1.0.4}/devservices/commands/down.py +1 -1
  4. {devservices-1.0.3 → devservices-1.0.4}/devservices/commands/logs.py +1 -1
  5. {devservices-1.0.3 → devservices-1.0.4}/devservices/commands/status.py +1 -1
  6. {devservices-1.0.3 → devservices-1.0.4}/devservices/commands/up.py +73 -2
  7. {devservices-1.0.3 → devservices-1.0.4}/devservices/utils/dependencies.py +81 -0
  8. {devservices-1.0.3 → devservices-1.0.4}/devservices/utils/docker_compose.py +2 -3
  9. {devservices-1.0.3 → devservices-1.0.4}/devservices.egg-info/PKG-INFO +1 -1
  10. {devservices-1.0.3 → devservices-1.0.4}/pyproject.toml +1 -1
  11. {devservices-1.0.3 → devservices-1.0.4}/tests/commands/test_up.py +263 -0
  12. {devservices-1.0.3 → devservices-1.0.4}/tests/utils/test_dependencies.py +422 -0
  13. {devservices-1.0.3 → devservices-1.0.4}/tests/utils/test_docker_compose.py +127 -22
  14. {devservices-1.0.3 → devservices-1.0.4}/LICENSE.md +0 -0
  15. {devservices-1.0.3 → devservices-1.0.4}/devservices/__init__.py +0 -0
  16. {devservices-1.0.3 → devservices-1.0.4}/devservices/commands/__init__.py +0 -0
  17. {devservices-1.0.3 → devservices-1.0.4}/devservices/commands/check_for_update.py +0 -0
  18. {devservices-1.0.3 → devservices-1.0.4}/devservices/commands/list_dependencies.py +0 -0
  19. {devservices-1.0.3 → devservices-1.0.4}/devservices/commands/list_services.py +0 -0
  20. {devservices-1.0.3 → devservices-1.0.4}/devservices/commands/purge.py +0 -0
  21. {devservices-1.0.3 → devservices-1.0.4}/devservices/commands/update.py +0 -0
  22. {devservices-1.0.3 → devservices-1.0.4}/devservices/configs/service_config.py +0 -0
  23. {devservices-1.0.3 → devservices-1.0.4}/devservices/constants.py +0 -0
  24. {devservices-1.0.3 → devservices-1.0.4}/devservices/exceptions.py +0 -0
  25. {devservices-1.0.3 → devservices-1.0.4}/devservices/main.py +0 -0
  26. {devservices-1.0.3 → devservices-1.0.4}/devservices/utils/__init__.py +0 -0
  27. {devservices-1.0.3 → devservices-1.0.4}/devservices/utils/console.py +0 -0
  28. {devservices-1.0.3 → devservices-1.0.4}/devservices/utils/devenv.py +0 -0
  29. {devservices-1.0.3 → devservices-1.0.4}/devservices/utils/docker.py +0 -0
  30. {devservices-1.0.3 → devservices-1.0.4}/devservices/utils/file_lock.py +0 -0
  31. {devservices-1.0.3 → devservices-1.0.4}/devservices/utils/install_binary.py +0 -0
  32. {devservices-1.0.3 → devservices-1.0.4}/devservices/utils/services.py +0 -0
  33. {devservices-1.0.3 → devservices-1.0.4}/devservices/utils/state.py +0 -0
  34. {devservices-1.0.3 → devservices-1.0.4}/devservices.egg-info/SOURCES.txt +0 -0
  35. {devservices-1.0.3 → devservices-1.0.4}/devservices.egg-info/dependency_links.txt +0 -0
  36. {devservices-1.0.3 → devservices-1.0.4}/devservices.egg-info/entry_points.txt +0 -0
  37. {devservices-1.0.3 → devservices-1.0.4}/devservices.egg-info/requires.txt +0 -0
  38. {devservices-1.0.3 → devservices-1.0.4}/devservices.egg-info/top_level.txt +0 -0
  39. {devservices-1.0.3 → devservices-1.0.4}/setup.cfg +0 -0
  40. {devservices-1.0.3 → devservices-1.0.4}/testing/__init__.py +0 -0
  41. {devservices-1.0.3 → devservices-1.0.4}/testing/utils.py +0 -0
  42. {devservices-1.0.3 → devservices-1.0.4}/tests/__init__.py +0 -0
  43. {devservices-1.0.3 → devservices-1.0.4}/tests/commands/test_down.py +0 -0
  44. {devservices-1.0.3 → devservices-1.0.4}/tests/commands/test_list_services.py +0 -0
  45. {devservices-1.0.3 → devservices-1.0.4}/tests/commands/test_logs.py +0 -0
  46. {devservices-1.0.3 → devservices-1.0.4}/tests/commands/test_purge.py +0 -0
  47. {devservices-1.0.3 → devservices-1.0.4}/tests/commands/test_update.py +0 -0
  48. {devservices-1.0.3 → devservices-1.0.4}/tests/configs/test_service_config.py +0 -0
  49. {devservices-1.0.3 → devservices-1.0.4}/tests/conftest.py +0 -0
  50. {devservices-1.0.3 → devservices-1.0.4}/tests/utils/test_docker.py +0 -0
  51. {devservices-1.0.3 → devservices-1.0.4}/tests/utils/test_install_binary.py +0 -0
  52. {devservices-1.0.3 → devservices-1.0.4}/tests/utils/test_state.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.4
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.4
15
15
  ```
16
16
 
17
17
 
@@ -126,7 +126,7 @@ def _down(
126
126
  ] = relative_local_dependency_directory
127
127
  docker_compose_commands = get_docker_compose_commands_to_run(
128
128
  service=service,
129
- remote_dependencies=remote_dependencies,
129
+ remote_dependencies=list(remote_dependencies),
130
130
  current_env=current_env,
131
131
  command="down",
132
132
  options=[],
@@ -99,7 +99,7 @@ def _logs(
99
99
  ] = relative_local_dependency_directory
100
100
  docker_compose_commands = get_docker_compose_commands_to_run(
101
101
  service=service,
102
- remote_dependencies=remote_dependencies,
102
+ remote_dependencies=list(remote_dependencies),
103
103
  current_env=current_env,
104
104
  command="logs",
105
105
  options=["-n", MAX_LOG_LINES],
@@ -137,7 +137,7 @@ def _status(
137
137
  ] = relative_local_dependency_directory
138
138
  docker_compose_commands = get_docker_compose_commands_to_run(
139
139
  service=service,
140
- remote_dependencies=remote_dependencies,
140
+ remote_dependencies=list(remote_dependencies),
141
141
  current_env=current_env,
142
142
  command="ps",
143
143
  options=["--format", "json"],
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import concurrent.futures
3
4
  import os
4
5
  import subprocess
5
6
  from argparse import _SubParsersAction
@@ -21,6 +22,8 @@ from devservices.exceptions import ModeDoesNotExistError
21
22
  from devservices.exceptions import ServiceNotFoundError
22
23
  from devservices.utils.console import Console
23
24
  from devservices.utils.console import Status
25
+ from devservices.utils.dependencies import construct_dependency_graph
26
+ from devservices.utils.dependencies import get_non_shared_remote_dependencies
24
27
  from devservices.utils.dependencies import install_and_verify_dependencies
25
28
  from devservices.utils.dependencies import InstalledRemoteDependency
26
29
  from devservices.utils.docker_compose import get_docker_compose_commands_to_run
@@ -63,8 +66,58 @@ def up(args: Namespace) -> None:
63
66
  modes = service.config.modes
64
67
  mode = args.mode
65
68
 
69
+ state = State()
70
+ started_services = state.get_started_services()
71
+ running_mode = state.get_mode_for_service(service.name) or "default"
72
+
73
+ # TODO: Remove this once we properly handle mode switching
74
+ if service.name in started_services and running_mode != mode:
75
+ console.warning(
76
+ f"Service '{service.name}' is already running in mode: '{running_mode}', restarting in mode: '{mode}'"
77
+ )
78
+ with Status() as status:
79
+ try:
80
+ remote_dependencies = install_and_verify_dependencies(
81
+ service, mode=running_mode
82
+ )
83
+ except DependencyError as de:
84
+ capture_exception(de)
85
+ status.failure(str(de))
86
+ exit(1)
87
+ except ModeDoesNotExistError as mde:
88
+ capture_exception(mde)
89
+ status.failure(str(mde))
90
+ exit(1)
91
+ service_config_file_path = os.path.join(
92
+ service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
93
+ )
94
+ current_env = os.environ.copy()
95
+ running_mode_dependencies = modes[running_mode]
96
+ remote_dependencies_to_bring_down = get_non_shared_remote_dependencies(
97
+ service, remote_dependencies
98
+ )
99
+ down_docker_compose_commands = get_docker_compose_commands_to_run(
100
+ service=service,
101
+ remote_dependencies=list(remote_dependencies_to_bring_down),
102
+ current_env=current_env,
103
+ command="down",
104
+ options=[],
105
+ service_config_file_path=service_config_file_path,
106
+ mode_dependencies=running_mode_dependencies,
107
+ )
108
+
109
+ with concurrent.futures.ThreadPoolExecutor() as executor:
110
+ futures = [
111
+ executor.submit(run_cmd, cmd, current_env)
112
+ for cmd in down_docker_compose_commands
113
+ ]
114
+ for future in concurrent.futures.as_completed(futures):
115
+ future.result()
116
+
117
+ state.remove_started_service(service.name)
118
+
66
119
  with Status(
67
- lambda: console.warning(f"Starting {service.name}"),
120
+ lambda: console.warning(f"Starting '{service.name}' in mode: '{mode}'"),
68
121
  lambda: console.success(f"{service.name} started"),
69
122
  ) as status:
70
123
  try:
@@ -79,6 +132,11 @@ def up(args: Namespace) -> None:
79
132
  except ModeDoesNotExistError as mde:
80
133
  status.failure(str(mde))
81
134
  exit(1)
135
+ try:
136
+ _create_devservices_network()
137
+ except subprocess.CalledProcessError:
138
+ # Network already exists, ignore the error
139
+ pass
82
140
  try:
83
141
  mode_dependencies = modes[mode]
84
142
  _up(service, remote_dependencies, mode_dependencies, status)
@@ -119,9 +177,14 @@ def _up(
119
177
  DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY
120
178
  ] = relative_local_dependency_directory
121
179
  options = ["-d"]
180
+ dependency_graph = construct_dependency_graph(service)
181
+ starting_order = dependency_graph.get_starting_order()
182
+ sorted_remote_dependencies = sorted(
183
+ remote_dependencies, key=lambda dep: starting_order.index(dep.service_name)
184
+ )
122
185
  docker_compose_commands = get_docker_compose_commands_to_run(
123
186
  service=service,
124
- remote_dependencies=remote_dependencies,
187
+ remote_dependencies=sorted_remote_dependencies,
125
188
  current_env=current_env,
126
189
  command="up",
127
190
  options=options,
@@ -131,3 +194,11 @@ def _up(
131
194
 
132
195
  for cmd in docker_compose_commands:
133
196
  _bring_up_dependency(cmd, current_env, status, len(options))
197
+
198
+
199
+ def _create_devservices_network() -> None:
200
+ subprocess.run(
201
+ ["docker", "network", "create", "devservices"],
202
+ stdout=subprocess.DEVNULL,
203
+ stderr=subprocess.DEVNULL,
204
+ )
@@ -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
@@ -457,3 +515,26 @@ def _run_command(
457
515
  logger = logging.getLogger(LOGGER_NAME)
458
516
  logger.debug(f"Running command: {' '.join(cmd)} in {cwd}")
459
517
  subprocess.run(cmd, cwd=cwd, check=True, stdout=stdout, stderr=subprocess.DEVNULL)
518
+
519
+
520
+ def get_remote_dependency_config(remote_config: RemoteConfig) -> ServiceConfig:
521
+ dependency_repo_dir = os.path.join(
522
+ DEVSERVICES_DEPENDENCIES_CACHE_DIR,
523
+ DEPENDENCY_CONFIG_VERSION,
524
+ remote_config.repo_name,
525
+ )
526
+ return load_service_config_from_file(dependency_repo_dir)
527
+
528
+
529
+ def construct_dependency_graph(service: Service) -> DependencyGraph:
530
+ dependency_graph = DependencyGraph()
531
+
532
+ def _construct_dependency_graph(service_config: ServiceConfig) -> None:
533
+ for dependency_name, dependency in service_config.dependencies.items():
534
+ dependency_graph.add_edge(service_config.service_name, dependency_name)
535
+ if _has_remote_config(dependency.remote):
536
+ dependency_config = get_remote_dependency_config(dependency.remote)
537
+ _construct_dependency_graph(dependency_config)
538
+
539
+ _construct_dependency_graph(service.config)
540
+ return dependency_graph
@@ -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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: devservices
3
- Version: 1.0.3
3
+ Version: 1.0.4
4
4
  Requires-Python: >=3.10
5
5
  License-File: LICENSE.md
6
6
  Requires-Dist: pyyaml
@@ -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.4"
8
8
  # 3.10 is just for internal pypi compat
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -14,7 +14,10 @@ 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
16
  from devservices.exceptions import DependencyError
17
+ from devservices.utils.state import State
17
18
  from testing.utils import create_config_file
19
+ from testing.utils import create_mock_git_repo
20
+ from testing.utils import run_git_command
18
21
 
19
22
 
20
23
  @mock.patch(
@@ -26,7 +29,9 @@ from testing.utils import create_config_file
26
29
  ),
27
30
  )
28
31
  @mock.patch("devservices.utils.state.State.add_started_service")
32
+ @mock.patch("devservices.commands.up._create_devservices_network")
29
33
  def test_up_simple(
34
+ mock_create_devservices_network: mock.Mock,
30
35
  mock_add_started_service: mock.Mock,
31
36
  mock_run: mock.Mock,
32
37
  tmp_path: Path,
@@ -69,6 +74,8 @@ def test_up_simple(
69
74
  == f"../dependency-dir/{DEPENDENCY_CONFIG_VERSION}"
70
75
  )
71
76
 
77
+ mock_create_devservices_network.assert_called_once()
78
+
72
79
  mock_run.assert_called_with(
73
80
  [
74
81
  "docker",
@@ -91,13 +98,16 @@ def test_up_simple(
91
98
  mock_add_started_service.assert_called_with("example-service", "default")
92
99
  captured = capsys.readouterr()
93
100
  assert "Retrieving dependencies" in captured.out.strip()
101
+ assert "Starting 'example-service' in mode: 'default'" in captured.out.strip()
94
102
  assert "Starting clickhouse" in captured.out.strip()
95
103
  assert "Starting redis" in captured.out.strip()
96
104
 
97
105
 
98
106
  @mock.patch("devservices.utils.docker_compose.subprocess.run")
99
107
  @mock.patch("devservices.utils.state.State.add_started_service")
108
+ @mock.patch("devservices.commands.up._create_devservices_network")
100
109
  def test_up_dependency_error(
110
+ mock_create_devservices_network: mock.Mock,
101
111
  mock_add_started_service: mock.Mock,
102
112
  mock_run: mock.Mock,
103
113
  capsys: pytest.CaptureFixture[str],
@@ -135,6 +145,7 @@ def test_up_dependency_error(
135
145
  with pytest.raises(SystemExit):
136
146
  up(args)
137
147
 
148
+ mock_create_devservices_network.assert_not_called()
138
149
  # Capture the printed output
139
150
  captured = capsys.readouterr()
140
151
 
@@ -144,13 +155,18 @@ def test_up_dependency_error(
144
155
 
145
156
  captured = capsys.readouterr()
146
157
  assert "Retrieving dependencies" not in captured.out.strip()
158
+ assert (
159
+ "Starting 'example-service' in mode: 'default'" not in captured.out.strip()
160
+ )
147
161
  assert "Starting clickhouse" not in captured.out.strip()
148
162
  assert "Starting redis" not in captured.out.strip()
149
163
 
150
164
 
151
165
  @mock.patch("devservices.utils.docker_compose.subprocess.run")
152
166
  @mock.patch("devservices.utils.state.State.add_started_service")
167
+ @mock.patch("devservices.commands.up._create_devservices_network")
153
168
  def test_up_error(
169
+ mock_create_devservices_network: mock.Mock,
154
170
  mock_add_started_service: mock.Mock,
155
171
  mock_run: mock.Mock,
156
172
  capsys: pytest.CaptureFixture[str],
@@ -185,6 +201,8 @@ def test_up_error(
185
201
  with pytest.raises(SystemExit):
186
202
  up(args)
187
203
 
204
+ mock_create_devservices_network.assert_called_once()
205
+
188
206
  # Capture the printed output
189
207
  captured = capsys.readouterr()
190
208
 
@@ -196,6 +214,7 @@ def test_up_error(
196
214
 
197
215
  captured = capsys.readouterr()
198
216
  assert "Retrieving dependencies" not in captured.out.strip()
217
+ assert "Starting 'example-service' in mode: 'default'" not in captured.out.strip()
199
218
  assert "Starting clickhouse" not in captured.out.strip()
200
219
  assert "Starting redis" not in captured.out.strip()
201
220
 
@@ -209,7 +228,9 @@ def test_up_error(
209
228
  ),
210
229
  )
211
230
  @mock.patch("devservices.utils.state.State.add_started_service")
231
+ @mock.patch("devservices.commands.up._create_devservices_network")
212
232
  def test_up_mode_simple(
233
+ mock_create_devservices_network: mock.Mock,
213
234
  mock_add_started_service: mock.Mock,
214
235
  mock_run: mock.Mock,
215
236
  tmp_path: Path,
@@ -252,6 +273,8 @@ def test_up_mode_simple(
252
273
  == f"../dependency-dir/{DEPENDENCY_CONFIG_VERSION}"
253
274
  )
254
275
 
276
+ mock_create_devservices_network.assert_called_once()
277
+
255
278
  mock_run.assert_called_with(
256
279
  [
257
280
  "docker",
@@ -273,6 +296,7 @@ def test_up_mode_simple(
273
296
  mock_add_started_service.assert_called_with("example-service", "test")
274
297
  captured = capsys.readouterr()
275
298
  assert "Retrieving dependencies" in captured.out.strip()
299
+ assert "Starting 'example-service' in mode: 'test'" in captured.out.strip()
276
300
  assert "Starting redis" in captured.out.strip()
277
301
 
278
302
 
@@ -334,5 +358,244 @@ def test_up_mode_does_not_exist(
334
358
 
335
359
  captured = capsys.readouterr()
336
360
  assert "Retrieving dependencies" not in captured.out.strip()
361
+ assert "Starting 'example-service' in mode: 'test'" not in captured.out.strip()
337
362
  assert "Starting clickhouse" not in captured.out.strip()
338
363
  assert "Starting redis" not in captured.out.strip()
364
+
365
+
366
+ @mock.patch(
367
+ "devservices.utils.docker_compose.subprocess.run",
368
+ return_value=subprocess.CompletedProcess(
369
+ args=["docker", "compose", "config", "--services"],
370
+ returncode=0,
371
+ stdout="clickhouse\nredis\n",
372
+ ),
373
+ )
374
+ def test_up_switching_modes(
375
+ mock_run: mock.Mock,
376
+ tmp_path: Path,
377
+ capsys: pytest.CaptureFixture[str],
378
+ ) -> None:
379
+ with (
380
+ mock.patch(
381
+ "devservices.commands.up.DEVSERVICES_DEPENDENCIES_CACHE_DIR",
382
+ str(tmp_path / "dependency-dir"),
383
+ ),
384
+ mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
385
+ ):
386
+ config = {
387
+ "x-sentry-service-config": {
388
+ "version": 0.1,
389
+ "service_name": "example-service",
390
+ "dependencies": {
391
+ "redis": {"description": "Redis"},
392
+ "clickhouse": {"description": "Clickhouse"},
393
+ },
394
+ "modes": {"default": ["redis", "clickhouse"], "test": ["redis"]},
395
+ },
396
+ "services": {
397
+ "redis": {"image": "redis:6.2.14-alpine"},
398
+ "clickhouse": {
399
+ "image": "altinity/clickhouse-server:23.8.11.29.altinitystable"
400
+ },
401
+ },
402
+ }
403
+
404
+ service_path = tmp_path / "example-service"
405
+ create_config_file(service_path, config)
406
+ os.chdir(service_path)
407
+
408
+ state = State()
409
+ state.add_started_service("example-service", "default")
410
+
411
+ args = Namespace(service_name=None, debug=False, mode="test")
412
+ up(args)
413
+
414
+ mock_run.assert_has_calls(
415
+ [
416
+ mock.call(
417
+ [
418
+ "docker",
419
+ "compose",
420
+ "-p",
421
+ "example-service",
422
+ "-f",
423
+ f"{service_path}/{DEVSERVICES_DIR_NAME}/{CONFIG_FILE_NAME}",
424
+ "down",
425
+ "clickhouse",
426
+ "redis",
427
+ ],
428
+ check=True,
429
+ capture_output=True,
430
+ text=True,
431
+ env=mock.ANY,
432
+ ),
433
+ mock.call(
434
+ [
435
+ "docker",
436
+ "compose",
437
+ "-p",
438
+ "example-service",
439
+ "-f",
440
+ f"{service_path}/{DEVSERVICES_DIR_NAME}/{CONFIG_FILE_NAME}",
441
+ "up",
442
+ "redis",
443
+ "-d",
444
+ ],
445
+ check=True,
446
+ capture_output=True,
447
+ text=True,
448
+ env=mock.ANY,
449
+ ),
450
+ ],
451
+ any_order=True,
452
+ )
453
+
454
+ captured = capsys.readouterr()
455
+ assert (
456
+ "Service 'example-service' is already running in mode: 'default', restarting in mode: 'test'"
457
+ in captured.out.strip()
458
+ )
459
+ assert "Starting 'example-service' in mode: 'test'" in captured.out.strip()
460
+ assert "Retrieving dependencies" in captured.out.strip()
461
+ assert "Starting redis" in captured.out.strip()
462
+
463
+
464
+ def test_up_switching_modes_overlapping_running_service(
465
+ tmp_path: Path,
466
+ capsys: pytest.CaptureFixture[str],
467
+ ) -> None:
468
+ with (
469
+ mock.patch(
470
+ "devservices.commands.up.DEVSERVICES_DEPENDENCIES_CACHE_DIR",
471
+ str(tmp_path / "dependency-dir"),
472
+ ),
473
+ mock.patch(
474
+ "devservices.utils.dependencies.DEVSERVICES_DEPENDENCIES_CACHE_DIR",
475
+ str(tmp_path / "dependency-dir"),
476
+ ),
477
+ mock.patch(
478
+ "devservices.utils.services.get_coderoot",
479
+ return_value=str(tmp_path / "code"),
480
+ ),
481
+ mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
482
+ ):
483
+ redis_repo_path = tmp_path / "redis"
484
+ create_mock_git_repo("blank_repo", redis_repo_path)
485
+ mock_git_repo_config = {
486
+ "x-sentry-service-config": {
487
+ "version": 0.1,
488
+ "service_name": "shared-redis",
489
+ "dependencies": {},
490
+ "modes": {"default": []},
491
+ },
492
+ "services": {
493
+ "redis": {"image": "redis:6.2.14-alpine"},
494
+ },
495
+ }
496
+ create_config_file(redis_repo_path, mock_git_repo_config)
497
+ run_git_command(["add", "."], cwd=redis_repo_path)
498
+ run_git_command(["commit", "-m", "Add devservices config"], cwd=redis_repo_path)
499
+ config = {
500
+ "x-sentry-service-config": {
501
+ "version": 0.1,
502
+ "service_name": "example-service",
503
+ "dependencies": {
504
+ "redis": {
505
+ "description": "Redis",
506
+ "remote": {
507
+ "repo_name": "redis",
508
+ "branch": "main",
509
+ "repo_link": f"file://{redis_repo_path}",
510
+ },
511
+ },
512
+ "clickhouse": {"description": "Clickhouse"},
513
+ },
514
+ "modes": {"default": ["redis", "clickhouse"], "test": ["clickhouse"]},
515
+ },
516
+ "services": {
517
+ "clickhouse": {
518
+ "image": "altinity/clickhouse-server:23.8.11.29.altinitystable"
519
+ },
520
+ },
521
+ }
522
+ other_config = {
523
+ "x-sentry-service-config": {
524
+ "version": 0.1,
525
+ "service_name": "other-service",
526
+ "dependencies": {
527
+ "redis": {
528
+ "description": "Redis",
529
+ "remote": {
530
+ "repo_name": "redis",
531
+ "branch": "main",
532
+ "repo_link": f"file://{redis_repo_path}",
533
+ },
534
+ },
535
+ },
536
+ "modes": {"default": ["redis"]},
537
+ },
538
+ }
539
+
540
+ service_path = tmp_path / "code" / "example-service"
541
+ other_service_path = tmp_path / "code" / "other-service"
542
+ create_config_file(service_path, config)
543
+ create_config_file(other_service_path, other_config)
544
+ os.chdir(service_path)
545
+
546
+ state = State()
547
+ state.add_started_service("example-service", "default")
548
+ state.add_started_service("other-service", "default")
549
+
550
+ args = Namespace(service_name="example-service", debug=False, mode="test")
551
+
552
+ with mock.patch(
553
+ "devservices.commands.up.run_cmd",
554
+ return_value=subprocess.CompletedProcess(
555
+ args=["docker", "compose", "config", "--services"],
556
+ returncode=0,
557
+ stdout="clickhouse\n",
558
+ ),
559
+ ) as mock_run:
560
+ up(args)
561
+
562
+ mock_run.assert_has_calls(
563
+ [
564
+ mock.call(
565
+ [
566
+ "docker",
567
+ "compose",
568
+ "-p",
569
+ "example-service",
570
+ "-f",
571
+ f"{service_path}/{DEVSERVICES_DIR_NAME}/{CONFIG_FILE_NAME}",
572
+ "down",
573
+ "clickhouse",
574
+ ],
575
+ mock.ANY,
576
+ ),
577
+ mock.call(
578
+ [
579
+ "docker",
580
+ "compose",
581
+ "-p",
582
+ "example-service",
583
+ "-f",
584
+ f"{service_path}/{DEVSERVICES_DIR_NAME}/{CONFIG_FILE_NAME}",
585
+ "up",
586
+ "clickhouse",
587
+ "-d",
588
+ ],
589
+ mock.ANY,
590
+ ),
591
+ ],
592
+ )
593
+
594
+ captured = capsys.readouterr()
595
+ assert (
596
+ "Service 'example-service' is already running in mode: 'default', restarting in mode: 'test'"
597
+ in captured.out.strip()
598
+ )
599
+ assert "Starting 'example-service' in mode: 'test'" in captured.out.strip()
600
+ assert "Retrieving dependencies" in captured.out.strip()
601
+ assert "Starting clickhouse" in captured.out.strip()