devservices 1.1.0__tar.gz → 1.1.2__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 (66) hide show
  1. {devservices-1.1.0 → devservices-1.1.2}/PKG-INFO +2 -2
  2. {devservices-1.1.0 → devservices-1.1.2}/README.md +2 -1
  3. devservices-1.1.2/devenv/sync.py +24 -0
  4. {devservices-1.1.0 → devservices-1.1.2}/devservices/commands/down.py +56 -16
  5. devservices-1.1.2/devservices/commands/serve.py +68 -0
  6. {devservices-1.1.0 → devservices-1.1.2}/devservices/commands/toggle.py +11 -6
  7. {devservices-1.1.0 → devservices-1.1.2}/devservices/commands/up.py +33 -9
  8. {devservices-1.1.0 → devservices-1.1.2}/devservices/constants.py +9 -1
  9. {devservices-1.1.0 → devservices-1.1.2}/devservices/main.py +2 -0
  10. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/dependencies.py +14 -4
  11. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/supervisor.py +80 -6
  12. {devservices-1.1.0 → devservices-1.1.2}/devservices.egg-info/PKG-INFO +2 -2
  13. {devservices-1.1.0 → devservices-1.1.2}/devservices.egg-info/SOURCES.txt +3 -0
  14. {devservices-1.1.0 → devservices-1.1.2}/devservices.egg-info/top_level.txt +1 -0
  15. {devservices-1.1.0 → devservices-1.1.2}/pyproject.toml +3 -3
  16. {devservices-1.1.0 → devservices-1.1.2}/testing/utils.py +9 -0
  17. {devservices-1.1.0 → devservices-1.1.2}/tests/commands/test_down.py +56 -40
  18. devservices-1.1.2/tests/commands/test_serve.py +163 -0
  19. {devservices-1.1.0 → devservices-1.1.2}/tests/commands/test_toggle.py +39 -16
  20. {devservices-1.1.0 → devservices-1.1.2}/tests/commands/test_up.py +445 -128
  21. {devservices-1.1.0 → devservices-1.1.2}/tests/utils/test_dependencies.py +177 -6
  22. {devservices-1.1.0 → devservices-1.1.2}/tests/utils/test_docker_compose.py +3 -3
  23. devservices-1.1.2/tests/utils/test_supervisor.py +406 -0
  24. devservices-1.1.0/tests/utils/test_supervisor.py +0 -189
  25. {devservices-1.1.0 → devservices-1.1.2}/LICENSE.md +0 -0
  26. {devservices-1.1.0 → devservices-1.1.2}/devservices/__init__.py +0 -0
  27. {devservices-1.1.0 → devservices-1.1.2}/devservices/commands/__init__.py +0 -0
  28. {devservices-1.1.0 → devservices-1.1.2}/devservices/commands/list_dependencies.py +0 -0
  29. {devservices-1.1.0 → devservices-1.1.2}/devservices/commands/list_services.py +0 -0
  30. {devservices-1.1.0 → devservices-1.1.2}/devservices/commands/logs.py +0 -0
  31. {devservices-1.1.0 → devservices-1.1.2}/devservices/commands/purge.py +0 -0
  32. {devservices-1.1.0 → devservices-1.1.2}/devservices/commands/status.py +0 -0
  33. {devservices-1.1.0 → devservices-1.1.2}/devservices/commands/update.py +0 -0
  34. {devservices-1.1.0 → devservices-1.1.2}/devservices/configs/service_config.py +0 -0
  35. {devservices-1.1.0 → devservices-1.1.2}/devservices/exceptions.py +0 -0
  36. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/__init__.py +0 -0
  37. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/check_for_update.py +0 -0
  38. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/console.py +0 -0
  39. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/devenv.py +0 -0
  40. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/docker.py +0 -0
  41. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/docker_compose.py +0 -0
  42. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/file_lock.py +0 -0
  43. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/git.py +0 -0
  44. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/install_binary.py +0 -0
  45. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/services.py +0 -0
  46. {devservices-1.1.0 → devservices-1.1.2}/devservices/utils/state.py +0 -0
  47. {devservices-1.1.0 → devservices-1.1.2}/devservices.egg-info/dependency_links.txt +0 -0
  48. {devservices-1.1.0 → devservices-1.1.2}/devservices.egg-info/entry_points.txt +0 -0
  49. {devservices-1.1.0 → devservices-1.1.2}/devservices.egg-info/requires.txt +0 -0
  50. {devservices-1.1.0 → devservices-1.1.2}/setup.cfg +0 -0
  51. {devservices-1.1.0 → devservices-1.1.2}/testing/__init__.py +0 -0
  52. {devservices-1.1.0 → devservices-1.1.2}/tests/__init__.py +0 -0
  53. {devservices-1.1.0 → devservices-1.1.2}/tests/commands/test_list_dependencies.py +0 -0
  54. {devservices-1.1.0 → devservices-1.1.2}/tests/commands/test_list_services.py +0 -0
  55. {devservices-1.1.0 → devservices-1.1.2}/tests/commands/test_logs.py +0 -0
  56. {devservices-1.1.0 → devservices-1.1.2}/tests/commands/test_purge.py +0 -0
  57. {devservices-1.1.0 → devservices-1.1.2}/tests/commands/test_status.py +0 -0
  58. {devservices-1.1.0 → devservices-1.1.2}/tests/commands/test_update.py +0 -0
  59. {devservices-1.1.0 → devservices-1.1.2}/tests/configs/test_service_config.py +0 -0
  60. {devservices-1.1.0 → devservices-1.1.2}/tests/conftest.py +0 -0
  61. {devservices-1.1.0 → devservices-1.1.2}/tests/utils/test_check_for_update.py +0 -0
  62. {devservices-1.1.0 → devservices-1.1.2}/tests/utils/test_docker.py +0 -0
  63. {devservices-1.1.0 → devservices-1.1.2}/tests/utils/test_git.py +0 -0
  64. {devservices-1.1.0 → devservices-1.1.2}/tests/utils/test_install_binary.py +0 -0
  65. {devservices-1.1.0 → devservices-1.1.2}/tests/utils/test_services.py +0 -0
  66. {devservices-1.1.0 → devservices-1.1.2}/tests/utils/test_state.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.1.0
4
- Requires-Python: >=3.10
3
+ Version: 1.1.2
4
+ Requires-Python: >=3.11
5
5
  License-File: LICENSE.md
6
6
  Requires-Dist: pyyaml
7
7
  Requires-Dist: sentry-devenv
@@ -22,6 +22,7 @@ NOTE: service-name is an optional parameter. If not provided, devservices will a
22
22
  - `devservices list-dependencies <service-name>`: List all dependencies for a service and whether they are enabled/disabled.
23
23
  - `devservices update` Update devservices to the latest version.
24
24
  - `devservices purge`: Purge the local devservices cache.
25
+ - `devservices toggle <service-name>`: Toggle the runtime for a service between containerized and local.
25
26
 
26
27
  ## Installation
27
28
 
@@ -30,7 +31,7 @@ NOTE: service-name is an optional parameter. If not provided, devservices will a
30
31
  The recommended way to install devservices is through a virtualenv in the requirements.txt. Once that is installed and a devservices config file is added, you should be able to run `devservices up` to begin local development.
31
32
 
32
33
  ```
33
- devservices==1.1.0
34
+ devservices==1.1.2
34
35
  ```
35
36
 
36
37
  ### 2. Add devservices config files
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from devenv.lib import config
4
+ from devenv.lib import proc
5
+ from devenv.lib import venv
6
+
7
+
8
+ def main(context: dict[str, str]) -> int:
9
+ reporoot = context["reporoot"]
10
+
11
+ venv_dir, python_version, requirements, editable_paths, bins = venv.get(
12
+ reporoot,
13
+ "venv",
14
+ )
15
+ url, sha256 = config.get_python(reporoot, python_version)
16
+ print(f"ensuring venv at {venv_dir}...")
17
+ venv.ensure(venv_dir, python_version, url, sha256)
18
+
19
+ print(f"syncing venv with {requirements}...")
20
+ venv.sync(reporoot, venv_dir, requirements, editable_paths, bins)
21
+
22
+ proc.run(("pre-commit", "install", "--install-hooks", "-f"))
23
+
24
+ return 0
@@ -53,6 +53,12 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
53
53
  action="store_true",
54
54
  default=False,
55
55
  )
56
+ parser.add_argument(
57
+ "--exclude-local",
58
+ help="Exclude dependencies with local runtime from being brought down",
59
+ action="store_true",
60
+ default=False,
61
+ )
56
62
  parser.set_defaults(func=down)
57
63
 
58
64
 
@@ -77,6 +83,7 @@ def down(args: Namespace) -> None:
77
83
  exit(1)
78
84
 
79
85
  modes = service.config.modes
86
+ exclude_local = args.exclude_local
80
87
 
81
88
  state = State()
82
89
  starting_services = set(state.get_service_entries(StateTables.STARTING_SERVICES))
@@ -113,7 +120,7 @@ def down(args: Namespace) -> None:
113
120
  exit(1)
114
121
  try:
115
122
  remote_dependencies = get_non_shared_remote_dependencies(
116
- service, remote_dependencies
123
+ service, remote_dependencies, exclude_local
117
124
  )
118
125
  except DependencyError as de:
119
126
  capture_exception(de)
@@ -125,13 +132,13 @@ def down(args: Namespace) -> None:
125
132
  # Check if any service depends on the service we are trying to bring down
126
133
  # TODO: We should also take into account the active modes of the other services (this is not trivial to do)
127
134
  other_started_services = active_services.difference({service.name})
128
- services_with_local_runtime = state.get_services_by_runtime(
135
+ services_with_local_runtimes = state.get_services_by_runtime(
129
136
  ServiceRuntime.LOCAL
130
137
  )
131
138
  dependent_service_name = None
132
139
  # We can ignore checking if anything relies on the service
133
140
  # if it is a locally running service
134
- if service.name not in services_with_local_runtime:
141
+ if service.name not in services_with_local_runtimes:
135
142
  dependent_service_name = _get_dependent_service(
136
143
  service, other_started_services, state
137
144
  )
@@ -140,7 +147,11 @@ def down(args: Namespace) -> None:
140
147
  if dependent_service_name is None:
141
148
  try:
142
149
  bring_down_service(
143
- service, remote_dependencies, list(mode_dependencies), status
150
+ service,
151
+ remote_dependencies,
152
+ list(mode_dependencies),
153
+ exclude_local,
154
+ status,
144
155
  )
145
156
  except DockerComposeError as dce:
146
157
  capture_exception(dce, level="info")
@@ -154,6 +165,24 @@ def down(args: Namespace) -> None:
154
165
  # TODO: We should factor in healthchecks here before marking service as not running
155
166
  state.remove_service_entry(service.name, StateTables.STARTING_SERVICES)
156
167
  state.remove_service_entry(service.name, StateTables.STARTED_SERVICES)
168
+
169
+ dependencies_with_local_runtimes = set()
170
+ for service_with_local_runtime in services_with_local_runtimes:
171
+ if service_with_local_runtime in {
172
+ dep.service_name for dep in remote_dependencies
173
+ }:
174
+ dependencies_with_local_runtimes.add(service_with_local_runtime)
175
+
176
+ active_dependencies_with_local_runtimes = set()
177
+ for dependency_with_local_runtime in dependencies_with_local_runtimes:
178
+ if dependency_with_local_runtime in active_services:
179
+ active_dependencies_with_local_runtimes.add(dependency_with_local_runtime)
180
+
181
+ if not exclude_local and len(active_dependencies_with_local_runtimes) > 0:
182
+ status.warning("Stopping dependencies with local runtimes...")
183
+ for local_dependency in active_dependencies_with_local_runtimes:
184
+ down(Namespace(service_name=local_dependency, exclude_local=exclude_local))
185
+
157
186
  if dependent_service_name is None:
158
187
  console.success(f"{service.name} stopped")
159
188
 
@@ -162,6 +191,7 @@ def bring_down_service(
162
191
  service: Service,
163
192
  remote_dependencies: set[InstalledRemoteDependency],
164
193
  mode_dependencies: list[str],
194
+ exclude_local: bool,
165
195
  status: Status,
166
196
  ) -> None:
167
197
  relative_local_dependency_directory = os.path.relpath(
@@ -171,30 +201,40 @@ def bring_down_service(
171
201
  service_config_file_path = os.path.join(
172
202
  service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
173
203
  )
204
+
174
205
  # Set the environment variable for the local dependencies directory to be used by docker compose
175
206
  current_env = os.environ.copy()
176
207
  current_env[
177
208
  DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY
178
209
  ] = relative_local_dependency_directory
179
210
  state = State()
180
- # We want to ignore any dependencies that are set to run locally
181
- locally_running_services = state.get_services_by_runtime(ServiceRuntime.LOCAL)
182
- mode_dependencies = [
183
- dep for dep in mode_dependencies if dep not in locally_running_services
184
- ]
185
- remote_dependencies = {
186
- dep
187
- for dep in remote_dependencies
188
- if dep.service_name not in locally_running_services
189
- }
211
+
212
+ # We want to ignore any dependencies that are set to run locally if we are excluding local dependencies
213
+ services_with_local_runtimes = state.get_services_by_runtime(ServiceRuntime.LOCAL)
214
+
215
+ dependencies_with_local_runtimes = set()
216
+ for service_with_local_runtime in services_with_local_runtimes:
217
+ if service_with_local_runtime in {
218
+ dep.service_name for dep in remote_dependencies
219
+ }:
220
+ dependencies_with_local_runtimes.add(service_with_local_runtime)
221
+
190
222
  docker_compose_commands = get_docker_compose_commands_to_run(
191
223
  service=service,
192
- remote_dependencies=list(remote_dependencies),
224
+ remote_dependencies=[
225
+ dep
226
+ for dep in remote_dependencies
227
+ if dep.service_name not in dependencies_with_local_runtimes
228
+ ],
193
229
  current_env=current_env,
194
230
  command="stop",
195
231
  options=[],
196
232
  service_config_file_path=service_config_file_path,
197
- mode_dependencies=mode_dependencies,
233
+ mode_dependencies=[
234
+ dep
235
+ for dep in mode_dependencies
236
+ if dep not in dependencies_with_local_runtimes
237
+ ],
198
238
  )
199
239
 
200
240
  cmd_outputs = []
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pty
5
+ import shlex
6
+ from argparse import _SubParsersAction
7
+ from argparse import ArgumentParser
8
+ from argparse import Namespace
9
+
10
+ from sentry_sdk import capture_exception
11
+
12
+ from devservices.constants import DEVSERVICES_DIR_NAME
13
+ from devservices.constants import PROGRAMS_CONF_FILE_NAME
14
+ from devservices.exceptions import ConfigError
15
+ from devservices.exceptions import ConfigNotFoundError
16
+ from devservices.exceptions import SupervisorConfigError
17
+ from devservices.utils.console import Console
18
+ from devservices.utils.services import find_matching_service
19
+ from devservices.utils.supervisor import SupervisorManager
20
+
21
+
22
+ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
23
+ # prefix_chars is a hack to allow all options to be passed through to the devserver without argparse complaining
24
+ parser = subparsers.add_parser(
25
+ "serve", help="Serve the devserver", prefix_chars="+"
26
+ )
27
+ parser.add_argument(
28
+ "extra", nargs="*", help="Flags to pass through to the devserver"
29
+ )
30
+ parser.set_defaults(func=serve)
31
+
32
+
33
+ def serve(args: Namespace) -> None:
34
+ """Serve the devserver."""
35
+ console = Console()
36
+
37
+ try:
38
+ service = find_matching_service()
39
+ except ConfigNotFoundError as e:
40
+ console.failure(
41
+ f"{str(e)}. Please run the command from a directory with a valid devservices configuration."
42
+ )
43
+ return
44
+ except ConfigError as e:
45
+ capture_exception(e)
46
+ console.failure(str(e))
47
+ exit(1)
48
+
49
+ programs_config_path = os.path.join(
50
+ service.repo_path, f"{DEVSERVICES_DIR_NAME}/{PROGRAMS_CONF_FILE_NAME}"
51
+ )
52
+ if not os.path.exists(programs_config_path):
53
+ console.failure(f"No programs.conf file found in {programs_config_path}.")
54
+ return
55
+ manager = SupervisorManager(
56
+ programs_config_path,
57
+ service_name=service.name,
58
+ )
59
+
60
+ try:
61
+ devserver_command = manager.get_program_command("devserver")
62
+ except SupervisorConfigError as e:
63
+ capture_exception(e, level="info")
64
+ console.failure(f"Error when getting devserver command: {str(e)}")
65
+ return
66
+
67
+ argv = shlex.split(devserver_command) + args.extra
68
+ pty.spawn(argv)
@@ -40,7 +40,7 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
40
40
  parser.add_argument(
41
41
  "runtime",
42
42
  help="Runtime to use for the service",
43
- choices=[ServiceRuntime.CONTAINERIZED, ServiceRuntime.LOCAL],
43
+ choices=[runtime.value for runtime in ServiceRuntime],
44
44
  nargs="?",
45
45
  default=None,
46
46
  )
@@ -71,7 +71,7 @@ def toggle(args: Namespace) -> None:
71
71
  state = State()
72
72
  current_runtime = state.get_service_runtime(service.name)
73
73
  if desired_runtime is None:
74
- desired_runtime = get_opposite_runtime(current_runtime)
74
+ desired_runtime = get_opposite_runtime(current_runtime).value
75
75
  if current_runtime == desired_runtime:
76
76
  console.warning(
77
77
  f"{service.name} is already running in {desired_runtime} runtime"
@@ -105,7 +105,7 @@ def handle_transition_to_local_runtime(service_to_transition: Service) -> None:
105
105
  if service_to_transition.name in active_services:
106
106
  state.update_service_runtime(service_to_transition.name, ServiceRuntime.LOCAL)
107
107
  console.success(
108
- f"{service_to_transition.name} is now running in {ServiceRuntime.LOCAL} runtime"
108
+ f"{service_to_transition.name} is now running in {ServiceRuntime.LOCAL.value} runtime"
109
109
  )
110
110
  return
111
111
 
@@ -196,7 +196,7 @@ def restart_dependent_services(
196
196
  console = Console()
197
197
  with Status(
198
198
  on_start=lambda: console.warning(
199
- f"Restarting dependent services to ensure {service_name} is running in a {ServiceRuntime.CONTAINERIZED} runtime"
199
+ f"Restarting dependent services to ensure {service_name} is running in a {ServiceRuntime.CONTAINERIZED.value} runtime"
200
200
  ),
201
201
  ) as status:
202
202
  for dependent_service in dependent_services:
@@ -223,6 +223,7 @@ def bring_down_containerized_service(
223
223
  ) -> None:
224
224
  """Bring down a containerized service running within another service."""
225
225
  console = Console()
226
+ exclude_local = True
226
227
  with Status(
227
228
  lambda: console.warning(f"Stopping {service.name}"),
228
229
  ) as status:
@@ -242,7 +243,7 @@ def bring_down_containerized_service(
242
243
  exit(1)
243
244
  try:
244
245
  remote_dependencies = get_non_shared_remote_dependencies(
245
- service, remote_dependencies
246
+ service, remote_dependencies, exclude_local
246
247
  )
247
248
  except DependencyError as de:
248
249
  capture_exception(de)
@@ -252,7 +253,11 @@ def bring_down_containerized_service(
252
253
  exit(1)
253
254
  try:
254
255
  bring_down_service(
255
- service, remote_dependencies, sorted(list(mode_dependencies)), status
256
+ service,
257
+ remote_dependencies,
258
+ sorted(list(mode_dependencies)),
259
+ exclude_local,
260
+ status,
256
261
  )
257
262
  except DockerComposeError as dce:
258
263
  capture_exception(dce, level="info")
@@ -56,10 +56,16 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
56
56
  help="Mode to use for the service",
57
57
  default="default",
58
58
  )
59
+ parser.add_argument(
60
+ "--exclude-local",
61
+ help="Exclude dependencies with local runtime from being started",
62
+ action="store_true",
63
+ default=False,
64
+ )
59
65
  parser.set_defaults(func=up)
60
66
 
61
67
 
62
- def up(args: Namespace) -> None:
68
+ def up(args: Namespace, existing_status: Status | None = None) -> None:
63
69
  """Bring up a service and its dependencies."""
64
70
  console = Console()
65
71
  service_name = args.service_name
@@ -85,8 +91,12 @@ def up(args: Namespace) -> None:
85
91
  state = State()
86
92
 
87
93
  with Status(
88
- lambda: console.warning(f"Starting '{service.name}' in mode: '{mode}'"),
89
- lambda: console.success(f"{service.name} started"),
94
+ lambda: console.warning(f"Starting '{service.name}' in mode: '{mode}'")
95
+ if existing_status is None
96
+ else existing_status.warning(f"Starting '{service.name}' in mode: '{mode}'"),
97
+ lambda: console.success(f"{service.name} started")
98
+ if existing_status is None
99
+ else existing_status.success(f"{service.name} started"),
90
100
  ) as status:
91
101
  services_with_local_runtime = state.get_services_by_runtime(
92
102
  ServiceRuntime.LOCAL
@@ -99,9 +109,10 @@ def up(args: Namespace) -> None:
99
109
  and service_with_local_runtime in modes[mode]
100
110
  ):
101
111
  skipped_services.add(service_with_local_runtime)
102
- status.warning(
103
- f"Skipping '{service_with_local_runtime}' as it is set to run locally"
104
- )
112
+ if args.exclude_local:
113
+ status.warning(
114
+ f"Skipping '{service_with_local_runtime}' as it is set to run locally"
115
+ )
105
116
  try:
106
117
  status.info("Retrieving dependencies")
107
118
  remote_dependencies = install_and_verify_dependencies(
@@ -131,9 +142,10 @@ def up(args: Namespace) -> None:
131
142
  and service_with_local_runtime not in skipped_services
132
143
  ):
133
144
  skipped_services.add(service_with_local_runtime)
134
- status.warning(
135
- f"Skipping '{service_with_local_runtime}' as it is set to run locally"
136
- )
145
+ if args.exclude_local:
146
+ status.warning(
147
+ f"Skipping '{service_with_local_runtime}' as it is set to run locally"
148
+ )
137
149
  # We want to ignore any dependencies that are set to run locally
138
150
  mode_dependencies = [
139
151
  dep for dep in mode_dependencies if dep not in services_with_local_runtime
@@ -144,6 +156,18 @@ def up(args: Namespace) -> None:
144
156
  if dep.service_name not in services_with_local_runtime
145
157
  }
146
158
  try:
159
+ if not args.exclude_local:
160
+ status.warning("Starting dependencies with local runtimes...")
161
+ for skipped_service in skipped_services:
162
+ up(
163
+ Namespace(
164
+ service_name=skipped_service,
165
+ mode=mode,
166
+ exclude_local=True,
167
+ ),
168
+ status,
169
+ )
170
+ status.warning(f"Continuing with service '{service.name}'")
147
171
  _up(service, [mode], remote_dependencies, mode_dependencies, status)
148
172
  except DockerComposeError as dce:
149
173
  capture_exception(dce, level="info")
@@ -18,6 +18,7 @@ class Color:
18
18
  MINIMUM_DOCKER_COMPOSE_VERSION = "2.29.7"
19
19
  DEVSERVICES_DIR_NAME = "devservices"
20
20
  CONFIG_FILE_NAME = "config.yml"
21
+ PROGRAMS_CONF_FILE_NAME = "programs.conf"
21
22
  DOCKER_CONFIG_DIR = os.environ.get("DOCKER_CONFIG", os.path.expanduser("~/.docker"))
22
23
  DOCKER_USER_PLUGIN_DIR = os.path.join(DOCKER_CONFIG_DIR, "cli-plugins/")
23
24
 
@@ -39,7 +40,14 @@ DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS = {
39
40
  DEVSERVICES_RELEASES_URL = (
40
41
  "https://api.github.com/repos/getsentry/devservices/releases/latest"
41
42
  )
42
- DOCKER_COMPOSE_DOWNLOAD_URL = "https://github.com/docker/compose/releases/download"
43
+
44
+ # We mirror this in our GCP bucket since GitHub downloads can be flaky at times.
45
+ # gsutil cp docker-compose-darwin-aarch64 gs://sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-darwin-aarch64
46
+ # gsutil cp docker-compose-linux-x86_64 gs://sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-linux-x86_64
47
+ DOCKER_COMPOSE_DOWNLOAD_URL = (
48
+ "https://storage.googleapis.com/sentry-dev-infra-assets/docker-compose"
49
+ )
50
+
43
51
  DEVSERVICES_DOWNLOAD_URL = "https://github.com/getsentry/devservices/releases/download"
44
52
  BINARY_PERMISSIONS = 0o755
45
53
  MAX_LOG_LINES = "100"
@@ -24,6 +24,7 @@ from devservices.commands import list_dependencies
24
24
  from devservices.commands import list_services
25
25
  from devservices.commands import logs
26
26
  from devservices.commands import purge
27
+ from devservices.commands import serve
27
28
  from devservices.commands import status
28
29
  from devservices.commands import toggle
29
30
  from devservices.commands import up
@@ -147,6 +148,7 @@ def main() -> None:
147
148
  logs.add_parser(subparsers)
148
149
  update.add_parser(subparsers)
149
150
  purge.add_parser(subparsers)
151
+ serve.add_parser(subparsers)
150
152
  toggle.add_parser(subparsers)
151
153
 
152
154
  args = parser.parse_args()
@@ -39,6 +39,7 @@ from devservices.exceptions import UnableToCloneDependencyError
39
39
  from devservices.utils.file_lock import lock
40
40
  from devservices.utils.services import find_matching_service
41
41
  from devservices.utils.services import Service
42
+ from devservices.utils.state import ServiceRuntime
42
43
  from devservices.utils.state import State
43
44
  from devservices.utils.state import StateTables
44
45
 
@@ -279,7 +280,9 @@ def verify_local_dependencies(dependencies: list[Dependency]) -> bool:
279
280
 
280
281
 
281
282
  def get_non_shared_remote_dependencies(
282
- service_to_stop: Service, remote_dependencies: set[InstalledRemoteDependency]
283
+ service_to_stop: Service,
284
+ remote_dependencies: set[InstalledRemoteDependency],
285
+ exclude_local: bool,
283
286
  ) -> set[InstalledRemoteDependency]:
284
287
  state = State()
285
288
  starting_services = set(state.get_service_entries(StateTables.STARTING_SERVICES))
@@ -291,6 +294,7 @@ def get_non_shared_remote_dependencies(
291
294
 
292
295
  active_modes: dict[str, list[str]] = dict()
293
296
  for active_service in active_services:
297
+ # TODO: We probably shouldn't use an OR here, but an AND
294
298
  starting_modes = state.get_active_modes_for_service(
295
299
  active_service, StateTables.STARTING_SERVICES
296
300
  )
@@ -303,9 +307,15 @@ def get_non_shared_remote_dependencies(
303
307
  base_running_service_names: set[str] = set()
304
308
  for started_service_name in active_services:
305
309
  started_service = find_matching_service(started_service_name)
306
- for dependency_name in service_to_stop.config.dependencies.keys():
307
- if dependency_name == started_service.config.service_name:
308
- base_running_service_names.add(started_service_name)
310
+ started_service_runtime = state.get_service_runtime(started_service_name)
311
+ if exclude_local or started_service_runtime != ServiceRuntime.LOCAL:
312
+ # TODO: In theory, we should only be able to run the base-service when a dependent service is running if
313
+ # 1. the dependent service is using a mode that doesn't include the base-service
314
+ # 2. the base-service is using a local runtime
315
+ # But we don't restrict the other cases currently
316
+ for dependency_name in service_to_stop.config.dependencies.keys():
317
+ if dependency_name == started_service.config.service_name:
318
+ base_running_service_names.add(started_service_name)
309
319
 
310
320
  started_service_modes = active_modes[started_service_name]
311
321
  # Only consider the dependencies of the modes that are running
@@ -6,12 +6,33 @@ import os
6
6
  import socket
7
7
  import subprocess
8
8
  import xmlrpc.client
9
+ from enum import IntEnum
10
+
11
+ from supervisor.options import ServerOptions
9
12
 
10
13
  from devservices.constants import DEVSERVICES_SUPERVISOR_CONFIG_DIR
11
14
  from devservices.exceptions import SupervisorConfigError
12
15
  from devservices.exceptions import SupervisorConnectionError
13
16
  from devservices.exceptions import SupervisorError
14
17
  from devservices.exceptions import SupervisorProcessError
18
+ from devservices.utils.console import Console
19
+
20
+
21
+ class SupervisorProcessState(IntEnum):
22
+ """
23
+ Supervisor process states.
24
+
25
+ https://supervisord.org/subprocess.html#process-states
26
+ """
27
+
28
+ STOPPED = 0
29
+ STARTING = 10
30
+ RUNNING = 20
31
+ BACKOFF = 30
32
+ STOPPING = 40
33
+ EXITED = 100
34
+ FATAL = 200
35
+ UNKNOWN = 1000
15
36
 
16
37
 
17
38
  class UnixSocketHTTPConnection(http.client.HTTPConnection):
@@ -101,6 +122,21 @@ class SupervisorManager:
101
122
  f"Failed to connect to supervisor XML-RPC server: {e.errmsg}"
102
123
  )
103
124
 
125
+ def _is_program_running(self, program_name: str) -> bool:
126
+ try:
127
+ client = self._get_rpc_client()
128
+ process_info = client.supervisor.getProcessInfo(program_name)
129
+ if not isinstance(process_info, dict):
130
+ return False
131
+
132
+ state = process_info.get("state")
133
+ if not isinstance(state, int):
134
+ return False
135
+ return state == SupervisorProcessState.RUNNING
136
+ except xmlrpc.client.Fault:
137
+ # If we can't get the process info, assume it's not running
138
+ return False
139
+
104
140
  def start_supervisor_daemon(self) -> None:
105
141
  try:
106
142
  subprocess.run(["supervisord", "-c", self.config_file_path], check=True)
@@ -117,18 +153,56 @@ class SupervisorManager:
117
153
  except xmlrpc.client.Fault as e:
118
154
  raise SupervisorError(f"Failed to stop supervisor: {e.faultString}")
119
155
 
120
- def start_program(self, program_name: str) -> None:
156
+ def start_process(self, name: str) -> None:
157
+ if self._is_program_running(name):
158
+ return
121
159
  try:
122
- self._get_rpc_client().supervisor.startProcess(program_name)
160
+ self._get_rpc_client().supervisor.startProcess(name)
123
161
  except xmlrpc.client.Fault as e:
124
162
  raise SupervisorProcessError(
125
- f"Failed to start program {program_name}: {e.faultString}"
163
+ f"Failed to start process {name}: {e.faultString}"
126
164
  )
127
165
 
128
- def stop_program(self, program_name: str) -> None:
166
+ def stop_process(self, name: str) -> None:
167
+ if not self._is_program_running(name):
168
+ return
129
169
  try:
130
- self._get_rpc_client().supervisor.stopProcess(program_name)
170
+ self._get_rpc_client().supervisor.stopProcess(name)
131
171
  except xmlrpc.client.Fault as e:
132
172
  raise SupervisorProcessError(
133
- f"Failed to stop program {program_name}: {e.faultString}"
173
+ f"Failed to stop process {name}: {e.faultString}"
174
+ )
175
+
176
+ def get_program_command(self, program_name: str) -> str:
177
+ opts = ServerOptions()
178
+ opts.configfile = self.config_file_path
179
+ opts.process_config()
180
+ for group in opts.process_group_configs:
181
+ for proc in group.process_configs:
182
+ if proc.name == program_name and isinstance(proc.command, str):
183
+ return proc.command
184
+ raise SupervisorConfigError(f"Program {program_name} not found in config")
185
+
186
+ def tail_program_logs(self, program_name: str) -> None:
187
+ if not self._is_program_running(program_name):
188
+ console = Console()
189
+ console.failure(f"Program {program_name} is not running")
190
+ return
191
+
192
+ try:
193
+ # Use supervisorctl tail command
194
+ subprocess.run(
195
+ [
196
+ "supervisorctl",
197
+ "-c",
198
+ self.config_file_path,
199
+ "tail",
200
+ "-f",
201
+ program_name,
202
+ ],
203
+ check=True,
134
204
  )
205
+ except subprocess.CalledProcessError as e:
206
+ raise SupervisorError(f"Failed to tail logs for {program_name}: {str(e)}")
207
+ except KeyboardInterrupt:
208
+ pass
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.1.0
4
- Requires-Python: >=3.10
3
+ Version: 1.1.2
4
+ Requires-Python: >=3.11
5
5
  License-File: LICENSE.md
6
6
  Requires-Dist: pyyaml
7
7
  Requires-Dist: sentry-devenv
@@ -2,6 +2,7 @@ LICENSE.md
2
2
  README.md
3
3
  pyproject.toml
4
4
  setup.cfg
5
+ devenv/sync.py
5
6
  devservices/__init__.py
6
7
  devservices/constants.py
7
8
  devservices/exceptions.py
@@ -18,6 +19,7 @@ devservices/commands/list_dependencies.py
18
19
  devservices/commands/list_services.py
19
20
  devservices/commands/logs.py
20
21
  devservices/commands/purge.py
22
+ devservices/commands/serve.py
21
23
  devservices/commands/status.py
22
24
  devservices/commands/toggle.py
23
25
  devservices/commands/up.py
@@ -45,6 +47,7 @@ tests/commands/test_list_dependencies.py
45
47
  tests/commands/test_list_services.py
46
48
  tests/commands/test_logs.py
47
49
  tests/commands/test_purge.py
50
+ tests/commands/test_serve.py
48
51
  tests/commands/test_status.py
49
52
  tests/commands/test_toggle.py
50
53
  tests/commands/test_up.py
@@ -1,3 +1,4 @@
1
+ devenv
1
2
  devservices
2
3
  dist
3
4
  scripts
@@ -4,9 +4,9 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devservices"
7
- version = "1.1.0"
8
- # 3.10 is just for internal pypi compat
9
- requires-python = ">=3.10"
7
+ version = "1.1.2"
8
+ # 3.11 is just for internal pypi compat
9
+ requires-python = ">=3.11"
10
10
  dependencies = [
11
11
  "pyyaml",
12
12
  "sentry-devenv",