devservices 1.1.0__tar.gz → 1.1.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.
Files changed (63) hide show
  1. {devservices-1.1.0 → devservices-1.1.1}/PKG-INFO +1 -1
  2. {devservices-1.1.0 → devservices-1.1.1}/README.md +2 -1
  3. {devservices-1.1.0 → devservices-1.1.1}/devservices/constants.py +8 -1
  4. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/supervisor.py +62 -0
  5. {devservices-1.1.0 → devservices-1.1.1}/devservices.egg-info/PKG-INFO +1 -1
  6. {devservices-1.1.0 → devservices-1.1.1}/pyproject.toml +1 -1
  7. {devservices-1.1.0 → devservices-1.1.1}/tests/utils/test_docker_compose.py +3 -3
  8. devservices-1.1.1/tests/utils/test_supervisor.py +388 -0
  9. devservices-1.1.0/tests/utils/test_supervisor.py +0 -189
  10. {devservices-1.1.0 → devservices-1.1.1}/LICENSE.md +0 -0
  11. {devservices-1.1.0 → devservices-1.1.1}/devservices/__init__.py +0 -0
  12. {devservices-1.1.0 → devservices-1.1.1}/devservices/commands/__init__.py +0 -0
  13. {devservices-1.1.0 → devservices-1.1.1}/devservices/commands/down.py +0 -0
  14. {devservices-1.1.0 → devservices-1.1.1}/devservices/commands/list_dependencies.py +0 -0
  15. {devservices-1.1.0 → devservices-1.1.1}/devservices/commands/list_services.py +0 -0
  16. {devservices-1.1.0 → devservices-1.1.1}/devservices/commands/logs.py +0 -0
  17. {devservices-1.1.0 → devservices-1.1.1}/devservices/commands/purge.py +0 -0
  18. {devservices-1.1.0 → devservices-1.1.1}/devservices/commands/status.py +0 -0
  19. {devservices-1.1.0 → devservices-1.1.1}/devservices/commands/toggle.py +0 -0
  20. {devservices-1.1.0 → devservices-1.1.1}/devservices/commands/up.py +0 -0
  21. {devservices-1.1.0 → devservices-1.1.1}/devservices/commands/update.py +0 -0
  22. {devservices-1.1.0 → devservices-1.1.1}/devservices/configs/service_config.py +0 -0
  23. {devservices-1.1.0 → devservices-1.1.1}/devservices/exceptions.py +0 -0
  24. {devservices-1.1.0 → devservices-1.1.1}/devservices/main.py +0 -0
  25. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/__init__.py +0 -0
  26. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/check_for_update.py +0 -0
  27. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/console.py +0 -0
  28. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/dependencies.py +0 -0
  29. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/devenv.py +0 -0
  30. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/docker.py +0 -0
  31. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/docker_compose.py +0 -0
  32. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/file_lock.py +0 -0
  33. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/git.py +0 -0
  34. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/install_binary.py +0 -0
  35. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/services.py +0 -0
  36. {devservices-1.1.0 → devservices-1.1.1}/devservices/utils/state.py +0 -0
  37. {devservices-1.1.0 → devservices-1.1.1}/devservices.egg-info/SOURCES.txt +0 -0
  38. {devservices-1.1.0 → devservices-1.1.1}/devservices.egg-info/dependency_links.txt +0 -0
  39. {devservices-1.1.0 → devservices-1.1.1}/devservices.egg-info/entry_points.txt +0 -0
  40. {devservices-1.1.0 → devservices-1.1.1}/devservices.egg-info/requires.txt +0 -0
  41. {devservices-1.1.0 → devservices-1.1.1}/devservices.egg-info/top_level.txt +0 -0
  42. {devservices-1.1.0 → devservices-1.1.1}/setup.cfg +0 -0
  43. {devservices-1.1.0 → devservices-1.1.1}/testing/__init__.py +0 -0
  44. {devservices-1.1.0 → devservices-1.1.1}/testing/utils.py +0 -0
  45. {devservices-1.1.0 → devservices-1.1.1}/tests/__init__.py +0 -0
  46. {devservices-1.1.0 → devservices-1.1.1}/tests/commands/test_down.py +0 -0
  47. {devservices-1.1.0 → devservices-1.1.1}/tests/commands/test_list_dependencies.py +0 -0
  48. {devservices-1.1.0 → devservices-1.1.1}/tests/commands/test_list_services.py +0 -0
  49. {devservices-1.1.0 → devservices-1.1.1}/tests/commands/test_logs.py +0 -0
  50. {devservices-1.1.0 → devservices-1.1.1}/tests/commands/test_purge.py +0 -0
  51. {devservices-1.1.0 → devservices-1.1.1}/tests/commands/test_status.py +0 -0
  52. {devservices-1.1.0 → devservices-1.1.1}/tests/commands/test_toggle.py +0 -0
  53. {devservices-1.1.0 → devservices-1.1.1}/tests/commands/test_up.py +0 -0
  54. {devservices-1.1.0 → devservices-1.1.1}/tests/commands/test_update.py +0 -0
  55. {devservices-1.1.0 → devservices-1.1.1}/tests/configs/test_service_config.py +0 -0
  56. {devservices-1.1.0 → devservices-1.1.1}/tests/conftest.py +0 -0
  57. {devservices-1.1.0 → devservices-1.1.1}/tests/utils/test_check_for_update.py +0 -0
  58. {devservices-1.1.0 → devservices-1.1.1}/tests/utils/test_dependencies.py +0 -0
  59. {devservices-1.1.0 → devservices-1.1.1}/tests/utils/test_docker.py +0 -0
  60. {devservices-1.1.0 → devservices-1.1.1}/tests/utils/test_git.py +0 -0
  61. {devservices-1.1.0 → devservices-1.1.1}/tests/utils/test_install_binary.py +0 -0
  62. {devservices-1.1.0 → devservices-1.1.1}/tests/utils/test_services.py +0 -0
  63. {devservices-1.1.0 → devservices-1.1.1}/tests/utils/test_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.1.0
3
+ Version: 1.1.1
4
4
  Requires-Python: >=3.10
5
5
  License-File: LICENSE.md
6
6
  Requires-Dist: pyyaml
@@ -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.1
34
35
  ```
35
36
 
36
37
  ### 2. Add devservices config files
@@ -39,7 +39,14 @@ DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS = {
39
39
  DEVSERVICES_RELEASES_URL = (
40
40
  "https://api.github.com/repos/getsentry/devservices/releases/latest"
41
41
  )
42
- DOCKER_COMPOSE_DOWNLOAD_URL = "https://github.com/docker/compose/releases/download"
42
+
43
+ # We mirror this in our GCP bucket since GitHub downloads can be flaky at times.
44
+ # gsutil cp docker-compose-darwin-aarch64 gs://sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-darwin-aarch64
45
+ # gsutil cp docker-compose-linux-x86_64 gs://sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-linux-x86_64
46
+ DOCKER_COMPOSE_DOWNLOAD_URL = (
47
+ "https://storage.googleapis.com/sentry-dev-infra-assets/docker-compose"
48
+ )
49
+
43
50
  DEVSERVICES_DOWNLOAD_URL = "https://github.com/getsentry/devservices/releases/download"
44
51
  BINARY_PERMISSIONS = 0o755
45
52
  MAX_LOG_LINES = "100"
@@ -6,12 +6,31 @@ import os
6
6
  import socket
7
7
  import subprocess
8
8
  import xmlrpc.client
9
+ from enum import IntEnum
9
10
 
10
11
  from devservices.constants import DEVSERVICES_SUPERVISOR_CONFIG_DIR
11
12
  from devservices.exceptions import SupervisorConfigError
12
13
  from devservices.exceptions import SupervisorConnectionError
13
14
  from devservices.exceptions import SupervisorError
14
15
  from devservices.exceptions import SupervisorProcessError
16
+ from devservices.utils.console import Console
17
+
18
+
19
+ class SupervisorProcessState(IntEnum):
20
+ """
21
+ Supervisor process states.
22
+
23
+ https://supervisord.org/subprocess.html#process-states
24
+ """
25
+
26
+ STOPPED = 0
27
+ STARTING = 10
28
+ RUNNING = 20
29
+ BACKOFF = 30
30
+ STOPPING = 40
31
+ EXITED = 100
32
+ FATAL = 200
33
+ UNKNOWN = 1000
15
34
 
16
35
 
17
36
  class UnixSocketHTTPConnection(http.client.HTTPConnection):
@@ -101,6 +120,21 @@ class SupervisorManager:
101
120
  f"Failed to connect to supervisor XML-RPC server: {e.errmsg}"
102
121
  )
103
122
 
123
+ def _is_program_running(self, program_name: str) -> bool:
124
+ try:
125
+ client = self._get_rpc_client()
126
+ process_info = client.supervisor.getProcessInfo(program_name)
127
+ if not isinstance(process_info, dict):
128
+ return False
129
+
130
+ state = process_info.get("state")
131
+ if not isinstance(state, int):
132
+ return False
133
+ return state == SupervisorProcessState.RUNNING
134
+ except xmlrpc.client.Fault:
135
+ # If we can't get the process info, assume it's not running
136
+ return False
137
+
104
138
  def start_supervisor_daemon(self) -> None:
105
139
  try:
106
140
  subprocess.run(["supervisord", "-c", self.config_file_path], check=True)
@@ -118,6 +152,8 @@ class SupervisorManager:
118
152
  raise SupervisorError(f"Failed to stop supervisor: {e.faultString}")
119
153
 
120
154
  def start_program(self, program_name: str) -> None:
155
+ if self._is_program_running(program_name):
156
+ return
121
157
  try:
122
158
  self._get_rpc_client().supervisor.startProcess(program_name)
123
159
  except xmlrpc.client.Fault as e:
@@ -126,9 +162,35 @@ class SupervisorManager:
126
162
  )
127
163
 
128
164
  def stop_program(self, program_name: str) -> None:
165
+ if not self._is_program_running(program_name):
166
+ return
129
167
  try:
130
168
  self._get_rpc_client().supervisor.stopProcess(program_name)
131
169
  except xmlrpc.client.Fault as e:
132
170
  raise SupervisorProcessError(
133
171
  f"Failed to stop program {program_name}: {e.faultString}"
134
172
  )
173
+
174
+ def tail_program_logs(self, program_name: str) -> None:
175
+ if not self._is_program_running(program_name):
176
+ console = Console()
177
+ console.failure(f"Program {program_name} is not running")
178
+ return
179
+
180
+ try:
181
+ # Use supervisorctl tail command
182
+ subprocess.run(
183
+ [
184
+ "supervisorctl",
185
+ "-c",
186
+ self.config_file_path,
187
+ "tail",
188
+ "-f",
189
+ program_name,
190
+ ],
191
+ check=True,
192
+ )
193
+ except subprocess.CalledProcessError as e:
194
+ raise SupervisorError(f"Failed to tail logs for {program_name}: {str(e)}")
195
+ except KeyboardInterrupt:
196
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.1.0
3
+ Version: 1.1.1
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.1.0"
7
+ version = "1.1.1"
8
8
  # 3.10 is just for internal pypi compat
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -216,7 +216,7 @@ def test_install_docker_compose_macos_arm64(
216
216
  mock_tempdir.return_value.__enter__.return_value = "tempdir"
217
217
  install_docker_compose()
218
218
  mock_urlretrieve.assert_called_once_with(
219
- "https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-darwin-aarch64",
219
+ "https://storage.googleapis.com/sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-darwin-aarch64",
220
220
  "tempdir/docker-compose",
221
221
  )
222
222
  mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755)
@@ -249,7 +249,7 @@ def test_install_docker_compose_linux_x86(
249
249
  mock_tempdir.return_value.__enter__.return_value = "tempdir"
250
250
  install_docker_compose()
251
251
  mock_urlretrieve.assert_called_once_with(
252
- "https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-x86_64",
252
+ "https://storage.googleapis.com/sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-linux-x86_64",
253
253
  "tempdir/docker-compose",
254
254
  )
255
255
  mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755)
@@ -286,7 +286,7 @@ def test_install_docker_compose_custom_docker_config_dir(
286
286
  ):
287
287
  install_docker_compose()
288
288
  mock_urlretrieve.assert_called_once_with(
289
- "https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-darwin-aarch64",
289
+ "https://storage.googleapis.com/sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-darwin-aarch64",
290
290
  "tempdir/docker-compose",
291
291
  )
292
292
  mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755)
@@ -0,0 +1,388 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+ import subprocess
5
+ import xmlrpc.client
6
+ from pathlib import Path
7
+ from unittest import mock
8
+
9
+ import pytest
10
+
11
+ from devservices.constants import DEVSERVICES_DIR_NAME
12
+ from devservices.exceptions import SupervisorConfigError
13
+ from devservices.exceptions import SupervisorConnectionError
14
+ from devservices.exceptions import SupervisorError
15
+ from devservices.exceptions import SupervisorProcessError
16
+ from devservices.utils.supervisor import SupervisorManager
17
+ from devservices.utils.supervisor import SupervisorProcessState
18
+ from devservices.utils.supervisor import UnixSocketHTTPConnection
19
+ from devservices.utils.supervisor import UnixSocketTransport
20
+
21
+
22
+ @mock.patch("socket.socket")
23
+ def test_unix_socket_http_connection_connect(
24
+ mock_socket: mock.MagicMock, tmp_path: Path
25
+ ) -> None:
26
+ socket_path = str(tmp_path / "test.sock")
27
+ mock_sock = mock_socket.return_value
28
+
29
+ conn = UnixSocketHTTPConnection(socket_path)
30
+ conn.connect()
31
+
32
+ mock_socket.assert_called_once_with(socket.AF_UNIX, socket.SOCK_STREAM)
33
+ mock_sock.connect.assert_called_once_with(socket_path)
34
+ assert conn.sock == mock_sock
35
+
36
+
37
+ @mock.patch("socket.socket")
38
+ def test_unix_socket_transport_make_connection(
39
+ mock_socket: mock.MagicMock, tmp_path: Path
40
+ ) -> None:
41
+ """
42
+ Test that the Unix socket transport correctly attempts to connect to the socket.
43
+ """
44
+ socket_path = str(tmp_path / "test.sock")
45
+ mock_sock = mock_socket.return_value
46
+
47
+ transport = UnixSocketTransport(socket_path)
48
+
49
+ connection = transport.make_connection("localhost")
50
+
51
+ # Connect the socket - this happens when we make an RPC call
52
+ connection.connect()
53
+
54
+ # Verify socket creation with correct family and type
55
+ mock_socket.assert_called_with(socket.AF_UNIX, socket.SOCK_STREAM)
56
+ # Verify connection to the right path
57
+ mock_sock.connect.assert_called_with(socket_path)
58
+
59
+
60
+ @pytest.fixture
61
+ def supervisor_manager(tmp_path: Path) -> SupervisorManager:
62
+ with mock.patch(
63
+ "devservices.utils.supervisor.DEVSERVICES_SUPERVISOR_CONFIG_DIR", tmp_path
64
+ ):
65
+ config_file_path = tmp_path / DEVSERVICES_DIR_NAME / "processes.conf"
66
+ config_file_path.parent.mkdir(parents=True, exist_ok=True)
67
+ config_file_path.write_text(
68
+ """
69
+ [program:test_program]
70
+ command = python test_program.py
71
+ """
72
+ )
73
+ return SupervisorManager(
74
+ config_file_path=str(config_file_path), service_name="test-service"
75
+ )
76
+
77
+
78
+ def test_init_with_config_file(supervisor_manager: SupervisorManager) -> None:
79
+ assert supervisor_manager.service_name == "test-service"
80
+ assert "test-service.processes.conf" in supervisor_manager.config_file_path
81
+
82
+
83
+ def test_init_with_nonexistent_config() -> None:
84
+ with pytest.raises(SupervisorConfigError):
85
+ SupervisorManager(
86
+ config_file_path="/nonexistent/path.conf", service_name="test-service"
87
+ )
88
+
89
+
90
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
91
+ def test_get_rpc_client_success(
92
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
93
+ ) -> None:
94
+ mock_rpc_client.return_value = mock.MagicMock()
95
+ client = supervisor_manager._get_rpc_client()
96
+ assert client is not None
97
+ mock_rpc_client.assert_called_once()
98
+ transport_arg = mock_rpc_client.call_args[1]["transport"]
99
+ assert isinstance(transport_arg, UnixSocketTransport)
100
+ assert transport_arg.socket_path == supervisor_manager.socket_path
101
+
102
+
103
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
104
+ def test_get_rpc_client_failure(
105
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
106
+ ) -> None:
107
+ mock_rpc_client.side_effect = xmlrpc.client.Fault(1, "Error")
108
+ with pytest.raises(SupervisorConnectionError):
109
+ supervisor_manager._get_rpc_client()
110
+ mock_rpc_client.assert_called_once()
111
+ transport_arg = mock_rpc_client.call_args[1]["transport"]
112
+ assert isinstance(transport_arg, UnixSocketTransport)
113
+ assert transport_arg.socket_path == supervisor_manager.socket_path
114
+
115
+
116
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
117
+ def test_is_program_running_success(
118
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
119
+ ) -> None:
120
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = {
121
+ "state": SupervisorProcessState.RUNNING
122
+ }
123
+ assert supervisor_manager._is_program_running("test_program")
124
+
125
+
126
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
127
+ def test_is_program_running_program_not_running(
128
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
129
+ ) -> None:
130
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = {
131
+ "state": SupervisorProcessState.STOPPED
132
+ }
133
+ assert not supervisor_manager._is_program_running("test_program")
134
+
135
+
136
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
137
+ def test_is_program_running_typing_error(
138
+ mock_rpc_client: mock.MagicMock,
139
+ supervisor_manager: SupervisorManager,
140
+ capsys: pytest.CaptureFixture[str],
141
+ ) -> None:
142
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = 1
143
+ assert not supervisor_manager._is_program_running("test_program")
144
+ mock_rpc_client.return_value.supervisor.getProcessInfo.side_effect = {
145
+ "state": [SupervisorProcessState.STOPPED]
146
+ }
147
+ assert not supervisor_manager._is_program_running("test_program")
148
+
149
+
150
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
151
+ def test_is_program_running_failure(
152
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
153
+ ) -> None:
154
+ mock_rpc_client.return_value.supervisor.getProcessInfo.side_effect = (
155
+ xmlrpc.client.Fault(1, "Error")
156
+ )
157
+ assert not supervisor_manager._is_program_running("test_program")
158
+
159
+
160
+ @mock.patch("devservices.utils.supervisor.subprocess.run")
161
+ def test_start_supervisor_daemon_success(
162
+ mock_subprocess_run: mock.MagicMock, supervisor_manager: SupervisorManager
163
+ ) -> None:
164
+ supervisor_manager.start_supervisor_daemon()
165
+ mock_subprocess_run.assert_called_once_with(
166
+ ["supervisord", "-c", supervisor_manager.config_file_path], check=True
167
+ )
168
+
169
+
170
+ @mock.patch("devservices.utils.supervisor.subprocess.run")
171
+ def test_start_supervisor_daemon_subprocess_failure(
172
+ mock_subprocess_run: mock.MagicMock, supervisor_manager: SupervisorManager
173
+ ) -> None:
174
+ mock_subprocess_run.side_effect = subprocess.CalledProcessError(1, "supervisord")
175
+ with pytest.raises(SupervisorError):
176
+ supervisor_manager.start_supervisor_daemon()
177
+
178
+
179
+ @mock.patch("devservices.utils.supervisor.subprocess.run")
180
+ def test_start_supervisor_daemon_file_not_found_failure(
181
+ mock_subprocess_run: mock.MagicMock, supervisor_manager: SupervisorManager
182
+ ) -> None:
183
+ mock_subprocess_run.side_effect = FileNotFoundError("supervisord")
184
+ with pytest.raises(SupervisorError):
185
+ supervisor_manager.start_supervisor_daemon()
186
+
187
+
188
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
189
+ def test_stop_supervisor_daemon_success(
190
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
191
+ ) -> None:
192
+ supervisor_manager.stop_supervisor_daemon()
193
+ supervisor_manager._get_rpc_client().supervisor.shutdown.assert_called_once()
194
+
195
+
196
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
197
+ def test_stop_supervisor_daemon_failure(
198
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
199
+ ) -> None:
200
+ mock_rpc_client.return_value.supervisor.shutdown.side_effect = xmlrpc.client.Fault(
201
+ 1, "Error"
202
+ )
203
+ with pytest.raises(SupervisorError):
204
+ supervisor_manager.stop_supervisor_daemon()
205
+
206
+
207
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
208
+ def test_start_program_success(
209
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
210
+ ) -> None:
211
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = {
212
+ "state": SupervisorProcessState.STOPPED
213
+ }
214
+ supervisor_manager.start_program("test_program")
215
+ supervisor_manager._get_rpc_client().supervisor.startProcess.assert_called_once_with(
216
+ "test_program"
217
+ )
218
+
219
+
220
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
221
+ def test_start_program_failure(
222
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
223
+ ) -> None:
224
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = {
225
+ "state": SupervisorProcessState.STOPPED
226
+ }
227
+ mock_rpc_client.return_value.supervisor.startProcess.side_effect = (
228
+ xmlrpc.client.Fault(1, "Error")
229
+ )
230
+ with pytest.raises(SupervisorProcessError):
231
+ supervisor_manager.start_program("test_program")
232
+
233
+
234
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
235
+ def test_start_program_already_running(
236
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
237
+ ) -> None:
238
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = {
239
+ "state": SupervisorProcessState.RUNNING
240
+ }
241
+ supervisor_manager.start_program("test_program")
242
+ mock_rpc_client.supervisor.startProcess.assert_not_called()
243
+
244
+
245
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
246
+ def test_stop_program_success(
247
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
248
+ ) -> None:
249
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = {
250
+ "state": SupervisorProcessState.RUNNING
251
+ }
252
+ supervisor_manager.stop_program("test_program")
253
+ supervisor_manager._get_rpc_client().supervisor.stopProcess.assert_called_once_with(
254
+ "test_program"
255
+ )
256
+
257
+
258
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
259
+ def test_stop_program_failure(
260
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
261
+ ) -> None:
262
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = {
263
+ "state": SupervisorProcessState.RUNNING
264
+ }
265
+ mock_rpc_client.return_value.supervisor.stopProcess.side_effect = (
266
+ xmlrpc.client.Fault(1, "Error")
267
+ )
268
+ with pytest.raises(SupervisorProcessError):
269
+ supervisor_manager.stop_program("test_program")
270
+
271
+
272
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
273
+ def test_stop_program_not_running(
274
+ mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
275
+ ) -> None:
276
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = {
277
+ "state": SupervisorProcessState.STOPPED
278
+ }
279
+ supervisor_manager.stop_program("test_program")
280
+ mock_rpc_client.supervisor.stopProcess.assert_not_called()
281
+
282
+
283
+ def test_extend_config_file(
284
+ supervisor_manager: SupervisorManager, tmp_path: Path
285
+ ) -> None:
286
+ assert supervisor_manager.config_file_path == str(
287
+ tmp_path / "test-service.processes.conf"
288
+ )
289
+ with open(supervisor_manager.config_file_path, "r") as f:
290
+ assert (
291
+ f.read()
292
+ == f"""[program:test_program]
293
+ command = python test_program.py
294
+
295
+ [unix_http_server]
296
+ file = {tmp_path}/test-service.sock
297
+
298
+ [supervisord]
299
+ pidfile = {tmp_path}/test-service.pid
300
+
301
+ [supervisorctl]
302
+ serverurl = unix://{tmp_path}/test-service.sock
303
+
304
+ [rpcinterface:supervisor]
305
+ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
306
+
307
+ """
308
+ )
309
+
310
+
311
+ @mock.patch("devservices.utils.supervisor.subprocess.run")
312
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
313
+ def tail_program_logs_success(
314
+ mock_rpc_client: mock.MagicMock,
315
+ mock_subprocess_run: mock.MagicMock,
316
+ supervisor_manager: SupervisorManager,
317
+ ) -> None:
318
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = {
319
+ "state": SupervisorProcessState.RUNNING
320
+ }
321
+ supervisor_manager.tail_program_logs("test_program")
322
+ mock_subprocess_run.assert_called_once_with(
323
+ [
324
+ "supervisorctl",
325
+ "-c",
326
+ supervisor_manager.config_file_path,
327
+ "fg",
328
+ "test_program",
329
+ ],
330
+ check=True,
331
+ )
332
+
333
+
334
+ @mock.patch("devservices.utils.supervisor.subprocess.run")
335
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
336
+ def test_tail_program_logs_not_running(
337
+ mock_rpc_client: mock.MagicMock,
338
+ mock_subprocess_run: mock.MagicMock,
339
+ supervisor_manager: SupervisorManager,
340
+ capsys: pytest.CaptureFixture[str],
341
+ ) -> None:
342
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = {
343
+ "state": SupervisorProcessState.STOPPED
344
+ }
345
+ supervisor_manager.tail_program_logs("test_program")
346
+ captured = capsys.readouterr()
347
+ assert "Program test_program is not running" in captured.out
348
+ mock_subprocess_run.assert_not_called()
349
+
350
+
351
+ @mock.patch("devservices.utils.supervisor.subprocess.run")
352
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
353
+ def test_tail_program_logs_failure(
354
+ mock_rpc_client: mock.MagicMock,
355
+ mock_subprocess_run: mock.MagicMock,
356
+ supervisor_manager: SupervisorManager,
357
+ ) -> None:
358
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = {
359
+ "state": SupervisorProcessState.RUNNING
360
+ }
361
+ mock_subprocess_run.side_effect = subprocess.CalledProcessError(1, "supervisorctl")
362
+ with pytest.raises(SupervisorError, match="Failed to tail logs for test_program"):
363
+ supervisor_manager.tail_program_logs("test_program")
364
+
365
+
366
+ @mock.patch("devservices.utils.supervisor.subprocess.run")
367
+ @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
368
+ def test_tail_program_logs_keyboard_interrupt(
369
+ mock_rpc_client: mock.MagicMock,
370
+ mock_subprocess_run: mock.MagicMock,
371
+ supervisor_manager: SupervisorManager,
372
+ ) -> None:
373
+ mock_rpc_client.return_value.supervisor.getProcessInfo.return_value = {
374
+ "state": SupervisorProcessState.RUNNING
375
+ }
376
+ mock_subprocess_run.side_effect = KeyboardInterrupt()
377
+ supervisor_manager.tail_program_logs("test_program")
378
+ mock_subprocess_run.assert_called_once_with(
379
+ [
380
+ "supervisorctl",
381
+ "-c",
382
+ supervisor_manager.config_file_path,
383
+ "tail",
384
+ "-f",
385
+ "test_program",
386
+ ],
387
+ check=True,
388
+ )
@@ -1,189 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import subprocess
4
- import xmlrpc.client
5
- from pathlib import Path
6
- from unittest import mock
7
-
8
- import pytest
9
-
10
- from devservices.constants import DEVSERVICES_DIR_NAME
11
- from devservices.exceptions import SupervisorConfigError
12
- from devservices.exceptions import SupervisorConnectionError
13
- from devservices.exceptions import SupervisorError
14
- from devservices.exceptions import SupervisorProcessError
15
- from devservices.utils.supervisor import SupervisorManager
16
- from devservices.utils.supervisor import UnixSocketTransport
17
-
18
-
19
- @pytest.fixture
20
- def supervisor_manager(tmp_path: Path) -> SupervisorManager:
21
- with mock.patch(
22
- "devservices.utils.supervisor.DEVSERVICES_SUPERVISOR_CONFIG_DIR", tmp_path
23
- ):
24
- config_file_path = tmp_path / DEVSERVICES_DIR_NAME / "processes.conf"
25
- config_file_path.parent.mkdir(parents=True, exist_ok=True)
26
- config_file_path.write_text(
27
- """
28
- [program:test_program]
29
- command = python test_program.py
30
- """
31
- )
32
- return SupervisorManager(
33
- config_file_path=str(config_file_path), service_name="test-service"
34
- )
35
-
36
-
37
- def test_init_with_config_file(supervisor_manager: SupervisorManager) -> None:
38
- assert supervisor_manager.service_name == "test-service"
39
- assert "test-service.processes.conf" in supervisor_manager.config_file_path
40
-
41
-
42
- def test_init_with_nonexistent_config() -> None:
43
- with pytest.raises(SupervisorConfigError):
44
- SupervisorManager(
45
- config_file_path="/nonexistent/path.conf", service_name="test-service"
46
- )
47
-
48
-
49
- @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
50
- def test_get_rpc_client_success(
51
- mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
52
- ) -> None:
53
- mock_rpc_client.return_value = mock.MagicMock()
54
- client = supervisor_manager._get_rpc_client()
55
- assert client is not None
56
- mock_rpc_client.assert_called_once()
57
- transport_arg = mock_rpc_client.call_args[1]["transport"]
58
- assert isinstance(transport_arg, UnixSocketTransport)
59
- assert transport_arg.socket_path == supervisor_manager.socket_path
60
-
61
-
62
- @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
63
- def test_get_rpc_client_failure(
64
- mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
65
- ) -> None:
66
- mock_rpc_client.side_effect = xmlrpc.client.Fault(1, "Error")
67
- with pytest.raises(SupervisorConnectionError):
68
- supervisor_manager._get_rpc_client()
69
- mock_rpc_client.assert_called_once()
70
- transport_arg = mock_rpc_client.call_args[1]["transport"]
71
- assert isinstance(transport_arg, UnixSocketTransport)
72
- assert transport_arg.socket_path == supervisor_manager.socket_path
73
-
74
-
75
- @mock.patch("devservices.utils.supervisor.subprocess.run")
76
- def test_start_supervisor_daemon_success(
77
- mock_subprocess_run: mock.MagicMock, supervisor_manager: SupervisorManager
78
- ) -> None:
79
- supervisor_manager.start_supervisor_daemon()
80
- mock_subprocess_run.assert_called_once_with(
81
- ["supervisord", "-c", supervisor_manager.config_file_path], check=True
82
- )
83
-
84
-
85
- @mock.patch("devservices.utils.supervisor.subprocess.run")
86
- def test_start_supervisor_daemon_subprocess_failure(
87
- mock_subprocess_run: mock.MagicMock, supervisor_manager: SupervisorManager
88
- ) -> None:
89
- mock_subprocess_run.side_effect = subprocess.CalledProcessError(1, "supervisord")
90
- with pytest.raises(SupervisorError):
91
- supervisor_manager.start_supervisor_daemon()
92
-
93
-
94
- @mock.patch("devservices.utils.supervisor.subprocess.run")
95
- def test_start_supervisor_daemon_file_not_found_failure(
96
- mock_subprocess_run: mock.MagicMock, supervisor_manager: SupervisorManager
97
- ) -> None:
98
- mock_subprocess_run.side_effect = FileNotFoundError("supervisord")
99
- with pytest.raises(SupervisorError):
100
- supervisor_manager.start_supervisor_daemon()
101
-
102
-
103
- @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
104
- def test_stop_supervisor_daemon_success(
105
- mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
106
- ) -> None:
107
- supervisor_manager.stop_supervisor_daemon()
108
- supervisor_manager._get_rpc_client().supervisor.shutdown.assert_called_once()
109
-
110
-
111
- @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
112
- def test_stop_supervisor_daemon_failure(
113
- mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
114
- ) -> None:
115
- mock_rpc_client.return_value.supervisor.shutdown.side_effect = xmlrpc.client.Fault(
116
- 1, "Error"
117
- )
118
- with pytest.raises(SupervisorError):
119
- supervisor_manager.stop_supervisor_daemon()
120
-
121
-
122
- @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
123
- def test_start_program_success(
124
- mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
125
- ) -> None:
126
- supervisor_manager.start_program("test_program")
127
- supervisor_manager._get_rpc_client().supervisor.startProcess.assert_called_once_with(
128
- "test_program"
129
- )
130
-
131
-
132
- @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
133
- def test_start_program_failure(
134
- mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
135
- ) -> None:
136
- mock_rpc_client.return_value.supervisor.startProcess.side_effect = (
137
- xmlrpc.client.Fault(1, "Error")
138
- )
139
- with pytest.raises(SupervisorProcessError):
140
- supervisor_manager.start_program("test_program")
141
-
142
-
143
- @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
144
- def test_stop_program_success(
145
- mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
146
- ) -> None:
147
- supervisor_manager.stop_program("test_program")
148
- supervisor_manager._get_rpc_client().supervisor.stopProcess.assert_called_once_with(
149
- "test_program"
150
- )
151
-
152
-
153
- @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy")
154
- def test_stop_program_failure(
155
- mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager
156
- ) -> None:
157
- mock_rpc_client.return_value.supervisor.stopProcess.side_effect = (
158
- xmlrpc.client.Fault(1, "Error")
159
- )
160
- with pytest.raises(SupervisorProcessError):
161
- supervisor_manager.stop_program("test_program")
162
-
163
-
164
- def test_extend_config_file(
165
- supervisor_manager: SupervisorManager, tmp_path: Path
166
- ) -> None:
167
- assert supervisor_manager.config_file_path == str(
168
- tmp_path / "test-service.processes.conf"
169
- )
170
- with open(supervisor_manager.config_file_path, "r") as f:
171
- assert (
172
- f.read()
173
- == f"""[program:test_program]
174
- command = python test_program.py
175
-
176
- [unix_http_server]
177
- file = {tmp_path}/test-service.sock
178
-
179
- [supervisord]
180
- pidfile = {tmp_path}/test-service.pid
181
-
182
- [supervisorctl]
183
- serverurl = unix://{tmp_path}/test-service.sock
184
-
185
- [rpcinterface:supervisor]
186
- supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
187
-
188
- """
189
- )
File without changes
File without changes