arthexis 0.1.13__py3-none-any.whl → 0.1.14__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.

Files changed (107) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +708 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2200
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1128
  104. arthexis-0.1.13.dist-info/RECORD +0 -105
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
core/github_helper.py CHANGED
@@ -1,25 +1,188 @@
1
- """Helpers for reporting exceptions to GitHub."""
2
-
3
- from __future__ import annotations
4
-
5
- import logging
6
- from typing import Any
7
-
8
- from celery import shared_task
9
-
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- @shared_task
15
- def report_exception_to_github(payload: dict[str, Any]) -> None:
16
- """Send exception context to the GitHub issue helper.
17
-
18
- The task is intentionally light-weight in this repository. Deployments can
19
- replace it with an implementation that forwards ``payload`` to the
20
- automation responsible for creating GitHub issues.
21
- """
22
-
23
- logger.info(
24
- "Queued GitHub issue report for %s", payload.get("fingerprint", "<unknown>")
25
- )
1
+ """Helpers for reporting exceptions to GitHub and managing repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from collections.abc import Mapping
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from celery import shared_task
11
+ import requests
12
+
13
+
14
+ if TYPE_CHECKING: # pragma: no cover - typing only
15
+ from .models import Package
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ GITHUB_API_ROOT = "https://api.github.com"
22
+ REQUEST_TIMEOUT = 10
23
+
24
+
25
+ class GitHubRepositoryError(RuntimeError):
26
+ """Raised when a GitHub repository operation fails."""
27
+
28
+
29
+
30
+ @shared_task
31
+ def report_exception_to_github(payload: dict[str, Any]) -> None:
32
+ """Send exception context to the GitHub issue helper.
33
+
34
+ The task is intentionally light-weight in this repository. Deployments can
35
+ replace it with an implementation that forwards ``payload`` to the
36
+ automation responsible for creating GitHub issues.
37
+ """
38
+
39
+ logger.info(
40
+ "Queued GitHub issue report for %s", payload.get("fingerprint", "<unknown>")
41
+ )
42
+
43
+
44
+ def _resolve_github_token(package: Package | None) -> str:
45
+ """Return the GitHub token for ``package``.
46
+
47
+ Preference is given to the release manager associated with the package.
48
+ When unavailable, fall back to the ``GITHUB_TOKEN`` environment variable.
49
+ """
50
+
51
+ if package:
52
+ manager = getattr(package, "release_manager", None)
53
+ if manager:
54
+ token = getattr(manager, "github_token", "")
55
+ if token:
56
+ cleaned = str(token).strip()
57
+ if cleaned:
58
+ return cleaned
59
+
60
+ token = os.environ.get("GITHUB_TOKEN", "")
61
+ cleaned_env = token.strip() if isinstance(token, str) else str(token).strip()
62
+ if not cleaned_env:
63
+ raise GitHubRepositoryError("GitHub token is not configured")
64
+ return cleaned_env
65
+
66
+
67
+ def _build_headers(token: str) -> Mapping[str, str]:
68
+ return {
69
+ "Accept": "application/vnd.github+json",
70
+ "Authorization": f"token {token}",
71
+ "User-Agent": "arthexis-admin",
72
+ }
73
+
74
+
75
+ def _build_payload(repo: str, *, private: bool, description: str | None) -> dict[str, Any]:
76
+ payload: dict[str, Any] = {"name": repo, "private": private}
77
+ if description:
78
+ payload["description"] = description
79
+ return payload
80
+
81
+
82
+ def _extract_error_message(response: requests.Response) -> str:
83
+ try:
84
+ data = response.json()
85
+ except ValueError:
86
+ data = {}
87
+
88
+ message = data.get("message") or response.text or "GitHub repository request failed"
89
+ errors = data.get("errors")
90
+ details: list[str] = []
91
+ if isinstance(errors, list):
92
+ for entry in errors:
93
+ if isinstance(entry, str):
94
+ details.append(entry)
95
+ elif isinstance(entry, Mapping):
96
+ text = entry.get("message") or entry.get("code")
97
+ if text:
98
+ details.append(str(text))
99
+
100
+ if details:
101
+ message = f"{message} ({'; '.join(details)})"
102
+
103
+ return message
104
+
105
+
106
+ def _safe_json(response: requests.Response) -> dict[str, Any]:
107
+ try:
108
+ data = response.json()
109
+ except ValueError:
110
+ data = {}
111
+ return data
112
+
113
+
114
+ def create_repository_for_package(
115
+ package: Package,
116
+ *,
117
+ owner: str,
118
+ repo: str,
119
+ private: bool = False,
120
+ description: str | None = None,
121
+ ) -> str:
122
+ """Create a GitHub repository and return its canonical URL.
123
+
124
+ The helper attempts to create the repository under ``owner`` when provided.
125
+ If the authenticated token lacks access to the organization, the helper
126
+ falls back to creating the repository for the authenticated user. On
127
+ success, the GitHub HTML URL for the repository is returned.
128
+ """
129
+
130
+ token = _resolve_github_token(package)
131
+ headers = _build_headers(token)
132
+ payload = _build_payload(repo, private=private, description=description)
133
+
134
+ endpoints: list[str] = []
135
+ owner = owner.strip()
136
+ if owner:
137
+ endpoints.append(f"{GITHUB_API_ROOT}/orgs/{owner}/repos")
138
+ endpoints.append(f"{GITHUB_API_ROOT}/user/repos")
139
+
140
+ last_error: str | None = None
141
+
142
+ for index, endpoint in enumerate(endpoints):
143
+ try:
144
+ response = requests.post(
145
+ endpoint,
146
+ json=payload,
147
+ headers=headers,
148
+ timeout=REQUEST_TIMEOUT,
149
+ )
150
+ except requests.RequestException as exc: # pragma: no cover - network failure
151
+ logger.exception(
152
+ "GitHub repository creation request failed for %s/%s", owner, repo
153
+ )
154
+ raise GitHubRepositoryError(str(exc)) from exc
155
+
156
+ if 200 <= response.status_code < 300:
157
+ data = _safe_json(response)
158
+ html_url = data.get("html_url")
159
+ if html_url:
160
+ return html_url
161
+
162
+ resolved_owner = (
163
+ data.get("owner", {}).get("login")
164
+ if isinstance(data.get("owner"), Mapping)
165
+ else owner
166
+ )
167
+ resolved_owner = (resolved_owner or owner).strip("/")
168
+ return f"https://github.com/{resolved_owner}/{repo}"
169
+
170
+ message = _extract_error_message(response)
171
+ logger.error(
172
+ "GitHub repository creation failed for %s/%s (%s): %s",
173
+ owner or "<user>",
174
+ repo,
175
+ response.status_code,
176
+ message,
177
+ )
178
+ last_error = message
179
+
180
+ # If we're attempting to create within an organization and receive a
181
+ # not found or forbidden error, fall back to creating for the
182
+ # authenticated user.
183
+ if index == 0 and owner and response.status_code in {403, 404}:
184
+ continue
185
+
186
+ break
187
+
188
+ raise GitHubRepositoryError(last_error or "GitHub repository creation failed")
core/github_issues.py CHANGED
@@ -1,172 +1,178 @@
1
- from __future__ import annotations
2
-
3
- import hashlib
4
- import logging
5
- import os
6
- from datetime import datetime, timedelta
7
- from pathlib import Path
8
- from typing import Iterable, Mapping
9
-
10
- import requests
11
-
12
- from .models import Package, PackageRelease
13
- from .release import DEFAULT_PACKAGE
14
-
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
- BASE_DIR = Path(__file__).resolve().parent.parent
19
- LOCK_DIR = BASE_DIR / "locks" / "github-issues"
20
- LOCK_TTL = timedelta(hours=1)
21
- REQUEST_TIMEOUT = 10
22
-
23
-
24
- def resolve_repository() -> tuple[str, str]:
25
- """Return the ``(owner, repo)`` tuple for the active package."""
26
-
27
- package = Package.objects.filter(is_active=True).first()
28
- repository_url = (
29
- package.repository_url
30
- if package and package.repository_url
31
- else DEFAULT_PACKAGE.repository_url
32
- )
33
-
34
- owner: str
35
- repo: str
36
-
37
- if repository_url.startswith("git@"):
38
- _, _, remainder = repository_url.partition(":")
39
- path = remainder
40
- else:
41
- from urllib.parse import urlparse
42
-
43
- parsed = urlparse(repository_url)
44
- path = parsed.path
45
-
46
- path = path.strip("/")
47
- if path.endswith(".git"):
48
- path = path[:-4]
49
-
50
- segments = [segment for segment in path.split("/") if segment]
51
- if len(segments) < 2:
52
- raise ValueError(f"Invalid repository URL: {repository_url!r}")
53
-
54
- owner, repo = segments[-2], segments[-1]
55
- return owner, repo
56
-
57
-
58
- def get_github_token() -> str:
59
- """Return the configured GitHub token.
60
-
61
- Preference is given to the latest :class:`~core.models.PackageRelease`.
62
- When unavailable, fall back to the ``GITHUB_TOKEN`` environment variable.
63
- """
64
-
65
- latest_release = PackageRelease.latest()
66
- if latest_release:
67
- token = latest_release.get_github_token()
68
- if token:
69
- return token
70
-
71
- try:
72
- return os.environ["GITHUB_TOKEN"]
73
- except KeyError as exc: # pragma: no cover - defensive guard
74
- raise RuntimeError("GitHub token is not configured") from exc
75
-
76
-
77
- def _ensure_lock_dir() -> None:
78
- LOCK_DIR.mkdir(parents=True, exist_ok=True)
79
-
80
-
81
- def _fingerprint_digest(fingerprint: str) -> str:
82
- return hashlib.sha256(str(fingerprint).encode("utf-8")).hexdigest()
83
-
84
-
85
- def _fingerprint_path(fingerprint: str) -> Path:
86
- return LOCK_DIR / _fingerprint_digest(fingerprint)
87
-
88
-
89
- def _has_recent_marker(lock_path: Path) -> bool:
90
- if not lock_path.exists():
91
- return False
92
-
93
- marker_age = datetime.utcnow() - datetime.utcfromtimestamp(
94
- lock_path.stat().st_mtime
95
- )
96
- return marker_age < LOCK_TTL
97
-
98
-
99
- def build_issue_payload(
100
- title: str,
101
- body: str,
102
- labels: Iterable[str] | None = None,
103
- fingerprint: str | None = None,
104
- ) -> Mapping[str, object] | None:
105
- """Return an API payload for GitHub issues.
106
-
107
- When ``fingerprint`` is provided, duplicate submissions within ``LOCK_TTL``
108
- are ignored by returning ``None``. A marker is kept on disk to prevent
109
- repeated reports during the cooldown window.
110
- """
111
-
112
- payload: dict[str, object] = {"title": title, "body": body}
113
-
114
- if labels:
115
- deduped = list(dict.fromkeys(labels))
116
- if deduped:
117
- payload["labels"] = deduped
118
-
119
- if fingerprint:
120
- _ensure_lock_dir()
121
- lock_path = _fingerprint_path(fingerprint)
122
- if _has_recent_marker(lock_path):
123
- logger.info("Skipping GitHub issue for active fingerprint %s", fingerprint)
124
- return None
125
-
126
- lock_path.write_text(datetime.utcnow().isoformat(), encoding="utf-8")
127
- digest = _fingerprint_digest(fingerprint)
128
- payload["body"] = f"{body}\n\n<!-- fingerprint:{digest} -->"
129
-
130
- return payload
131
-
132
-
133
- def create_issue(
134
- title: str,
135
- body: str,
136
- labels: Iterable[str] | None = None,
137
- fingerprint: str | None = None,
138
- ) -> requests.Response | None:
139
- """Create a GitHub issue using the configured repository and token."""
140
-
141
- payload = build_issue_payload(title, body, labels=labels, fingerprint=fingerprint)
142
- if payload is None:
143
- return None
144
-
145
- owner, repo = resolve_repository()
146
- token = get_github_token()
147
-
148
- headers = {
149
- "Accept": "application/vnd.github+json",
150
- "Authorization": f"token {token}",
151
- "User-Agent": "arthexis-runtime-reporter",
152
- }
153
- url = f"https://api.github.com/repos/{owner}/{repo}/issues"
154
-
155
- response = requests.post(
156
- url, json=payload, headers=headers, timeout=REQUEST_TIMEOUT
157
- )
158
- if not (200 <= response.status_code < 300):
159
- logger.error(
160
- "GitHub issue creation failed with status %s: %s",
161
- response.status_code,
162
- response.text,
163
- )
164
- response.raise_for_status()
165
-
166
- logger.info(
167
- "GitHub issue created for %s/%s with status %s",
168
- owner,
169
- repo,
170
- response.status_code,
171
- )
172
- return response
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Iterable, Mapping
9
+
10
+ import requests
11
+
12
+ from .models import Package, PackageRelease
13
+ from .release import DEFAULT_PACKAGE
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ BASE_DIR = Path(__file__).resolve().parent.parent
19
+ LOCK_DIR = BASE_DIR / "locks" / "github-issues"
20
+ LOCK_TTL = timedelta(hours=1)
21
+ REQUEST_TIMEOUT = 10
22
+
23
+
24
+ def resolve_repository() -> tuple[str, str]:
25
+ """Return the ``(owner, repo)`` tuple for the active package."""
26
+
27
+ package = Package.objects.filter(is_active=True).first()
28
+
29
+ repository_url: str
30
+ if package is not None:
31
+ raw_url = getattr(package, "repository_url", "")
32
+ if raw_url is None:
33
+ cleaned_url = ""
34
+ else:
35
+ cleaned_url = str(raw_url).strip()
36
+ repository_url = cleaned_url or DEFAULT_PACKAGE.repository_url
37
+ else:
38
+ repository_url = DEFAULT_PACKAGE.repository_url
39
+
40
+ owner: str
41
+ repo: str
42
+
43
+ if repository_url.startswith("git@"):
44
+ _, _, remainder = repository_url.partition(":")
45
+ path = remainder
46
+ else:
47
+ from urllib.parse import urlparse
48
+
49
+ parsed = urlparse(repository_url)
50
+ path = parsed.path
51
+
52
+ path = path.strip("/")
53
+ if path.endswith(".git"):
54
+ path = path[:-4]
55
+
56
+ segments = [segment for segment in path.split("/") if segment]
57
+ if len(segments) < 2:
58
+ raise ValueError(f"Invalid repository URL: {repository_url!r}")
59
+
60
+ owner, repo = segments[-2], segments[-1]
61
+ return owner, repo
62
+
63
+
64
+ def get_github_token() -> str:
65
+ """Return the configured GitHub token.
66
+
67
+ Preference is given to the latest :class:`~core.models.PackageRelease`.
68
+ When unavailable, fall back to the ``GITHUB_TOKEN`` environment variable.
69
+ """
70
+
71
+ latest_release = PackageRelease.latest()
72
+ if latest_release:
73
+ token = latest_release.get_github_token()
74
+ if token:
75
+ return token
76
+
77
+ try:
78
+ return os.environ["GITHUB_TOKEN"]
79
+ except KeyError as exc: # pragma: no cover - defensive guard
80
+ raise RuntimeError("GitHub token is not configured") from exc
81
+
82
+
83
+ def _ensure_lock_dir() -> None:
84
+ LOCK_DIR.mkdir(parents=True, exist_ok=True)
85
+
86
+
87
+ def _fingerprint_digest(fingerprint: str) -> str:
88
+ return hashlib.sha256(str(fingerprint).encode("utf-8")).hexdigest()
89
+
90
+
91
+ def _fingerprint_path(fingerprint: str) -> Path:
92
+ return LOCK_DIR / _fingerprint_digest(fingerprint)
93
+
94
+
95
+ def _has_recent_marker(lock_path: Path) -> bool:
96
+ if not lock_path.exists():
97
+ return False
98
+
99
+ marker_age = datetime.utcnow() - datetime.utcfromtimestamp(
100
+ lock_path.stat().st_mtime
101
+ )
102
+ return marker_age < LOCK_TTL
103
+
104
+
105
+ def build_issue_payload(
106
+ title: str,
107
+ body: str,
108
+ labels: Iterable[str] | None = None,
109
+ fingerprint: str | None = None,
110
+ ) -> Mapping[str, object] | None:
111
+ """Return an API payload for GitHub issues.
112
+
113
+ When ``fingerprint`` is provided, duplicate submissions within ``LOCK_TTL``
114
+ are ignored by returning ``None``. A marker is kept on disk to prevent
115
+ repeated reports during the cooldown window.
116
+ """
117
+
118
+ payload: dict[str, object] = {"title": title, "body": body}
119
+
120
+ if labels:
121
+ deduped = list(dict.fromkeys(labels))
122
+ if deduped:
123
+ payload["labels"] = deduped
124
+
125
+ if fingerprint:
126
+ _ensure_lock_dir()
127
+ lock_path = _fingerprint_path(fingerprint)
128
+ if _has_recent_marker(lock_path):
129
+ logger.info("Skipping GitHub issue for active fingerprint %s", fingerprint)
130
+ return None
131
+
132
+ lock_path.write_text(datetime.utcnow().isoformat(), encoding="utf-8")
133
+ digest = _fingerprint_digest(fingerprint)
134
+ payload["body"] = f"{body}\n\n<!-- fingerprint:{digest} -->"
135
+
136
+ return payload
137
+
138
+
139
+ def create_issue(
140
+ title: str,
141
+ body: str,
142
+ labels: Iterable[str] | None = None,
143
+ fingerprint: str | None = None,
144
+ ) -> requests.Response | None:
145
+ """Create a GitHub issue using the configured repository and token."""
146
+
147
+ payload = build_issue_payload(title, body, labels=labels, fingerprint=fingerprint)
148
+ if payload is None:
149
+ return None
150
+
151
+ owner, repo = resolve_repository()
152
+ token = get_github_token()
153
+
154
+ headers = {
155
+ "Accept": "application/vnd.github+json",
156
+ "Authorization": f"token {token}",
157
+ "User-Agent": "arthexis-runtime-reporter",
158
+ }
159
+ url = f"https://api.github.com/repos/{owner}/{repo}/issues"
160
+
161
+ response = requests.post(
162
+ url, json=payload, headers=headers, timeout=REQUEST_TIMEOUT
163
+ )
164
+ if not (200 <= response.status_code < 300):
165
+ logger.error(
166
+ "GitHub issue creation failed with status %s: %s",
167
+ response.status_code,
168
+ response.text,
169
+ )
170
+ response.raise_for_status()
171
+
172
+ logger.info(
173
+ "GitHub issue created for %s/%s with status %s",
174
+ owner,
175
+ repo,
176
+ response.status_code,
177
+ )
178
+ return response
core/github_repos.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Mapping
5
+
6
+ import requests
7
+
8
+ from .github_issues import REQUEST_TIMEOUT, get_github_token
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _build_repository_payload(
15
+ repo: str,
16
+ visibility: str,
17
+ description: str | None,
18
+ ) -> Mapping[str, object]:
19
+ payload: dict[str, object] = {"name": repo, "visibility": visibility}
20
+
21
+ if description is not None:
22
+ payload["description"] = description
23
+
24
+ return payload
25
+
26
+
27
+ def create_repository(
28
+ owner: str | None,
29
+ repo: str,
30
+ *,
31
+ visibility: str = "private",
32
+ description: str | None = None,
33
+ ) -> requests.Response:
34
+ """Create a GitHub repository for the authenticated user or organisation."""
35
+
36
+ token = get_github_token()
37
+
38
+ headers = {
39
+ "Accept": "application/vnd.github+json",
40
+ "Authorization": f"token {token}",
41
+ "User-Agent": "arthexis-runtime-reporter",
42
+ }
43
+
44
+ if owner:
45
+ url = f"https://api.github.com/orgs/{owner}/repos"
46
+ else:
47
+ url = "https://api.github.com/user/repos"
48
+
49
+ payload = _build_repository_payload(repo, visibility, description)
50
+
51
+ response = requests.post(
52
+ url,
53
+ json=payload,
54
+ headers=headers,
55
+ timeout=REQUEST_TIMEOUT,
56
+ )
57
+
58
+ if not (200 <= response.status_code < 300):
59
+ logger.error(
60
+ "GitHub repository creation failed with status %s: %s",
61
+ response.status_code,
62
+ response.text,
63
+ )
64
+ response.raise_for_status()
65
+
66
+ logger.info(
67
+ "GitHub repository created for %s with status %s",
68
+ owner or "authenticated user",
69
+ response.status_code,
70
+ )
71
+
72
+ return response