arthexis 0.1.3__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.3.dist-info/METADATA +126 -0
- arthexis-0.1.3.dist-info/RECORD +73 -0
- arthexis-0.1.3.dist-info/WHEEL +5 -0
- arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
- arthexis-0.1.3.dist-info/top_level.txt +5 -0
- config/__init__.py +6 -0
- config/active_app.py +15 -0
- config/asgi.py +29 -0
- config/auth_app.py +8 -0
- config/celery.py +19 -0
- config/context_processors.py +68 -0
- config/loadenv.py +11 -0
- config/logging.py +43 -0
- config/middleware.py +25 -0
- config/offline.py +47 -0
- config/settings.py +374 -0
- config/urls.py +91 -0
- config/wsgi.py +17 -0
- core/__init__.py +0 -0
- core/admin.py +830 -0
- core/apps.py +67 -0
- core/backends.py +82 -0
- core/entity.py +97 -0
- core/environment.py +43 -0
- core/fields.py +70 -0
- core/lcd_screen.py +77 -0
- core/middleware.py +34 -0
- core/models.py +1277 -0
- core/notifications.py +95 -0
- core/release.py +451 -0
- core/system.py +111 -0
- core/tasks.py +100 -0
- core/tests.py +483 -0
- core/urls.py +11 -0
- core/user_data.py +333 -0
- core/views.py +431 -0
- nodes/__init__.py +0 -0
- nodes/actions.py +72 -0
- nodes/admin.py +347 -0
- nodes/apps.py +76 -0
- nodes/lcd.py +151 -0
- nodes/models.py +577 -0
- nodes/tasks.py +50 -0
- nodes/tests.py +1072 -0
- nodes/urls.py +13 -0
- nodes/utils.py +62 -0
- nodes/views.py +262 -0
- ocpp/__init__.py +0 -0
- ocpp/admin.py +392 -0
- ocpp/apps.py +24 -0
- ocpp/consumers.py +267 -0
- ocpp/evcs.py +911 -0
- ocpp/models.py +300 -0
- ocpp/routing.py +9 -0
- ocpp/simulator.py +357 -0
- ocpp/store.py +175 -0
- ocpp/tasks.py +27 -0
- ocpp/test_export_import.py +129 -0
- ocpp/test_rfid.py +345 -0
- ocpp/tests.py +1229 -0
- ocpp/transactions_io.py +119 -0
- ocpp/urls.py +17 -0
- ocpp/views.py +359 -0
- pages/__init__.py +0 -0
- pages/admin.py +231 -0
- pages/apps.py +10 -0
- pages/checks.py +41 -0
- pages/context_processors.py +72 -0
- pages/models.py +224 -0
- pages/tests.py +628 -0
- pages/urls.py +17 -0
- pages/utils.py +13 -0
- pages/views.py +191 -0
core/notifications.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Simple notification helper for a 16x2 LCD display.
|
|
2
|
+
|
|
3
|
+
Messages are written to a lock file read by an independent service that
|
|
4
|
+
updates the LCD. If writing to the lock file fails, a Windows
|
|
5
|
+
notification or log entry is used as a fallback. Each line is truncated
|
|
6
|
+
to 64 characters; scrolling is handled by the LCD service.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
try: # pragma: no cover - optional dependency
|
|
16
|
+
from plyer import notification as plyer_notification
|
|
17
|
+
except Exception: # pragma: no cover - plyer may not be installed
|
|
18
|
+
plyer_notification = None
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NotificationManager:
|
|
24
|
+
"""Write notifications to a lock file or fall back to GUI/log output."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, lock_file: Path | None = None) -> None:
|
|
27
|
+
base_dir = Path(__file__).resolve().parents[1]
|
|
28
|
+
self.lock_file = lock_file or base_dir / "locks" / "lcd_screen.lck"
|
|
29
|
+
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
# ``plyer`` is only available on Windows and can fail when used in
|
|
31
|
+
# a non-interactive environment (e.g. service or CI).
|
|
32
|
+
# Any failure will fallback to logging quietly.
|
|
33
|
+
|
|
34
|
+
def _write_lock_file(self, subject: str, body: str) -> None:
|
|
35
|
+
self.lock_file.write_text(f"{subject}\n{body}\n", encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
def send(self, subject: str, body: str = "") -> bool:
|
|
38
|
+
"""Store *subject* and *body* in ``lcd_screen.lck`` when available.
|
|
39
|
+
|
|
40
|
+
The method truncates each line to 64 characters. If the lock file is
|
|
41
|
+
missing or writing fails, a GUI/log notification is used instead. In
|
|
42
|
+
either case the function returns ``True`` so callers do not keep
|
|
43
|
+
retrying in a loop when only the fallback is available.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
if self.lock_file.exists():
|
|
47
|
+
try:
|
|
48
|
+
self._write_lock_file(subject[:64], body[:64])
|
|
49
|
+
return True
|
|
50
|
+
except Exception as exc: # pragma: no cover - filesystem dependent
|
|
51
|
+
logger.warning("LCD lock file write failed: %s", exc)
|
|
52
|
+
else:
|
|
53
|
+
logger.debug("LCD lock file missing; using fallback notification")
|
|
54
|
+
self._gui_display(subject, body)
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
def send_async(self, subject: str, body: str = "") -> None:
|
|
58
|
+
"""Dispatch :meth:`send` on a background thread."""
|
|
59
|
+
|
|
60
|
+
def _send() -> None:
|
|
61
|
+
try:
|
|
62
|
+
self.send(subject, body)
|
|
63
|
+
except Exception:
|
|
64
|
+
# Notification failures shouldn't affect callers.
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
threading.Thread(target=_send, daemon=True).start()
|
|
68
|
+
|
|
69
|
+
# GUI/log fallback ------------------------------------------------
|
|
70
|
+
def _gui_display(self, subject: str, body: str) -> None:
|
|
71
|
+
if sys.platform.startswith("win") and plyer_notification:
|
|
72
|
+
try: # pragma: no cover - depends on platform
|
|
73
|
+
plyer_notification.notify(
|
|
74
|
+
title="Arthexis", message=f"{subject}\n{body}", timeout=6
|
|
75
|
+
)
|
|
76
|
+
return
|
|
77
|
+
except Exception as exc: # pragma: no cover - depends on platform
|
|
78
|
+
logger.warning("Windows notification failed: %s", exc)
|
|
79
|
+
logger.info("%s %s", subject, body)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Global manager used throughout the project
|
|
83
|
+
manager = NotificationManager()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def notify(subject: str, body: str = "") -> bool:
|
|
87
|
+
"""Convenience wrapper using the global :class:`NotificationManager`."""
|
|
88
|
+
|
|
89
|
+
return manager.send(subject=subject, body=body)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def notify_async(subject: str, body: str = "") -> None:
|
|
93
|
+
"""Run :func:`notify` without blocking the caller."""
|
|
94
|
+
|
|
95
|
+
manager.send_async(subject=subject, body=body)
|
core/release.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
try: # pragma: no cover - optional dependency
|
|
11
|
+
import toml # type: ignore
|
|
12
|
+
except Exception: # pragma: no cover - fallback when missing
|
|
13
|
+
toml = None # type: ignore
|
|
14
|
+
|
|
15
|
+
from config.offline import requires_network, network_available
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Package:
|
|
20
|
+
"""Metadata for building a distributable package."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
description: str
|
|
24
|
+
author: str
|
|
25
|
+
email: str
|
|
26
|
+
python_requires: str
|
|
27
|
+
license: str
|
|
28
|
+
repository_url: str = "https://github.com/arthexis/arthexis"
|
|
29
|
+
homepage_url: str = "https://arthexis.com"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Credentials:
|
|
34
|
+
"""Credentials for uploading to PyPI."""
|
|
35
|
+
|
|
36
|
+
token: Optional[str] = None
|
|
37
|
+
username: Optional[str] = None
|
|
38
|
+
password: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
def twine_args(self) -> list[str]:
|
|
41
|
+
if self.token:
|
|
42
|
+
return ["--username", "__token__", "--password", self.token]
|
|
43
|
+
if self.username and self.password:
|
|
44
|
+
return ["--username", self.username, "--password", self.password]
|
|
45
|
+
raise ValueError("Missing PyPI credentials")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
DEFAULT_PACKAGE = Package(
|
|
49
|
+
name="arthexis",
|
|
50
|
+
description="Django-based MESH system",
|
|
51
|
+
author="Rafael J. Guillén-Osorio",
|
|
52
|
+
email="tecnologia@gelectriic.com",
|
|
53
|
+
python_requires=">=3.10",
|
|
54
|
+
license="MIT",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ReleaseError(Exception):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestsFailed(ReleaseError):
|
|
63
|
+
"""Raised when the test suite fails.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
log_path: Location of the saved test log.
|
|
67
|
+
output: Combined stdout/stderr from the test run.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, log_path: Path, output: str):
|
|
71
|
+
super().__init__("Tests failed")
|
|
72
|
+
self.log_path = log_path
|
|
73
|
+
self.output = output
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
|
77
|
+
return subprocess.run(cmd, check=check)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _git_clean() -> bool:
|
|
81
|
+
proc = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True)
|
|
82
|
+
return not proc.stdout.strip()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _current_commit() -> str:
|
|
86
|
+
return subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _current_branch() -> str:
|
|
90
|
+
return (
|
|
91
|
+
subprocess.check_output([
|
|
92
|
+
"git",
|
|
93
|
+
"rev-parse",
|
|
94
|
+
"--abbrev-ref",
|
|
95
|
+
"HEAD",
|
|
96
|
+
]).decode().strip()
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _manager_credentials() -> Optional[Credentials]:
|
|
101
|
+
"""Return credentials from the Package's release manager if available."""
|
|
102
|
+
try: # pragma: no cover - optional dependency
|
|
103
|
+
from core.models import Package as PackageModel
|
|
104
|
+
|
|
105
|
+
package_obj = PackageModel.objects.select_related("release_manager").first()
|
|
106
|
+
if package_obj and package_obj.release_manager:
|
|
107
|
+
return package_obj.release_manager.to_credentials()
|
|
108
|
+
except Exception:
|
|
109
|
+
return None
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run_tests(log_path: Optional[Path] = None) -> subprocess.CompletedProcess:
|
|
114
|
+
"""Run the project's test suite and write output to ``log_path``.
|
|
115
|
+
|
|
116
|
+
The log file is stored separately from regular application logs to avoid
|
|
117
|
+
mixing test output with runtime logging.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
log_path = log_path or Path("logs/test.log")
|
|
121
|
+
proc = subprocess.run(
|
|
122
|
+
[sys.executable, "manage.py", "test"],
|
|
123
|
+
capture_output=True,
|
|
124
|
+
text=True,
|
|
125
|
+
)
|
|
126
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
log_path.write_text(proc.stdout + proc.stderr, encoding="utf-8")
|
|
128
|
+
return proc
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _write_pyproject(package: Package, version: str, requirements: list[str]) -> None:
|
|
132
|
+
content = {
|
|
133
|
+
"build-system": {
|
|
134
|
+
"requires": ["setuptools", "wheel"],
|
|
135
|
+
"build-backend": "setuptools.build_meta",
|
|
136
|
+
},
|
|
137
|
+
"project": {
|
|
138
|
+
"name": package.name,
|
|
139
|
+
"version": version,
|
|
140
|
+
"description": package.description,
|
|
141
|
+
"readme": {"file": "README.md", "content-type": "text/markdown"},
|
|
142
|
+
"requires-python": package.python_requires,
|
|
143
|
+
"license": package.license,
|
|
144
|
+
"authors": [{"name": package.author, "email": package.email}],
|
|
145
|
+
"classifiers": [
|
|
146
|
+
"Programming Language :: Python :: 3",
|
|
147
|
+
"Framework :: Django",
|
|
148
|
+
],
|
|
149
|
+
"dependencies": requirements,
|
|
150
|
+
"urls": {
|
|
151
|
+
"Repository": package.repository_url,
|
|
152
|
+
"Homepage": package.homepage_url,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
"tool": {
|
|
156
|
+
"setuptools": {
|
|
157
|
+
"packages": [
|
|
158
|
+
"core",
|
|
159
|
+
"config",
|
|
160
|
+
"nodes",
|
|
161
|
+
"ocpp",
|
|
162
|
+
"pages",
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def _dump_toml(data: dict) -> str:
|
|
169
|
+
if toml is not None and hasattr(toml, "dumps"):
|
|
170
|
+
return toml.dumps(data)
|
|
171
|
+
import json
|
|
172
|
+
return json.dumps(data)
|
|
173
|
+
|
|
174
|
+
Path("pyproject.toml").write_text(_dump_toml(content), encoding="utf-8")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _ensure_changelog() -> str:
|
|
178
|
+
header = "Changelog\n=========\n\n"
|
|
179
|
+
path = Path("CHANGELOG.rst")
|
|
180
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
181
|
+
if not text.startswith("Changelog"):
|
|
182
|
+
text = header + text
|
|
183
|
+
if "Unreleased" not in text:
|
|
184
|
+
text = text[: len(header)] + "Unreleased\n----------\n\n" + text[len(header):]
|
|
185
|
+
return text
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _pop_unreleased(text: str) -> tuple[str, str]:
|
|
189
|
+
lines = text.splitlines()
|
|
190
|
+
try:
|
|
191
|
+
idx = lines.index("Unreleased")
|
|
192
|
+
except ValueError:
|
|
193
|
+
return "", text
|
|
194
|
+
body = []
|
|
195
|
+
i = idx + 2
|
|
196
|
+
while i < len(lines) and lines[i].startswith("- "):
|
|
197
|
+
body.append(lines[i])
|
|
198
|
+
i += 1
|
|
199
|
+
if i < len(lines) and lines[i] == "":
|
|
200
|
+
i += 1
|
|
201
|
+
new_lines = lines[:idx] + lines[i:]
|
|
202
|
+
return "\n".join(body), "\n".join(new_lines) + ("\n" if text.endswith("\n") else "")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _last_changelog_revision() -> Optional[str]:
|
|
206
|
+
path = Path("CHANGELOG.rst")
|
|
207
|
+
if not path.exists():
|
|
208
|
+
return None
|
|
209
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
210
|
+
if "[revision" in line:
|
|
211
|
+
try:
|
|
212
|
+
return line.split("[revision", 1)[1].split("]", 1)[0].strip()
|
|
213
|
+
except Exception:
|
|
214
|
+
return None
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def update_changelog(version: str, revision: str, prev_revision: Optional[str] = None) -> None:
|
|
219
|
+
text = _ensure_changelog()
|
|
220
|
+
body, text = _pop_unreleased(text)
|
|
221
|
+
if not body:
|
|
222
|
+
prev_revision = prev_revision or _last_changelog_revision()
|
|
223
|
+
log_range = f"{prev_revision}..HEAD" if prev_revision else "HEAD"
|
|
224
|
+
proc = subprocess.run(
|
|
225
|
+
["git", "log", "--pretty=%h %s", "--no-merges", log_range],
|
|
226
|
+
capture_output=True,
|
|
227
|
+
text=True,
|
|
228
|
+
check=False,
|
|
229
|
+
)
|
|
230
|
+
body = "\n".join(
|
|
231
|
+
f"- {l.strip()}" for l in proc.stdout.splitlines() if l.strip()
|
|
232
|
+
)
|
|
233
|
+
header = f"{version} [revision {revision}]"
|
|
234
|
+
underline = "-" * len(header)
|
|
235
|
+
entry = "\n".join([header, underline, "", body, ""]).rstrip() + "\n\n"
|
|
236
|
+
base_header = "Changelog\n=========\n\n"
|
|
237
|
+
remaining = text[len(base_header):]
|
|
238
|
+
new_text = base_header + "Unreleased\n----------\n\n" + entry + remaining
|
|
239
|
+
Path("CHANGELOG.rst").write_text(new_text, encoding="utf-8")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@requires_network
|
|
243
|
+
def build(
|
|
244
|
+
*,
|
|
245
|
+
version: Optional[str] = None,
|
|
246
|
+
bump: bool = False,
|
|
247
|
+
tests: bool = False,
|
|
248
|
+
dist: bool = False,
|
|
249
|
+
twine: bool = False,
|
|
250
|
+
git: bool = False,
|
|
251
|
+
tag: bool = False,
|
|
252
|
+
all: bool = False,
|
|
253
|
+
force: bool = False,
|
|
254
|
+
package: Package = DEFAULT_PACKAGE,
|
|
255
|
+
creds: Optional[Credentials] = None,
|
|
256
|
+
stash: bool = False,
|
|
257
|
+
) -> None:
|
|
258
|
+
if all:
|
|
259
|
+
bump = dist = twine = git = tag = True
|
|
260
|
+
|
|
261
|
+
stashed = False
|
|
262
|
+
if not _git_clean():
|
|
263
|
+
if stash:
|
|
264
|
+
_run(["git", "stash", "--include-untracked"])
|
|
265
|
+
stashed = True
|
|
266
|
+
else:
|
|
267
|
+
raise ReleaseError(
|
|
268
|
+
"Git repository is not clean. Commit, stash, or enable auto stash before building."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if version is None:
|
|
272
|
+
version_path = Path("VERSION")
|
|
273
|
+
if not version_path.exists():
|
|
274
|
+
raise ReleaseError("VERSION file not found")
|
|
275
|
+
version = version_path.read_text().strip()
|
|
276
|
+
if bump:
|
|
277
|
+
major, minor, patch = map(int, version.split("."))
|
|
278
|
+
patch += 1
|
|
279
|
+
version = f"{major}.{minor}.{patch}"
|
|
280
|
+
version_path.write_text(version + "\n")
|
|
281
|
+
|
|
282
|
+
requirements = [
|
|
283
|
+
line.strip()
|
|
284
|
+
for line in Path("requirements.txt").read_text().splitlines()
|
|
285
|
+
if line.strip() and not line.startswith("#")
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
if tests:
|
|
289
|
+
log_path = Path("logs/test.log")
|
|
290
|
+
proc = run_tests(log_path=log_path)
|
|
291
|
+
if proc.returncode != 0:
|
|
292
|
+
raise TestsFailed(log_path, proc.stdout + proc.stderr)
|
|
293
|
+
|
|
294
|
+
commit_hash = _current_commit()
|
|
295
|
+
prev_revision = _last_changelog_revision()
|
|
296
|
+
update_changelog(version, commit_hash, prev_revision)
|
|
297
|
+
|
|
298
|
+
_write_pyproject(package, version, requirements)
|
|
299
|
+
if dist:
|
|
300
|
+
if Path("dist").exists():
|
|
301
|
+
for p in Path("dist").glob("*"):
|
|
302
|
+
p.unlink()
|
|
303
|
+
Path("dist").rmdir()
|
|
304
|
+
try:
|
|
305
|
+
import build # type: ignore
|
|
306
|
+
except Exception:
|
|
307
|
+
_run([sys.executable, "-m", "pip", "install", "build"])
|
|
308
|
+
_run([sys.executable, "-m", "build"])
|
|
309
|
+
|
|
310
|
+
if git:
|
|
311
|
+
files = ["VERSION", "pyproject.toml", "CHANGELOG.rst"]
|
|
312
|
+
_run(["git", "add"] + files)
|
|
313
|
+
msg = f"PyPI Release v{version}" if twine else f"Release v{version}"
|
|
314
|
+
_run(["git", "commit", "-m", msg])
|
|
315
|
+
_run(["git", "push"])
|
|
316
|
+
|
|
317
|
+
if tag:
|
|
318
|
+
tag_name = f"v{version}"
|
|
319
|
+
_run(["git", "tag", tag_name])
|
|
320
|
+
_run(["git", "push", "origin", tag_name])
|
|
321
|
+
|
|
322
|
+
if dist and twine:
|
|
323
|
+
if not force:
|
|
324
|
+
try: # pragma: no cover - requests optional
|
|
325
|
+
import requests # type: ignore
|
|
326
|
+
except Exception:
|
|
327
|
+
requests = None # type: ignore
|
|
328
|
+
if requests is not None:
|
|
329
|
+
resp = requests.get(
|
|
330
|
+
f"https://pypi.org/pypi/{package.name}/json"
|
|
331
|
+
)
|
|
332
|
+
if resp.ok:
|
|
333
|
+
releases = resp.json().get("releases", {})
|
|
334
|
+
if version in releases:
|
|
335
|
+
raise ReleaseError(
|
|
336
|
+
f"Version {version} already on PyPI"
|
|
337
|
+
)
|
|
338
|
+
creds = creds or _manager_credentials() or Credentials(
|
|
339
|
+
token=os.environ.get("PYPI_API_TOKEN"),
|
|
340
|
+
username=os.environ.get("PYPI_USERNAME"),
|
|
341
|
+
password=os.environ.get("PYPI_PASSWORD"),
|
|
342
|
+
)
|
|
343
|
+
files = sorted(str(p) for p in Path("dist").glob("*"))
|
|
344
|
+
if not files:
|
|
345
|
+
raise ReleaseError("dist directory is empty")
|
|
346
|
+
cmd = [sys.executable, "-m", "twine", "upload", *files]
|
|
347
|
+
try:
|
|
348
|
+
cmd += creds.twine_args()
|
|
349
|
+
except ValueError:
|
|
350
|
+
raise ReleaseError("Missing PyPI credentials")
|
|
351
|
+
_run(cmd)
|
|
352
|
+
|
|
353
|
+
if stashed:
|
|
354
|
+
_run(["git", "stash", "pop"], check=False)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def promote(
|
|
358
|
+
*,
|
|
359
|
+
package: Package = DEFAULT_PACKAGE,
|
|
360
|
+
version: str,
|
|
361
|
+
creds: Optional[Credentials] = None,
|
|
362
|
+
) -> tuple[str, str, str]:
|
|
363
|
+
"""Create a release branch and build the package without tests.
|
|
364
|
+
|
|
365
|
+
Returns a tuple of the release commit hash, the new branch name and the
|
|
366
|
+
original branch name.
|
|
367
|
+
"""
|
|
368
|
+
current = _current_branch()
|
|
369
|
+
tmp_branch = f"release/{version}"
|
|
370
|
+
stashed = False
|
|
371
|
+
try:
|
|
372
|
+
try:
|
|
373
|
+
_run(["git", "checkout", "-b", tmp_branch])
|
|
374
|
+
except subprocess.CalledProcessError:
|
|
375
|
+
_run(["git", "checkout", tmp_branch])
|
|
376
|
+
if not _git_clean():
|
|
377
|
+
_run(["git", "stash", "--include-untracked"])
|
|
378
|
+
stashed = True
|
|
379
|
+
build(
|
|
380
|
+
package=package,
|
|
381
|
+
version=version,
|
|
382
|
+
creds=creds,
|
|
383
|
+
tests=False,
|
|
384
|
+
dist=True,
|
|
385
|
+
git=False,
|
|
386
|
+
tag=False,
|
|
387
|
+
stash=True,
|
|
388
|
+
)
|
|
389
|
+
try: # best effort
|
|
390
|
+
_run(
|
|
391
|
+
[
|
|
392
|
+
sys.executable,
|
|
393
|
+
"manage.py",
|
|
394
|
+
"squashmigrations",
|
|
395
|
+
"core",
|
|
396
|
+
"0001",
|
|
397
|
+
"--noinput",
|
|
398
|
+
],
|
|
399
|
+
check=False,
|
|
400
|
+
)
|
|
401
|
+
except Exception:
|
|
402
|
+
# The squashmigrations command may not be available or could fail
|
|
403
|
+
# (e.g. when no migrations exist). Any errors should not interrupt
|
|
404
|
+
# the release promotion flow.
|
|
405
|
+
pass
|
|
406
|
+
_run(["git", "add", "."]) # add all changes
|
|
407
|
+
_run(["git", "commit", "-m", f"Release v{version}"])
|
|
408
|
+
commit_hash = _current_commit()
|
|
409
|
+
release_name = f"{package.name}-{version}-{commit_hash[:7]}"
|
|
410
|
+
branch = f"release-{release_name}"
|
|
411
|
+
_run(["git", "branch", "-m", branch])
|
|
412
|
+
except Exception:
|
|
413
|
+
_run(["git", "checkout", current])
|
|
414
|
+
raise
|
|
415
|
+
finally:
|
|
416
|
+
if stashed:
|
|
417
|
+
_run(["git", "stash", "pop"], check=False)
|
|
418
|
+
return commit_hash, branch, current
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def publish(
|
|
422
|
+
*, package: Package = DEFAULT_PACKAGE, version: str, creds: Optional[Credentials] = None
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Upload the existing distribution to PyPI."""
|
|
425
|
+
if network_available():
|
|
426
|
+
try: # pragma: no cover - requests optional
|
|
427
|
+
import requests # type: ignore
|
|
428
|
+
except Exception:
|
|
429
|
+
requests = None # type: ignore
|
|
430
|
+
if requests is not None:
|
|
431
|
+
resp = requests.get(f"https://pypi.org/pypi/{package.name}/json")
|
|
432
|
+
if resp.ok and version in resp.json().get("releases", {}):
|
|
433
|
+
raise ReleaseError(f"Version {version} already on PyPI")
|
|
434
|
+
if not Path("dist").exists():
|
|
435
|
+
raise ReleaseError("dist directory not found")
|
|
436
|
+
creds = creds or _manager_credentials() or Credentials(
|
|
437
|
+
token=os.environ.get("PYPI_API_TOKEN"),
|
|
438
|
+
username=os.environ.get("PYPI_USERNAME"),
|
|
439
|
+
password=os.environ.get("PYPI_PASSWORD"),
|
|
440
|
+
)
|
|
441
|
+
files = sorted(str(p) for p in Path("dist").glob("*"))
|
|
442
|
+
if not files:
|
|
443
|
+
raise ReleaseError("dist directory is empty")
|
|
444
|
+
cmd = [sys.executable, "-m", "twine", "upload", *files]
|
|
445
|
+
try:
|
|
446
|
+
cmd += creds.twine_args()
|
|
447
|
+
except ValueError:
|
|
448
|
+
raise ReleaseError("Missing PyPI credentials")
|
|
449
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
450
|
+
if proc.returncode != 0:
|
|
451
|
+
raise ReleaseError(proc.stdout + proc.stderr)
|
core/system.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import socket
|
|
5
|
+
import subprocess
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.contrib import admin
|
|
10
|
+
from django.shortcuts import redirect
|
|
11
|
+
from django.template.response import TemplateResponse
|
|
12
|
+
from django.urls import path, reverse
|
|
13
|
+
from django.utils.translation import gettext_lazy as _
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _gather_info() -> dict:
|
|
17
|
+
"""Collect basic system information similar to status-check.sh."""
|
|
18
|
+
base_dir = Path(settings.BASE_DIR)
|
|
19
|
+
lock_dir = base_dir / "locks"
|
|
20
|
+
info: dict[str, object] = {}
|
|
21
|
+
|
|
22
|
+
info["installed"] = (base_dir / ".venv").exists()
|
|
23
|
+
|
|
24
|
+
service_file = lock_dir / "service.lck"
|
|
25
|
+
info["service"] = (
|
|
26
|
+
service_file.read_text().strip() if service_file.exists() else ""
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
mode_file = lock_dir / "nginx_mode.lck"
|
|
30
|
+
mode = mode_file.read_text().strip() if mode_file.exists() else "internal"
|
|
31
|
+
info["mode"] = mode
|
|
32
|
+
info["port"] = 8000 if mode == "public" else 8888
|
|
33
|
+
|
|
34
|
+
role_file = lock_dir / "role.lck"
|
|
35
|
+
info["role"] = role_file.read_text().strip() if role_file.exists() else "unknown"
|
|
36
|
+
|
|
37
|
+
info["features"] = {
|
|
38
|
+
"celery": (lock_dir / "celery.lck").exists(),
|
|
39
|
+
"lcd_screen": (lock_dir / "lcd_screen.lck").exists(),
|
|
40
|
+
"control": (lock_dir / "control.lck").exists(),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
running = False
|
|
44
|
+
service_status = ""
|
|
45
|
+
service = info["service"]
|
|
46
|
+
if service and shutil.which("systemctl"):
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["systemctl", "is-active", str(service)],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
check=False,
|
|
53
|
+
)
|
|
54
|
+
service_status = result.stdout.strip()
|
|
55
|
+
running = service_status == "active"
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
else:
|
|
59
|
+
try:
|
|
60
|
+
subprocess.run(
|
|
61
|
+
["pgrep", "-f", "manage.py runserver"],
|
|
62
|
+
check=True,
|
|
63
|
+
stdout=subprocess.PIPE,
|
|
64
|
+
stderr=subprocess.PIPE,
|
|
65
|
+
)
|
|
66
|
+
running = True
|
|
67
|
+
except Exception:
|
|
68
|
+
running = False
|
|
69
|
+
info["running"] = running
|
|
70
|
+
info["service_status"] = service_status
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
hostname = socket.gethostname()
|
|
74
|
+
ip_list = socket.gethostbyname_ex(hostname)[2]
|
|
75
|
+
except Exception:
|
|
76
|
+
hostname = ""
|
|
77
|
+
ip_list = []
|
|
78
|
+
info["hostname"] = hostname
|
|
79
|
+
info["ip_addresses"] = ip_list
|
|
80
|
+
|
|
81
|
+
return info
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _system_view(request):
|
|
85
|
+
info = _gather_info()
|
|
86
|
+
if request.method == "POST" and request.user.is_superuser:
|
|
87
|
+
action = request.POST.get("action")
|
|
88
|
+
stop_script = Path(settings.BASE_DIR) / "stop.sh"
|
|
89
|
+
args = [str(stop_script)]
|
|
90
|
+
if action == "stop" and info["service"]:
|
|
91
|
+
args.append("--all")
|
|
92
|
+
subprocess.Popen(args)
|
|
93
|
+
return redirect(reverse("admin:index"))
|
|
94
|
+
|
|
95
|
+
context = admin.site.each_context(request)
|
|
96
|
+
context.update({"title": _("System"), "info": info})
|
|
97
|
+
return TemplateResponse(request, "admin/system.html", context)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def patch_admin_system_view() -> None:
|
|
101
|
+
"""Add custom admin view for system information."""
|
|
102
|
+
original_get_urls = admin.site.get_urls
|
|
103
|
+
|
|
104
|
+
def get_urls():
|
|
105
|
+
urls = original_get_urls()
|
|
106
|
+
custom = [
|
|
107
|
+
path("system/", admin.site.admin_view(_system_view), name="system"),
|
|
108
|
+
]
|
|
109
|
+
return custom + urls
|
|
110
|
+
|
|
111
|
+
admin.site.get_urls = get_urls
|