arthexis 0.1.6__py3-none-any.whl → 0.1.8__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.
core/checks.py ADDED
@@ -0,0 +1,29 @@
1
+ import hashlib
2
+ from pathlib import Path
3
+ from django.conf import settings
4
+ from django.core import checks
5
+
6
+
7
+ def _fixture_hash() -> str:
8
+ base_dir = Path(settings.BASE_DIR)
9
+ md5 = hashlib.md5()
10
+ for path in sorted(base_dir.glob("**/fixtures/*.json")):
11
+ md5.update(path.read_bytes())
12
+ return md5.hexdigest()
13
+
14
+
15
+ @checks.register(checks.Tags.database)
16
+ def check_unapplied_fixtures(app_configs=None, **kwargs):
17
+ """Warn if fixture files have changed since last refresh."""
18
+ hash_file = Path(settings.BASE_DIR) / "fixtures.md5"
19
+ stored = hash_file.read_text().strip() if hash_file.exists() else ""
20
+ current = _fixture_hash()
21
+ if stored != current:
22
+ return [
23
+ checks.Warning(
24
+ "Unapplied fixture changes detected.",
25
+ hint="Run env-refresh to apply fixtures.",
26
+ id="core.W001",
27
+ )
28
+ ]
29
+ return []
core/entity.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import copy
2
+ import logging
2
3
  import os
3
4
  import re
4
5
 
@@ -7,6 +8,8 @@ from django.conf import settings
7
8
  from django.db import models
8
9
  from django.contrib.auth.models import UserManager as DjangoUserManager
9
10
 
11
+ logger = logging.getLogger(__name__)
12
+
10
13
 
11
14
  class EntityQuerySet(models.QuerySet):
12
15
  def delete(self): # pragma: no cover - delegates to instance delete
@@ -78,14 +81,33 @@ class Entity(models.Model):
78
81
  root_name, key = match.group(1), match.group(2)
79
82
  try:
80
83
  root = SigilRoot.objects.get(prefix__iexact=root_name)
84
+ if root.context_type == SigilRoot.Context.CONFIG:
85
+ if root.prefix.upper() == "ENV":
86
+ if key in os.environ:
87
+ return os.environ[key]
88
+ logger.warning(
89
+ "Missing environment variable for sigil [%s.%s]",
90
+ root_name,
91
+ key,
92
+ )
93
+ return match.group(0)
94
+ if root.prefix.upper() == "SYS":
95
+ if hasattr(settings, key):
96
+ return str(getattr(settings, key))
97
+ logger.warning(
98
+ "Missing settings attribute for sigil [%s.%s]",
99
+ root_name,
100
+ key,
101
+ )
102
+ return match.group(0)
103
+ logger.warning(
104
+ "Unresolvable sigil [%s.%s]: unsupported context", root_name, key
105
+ )
81
106
  except SigilRoot.DoesNotExist:
82
- return ""
83
- if root.context_type == SigilRoot.Context.CONFIG:
84
- if root.prefix.upper() == "ENV":
85
- return os.environ.get(key, "")
86
- if root.prefix.upper() == "SYS":
87
- return str(getattr(settings, key, ""))
88
- return ""
107
+ logger.warning("Unknown sigil root [%s]", root_name)
108
+ except Exception:
109
+ logger.exception("Error resolving sigil [%s.%s]", root_name, key)
110
+ return match.group(0)
89
111
 
90
112
  return pattern.sub(repl, text)
91
113
 
core/models.py CHANGED
@@ -10,12 +10,14 @@ from django.utils.translation import gettext_lazy as _
10
10
  from django.core.validators import RegexValidator
11
11
  from django.core.exceptions import ValidationError
12
12
  from django.apps import apps
13
- from django.db.models.signals import m2m_changed, post_delete, post_save
13
+ from django.db.models.signals import m2m_changed
14
14
  from django.dispatch import receiver
15
15
  from datetime import timedelta
16
16
  from django.contrib.contenttypes.models import ContentType
17
17
  import hashlib
18
18
  import os
19
+ import subprocess
20
+ import secrets
19
21
  from io import BytesIO
20
22
  from django.core.files.base import ContentFile
21
23
  import qrcode
@@ -62,6 +64,34 @@ class SigilRoot(Entity):
62
64
  verbose_name_plural = "Sigil Roots"
63
65
 
64
66
 
67
+ class Lead(models.Model):
68
+ """Common request lead information."""
69
+
70
+ user = models.ForeignKey(
71
+ settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL
72
+ )
73
+ path = models.TextField(blank=True)
74
+ referer = models.TextField(blank=True)
75
+ user_agent = models.TextField(blank=True)
76
+ ip_address = models.GenericIPAddressField(null=True, blank=True)
77
+ created_on = models.DateTimeField(auto_now_add=True)
78
+
79
+ class Meta:
80
+ abstract = True
81
+
82
+
83
+ class InviteLead(Lead):
84
+ email = models.EmailField()
85
+ comment = models.TextField(blank=True)
86
+
87
+ class Meta:
88
+ verbose_name = "Invite Lead"
89
+ verbose_name_plural = "Invite Leads"
90
+
91
+ def __str__(self) -> str: # pragma: no cover - simple representation
92
+ return self.email
93
+
94
+
65
95
  class Address(Entity):
66
96
  """Physical location information for a user."""
67
97
 
@@ -1075,7 +1105,7 @@ class ReleaseManager(Entity):
1075
1105
  max_length=200,
1076
1106
  blank=True,
1077
1107
  help_text=(
1078
- "Personal access token used to create GitHub pull requests. "
1108
+ "Personal access token for GitHub operations. "
1079
1109
  "Used before the GITHUB_TOKEN environment variable."
1080
1110
  ),
1081
1111
  )
@@ -1124,14 +1154,30 @@ class Package(Entity):
1124
1154
  release_manager = models.ForeignKey(
1125
1155
  ReleaseManager, on_delete=models.SET_NULL, null=True, blank=True
1126
1156
  )
1157
+ is_active = models.BooleanField(
1158
+ default=False,
1159
+ help_text="Designates the active package for version comparisons",
1160
+ )
1127
1161
 
1128
1162
  class Meta:
1129
1163
  verbose_name = "Package"
1130
1164
  verbose_name_plural = "Packages"
1165
+ constraints = [
1166
+ models.UniqueConstraint(
1167
+ fields=("is_active",),
1168
+ condition=models.Q(is_active=True),
1169
+ name="unique_active_package",
1170
+ )
1171
+ ]
1131
1172
 
1132
1173
  def __str__(self) -> str: # pragma: no cover - trivial
1133
1174
  return self.name
1134
1175
 
1176
+ def save(self, *args, **kwargs):
1177
+ if self.is_active:
1178
+ type(self).objects.exclude(pk=self.pk).update(is_active=False)
1179
+ super().save(*args, **kwargs)
1180
+
1135
1181
  def to_package(self) -> ReleasePackage:
1136
1182
  """Return a :class:`ReleasePackage` instance from package data."""
1137
1183
  return ReleasePackage(
@@ -1159,7 +1205,6 @@ class PackageRelease(Entity):
1159
1205
  max_length=40, blank=True, default=revision_utils.get_revision, editable=False
1160
1206
  )
1161
1207
  pypi_url = models.URLField("PyPI URL", blank=True, editable=False)
1162
- pr_url = models.URLField("PR URL", blank=True, editable=False)
1163
1208
 
1164
1209
  class Meta:
1165
1210
  verbose_name = "Package Release"
@@ -1222,11 +1267,13 @@ class PackageRelease(Entity):
1222
1267
 
1223
1268
  @property
1224
1269
  def is_current(self) -> bool:
1225
- """Return ``True`` if this release matches the current revision."""
1226
- from utils import revision as revision_utils
1227
-
1228
- current = revision_utils.get_revision()
1229
- return bool(current) and current == self.revision
1270
+ """Return ``True`` when this release's version matches the VERSION file
1271
+ and its package is active."""
1272
+ version_path = Path("VERSION")
1273
+ if not version_path.exists():
1274
+ return False
1275
+ current_version = version_path.read_text().strip()
1276
+ return current_version == self.version and self.package.is_active
1230
1277
 
1231
1278
  @classmethod
1232
1279
  def latest(cls):
@@ -1251,17 +1298,29 @@ class PackageRelease(Entity):
1251
1298
  )
1252
1299
  self.revision = revision_utils.get_revision()
1253
1300
  self.save(update_fields=["revision"])
1301
+ PackageRelease.dump_fixture()
1302
+ if kwargs.get("git"):
1303
+ diff = subprocess.run(
1304
+ ["git", "status", "--porcelain", "core/fixtures/releases.json"],
1305
+ capture_output=True,
1306
+ text=True,
1307
+ )
1308
+ if diff.stdout.strip():
1309
+ release_utils._run(["git", "add", "core/fixtures/releases.json"])
1310
+ release_utils._run(
1311
+ [
1312
+ "git",
1313
+ "commit",
1314
+ "-m",
1315
+ f"chore: update release fixture for v{self.version}",
1316
+ ]
1317
+ )
1318
+ release_utils._run(["git", "push"])
1254
1319
 
1255
1320
  @property
1256
1321
  def revision_short(self) -> str:
1257
1322
  return self.revision[-6:] if self.revision else ""
1258
1323
 
1259
-
1260
- @receiver([post_save, post_delete], sender=PackageRelease)
1261
- def _update_release_fixture(sender, instance, **kwargs) -> None:
1262
- """Keep the release fixture in sync with the database."""
1263
- PackageRelease.dump_fixture()
1264
-
1265
1324
  # Ensure each RFID can only be linked to one energy account
1266
1325
  @receiver(m2m_changed, sender=EnergyAccount.rfids.through)
1267
1326
  def _rfid_unique_energy_account(sender, instance, action, reverse, model, pk_set, **kwargs):
@@ -1276,3 +1335,54 @@ def _rfid_unique_energy_account(sender, instance, action, reverse, model, pk_set
1276
1335
  ).exclude(energy_accounts=instance)
1277
1336
  if conflict.exists():
1278
1337
  raise ValidationError("RFID tags may only be assigned to one energy account.")
1338
+
1339
+
1340
+ def hash_key(key: str) -> str:
1341
+ """Return a SHA-256 hash for ``key``."""
1342
+
1343
+ return hashlib.sha256(key.encode()).hexdigest()
1344
+
1345
+
1346
+ class ChatProfile(models.Model):
1347
+ """Stores a hashed user key used by the assistant for authentication.
1348
+
1349
+ The plain-text ``user_key`` is generated server-side and shown only once.
1350
+ Users must supply this key in the ``Authorization: Bearer <user_key>``
1351
+ header when requesting protected endpoints. Only the hash is stored.
1352
+ """
1353
+
1354
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
1355
+ user = models.OneToOneField(
1356
+ settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="chat_profile"
1357
+ )
1358
+ user_key_hash = models.CharField(max_length=64, unique=True)
1359
+ scopes = models.JSONField(default=list, blank=True)
1360
+ created_at = models.DateTimeField(auto_now_add=True)
1361
+ last_used_at = models.DateTimeField(null=True, blank=True)
1362
+ is_active = models.BooleanField(default=True)
1363
+
1364
+ class Meta:
1365
+ db_table = "workgroup_chatprofile"
1366
+ verbose_name = "Chat Profile"
1367
+ verbose_name_plural = "Chat Profiles"
1368
+
1369
+ @classmethod
1370
+ def issue_key(cls, user) -> tuple["ChatProfile", str]:
1371
+ """Create or update a profile and return it with a new plain key."""
1372
+
1373
+ key = secrets.token_hex(32)
1374
+ key_hash = hash_key(key)
1375
+ profile, _ = cls.objects.update_or_create(
1376
+ user=user,
1377
+ defaults={"user_key_hash": key_hash, "last_used_at": None, "is_active": True},
1378
+ )
1379
+ return profile, key
1380
+
1381
+ def touch(self) -> None:
1382
+ """Record that the key was used."""
1383
+
1384
+ self.last_used_at = timezone.now()
1385
+ self.save(update_fields=["last_used_at"])
1386
+
1387
+ def __str__(self) -> str: # pragma: no cover - simple representation
1388
+ return f"ChatProfile for {self.user}"
core/release.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import os
4
4
  import subprocess
5
5
  import sys
6
+ import shutil
6
7
  from dataclasses import dataclass
7
8
  from pathlib import Path
8
9
  from typing import Optional
@@ -82,20 +83,12 @@ def _git_clean() -> bool:
82
83
  return not proc.stdout.strip()
83
84
 
84
85
 
85
- def _current_commit() -> str:
86
- return subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip()
86
+ def _git_has_staged_changes() -> bool:
87
+ """Return True if there are staged changes ready to commit."""
88
+ proc = subprocess.run(["git", "diff", "--cached", "--quiet"])
89
+ return proc.returncode != 0
87
90
 
88
91
 
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
92
 
100
93
  def _manager_credentials() -> Optional[Credentials]:
101
94
  """Return credentials from the Package's release manager if available."""
@@ -174,69 +167,6 @@ def _write_pyproject(package: Package, version: str, requirements: list[str]) ->
174
167
  Path("pyproject.toml").write_text(_dump_toml(content), encoding="utf-8")
175
168
 
176
169
 
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
170
 
241
171
 
242
172
  @requires_network
@@ -268,8 +198,8 @@ def build(
268
198
  "Git repository is not clean. Commit, stash, or enable auto stash before building."
269
199
  )
270
200
 
201
+ version_path = Path("VERSION")
271
202
  if version is None:
272
- version_path = Path("VERSION")
273
203
  if not version_path.exists():
274
204
  raise ReleaseError("VERSION file not found")
275
205
  version = version_path.read_text().strip()
@@ -278,6 +208,9 @@ def build(
278
208
  patch += 1
279
209
  version = f"{major}.{minor}.{patch}"
280
210
  version_path.write_text(version + "\n")
211
+ else:
212
+ # Ensure the VERSION file reflects the provided release version
213
+ version_path.write_text(version + "\n")
281
214
 
282
215
  requirements = [
283
216
  line.strip()
@@ -291,16 +224,10 @@ def build(
291
224
  if proc.returncode != 0:
292
225
  raise TestsFailed(log_path, proc.stdout + proc.stderr)
293
226
 
294
- commit_hash = _current_commit()
295
- prev_revision = _last_changelog_revision()
296
- update_changelog(version, commit_hash, prev_revision)
297
-
298
227
  _write_pyproject(package, version, requirements)
299
228
  if dist:
300
229
  if Path("dist").exists():
301
- for p in Path("dist").glob("*"):
302
- p.unlink()
303
- Path("dist").rmdir()
230
+ shutil.rmtree("dist")
304
231
  try:
305
232
  import build # type: ignore
306
233
  except Exception:
@@ -308,10 +235,11 @@ def build(
308
235
  _run([sys.executable, "-m", "build"])
309
236
 
310
237
  if git:
311
- files = ["VERSION", "pyproject.toml", "CHANGELOG.rst"]
238
+ files = ["VERSION", "pyproject.toml"]
312
239
  _run(["git", "add"] + files)
313
240
  msg = f"PyPI Release v{version}" if twine else f"Release v{version}"
314
- _run(["git", "commit", "-m", msg])
241
+ if _git_has_staged_changes():
242
+ _run(["git", "commit", "-m", msg])
315
243
  _run(["git", "push"])
316
244
 
317
245
  if tag:
@@ -359,63 +287,23 @@ def promote(
359
287
  package: Package = DEFAULT_PACKAGE,
360
288
  version: str,
361
289
  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
290
+ ) -> None:
291
+ """Build the package and commit the release on the current branch."""
292
+ if not _git_clean():
293
+ raise ReleaseError("Git repository is not clean")
294
+ build(
295
+ package=package,
296
+ version=version,
297
+ creds=creds,
298
+ tests=False,
299
+ dist=True,
300
+ git=False,
301
+ tag=False,
302
+ stash=False,
303
+ )
304
+ _run(["git", "add", "."]) # add all changes
305
+ if _git_has_staged_changes():
407
306
  _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
307
 
420
308
 
421
309
  def publish(
core/system.py CHANGED
@@ -31,8 +31,8 @@ def _gather_info() -> dict:
31
31
  info["mode"] = mode
32
32
  info["port"] = 8000 if mode == "public" else 8888
33
33
 
34
- role_file = lock_dir / "role.lck"
35
- info["role"] = role_file.read_text().strip() if role_file.exists() else "unknown"
34
+ # Use settings.NODE_ROLE as the single source of truth for the node role.
35
+ info["role"] = getattr(settings, "NODE_ROLE", "Terminal")
36
36
 
37
37
  info["features"] = {
38
38
  "celery": (lock_dir / "celery.lck").exists(),
@@ -0,0 +1,21 @@
1
+ import os
2
+
3
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
+
5
+ import django
6
+ django.setup()
7
+
8
+ from django.test import SimpleTestCase, override_settings
9
+ from core.system import _gather_info
10
+
11
+
12
+ class SystemInfoRoleTests(SimpleTestCase):
13
+ @override_settings(NODE_ROLE="Terminal")
14
+ def test_defaults_to_terminal(self):
15
+ info = _gather_info()
16
+ self.assertEqual(info["role"], "Terminal")
17
+
18
+ @override_settings(NODE_ROLE="Satellite")
19
+ def test_uses_settings_role(self):
20
+ info = _gather_info()
21
+ self.assertEqual(info["role"], "Satellite")