From e0b8835a00f77adfa154c11cee98941458d08c66 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 24 Aug 2026 12:53:50 +0200 Subject: [PATCH 01/33] Add GET /api/modules/classes/ for external module-class lookups (#65) Lets pyobs-robotic-backend resolve each configured module's class locally, so it can filter by interface on its own side. Dumb, hub-facing endpoint like api_acl_matrix/api_comm_user_map, authenticated via the existing HUB_CLIENTS shared-secret mechanism. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SE1UYzGvZXb89QRB3st6ci --- modules/services.py | 38 +++++++++++++++++++++++++ modules/tests.py | 68 +++++++++++++++++++++++++++++++++++++++++++++ modules/urls.py | 1 + modules/views.py | 13 +++++++++ 4 files changed, 120 insertions(+) diff --git a/modules/services.py b/modules/services.py index adb9e6d..f15c212 100644 --- a/modules/services.py +++ b/modules/services.py @@ -2009,6 +2009,44 @@ def get_resolved_acl(name: str) -> tuple[dict | None, str | None]: return acl, _block_source_file(config_file.read_text(), "acl") +def get_module_class(name: str) -> str | None: + """Returns a module's configured top-level `class:` (fully-qualified class name), + resolved the same way get_resolved_acl resolves acl: -- via pre_process_yaml + + yaml.safe_load, since class: can equally arrive through a YAML anchor/merge key, not + just be written directly. None if the config doesn't exist, doesn't parse, or has no + top-level "class:" key. + """ + validate_name(name) + config_file = _config_dir() / f"{name}.yaml" + if not config_file.exists(): + return None + try: + resolved = yaml.safe_load(pre_process_yaml(str(config_file))) or {} + except (OSError, yaml.YAMLError): + return None + cls = resolved.get("class") + return cls if isinstance(cls, str) and cls else None + + +def build_module_classes() -> dict[str, str]: + """Maps every local module name to its configured class: (e.g. + "pyobs.modules.camera.BaseCamera") -- feeds api/modules/classes/ (issue #65), which lets + an external caller (e.g. pyobs-robotic-backend) filter modules by interface on its own + side, using its own pyobs-core install, without this app importing pyobs.interfaces or + the module's actual class itself. + + A module whose config can't be resolved, or that has no top-level "class:" key, is + simply omitted -- not included with a None/error value -- since the caller only cares + about modules it can actually resolve to a class. + """ + result: dict[str, str] = {} + for name in list_modules(): + cls = get_module_class(name) + if cls: + result[name] = cls + return result + + def get_resolved_comm(name: str) -> tuple[str | None, str | None, str | None]: """Returns (comm_user, comm_password, source) for a module's *effective* comm: block -- the same resolution get_resolved_acl uses for acl:, via pre_process_yaml + diff --git a/modules/tests.py b/modules/tests.py index 974323d..76358d7 100644 --- a/modules/tests.py +++ b/modules/tests.py @@ -260,6 +260,74 @@ def test_malformed_allow_reports_error_not_raise(self): self.assertIsNotNone(error) +# ── services.get_module_class / build_module_classes (issue #65) ──────────────── + +class GetModuleClassTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.tmp_path = Path(self.tmp.name) + self._settings = override_settings(PYOBS_CONFIG_DIR=str(self.tmp_path), PYOBS_CONFIG_GIT_ENABLED=False) + self._settings.enable() + + def tearDown(self): + self._settings.disable() + self.tmp.cleanup() + + def _write(self, name: str, content: str) -> None: + (self.tmp_path / f"{name}.yaml").write_text(content) + + def test_missing_module_returns_none(self): + self.assertIsNone(services.get_module_class("nope")) + + def test_no_class_key_returns_none(self): + self._write("cam1", "comm:\n user: camera\n") + self.assertIsNone(services.get_module_class("cam1")) + + def test_class_defined_locally(self): + self._write("cam1", "class: pyobs.modules.camera.BaseCamera\n") + self.assertEqual(services.get_module_class("cam1"), "pyobs.modules.camera.BaseCamera") + + def test_class_via_include(self): + self._write("base.shared", "class: pyobs.modules.camera.BaseCamera\n") + self._write("cam1", "{include base.shared.yaml}\n") + self.assertEqual(services.get_module_class("cam1"), "pyobs.modules.camera.BaseCamera") + + def test_broken_config_returns_none_not_raise(self): + self._write("cam1", "class: [unterminated\n") + self.assertIsNone(services.get_module_class("cam1")) + + +class BuildModuleClassesTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.tmp_path = Path(self.tmp.name) + self._settings = override_settings(PYOBS_CONFIG_DIR=str(self.tmp_path), PYOBS_CONFIG_GIT_ENABLED=False) + self._settings.enable() + + def tearDown(self): + self._settings.disable() + self.tmp.cleanup() + + def _write(self, name: str, content: str) -> None: + (self.tmp_path / f"{name}.yaml").write_text(content) + + def test_maps_every_module_with_a_class(self): + self._write("cam1", "class: pyobs.modules.camera.BaseCamera\n") + self._write("tel1", "class: pyobs.modules.telescope.BaseTelescope\n") + self.assertEqual( + services.build_module_classes(), + {"cam1": "pyobs.modules.camera.BaseCamera", "tel1": "pyobs.modules.telescope.BaseTelescope"}, + ) + + def test_omits_modules_with_no_resolvable_class(self): + self._write("cam1", "class: pyobs.modules.camera.BaseCamera\n") + self._write("broken", "class: [unterminated\n") + self.assertEqual(services.build_module_classes(), {"cam1": "pyobs.modules.camera.BaseCamera"}) + + def test_no_modules_returns_empty_dict(self): + self.assertEqual(services.build_module_classes(), {}) + + # ── services.get_comm_user ──────────────────────────────────────────────────── class GetCommUserTests(unittest.TestCase): diff --git a/modules/urls.py b/modules/urls.py index 1a2eb45..36394d6 100644 --- a/modules/urls.py +++ b/modules/urls.py @@ -18,6 +18,7 @@ path("git-config/", views.git_config_page, name="git_config"), # API path("api/statuses/", views.api_all_statuses, name="api_all_statuses"), + path("api/modules/classes/", views.api_module_classes, name="api_module_classes"), path("api/packages/", views.api_packages, name="api_packages"), path("api/packages/update/status/", views.api_package_update_status, name="api_package_update_status"), path("api/packages//update/", views.api_package_update, name="api_package_update"), diff --git a/modules/views.py b/modules/views.py index 13acfa9..99e3977 100644 --- a/modules/views.py +++ b/modules/views.py @@ -749,6 +749,19 @@ def api_shared_config(request, name: str): return JsonResponse({"error": "Method not allowed"}, status=405) +@require_GET +def api_module_classes(request): + """Every configured module's class: on this host (issue #65) -- dumb, hub-facing, always + local, like api_acl_matrix/api_comm_user_map below: no _active_host proxying, since an + external caller (e.g. pyobs-robotic-backend) crossing a hub boundary already targets the + specific host it wants, authenticated via the existing HUB_CLIENTS shared-secret + mechanism (modules/middleware.py's HubTokenMiddleware), not a new auth scheme. Lets that + caller filter modules by interface (ICamera, ITelescope, ...) on its own side, using its + own pyobs-core install -- this app never imports pyobs.interfaces or the module's actual + class to answer this.""" + return JsonResponse(services.build_module_classes()) + + @require_GET def api_acl_matrix(request): """Queried by another pyobs-web-admin instance acting as a hub, to fold this From 56298ad5ceb8964464133f4ac4c81ddb9965fc64 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 24 Aug 2026 20:16:18 +0200 Subject: [PATCH 02/33] Give session/CSRF cookies a project-specific name Prevents cookie collisions with other pyobs Django apps (e.g. robotic-backend) when run on localhost at once - cookies are scoped by host, not port. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0135Rr2o6tUQycA5sgB7ZXmL --- pyobs_web_admin/settings.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyobs_web_admin/settings.py b/pyobs_web_admin/settings.py index 3c8e561..99bcd2f 100644 --- a/pyobs_web_admin/settings.py +++ b/pyobs_web_admin/settings.py @@ -65,6 +65,11 @@ # Sessions themselves stay in signed cookies regardless (no session table needed) SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" +# Distinct from other pyobs Django apps (e.g. robotic-backend) so browser cookies don't +# collide when both are run on localhost at once - cookies are scoped by host, not port. +SESSION_COOKIE_NAME = "web_admin_sessionid" +CSRF_COOKIE_NAME = "web_admin_csrftoken" + LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True From 8d4b5f157ea9f9b258ee456dee625d928c67c25c Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 24 Aug 2026 20:21:45 +0200 Subject: [PATCH 03/33] Fix CSRF cookie name in frontend JS after cookie rename getCsrfToken() (base.html and git_config.html) still read the old default 'csrftoken' cookie, which no longer exists now that CSRF_COOKIE_NAME is project-specific - broke POSTs from the UI with a 403 CSRF failure. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0135Rr2o6tUQycA5sgB7ZXmL --- templates/base.html | 5 +++-- templates/modules/git_config.html | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/templates/base.html b/templates/base.html index 0d7cedb..09f37e3 100644 --- a/templates/base.html +++ b/templates/base.html @@ -281,10 +281,11 @@ From fafba81ee3779e5c9ce476cb2066fb27ccdf2396 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 1 Sep 2026 18:01:04 +0200 Subject: [PATCH 27/33] Address review: fix concurrent-run race, resumed-job button state, wording - updateAllPackages() sets a bulkRunning flag synchronously before its first await, so two overlapping invocations (double click, or two tabs) can't both pass the "already updating?" guard and interleave queues. - resumeUpdateIfActive() disables Update all immediately when it detects a resumed in-flight job, instead of leaving it clickable until someone hits the alert. - Guard against a missing/undefined status.state (lock file gone between the start POST and first poll) by recording it as interrupted. - Reword the queue summary header: lumping interrupted/not-started into "failed" was misleading since the per-line list already shows real state. --- templates/modules/packages.html | 123 +++++++++++++++++++------------- 1 file changed, 74 insertions(+), 49 deletions(-) diff --git a/templates/modules/packages.html b/templates/modules/packages.html index 33180bb..e316ca1 100644 --- a/templates/modules/packages.html +++ b/templates/modules/packages.html @@ -216,6 +216,10 @@

Packages

return; } if (status.active) { + // Disable immediately so the button doesn't sit there looking clickable (with a stale + // count) for the whole duration of someone else's job -- pollUpdateStatus's own + // loadPackages() call recomputes the real count/disabled state once it finishes. + document.getElementById('btn-update-all').disabled = true; pollUpdateStatus(); } } @@ -263,68 +267,89 @@

Packages

function renderQueueSummary(outcomes) { document.getElementById('update-panel-spinner').classList.add('d-none'); - const failedCount = outcomes.filter(o => o.state !== 'success').length; - document.getElementById('update-panel-title').textContent = failedCount - ? `Update all: ${outcomes.length - failedCount}/${outcomes.length} updated, ${failedCount} failed` + const incompleteCount = outcomes.filter(o => o.state !== 'success').length; + document.getElementById('update-panel-title').textContent = incompleteCount + ? `Update all: ${outcomes.length - incompleteCount}/${outcomes.length} updated, ${incompleteCount} did not complete (see below)` : `Update all: ${outcomes.length}/${outcomes.length} updated`; document.getElementById('update-panel-log').textContent = outcomes.map(o => `${o.name}: ${o.state}${o.detail ? ' — ' + o.detail : ''}`).join('\n'); } -async function updateAllPackages() { - // Guard against a job already running (started by a manual click, or resumed from another - // admin/tab) -- starting the queue on top of it would just have every queued package rejected - // with "Already updating X" until that job finishes, which reads as the queue itself failing. - let existing; - try { - existing = await (await fetch("{% url 'api_package_update_status' %}")).json(); - } catch (e) { - existing = {active: false}; - } - if (existing.active) { - alert(`Already updating ${existing.name} -- wait for it to finish before updating all.`); - return; - } +// Set synchronously, before any await, so two overlapping calls (a double click, or two browser +// tabs open to the same page) can't both pass the "is a job already running?" check below and +// start interleaved queues -- the flock in update_package_start already makes that safe +// server-side, but two client-side loops racing each other still produced a garbled summary. +// This closes the same-tab race; a second tab still has its own bulkRunning, so a genuinely +// concurrent click from another admin's tab is only caught by the status-endpoint guard below +// (best-effort, same as any other check-then-act read). +let bulkRunning = false; - const queue = [...document.querySelectorAll('#packages-tbody tr[data-package]')] - .filter(row => !row.querySelector('button').disabled) - .map(row => row.dataset.package); - if (!queue.length) { +async function updateAllPackages() { + if (bulkRunning) { return; } - - disableBulkControls(); - document.getElementById('update-panel').classList.remove('d-none'); - const outcomes = []; - for (let i = 0; i < queue.length; i++) { - const name = queue[i]; - const titlePrefix = `${i + 1}/${queue.length}: `; - document.getElementById('update-panel-spinner').classList.remove('d-none'); - document.getElementById('update-panel-title').textContent = `${titlePrefix}Updating ${name}…`; - document.getElementById('update-panel-log').textContent = '(starting…)'; - - let started; + bulkRunning = true; + try { + // Guard against a job already running (started by a manual click, or resumed from another + // admin/tab) -- starting the queue on top of it would just have every queued package + // rejected with "Already updating X" until that job finishes, which reads as the queue + // itself failing. + let existing; try { - const resp = await fetch(`/api/packages/${encodeURIComponent(name)}/update/`, { - method: 'POST', - headers: {'X-CSRFToken': getCsrfToken()}, - }); - started = await resp.json(); + existing = await (await fetch("{% url 'api_package_update_status' %}")).json(); } catch (e) { - outcomes.push({name, state: 'not-started', detail: String(e)}); - continue; + existing = {active: false}; } - if (!started.ok) { - outcomes.push({name, state: 'not-started', detail: started.message || started.error}); - continue; + if (existing.active) { + alert(`Already updating ${existing.name} -- wait for it to finish before updating all.`); + return; } - const finalStatus = await awaitUpdateCompletion(titlePrefix); - outcomes.push({name, state: finalStatus.state}); - } - renderQueueSummary(outcomes); - document.getElementById('btn-refresh').disabled = false; - await loadPackages(); + const queue = [...document.querySelectorAll('#packages-tbody tr[data-package]')] + .filter(row => !row.querySelector('button').disabled) + .map(row => row.dataset.package); + if (!queue.length) { + return; + } + + disableBulkControls(); + document.getElementById('update-panel').classList.remove('d-none'); + const outcomes = []; + for (let i = 0; i < queue.length; i++) { + const name = queue[i]; + const titlePrefix = `${i + 1}/${queue.length}: `; + document.getElementById('update-panel-spinner').classList.remove('d-none'); + document.getElementById('update-panel-title').textContent = `${titlePrefix}Updating ${name}…`; + document.getElementById('update-panel-log').textContent = '(starting…)'; + + let started; + try { + const resp = await fetch(`/api/packages/${encodeURIComponent(name)}/update/`, { + method: 'POST', + headers: {'X-CSRFToken': getCsrfToken()}, + }); + started = await resp.json(); + } catch (e) { + outcomes.push({name, state: 'not-started', detail: String(e)}); + continue; + } + if (!started.ok) { + outcomes.push({name, state: 'not-started', detail: started.message || started.error}); + continue; + } + // A missing/undefined state means the job's on-disk record vanished between the start + // POST and the first poll (e.g. a run-dir wipe or server restart mid-queue) -- treat that + // as interrupted rather than recording an undefined outcome. + const finalStatus = await awaitUpdateCompletion(titlePrefix); + outcomes.push({name, state: finalStatus.state || 'interrupted'}); + } + + renderQueueSummary(outcomes); + document.getElementById('btn-refresh').disabled = false; + await loadPackages(); + } finally { + bulkRunning = false; + } } loadPackages(); From 4e466808c4b5bdb62a71a3df90a17eb0e5726275 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 1 Sep 2026 18:04:57 +0200 Subject: [PATCH 28/33] docs: add plan for log fullscreen button (#74) --- .../plans/2026-09-01-log-fullscreen-button.md | 116 ++++++++++++++++++ specs/plans/index.md | 3 + 2 files changed, 119 insertions(+) create mode 100644 specs/plans/2026-09-01-log-fullscreen-button.md diff --git a/specs/plans/2026-09-01-log-fullscreen-button.md b/specs/plans/2026-09-01-log-fullscreen-button.md new file mode 100644 index 0000000..3bd5d96 --- /dev/null +++ b/specs/plans/2026-09-01-log-fullscreen-button.md @@ -0,0 +1,116 @@ +# Plan: fullscreen button for logs + +Status: proposed (#74) + +## Problem + +The two log views (`templates/modules/all_logs.html`, fixed `height: 600px`; +`templates/modules/detail.html` `#tab-logs`, fixed `height: 520px`) render the log console at a +fixed height that's too small to follow a lively stream during an observing run or a long update. +There's no way to enlarge it short of browser zoom / DevTools. No fullscreen code exists anywhere +in the repo yet. + +The log JS in the two templates is intentionally duplicated "in lockstep" (see the comment at the +top of the notifications block in `detail.html`) — no shared static JS/CSS file exists for logs — +so this must be added identically in both places. + +## Design + +Overlay-only approach (no native `Element.requestFullscreen()`): a CSS class toggle makes the log +`
` fill the viewport via `position: fixed; inset: 0`. Chosen over the Fullscreen API because
+iOS Safari doesn't support `requestFullscreen()` on arbitrary elements, and the overlay is simpler
+to implement/test with one code path instead of two (native + fallback).
+
+### 1. CSS (added to both templates — `extra_head` block)
+
+`all_logs.html` has no `extra_head` block today; add one. `detail.html` already has one
+(lines 5–13) — append there.
+
+```css
+.log-fullscreen {
+  position: fixed; inset: 0; z-index: 1046; /* above sidebar (1044/1045), mobile navbar (1043) */
+  background: var(--pyobs-surface-bg);
+  display: flex; flex-direction: column;
+  padding: 1rem; margin: 0;
+}
+.log-fullscreen#log-output { flex: 1 1 auto; height: auto !important; }
+```
+
+Theme-aware via the existing `--pyobs-surface-bg` custom property (`templates/base.html:25-40`,
+defined for both light and dark).
+
+### 2. Toolbar button (both files)
+
+```html
+
+```
+
+- `all_logs.html`: in the toolbar div (~line 44-79), under the module checkboxes.
+- `detail.html`: in the `#tab-logs` toolbar (~line 138 area), same relative position.
+
+### 3. JS (added identically to both files, near the other log helpers)
+
+```js
+function toggleLogFullscreen() {
+  const pre = document.getElementById('log-output');
+  const btn = document.getElementById('log-fullscreen-btn');
+  const icon = btn.querySelector('i');
+  const isFs = pre.classList.toggle('log-fullscreen');
+  icon.className = isFs ? 'bi bi-fullscreen-exit' : 'bi bi-arrows-fullscreen';
+  btn.title = isFs ? 'Exit fullscreen' : 'Expand log to fullscreen';
+  if (isFs) document.addEventListener('keydown', escExitLogFullscreen);
+  else document.removeEventListener('keydown', escExitLogFullscreen);
+}
+function escExitLogFullscreen(e) {
+  if (e.key === 'Escape') toggleLogFullscreen();
+}
+```
+
+No `fullscreenchange`/`fullscreenerror` handling needed (overlay-only, no native API) — `Esc` is
+handled via a manual keydown listener instead.
+
+### 4. Interactions that must keep working — verified against current code, no changes needed
+
+- Auto-refresh (`logTimer`, `setInterval(fetchLogs, 3000)` — `all_logs.html:455,532` /
+  `detail.html:864,1017`): untouched, keeps running regardless of the DOM class.
+- `renderLogs()`'s scroll-position preservation (`wasNearBottom` from `pre.scrollHeight` /
+  `scrollTop` / `clientHeight`): unaffected — computed from the live element either way.
+- Ack badge / `isNewIssue()` / click+Shift-click time-range on `.log-line` spans: all bound to
+  `#log-output`, which is never replaced, only reclassed.
+- `fetchOlderLogs()` scroll-to-top loader: listens on `#log-output` scroll; class toggle doesn't
+  touch that listener.
+
+### 5. Collapsed-state restoration
+
+Free by construction: toggling `.log-fullscreen` off just removes the `position: fixed` override;
+the inline `style="height: 600px/520px"` on the `
` (`all_logs.html:90`, `detail.html:141`) is
+never removed, so it re-applies automatically. Scroll position is preserved since it's the same
+DOM node throughout (never detached/re-created).
+
+## Acceptance criteria
+
+- [ ] Fullscreen toggle button on both the All Logs page and the per-module Logs tab.
+- [ ] Expanded log fills the whole viewport (no sidebar/navbar space eaten).
+- [ ] Toolbar and all existing controls remain usable while expanded.
+- [ ] Auto-refresh, NEW/ack badge, older-log loading on scroll-to-top, and click/Shift+click
+      time-range still work while expanded.
+- [ ] Exit via button and via `Esc`; icon state stays in sync.
+- [ ] Collapsed state looks/behaves exactly as before (fixed heights restored after exit).
+- [ ] Works in current Chrome, Firefox, Safari, and iOS Safari (overlay works everywhere since it
+      doesn't depend on the Fullscreen API).
+- [ ] Both templates updated in lockstep (same behavior, same helper code).
+
+## Out of scope
+
+- Native Fullscreen API (`requestFullscreen()`) — overlay-only per the design decision above.
+- Keyboard shortcut (e.g. `f`) to toggle fullscreen — optional nicety in the issue, not required.
+- Packages page transient operation-log panel (`#update-panel-log` in
+  `templates/modules/packages.html`) — different kind of log, explicitly out of scope in #74.
+
+## Related
+
+- #44 (browser notifications for log WARNING+) — shares the per-module Logs tab / All Logs page;
+  keep the notification flow working while expanded.
+- #59 (logs for both config name and comm name) — touches the same log plumbing.
diff --git a/specs/plans/index.md b/specs/plans/index.md
index 0fd575c..1de352e 100644
--- a/specs/plans/index.md
+++ b/specs/plans/index.md
@@ -18,3 +18,6 @@ Implementation plans, checklist-style. A plan moves/folds into `design/` once it
   — make `api_module_classes` fleet-aware instead of always-local, following the `api_all_logs`
   self-aggregating pattern. **implemented (pyobs-web-admin side); portal follow-up open** (#68,
   pyobs-portal#119)
+- [2026-09-01-log-fullscreen-button.md](2026-09-01-log-fullscreen-button.md) — fullscreen toggle
+  for the log console on All Logs and the per-module Logs tab, overlay-only (no native Fullscreen
+  API, for iOS Safari support). **proposed** (#74)

From 7cffa5063e3d61ad9b65308533c613af023907c9 Mon Sep 17 00:00:00 2001
From: Tim-Oliver Husser 
Date: Tue, 1 Sep 2026 18:05:33 +0200
Subject: [PATCH 29/33] docs: mark update-all-packages plan implemented (PR
 #81)

---
 specs/plans/2026-09-01-update-all-packages.md | 9 +++++++--
 specs/plans/index.md                          | 4 ++--
 2 files changed, 9 insertions(+), 4 deletions(-)

diff --git a/specs/plans/2026-09-01-update-all-packages.md b/specs/plans/2026-09-01-update-all-packages.md
index 528a8f3..6ee3be7 100644
--- a/specs/plans/2026-09-01-update-all-packages.md
+++ b/specs/plans/2026-09-01-update-all-packages.md
@@ -1,8 +1,13 @@
 # Plan: "Update all" button on the Packages page
 
-Status: proposed
+Status: implemented
 
-Related: #79. Builds on `specs/plans/2026-08-16-async-package-update.md` (the background-job/lock
+Landed: PR #81 ("Add \"Update all\" bulk action to the Packages page", commits `0129b78`/`fafba81`,
+merged `afea89b`) — the "Update all" button, `updateAllPackages()`'s sequential queue, and the
+`bulkRunning`/`awaitUpdateCompletion` guards on `templates/modules/packages.html`. No backend
+changes, per the proposal below. Closes #79.
+
+Related: builds on `specs/plans/2026-08-16-async-package-update.md` (the background-job/lock
 design below is consumed as-is, not changed).
 
 ## Problem
diff --git a/specs/plans/index.md b/specs/plans/index.md
index c195143..fb61209 100644
--- a/specs/plans/index.md
+++ b/specs/plans/index.md
@@ -19,5 +19,5 @@ Implementation plans, checklist-style. A plan moves/folds into `design/` once it
   self-aggregating pattern. **implemented (pyobs-web-admin side); portal follow-up open** (#68,
   pyobs-portal#119)
 - [2026-09-01-update-all-packages.md](2026-09-01-update-all-packages.md) — sequential "update all"
-  button on the Packages page, queuing over the existing single-job update endpoint. **proposed**
-  (#79)
+  button on the Packages page, queuing over the existing single-job update endpoint.
+  **implemented, closed** (#79, PR #81)

From 2bf971410cc4bd9cd213ef2696a7934578caad8d Mon Sep 17 00:00:00 2001
From: Tim-Oliver Husser 
Date: Tue, 1 Sep 2026 18:17:29 +0200
Subject: [PATCH 30/33] feat: add fullscreen toggle for log console (#74)

Overlay-only (no native Fullscreen API, for iOS Safari support): a
#log-container wrapping toolbar + status + 
 gets position:fixed;inset:0
via a .log-fullscreen class toggle. Applied identically to both All Logs
and the per-module Logs tab, kept in lockstep per repo convention.

Verified in a running dev server: auto-refresh keeps polling while
expanded, Esc and the button both exit, collapsed state (600px/520px)
restores exactly.
---
 templates/modules/all_logs.html | 32 ++++++++++++++++++++++++++++++++
 templates/modules/detail.html   | 27 +++++++++++++++++++++++++++
 2 files changed, 59 insertions(+)

diff --git a/templates/modules/all_logs.html b/templates/modules/all_logs.html
index 1730bd7..6067cba 100644
--- a/templates/modules/all_logs.html
+++ b/templates/modules/all_logs.html
@@ -2,6 +2,18 @@
 
 {% block title %}All Logs — pyobs Web Admin{% endblock %}
 
+{% block extra_head %}
+
+{% endblock %}
+
 {% block content %}
 

All Logs

@@ -46,6 +58,7 @@

All Logs

{% endfor %}
+
@@ -89,6 +105,7 @@

All Logs

class="bg-black text-light p-3 rounded border border-secondary overflow-auto font-monospace" style="height: 600px; font-size: 0.78rem; white-space: pre-wrap; word-break: break-all;"> Loading…
+ {% endif %} {% endblock %} @@ -195,6 +212,21 @@

All Logs

badge.classList.toggle('d-none', count === 0); } +// ── Fullscreen toggle ──────────────────────────────────────────────────────── +function toggleLogFullscreen() { + const container = document.getElementById('log-container'); + const btn = document.getElementById('log-fullscreen-btn'); + const icon = btn.querySelector('i'); + const isFs = container.classList.toggle('log-fullscreen'); + icon.className = isFs ? 'bi bi-fullscreen-exit' : 'bi bi-arrows-fullscreen'; + btn.title = isFs ? 'Exit fullscreen' : 'Expand log to fullscreen'; + if (isFs) document.addEventListener('keydown', escExitLogFullscreen); + else document.removeEventListener('keydown', escExitLogFullscreen); +} +function escExitLogFullscreen(e) { + if (e.key === 'Escape') toggleLogFullscreen(); +} + function renderLine(line) { const e = escapeHtml(line); const level = parseLogLevel(line); diff --git a/templates/modules/detail.html b/templates/modules/detail.html index 932b211..2cc0b84 100644 --- a/templates/modules/detail.html +++ b/templates/modules/detail.html @@ -9,6 +9,13 @@ .CodeMirror { border: 1px solid #495057; border-radius: 0.375rem; font-size: 0.85rem; height: auto; min-height: 400px; } .CodeMirror-scroll { min-height: 400px; } .cm-include-block { color: #74c7ec !important; font-weight: bold; } + .log-fullscreen { + position: fixed; inset: 0; z-index: 1046; /* above sidebar (1044/1045), mobile navbar (1043) */ + background: var(--pyobs-surface-bg); + display: flex; flex-direction: column; + padding: 1rem; margin: 0; overflow: auto; + } + .log-fullscreen #log-output { flex: 1 1 auto; height: auto !important; min-height: 0; } {% endblock %} @@ -104,6 +111,7 @@

{{ module_name }}

+
@@ -141,6 +152,7 @@

{{ module_name }}

style="height: 520px; font-size: 0.78rem; white-space: pre-wrap; word-break: break-all;"> Loading…
+
@@ -647,6 +659,21 @@ badge.classList.toggle('d-none', count === 0); } +// ── Fullscreen toggle ──────────────────────────────────────────────────────── +function toggleLogFullscreen() { + const container = document.getElementById('log-container'); + const btn = document.getElementById('log-fullscreen-btn'); + const icon = btn.querySelector('i'); + const isFs = container.classList.toggle('log-fullscreen'); + icon.className = isFs ? 'bi bi-fullscreen-exit' : 'bi bi-arrows-fullscreen'; + btn.title = isFs ? 'Exit fullscreen' : 'Expand log to fullscreen'; + if (isFs) document.addEventListener('keydown', escExitLogFullscreen); + else document.removeEventListener('keydown', escExitLogFullscreen); +} +function escExitLogFullscreen(e) { + if (e.key === 'Escape') toggleLogFullscreen(); +} + // ── Browser notifications (issue #44) ────────────────────────────────────────── // Same mechanism as all_logs.html, kept in lockstep: WARNING+ lines that arrive between // fetches while the tab isn't visible fire an OS notification. The on/off toggle lives on From 64657affa0afec7bce705746c045a50fee34685d Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 1 Sep 2026 18:25:37 +0200 Subject: [PATCH 31/33] address review: aria-pressed on fullscreen toggle, update plan doc to match implementation - Add aria-pressed to the fullscreen button, kept in sync by toggleLogFullscreen(), since it's a stateful toggle unlike the file's other title-only icon buttons. - Update the plan's CSS/JS snippets to match what actually shipped: the .log-fullscreen class goes on the new #log-container wrapper (toolbar + pre), not on #log-output alone -- the original snippet would have hidden the toolbar behind the fixed pre, which is exactly the bug the real browser check caught during implementation. --- .../plans/2026-09-01-log-fullscreen-button.md | 36 +++++++++++++------ templates/modules/all_logs.html | 3 +- templates/modules/detail.html | 3 +- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/specs/plans/2026-09-01-log-fullscreen-button.md b/specs/plans/2026-09-01-log-fullscreen-button.md index 3bd5d96..9367f4f 100644 --- a/specs/plans/2026-09-01-log-fullscreen-button.md +++ b/specs/plans/2026-09-01-log-fullscreen-button.md @@ -16,37 +16,52 @@ so this must be added identically in both places. ## Design -Overlay-only approach (no native `Element.requestFullscreen()`): a CSS class toggle makes the log -`
` fill the viewport via `position: fixed; inset: 0`. Chosen over the Fullscreen API because
-iOS Safari doesn't support `requestFullscreen()` on arbitrary elements, and the overlay is simpler
-to implement/test with one code path instead of two (native + fallback).
+Overlay-only approach (no native `Element.requestFullscreen()`): a CSS class toggle makes a
+wrapper around the toolbar + status line + `
` fill the viewport via
+`position: fixed; inset: 0`. Chosen over the Fullscreen API because iOS Safari doesn't support
+`requestFullscreen()` on arbitrary elements, and the overlay is simpler to implement/test with one
+code path instead of two (native + fallback).
+
+**Implementation note:** the class must go on a wrapper (`#log-container`) around the toolbar and
+the `
`, not on the `
` alone — applying `position: fixed` to just the log box leaves the
+toolbar (a sibling `
` above it) behind the fixed layer, hiding filter/refresh/acknowledge/etc. +entirely. Caught via a real browser check during implementation, not by DOM inspection alone (the +DOM computed styles look fine either way — this is a visual/z-stacking issue). ### 1. CSS (added to both templates — `extra_head` block) `all_logs.html` has no `extra_head` block today; add one. `detail.html` already has one -(lines 5–13) — append there. +(lines 5–13) — append there. Both templates wrap the toolbar + `#log-older-status` + `
` in a
+new `
`. ```css .log-fullscreen { position: fixed; inset: 0; z-index: 1046; /* above sidebar (1044/1045), mobile navbar (1043) */ background: var(--pyobs-surface-bg); display: flex; flex-direction: column; - padding: 1rem; margin: 0; + padding: 1rem; margin: 0; overflow: auto; } -.log-fullscreen#log-output { flex: 1 1 auto; height: auto !important; } +.log-fullscreen #log-output { flex: 1 1 auto; height: auto !important; min-height: 0; } ``` +`min-height: 0` on the flex child is required so the `
` can shrink below its content height
+and scroll internally instead of the whole overlay growing past the viewport.
+
 Theme-aware via the existing `--pyobs-surface-bg` custom property (`templates/base.html:25-40`,
 defined for both light and dark).
 
 ### 2. Toolbar button (both files)
 
 ```html
-
 ```
 
+`aria-pressed` is kept in sync by `toggleLogFullscreen()` so screen-reader users get the toggle
+state (the file's other icon-only buttons rely on `title` alone, but this one is a stateful
+toggle, which is exactly the case `aria-pressed` is for).
+
 - `all_logs.html`: in the toolbar div (~line 44-79), under the module checkboxes.
 - `detail.html`: in the `#tab-logs` toolbar (~line 138 area), same relative position.
 
@@ -54,12 +69,13 @@ defined for both light and dark).
 
 ```js
 function toggleLogFullscreen() {
-  const pre = document.getElementById('log-output');
+  const container = document.getElementById('log-container');
   const btn = document.getElementById('log-fullscreen-btn');
   const icon = btn.querySelector('i');
-  const isFs = pre.classList.toggle('log-fullscreen');
+  const isFs = container.classList.toggle('log-fullscreen');
   icon.className = isFs ? 'bi bi-fullscreen-exit' : 'bi bi-arrows-fullscreen';
   btn.title = isFs ? 'Exit fullscreen' : 'Expand log to fullscreen';
+  btn.setAttribute('aria-pressed', String(isFs));
   if (isFs) document.addEventListener('keydown', escExitLogFullscreen);
   else document.removeEventListener('keydown', escExitLogFullscreen);
 }
diff --git a/templates/modules/all_logs.html b/templates/modules/all_logs.html
index 6067cba..ef90301 100644
--- a/templates/modules/all_logs.html
+++ b/templates/modules/all_logs.html
@@ -80,7 +80,7 @@ 

All Logs

-
@@ -220,6 +220,7 @@

All Logs

const isFs = container.classList.toggle('log-fullscreen'); icon.className = isFs ? 'bi bi-fullscreen-exit' : 'bi bi-arrows-fullscreen'; btn.title = isFs ? 'Exit fullscreen' : 'Expand log to fullscreen'; + btn.setAttribute('aria-pressed', String(isFs)); if (isFs) document.addEventListener('keydown', escExitLogFullscreen); else document.removeEventListener('keydown', escExitLogFullscreen); } diff --git a/templates/modules/detail.html b/templates/modules/detail.html index 2cc0b84..a8c9c2e 100644 --- a/templates/modules/detail.html +++ b/templates/modules/detail.html @@ -133,7 +133,7 @@

{{ module_name }}

-
@@ -667,6 +667,7 @@ const isFs = container.classList.toggle('log-fullscreen'); icon.className = isFs ? 'bi bi-fullscreen-exit' : 'bi bi-arrows-fullscreen'; btn.title = isFs ? 'Exit fullscreen' : 'Expand log to fullscreen'; + btn.setAttribute('aria-pressed', String(isFs)); if (isFs) document.addEventListener('keydown', escExitLogFullscreen); else document.removeEventListener('keydown', escExitLogFullscreen); } From 24a870e45d94f31e9ddc3a751fbe260106c0a52e Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 1 Sep 2026 20:24:23 +0200 Subject: [PATCH 32/33] docs: mark log-fullscreen-button plan implemented, closed (#74, PR #83) --- specs/plans/2026-09-01-log-fullscreen-button.md | 2 +- specs/plans/index.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/plans/2026-09-01-log-fullscreen-button.md b/specs/plans/2026-09-01-log-fullscreen-button.md index 9367f4f..3f9e4e4 100644 --- a/specs/plans/2026-09-01-log-fullscreen-button.md +++ b/specs/plans/2026-09-01-log-fullscreen-button.md @@ -1,6 +1,6 @@ # Plan: fullscreen button for logs -Status: proposed (#74) +Status: implemented, closed (#74, PR #83) ## Problem diff --git a/specs/plans/index.md b/specs/plans/index.md index f9f7bc0..79c2708 100644 --- a/specs/plans/index.md +++ b/specs/plans/index.md @@ -23,4 +23,4 @@ Implementation plans, checklist-style. A plan moves/folds into `design/` once it **implemented, closed** (#79, PR #81) - [2026-09-01-log-fullscreen-button.md](2026-09-01-log-fullscreen-button.md) — fullscreen toggle for the log console on All Logs and the per-module Logs tab, overlay-only (no native Fullscreen - API, for iOS Safari support). **proposed** (#74) + API, for iOS Safari support). **implemented, closed** (#74, PR #83) From 478f1af68e6246e8e99d43bb256f2ea18db8651a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:19:14 +0000 Subject: [PATCH 33/33] Bump ruff from 0.16.4 to 0.16.5 Bumps [ruff](https://github.com/astral-sh/ruff) from 0.16.4 to 0.16.5. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.4...0.16.5) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- uv.lock | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/uv.lock b/uv.lock index 5e39ed8..faed606 100644 --- a/uv.lock +++ b/uv.lock @@ -470,27 +470,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.4" +version = "0.16.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, - { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, - { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, - { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, - { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, - { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, - { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, - { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, - { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, - { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, - { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, ] [[package]]