arthexis 0.1.8__py3-none-any.whl → 0.1.10__py3-none-any.whl
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.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
core/tasks.py
CHANGED
|
@@ -1,17 +1,91 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import subprocess
|
|
4
|
-
from datetime import datetime
|
|
5
5
|
from pathlib import Path
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
6
8
|
|
|
7
9
|
from celery import shared_task
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from django.contrib.auth import get_user_model
|
|
12
|
+
from core import mailer
|
|
13
|
+
from core import github_issues
|
|
14
|
+
from django.utils import timezone
|
|
15
|
+
|
|
16
|
+
from nodes.models import NetMessage
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
AUTO_UPGRADE_HEALTH_DELAY_SECONDS = 30
|
|
20
|
+
AUTO_UPGRADE_HEALTH_MAX_ATTEMPTS = 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@shared_task
|
|
27
|
+
def heartbeat() -> None:
|
|
28
|
+
"""Log a simple heartbeat message."""
|
|
29
|
+
logger.info("Heartbeat task executed")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@shared_task
|
|
33
|
+
def birthday_greetings() -> None:
|
|
34
|
+
"""Send birthday greetings to users via Net Message and email."""
|
|
35
|
+
User = get_user_model()
|
|
36
|
+
today = timezone.localdate()
|
|
37
|
+
for user in User.objects.filter(birthday=today):
|
|
38
|
+
NetMessage.broadcast("Happy bday!", user.username)
|
|
39
|
+
if user.email:
|
|
40
|
+
mailer.send(
|
|
41
|
+
"Happy bday!",
|
|
42
|
+
f"Happy bday! {user.username}",
|
|
43
|
+
[user.email],
|
|
44
|
+
settings.DEFAULT_FROM_EMAIL,
|
|
45
|
+
fail_silently=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _auto_upgrade_log_path(base_dir: Path) -> Path:
|
|
50
|
+
"""Return the log file used for auto-upgrade events."""
|
|
51
|
+
|
|
52
|
+
log_dir = base_dir / "logs"
|
|
53
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
return log_dir / "auto-upgrade.log"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _append_auto_upgrade_log(base_dir: Path, message: str) -> None:
|
|
58
|
+
"""Append ``message`` to the auto-upgrade log, ignoring errors."""
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
log_file = _auto_upgrade_log_path(base_dir)
|
|
62
|
+
timestamp = timezone.now().isoformat()
|
|
63
|
+
with log_file.open("a") as fh:
|
|
64
|
+
fh.write(f"{timestamp} {message}\n")
|
|
65
|
+
except Exception: # pragma: no cover - best effort logging only
|
|
66
|
+
logger.warning("Failed to append auto-upgrade log entry: %s", message)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_service_url(base_dir: Path) -> str:
|
|
70
|
+
"""Return the local URL used to probe the Django suite."""
|
|
71
|
+
|
|
72
|
+
lock_dir = base_dir / "locks"
|
|
73
|
+
mode_file = lock_dir / "nginx_mode.lck"
|
|
74
|
+
mode = "internal"
|
|
75
|
+
if mode_file.exists():
|
|
76
|
+
try:
|
|
77
|
+
mode = mode_file.read_text().strip() or "internal"
|
|
78
|
+
except OSError:
|
|
79
|
+
mode = "internal"
|
|
80
|
+
port = 8000 if mode == "public" else 8888
|
|
81
|
+
return f"http://127.0.0.1:{port}/"
|
|
8
82
|
|
|
9
83
|
|
|
10
84
|
@shared_task
|
|
11
85
|
def check_github_updates() -> None:
|
|
12
86
|
"""Check the GitHub repo for updates and upgrade if needed."""
|
|
13
87
|
base_dir = Path(__file__).resolve().parent.parent
|
|
14
|
-
mode_file = base_dir / "
|
|
88
|
+
mode_file = base_dir / "locks" / "auto_upgrade.lck"
|
|
15
89
|
mode = "version"
|
|
16
90
|
if mode_file.exists():
|
|
17
91
|
mode = mode_file.read_text().strip()
|
|
@@ -19,11 +93,11 @@ def check_github_updates() -> None:
|
|
|
19
93
|
branch = "main"
|
|
20
94
|
subprocess.run(["git", "fetch", "origin", branch], cwd=base_dir, check=True)
|
|
21
95
|
|
|
22
|
-
|
|
23
|
-
log_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
-
log_file = log_dir / "auto-upgrade.log"
|
|
96
|
+
log_file = _auto_upgrade_log_path(base_dir)
|
|
25
97
|
with log_file.open("a") as fh:
|
|
26
|
-
fh.write(
|
|
98
|
+
fh.write(
|
|
99
|
+
f"{timezone.now().isoformat()} check_github_updates triggered\n"
|
|
100
|
+
)
|
|
27
101
|
|
|
28
102
|
notify = None
|
|
29
103
|
startup = None
|
|
@@ -36,56 +110,94 @@ def check_github_updates() -> None:
|
|
|
36
110
|
except Exception:
|
|
37
111
|
startup = None
|
|
38
112
|
|
|
113
|
+
upgrade_stamp = timezone.now().strftime("@ %Y%m%d %H:%M")
|
|
114
|
+
|
|
115
|
+
upgrade_was_applied = False
|
|
116
|
+
|
|
39
117
|
if mode == "latest":
|
|
40
|
-
local =
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
118
|
+
local = (
|
|
119
|
+
subprocess.check_output(["git", "rev-parse", branch], cwd=base_dir)
|
|
120
|
+
.decode()
|
|
121
|
+
.strip()
|
|
122
|
+
)
|
|
123
|
+
remote = (
|
|
124
|
+
subprocess.check_output(
|
|
125
|
+
[
|
|
126
|
+
"git",
|
|
127
|
+
"rev-parse",
|
|
128
|
+
f"origin/{branch}",
|
|
129
|
+
],
|
|
130
|
+
cwd=base_dir,
|
|
131
|
+
)
|
|
132
|
+
.decode()
|
|
133
|
+
.strip()
|
|
134
|
+
)
|
|
46
135
|
if local == remote:
|
|
47
136
|
if startup:
|
|
48
137
|
startup()
|
|
49
138
|
return
|
|
50
139
|
if notify:
|
|
51
|
-
notify("Upgrading...",
|
|
140
|
+
notify("Upgrading...", upgrade_stamp)
|
|
52
141
|
args = ["./upgrade.sh", "--latest", "--no-restart"]
|
|
142
|
+
upgrade_was_applied = True
|
|
53
143
|
else:
|
|
54
144
|
local = "0"
|
|
55
145
|
version_file = base_dir / "VERSION"
|
|
56
146
|
if version_file.exists():
|
|
57
147
|
local = version_file.read_text().strip()
|
|
58
|
-
remote =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
148
|
+
remote = (
|
|
149
|
+
subprocess.check_output(
|
|
150
|
+
[
|
|
151
|
+
"git",
|
|
152
|
+
"show",
|
|
153
|
+
f"origin/{branch}:VERSION",
|
|
154
|
+
],
|
|
155
|
+
cwd=base_dir,
|
|
156
|
+
)
|
|
157
|
+
.decode()
|
|
158
|
+
.strip()
|
|
159
|
+
)
|
|
63
160
|
if local == remote:
|
|
64
161
|
if startup:
|
|
65
162
|
startup()
|
|
66
163
|
return
|
|
67
164
|
if notify:
|
|
68
|
-
notify("Upgrading...",
|
|
165
|
+
notify("Upgrading...", upgrade_stamp)
|
|
69
166
|
args = ["./upgrade.sh", "--no-restart"]
|
|
167
|
+
upgrade_was_applied = True
|
|
70
168
|
|
|
71
169
|
with log_file.open("a") as fh:
|
|
72
|
-
fh.write(
|
|
170
|
+
fh.write(
|
|
171
|
+
f"{timezone.now().isoformat()} running: {' '.join(args)}\n"
|
|
172
|
+
)
|
|
73
173
|
|
|
74
174
|
subprocess.run(args, cwd=base_dir, check=True)
|
|
75
175
|
|
|
76
176
|
service_file = base_dir / "locks/service.lck"
|
|
77
177
|
if service_file.exists():
|
|
78
178
|
service = service_file.read_text().strip()
|
|
79
|
-
subprocess.run(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
179
|
+
subprocess.run(
|
|
180
|
+
[
|
|
181
|
+
"sudo",
|
|
182
|
+
"systemctl",
|
|
183
|
+
"kill",
|
|
184
|
+
"--signal=TERM",
|
|
185
|
+
service,
|
|
186
|
+
]
|
|
187
|
+
)
|
|
86
188
|
else:
|
|
87
189
|
subprocess.run(["pkill", "-f", "manage.py runserver"])
|
|
88
190
|
|
|
191
|
+
if upgrade_was_applied:
|
|
192
|
+
_append_auto_upgrade_log(
|
|
193
|
+
base_dir,
|
|
194
|
+
(
|
|
195
|
+
"Scheduled post-upgrade health check in %s seconds"
|
|
196
|
+
% AUTO_UPGRADE_HEALTH_DELAY_SECONDS
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
_schedule_health_check(1)
|
|
200
|
+
|
|
89
201
|
|
|
90
202
|
@shared_task
|
|
91
203
|
def poll_email_collectors() -> None:
|
|
@@ -98,3 +210,133 @@ def poll_email_collectors() -> None:
|
|
|
98
210
|
for collector in EmailCollector.objects.all():
|
|
99
211
|
collector.collect()
|
|
100
212
|
|
|
213
|
+
|
|
214
|
+
@shared_task
|
|
215
|
+
def report_runtime_issue(
|
|
216
|
+
title: str,
|
|
217
|
+
body: str,
|
|
218
|
+
labels: list[str] | None = None,
|
|
219
|
+
fingerprint: str | None = None,
|
|
220
|
+
):
|
|
221
|
+
"""Report a runtime issue to GitHub using :mod:`core.github_issues`."""
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
response = github_issues.create_issue(
|
|
225
|
+
title,
|
|
226
|
+
body,
|
|
227
|
+
labels=labels,
|
|
228
|
+
fingerprint=fingerprint,
|
|
229
|
+
)
|
|
230
|
+
except Exception:
|
|
231
|
+
logger.exception("Failed to report runtime issue '%s'", title)
|
|
232
|
+
raise
|
|
233
|
+
|
|
234
|
+
if response is None:
|
|
235
|
+
logger.info("Skipped GitHub issue creation for fingerprint %s", fingerprint)
|
|
236
|
+
else:
|
|
237
|
+
logger.info("Reported runtime issue '%s' to GitHub", title)
|
|
238
|
+
|
|
239
|
+
return response
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _record_health_check_result(
|
|
243
|
+
base_dir: Path, attempt: int, status: int | None, detail: str
|
|
244
|
+
) -> None:
|
|
245
|
+
status_display = status if status is not None else "unreachable"
|
|
246
|
+
message = "Health check attempt %s %s (%s)" % (attempt, detail, status_display)
|
|
247
|
+
_append_auto_upgrade_log(base_dir, message)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _schedule_health_check(next_attempt: int) -> None:
|
|
251
|
+
verify_auto_upgrade_health.apply_async(
|
|
252
|
+
kwargs={"attempt": next_attempt},
|
|
253
|
+
countdown=AUTO_UPGRADE_HEALTH_DELAY_SECONDS,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@shared_task
|
|
258
|
+
def verify_auto_upgrade_health(attempt: int = 1) -> bool | None:
|
|
259
|
+
"""Verify the upgraded suite responds successfully.
|
|
260
|
+
|
|
261
|
+
When the check fails three times in a row the upgrade is rolled back by
|
|
262
|
+
invoking ``upgrade.sh --revert``.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
base_dir = Path(__file__).resolve().parent.parent
|
|
266
|
+
url = _resolve_service_url(base_dir)
|
|
267
|
+
request = urllib.request.Request(
|
|
268
|
+
url,
|
|
269
|
+
headers={"User-Agent": "Arthexis-AutoUpgrade/1.0"},
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
status: int | None = None
|
|
273
|
+
try:
|
|
274
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
275
|
+
status = getattr(response, "status", response.getcode())
|
|
276
|
+
except urllib.error.HTTPError as exc:
|
|
277
|
+
status = exc.code
|
|
278
|
+
logger.warning(
|
|
279
|
+
"Auto-upgrade health check attempt %s returned HTTP %s", attempt, exc.code
|
|
280
|
+
)
|
|
281
|
+
except urllib.error.URLError as exc:
|
|
282
|
+
logger.warning(
|
|
283
|
+
"Auto-upgrade health check attempt %s failed: %s", attempt, exc
|
|
284
|
+
)
|
|
285
|
+
except Exception as exc: # pragma: no cover - unexpected network error
|
|
286
|
+
logger.exception(
|
|
287
|
+
"Unexpected error probing suite during auto-upgrade attempt %s", attempt
|
|
288
|
+
)
|
|
289
|
+
detail = f"failed with {exc}"
|
|
290
|
+
_record_health_check_result(base_dir, attempt, status, detail)
|
|
291
|
+
if attempt >= AUTO_UPGRADE_HEALTH_MAX_ATTEMPTS:
|
|
292
|
+
_append_auto_upgrade_log(
|
|
293
|
+
base_dir,
|
|
294
|
+
"Health check raised unexpected error; reverting upgrade",
|
|
295
|
+
)
|
|
296
|
+
subprocess.run(["./upgrade.sh", "--revert"], cwd=base_dir, check=True)
|
|
297
|
+
else:
|
|
298
|
+
_schedule_health_check(attempt + 1)
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
if status == 200:
|
|
302
|
+
_record_health_check_result(base_dir, attempt, status, "succeeded")
|
|
303
|
+
logger.info(
|
|
304
|
+
"Auto-upgrade health check succeeded on attempt %s with HTTP %s",
|
|
305
|
+
attempt,
|
|
306
|
+
status,
|
|
307
|
+
)
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
_record_health_check_result(base_dir, attempt, status, "failed")
|
|
311
|
+
|
|
312
|
+
if attempt >= AUTO_UPGRADE_HEALTH_MAX_ATTEMPTS:
|
|
313
|
+
logger.error(
|
|
314
|
+
"Auto-upgrade health check failed after %s attempts; reverting", attempt
|
|
315
|
+
)
|
|
316
|
+
_append_auto_upgrade_log(
|
|
317
|
+
base_dir,
|
|
318
|
+
"Health check failed three times; reverting upgrade",
|
|
319
|
+
)
|
|
320
|
+
subprocess.run(["./upgrade.sh", "--revert"], cwd=base_dir, check=True)
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
_schedule_health_check(attempt + 1)
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@shared_task
|
|
328
|
+
def run_client_report_schedule(schedule_id: int) -> None:
|
|
329
|
+
"""Execute a :class:`core.models.ClientReportSchedule` run."""
|
|
330
|
+
|
|
331
|
+
from core.models import ClientReportSchedule
|
|
332
|
+
|
|
333
|
+
schedule = ClientReportSchedule.objects.filter(pk=schedule_id).first()
|
|
334
|
+
if not schedule:
|
|
335
|
+
logger.warning("ClientReportSchedule %s no longer exists", schedule_id)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
schedule.run()
|
|
340
|
+
except Exception:
|
|
341
|
+
logger.exception("ClientReportSchedule %s failed", schedule_id)
|
|
342
|
+
raise
|
core/test_system_info.py
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from subprocess import CompletedProcess
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
2
6
|
|
|
3
7
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
4
8
|
|
|
5
9
|
import django
|
|
10
|
+
|
|
6
11
|
django.setup()
|
|
7
12
|
|
|
8
|
-
from django.
|
|
13
|
+
from django.conf import settings
|
|
14
|
+
from django.test import SimpleTestCase, TestCase, override_settings
|
|
15
|
+
from nodes.models import Node, NodeFeature, NodeRole
|
|
9
16
|
from core.system import _gather_info
|
|
10
17
|
|
|
11
18
|
|
|
@@ -19,3 +26,54 @@ class SystemInfoRoleTests(SimpleTestCase):
|
|
|
19
26
|
def test_uses_settings_role(self):
|
|
20
27
|
info = _gather_info()
|
|
21
28
|
self.assertEqual(info["role"], "Satellite")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SystemInfoScreenModeTests(SimpleTestCase):
|
|
32
|
+
def test_without_lockfile(self):
|
|
33
|
+
info = _gather_info()
|
|
34
|
+
self.assertEqual(info["screen_mode"], "")
|
|
35
|
+
|
|
36
|
+
def test_with_lockfile(self):
|
|
37
|
+
lock_dir = Path(settings.BASE_DIR) / "locks"
|
|
38
|
+
lock_dir.mkdir(exist_ok=True)
|
|
39
|
+
lock_file = lock_dir / "screen_mode.lck"
|
|
40
|
+
lock_file.write_text("tft")
|
|
41
|
+
try:
|
|
42
|
+
info = _gather_info()
|
|
43
|
+
self.assertEqual(info["screen_mode"], "tft")
|
|
44
|
+
finally:
|
|
45
|
+
lock_file.unlink()
|
|
46
|
+
if not any(lock_dir.iterdir()):
|
|
47
|
+
lock_dir.rmdir()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SystemInfoRevisionTests(SimpleTestCase):
|
|
51
|
+
@patch("core.system.revision.get_revision", return_value="abcdef1234567890")
|
|
52
|
+
def test_includes_full_revision(self, mock_revision):
|
|
53
|
+
info = _gather_info()
|
|
54
|
+
self.assertEqual(info["revision"], "abcdef1234567890")
|
|
55
|
+
mock_revision.assert_called_once()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SystemInfoRunserverDetectionTests(SimpleTestCase):
|
|
59
|
+
@patch("core.system.subprocess.run")
|
|
60
|
+
def test_detects_runserver_process_port(self, mock_run):
|
|
61
|
+
mock_run.return_value = CompletedProcess(
|
|
62
|
+
args=["pgrep"],
|
|
63
|
+
returncode=0,
|
|
64
|
+
stdout="123 python manage.py runserver 0.0.0.0:8000 --noreload\n",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
info = _gather_info()
|
|
68
|
+
|
|
69
|
+
self.assertTrue(info["running"])
|
|
70
|
+
self.assertEqual(info["port"], 8000)
|
|
71
|
+
|
|
72
|
+
@patch("core.system._probe_ports", return_value=(True, 8000))
|
|
73
|
+
@patch("core.system.subprocess.run", side_effect=FileNotFoundError)
|
|
74
|
+
def test_falls_back_to_port_probe_when_pgrep_missing(self, mock_run, mock_probe):
|
|
75
|
+
info = _gather_info()
|
|
76
|
+
|
|
77
|
+
self.assertTrue(info["running"])
|
|
78
|
+
self.assertEqual(info["port"], 8000)
|
|
79
|
+
|