ci: enforce strict mypy across the codebase (#2752)

* ci: enforce strict mypy across the codebase

Add a Python mypy job that runs on every PR (`.github/workflows/python-mypy.yaml`) and
the supporting type annotations to make `strict = true` pass cleanly across all 88
source files.

Configuration choices:
* `strict = true` with no global relaxations (no `disallow_subclassing_any = false`,
  no `disallow_untyped_decorators = false`, no `disable_error_code`,
  no `ignore_missing_imports`).
* `follow_imports = "normal"`.
* django-stubs + djangorestframework-stubs plugins; django_stubs_ext.monkeypatch()
  in settings so generic Django classes are subscriptable at runtime.
* Local stubs in `stubs/` for libraries that ship incomplete type info
  (drf_spectacular's view methods, redis-py's sync API).
* A scoped `[[tool.mypy.overrides]]` block lists 7 third-party libs without any
  type info (cec, geventwebsocket, hurry.filesize, pydbus, sh, splinter, vlc) so
  future stub releases will be noticed instead of silently ignored.

The two `# type: ignore` escape hatches that previously existed are gone:
`lib/utils.py` now imports `mplayer` from `sh` properly, and `tests/test_settings.py`
patches `os.getenv` via `mock.patch.object` instead of direct assignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: fix mypy CI runtime deps and unused HttpResponse import

* `lib/auth.py`: drop the local `HttpResponse` import; only the string-form
  annotation needs it and the runtime `isinstance` check only uses `HttpRequest`
  (caught by ruff's F401).
* Add a `mypy` dependency group (extends `dev-host` with the runtime deps that
  `anthias_django.settings` touches) so the django-stubs plugin can introspect
  the app registry on a fresh CI runner. Skips the heavy native-extension deps
  (cec, netifaces, etc.) that aren't needed for type checking.
* python-mypy workflow: install via `uv pip install --group mypy` (consistent
  with other workflows) and create a dummy `~/.screenly/screenly.conf` so the
  settings module's first-import side effect succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): migrate password hashing to PBKDF2 (CodeQL py/weak-sensitive-data-hashing)

CodeQL flagged the SHA256-based password hashing in lib/auth.py and api/views/v2.py
as a weak password hash (SHA256 is a fast hash, unsuitable for password storage).

Switch new passwords to Django's `make_password` (PBKDF2-SHA256, 600k iterations
by default in Django 4.2). Add `hash_password` / `verify_password` helpers in
lib/auth.py that:

* hash with PBKDF2 for any new password write,
* verify both Django-format hashes and legacy bare-SHA256 hex digests so existing
  config files still authenticate,
* opportunistically re-hash legacy SHA256 entries to PBKDF2 on a successful
  login, phasing out the weak format over time.

`settings.py` auto-migration now uses `hash_password` for first-load plaintext
passwords and recognises both legacy SHA256 and Django algorithm-prefixed
formats so it doesn't double-hash an already-stored hash.

Also fixes the stale ruff format check (5 files reformatted by `ruff format`)
that was breaking the python-lint job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: drop legacy SHA256 password verify and add docker-image-builder to mypy group

CodeQL kept flagging the SHA256 fallback path in `lib/auth.py:verify_password`
as `py/weak-sensitive-data-hashing`, even though it was scoped to a one-time
migration. Rather than suppress the alert, drop the legacy verify entirely so
no password material ever flows into hashlib.sha256.

Migration of existing installs now happens at settings load time:
* `settings.py` detects a 64-char hex (legacy SHA256) password hash on read
  and clears both the `password` field and `auth_backend`. The device stays
  reachable (no auth required) and the operator must re-set credentials via
  the web UI. A clear warning is logged so the change is visible.

Also fix the python-mypy CI job: include the `docker-image-builder` group in
the `mypy` group so `pygit2` and `python_on_whales` (imported by
`tools/image_builder/__main__.py`) resolve.

BREAKING: any Anthias install with a SHA256-format password in
`screenly.conf` will have basic auth disabled on first start of this version.
The operator must log in (no password required) and set a new password via
the UI to re-enable basic auth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review feedback (Copilot + manual review)

Critical:
* settings.py — `_get` previously called `hash_password()` at module-import
  time when it found a plaintext password. That imports Django's password
  hashers, which raises `ImproperlyConfigured` if reached before
  `django.setup()` (e.g. when `viewer/__init__.py` imports `settings`).
  Drop the auto-hash entirely and treat plaintext the same as legacy
  SHA256: clear it, disable basic auth, log an `error`-level warning, and
  persist the cleaned state via `_needs_save_after_load` so the warning
  doesn't repeat on every load.
* lib/backup_helper.py — `recover()` called `tar.extractall()` on an
  uploaded archive. A crafted backup with `../` paths or symlinks could
  overwrite arbitrary files under HOME. Add `_safe_extract()` that
  validates each member's resolved path stays inside the destination and
  rejects unsafe symlinks/hardlinks.
* api/helpers.py — `custom_exception_handler()` discarded DRF's default
  response and always returned 500, breaking 4xx propagation for
  `ValidationError`/`NotFound`/etc. Return DRF's response when present;
  fall back to 500 only when it's None.
* tests/test_settings.py — `broken_settings_should_raise_value_error`
  was missing the `test_` prefix and never actually ran. Renamed.

API correctness:
* api/views/mixins.py — replace bare `raise Exception(...)` for missing
  uploads / wrong file extensions / missing asset URI with DRF's
  `ValidationError` / `NotFound`, so callers get proper 4xx with a
  structured error body instead of a 500.
* anthias_app/helpers.py, api/serializers/{mixins,v1_1}.py — replace
  `assert video_duration is not None` with explicit raises. `assert` is
  stripped under `python -O`, which would silently turn the next call
  into an `AttributeError` on None.

Typing/stubs:
* host_agent.py — pass `decode_responses=True` to both `redis.Redis()`
  constructions and switch the channel name + command map keys from
  bytes to str. The local redis stub assumes decoded responses, and
  host_agent was the only caller violating that invariant.
* lib/auth.py — document the `R | HttpResponse` return-type collapse
  for DRF Response (mirrors Django's @login_required).
* api/serializers/__init__.py — comment why `UpdateAssetSerializer`'s
  fields are widened to `Field[Any, Any, Any, Any]` (so v2's overrides
  with different field types don't trip [assignment]).

Tests/CI:
* api/tests/test_v2_endpoints.py — also assert the stored hash starts
  with `pbkdf2_sha256$` so a regression to a weaker hasher is caught
  even if `verify_password()` itself were broken.
* .github/workflows/python-mypy.yaml — write a minimal valid
  `screenly.conf` (with section headers) instead of an empty file. The
  empty-file path only worked by accident of `_get`'s defensive
  try/except.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): force re-import of `settings` in fake_settings()

The `fake_settings()` test helper only deleted `sys.modules['settings']`
*after* the yield (i.e. on clean exit). Once a prior test imported
`settings` cleanly, subsequent `import settings` calls returned the cache
without re-instantiating `AnthiasSettings`, so the fixture's config file
was silently ignored.

This was hidden because `test_broken_settings_should_raise_value_error`
was missing the `test_` prefix and never ran. After renaming it in the
previous commit it now runs and exposes the bug.

Pop the module before import (and again in `finally`) so each test gets
a fresh `AnthiasSettings()` instance bound to the fixture file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot review feedback

- backup_helper: use dedicated BackupRecoverError instead of bare
  Exception so callers can map archive failures to 4xx responses.
- mixins: catch BackupRecoverError / TarError in RecoverViewMixin and
  re-raise as DRF ValidationError so bad uploads return 400, not 500.
- utils: drop top-level `from sh import ffprobe, mplayer`; resolve
  binaries lazily with sh.Command at call sites so a missing tool
  doesn't break module import.
- host_agent: import redis ConnectionError explicitly (mypy strict).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): unblock mypy and ruff format; sanitize recover error

- stubs/redis-stubs: export ConnectionError so host_agent's
  retry_if_exception_type(redis.ConnectionError) typechecks under the
  isolated mypy CI env (no real redis package installed).
- host_agent.py: switch to redis.ConnectionError (matches new stub).
- lib/utils.py: ruff format fix for the lazy sh.Command(...) call.
- api/views/mixins.py: don't echo backup-recover exception text into
  the API response (CodeQL py/stack-trace-exposure). Log server-side
  and return a generic 'Invalid backup archive.' message instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): generate server-side name for backup recovery upload

Don't pass the client-supplied `file_upload.name` into `path.join('static', ...)`.
A crafted name (path separators, leading `../`, or an absolute path) could
write outside the static directory before the tarball is parsed. Use a
UUID-named `.tar.gz` instead — the original filename is never needed
again after the content-type check.

Addresses suppressed Copilot review comment on api/views/mixins.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot review round 2

- lib/utils.py: catch sh.CommandNotFound in get_video_duration (return
  None) and url_fails (skip streaming probe) so missing ffprobe/mplayer
  don't surface as 500s.
- lib/auth.py: decode basic-auth credentials with partition(':') so
  passwords containing ':' are accepted (RFC 7617).
- lib/backup_helper.py: tighten _safe_extract — reject non-regular tar
  members (symlinks, hardlinks, device nodes, FIFOs) and extract members
  individually after validation instead of calling extractall().
- tests/test_backup_helper.py: add coverage for path traversal,
  absolute-path, symlink, and FIFO rejection in _safe_extract.
- settings.py: fail fast in AnthiasSettings.__init__ when HOME is unset
  instead of silently rooting all config/state paths at the cwd.
- api/serializers/v1_1.py: raise ValidationError when 'duration' is
  missing/invalid for non-video assets instead of an unhandled KeyError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(test): mark tar fixture/extract calls as NOSONAR

The new SafeExtractTest builds and opens single-member tar archives
specifically to test the safe-extract guard. SonarCloud flags any
tarfile.open(...) call (rule python:S5042). Annotate the two test-
fixture call sites with NOSONAR — same pattern used elsewhere in this
repo (e.g. host_agent.py).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ci): use 6-space step indentation in python-mypy.yaml

Match the majority style used by the other workflows in this repo.
The previous 4-space indent under `steps:` was valid YAML and ran
fine in CI, but consistency with build-webview.yaml,
build-balena-disk-image.yaml, deploy-website.yaml, etc. makes the
file easier to read alongside the rest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot review round 3

- api/serializers/v1_1.py: raise DRF ValidationError instead of
  ValueError when video duration can't be determined, so clients get
  a 4xx with a field-level error rather than a 500.
- api/views/mixins.py: ensure the uploaded backup tarball is removed
  in all paths (success and failure). recover() only deletes on
  success; without explicit cleanup, rejected archives — including
  attacker-controlled ones — accumulate under static/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: ruff format tests/test_backup_helper.py

Single line that fit within 79 chars but I had broken across three
lines during the merge resolution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot review round 4

* Add partial PEP 561 stubs in `stubs/channels-stubs/` covering the
  `channels` API surface we actually use: `AsyncWebsocketConsumer`
  (with `as_asgi`), `ProtocolTypeRouter`, `URLRouter`,
  `AllowedHostsOriginValidator`, and `get_channel_layer`. Drop the
  `# type: ignore[misc]` from `AssetConsumer` and remove `channels.*`
  from the mypy `ignore_missing_imports` overrides — the override
  block is back to listing only libs that genuinely ship no type info.

* Delete `_safe_extract` and `_is_within_directory` from
  `lib/backup_helper.py`, plus the corresponding `SafeExtractTest` and
  `_build_archive_with` from `tests/test_backup_helper.py`. After the
  master merge, `recover()` validates each member through
  `_safe_tar_member` (skip-on-fail) and extracts inside the loop, so
  the older raise-on-fail helper became dead code with no caller.
  `RecoverLegacyTarballTest::test_recover_skips_path_traversal_member`
  already exercises the path-traversal guard end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot review round 5

* `celery_tasks.py:cleanup()` — bail out with a logged error when HOME
  is unset instead of `path.join('', 'anthias_assets')` resolving to a
  relative path. The `find ... -delete` was never going to run on a
  Pi without HOME, but the silent fallback would have chewed through
  the celery worker's cwd if it ever did.

* `api/serializers/v1_1.py:CreateAssetSerializerV1_1.prepare_asset()` —
  fix the video duration handling so non-zero and omitted durations
  are no longer dropped on the floor. Previously only the magic
  `duration == 0` case set `asset['duration']`; a client posting
  `duration=30` for a video produced a serialized dict with no
  duration key, then the view did `Asset.objects.create(**data)` and
  the row got the field's default. Now: missing or 0 means "infer
  from the file via get_video_duration()"; any other integer is
  persisted as-is. Non-int values raise ValidationError up front.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Petersson
2026-04-27 14:53:37 +01:00
committed by GitHub
parent f421130b24
commit 93e5501847
80 changed files with 1709 additions and 724 deletions

60
.github/workflows/python-mypy.yaml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Run mypy
on:
push:
branches:
- 'master'
paths:
- pyproject.toml
- uv.lock
- '**/*.py'
- '.github/workflows/python-mypy.yaml'
pull_request:
branches:
- master
paths:
- pyproject.toml
- uv.lock
- '**/*.py'
- '.github/workflows/python-mypy.yaml'
jobs:
run-mypy:
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: ["3.11"]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: '0.9.17'
- name: Install dependencies
run: |
uv venv
uv pip install --group mypy
- name: Prepare config directory
run: |
# The django-stubs plugin imports anthias_django.settings, which
# transitively instantiates AnthiasSettings(). On first run that
# tries to read ~/.anthias/anthias.conf — write a minimal valid
# one with the section headers AnthiasSettings expects.
mkdir -p ~/.anthias
cat > ~/.anthias/anthias.conf <<'CONF'
[main]
[viewer]
[auth_basic]
CONF
- name: Run mypy
run: |
uv run mypy .

View File

@@ -4,7 +4,7 @@ from anthias_app.models import Asset
@admin.register(Asset)
class AssetAdmin(admin.ModelAdmin):
class AssetAdmin(admin.ModelAdmin[Asset]):
list_display = (
'asset_id',
'name',

View File

@@ -1,15 +1,17 @@
from typing import Any
from channels.generic.websocket import AsyncWebsocketConsumer
WS_GROUP = 'ws_server'
class AssetConsumer(AsyncWebsocketConsumer):
async def connect(self):
async def connect(self) -> None:
await self.channel_layer.group_add(WS_GROUP, self.channel_name)
await self.accept()
async def disconnect(self, code):
async def disconnect(self, code: int) -> None:
await self.channel_layer.group_discard(WS_GROUP, self.channel_name)
async def asset_update(self, event):
async def asset_update(self, event: dict[str, Any]) -> None:
await self.send(text_data=event['asset_id'])

View File

@@ -1,7 +1,9 @@
import uuid
from os import getenv, path
from typing import Any
import yaml
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.utils import timezone
@@ -11,7 +13,11 @@ from lib.utils import get_video_duration
from settings import settings
def template(request, template_name, context):
def template(
request: HttpRequest,
template_name: str,
context: dict[str, Any],
) -> HttpResponse:
"""
This is a helper function that is used to render a template
with some global context. This is used to avoid having to
@@ -33,16 +39,20 @@ def template(request, template_name, context):
return render(request, template_name, context)
def prepare_default_asset(**kwargs):
def prepare_default_asset(**kwargs: Any) -> dict[str, Any] | None:
if kwargs['mimetype'] not in ['image', 'video', 'webpage']:
return
return None
asset_id = 'default_{}'.format(uuid.uuid4().hex)
duration = (
int(get_video_duration(kwargs['uri']).total_seconds())
if 'video' == kwargs['mimetype']
else kwargs['duration']
)
if 'video' == kwargs['mimetype']:
video_duration = get_video_duration(kwargs['uri'])
if video_duration is None:
raise ValueError(
f'Could not determine duration of video {kwargs["uri"]!r}'
)
duration = int(video_duration.total_seconds())
else:
duration = kwargs['duration']
return {
'asset_id': asset_id,
@@ -60,7 +70,7 @@ def prepare_default_asset(**kwargs):
}
def add_default_assets():
def add_default_assets() -> None:
settings.load()
datetime_now = timezone.now()
@@ -71,7 +81,7 @@ def add_default_assets():
}
default_assets_yaml = path.join(
getenv('HOME'),
getenv('HOME') or '',
'.anthias/default_assets.yml',
)
@@ -92,7 +102,7 @@ def add_default_assets():
Asset.objects.create(**asset)
def remove_default_assets():
def remove_default_assets() -> None:
settings.load()
for asset in Asset.objects.all():

View File

@@ -4,7 +4,7 @@ from django.db import models
from django.utils import timezone
def generate_asset_id():
def generate_asset_id() -> str:
return uuid.uuid4().hex
@@ -28,12 +28,12 @@ class Asset(models.Model):
class Meta:
db_table = 'assets'
def __str__(self):
return self.name
def __str__(self) -> str:
return str(self.name)
def is_active(self):
def is_active(self) -> bool:
if self.is_enabled and self.start_date and self.end_date:
current_time = timezone.now()
return self.start_date < current_time < self.end_date
return bool(self.start_date < current_time < self.end_date)
return False

View File

@@ -1,8 +1,10 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
from unittest import mock
from django.http import Http404
from django.http.response import HttpResponseBase
from django.test import RequestFactory, TestCase
from anthias_app import views_files
@@ -18,7 +20,7 @@ PUBLIC_IP = '8.8.8.8' # NOSONAR
class AnthiasAssetsViewTest(TestCase):
def setUp(self):
def setUp(self) -> None:
self.factory = RequestFactory()
self.tmp = TemporaryDirectory()
self.root = Path(self.tmp.name)
@@ -28,44 +30,44 @@ class AnthiasAssetsViewTest(TestCase):
)
self.root_patch.start()
def tearDown(self):
def tearDown(self) -> None:
self.root_patch.stop()
self.tmp.cleanup()
def _get(self, path, remote_addr):
def _get(self, path: str, remote_addr: str) -> HttpResponseBase:
request = self.factory.get(path, REMOTE_ADDR=remote_addr)
# views_files.anthias_assets is wrapped by require_client_in.
filename = path.removeprefix('/anthias_assets/')
return views_files.anthias_assets(request, filename=filename)
def test_allows_docker_bridge_client(self):
def test_allows_docker_bridge_client(self) -> None:
response = self._get('/anthias_assets/hello.txt', DOCKER_BRIDGE_IP)
self.assertEqual(response.status_code, 200)
def test_blocks_public_ip(self):
def test_blocks_public_ip(self) -> None:
response = self._get('/anthias_assets/hello.txt', PUBLIC_IP)
self.assertEqual(response.status_code, 403)
def test_blocks_lan_ip(self):
def test_blocks_lan_ip(self) -> None:
# 192.168/16 is intentionally excluded from the asset allowlist.
response = self._get('/anthias_assets/hello.txt', LAN_IP_192_BLOCKED)
self.assertEqual(response.status_code, 403)
def test_missing_file_404(self):
def test_missing_file_404(self) -> None:
request = self.factory.get(
'/anthias_assets/missing.txt', REMOTE_ADDR=DOCKER_BRIDGE_IP
)
with self.assertRaises(Http404):
views_files.anthias_assets(request, filename='missing.txt')
def test_traversal_404(self):
def test_traversal_404(self) -> None:
request = self.factory.get(
'/anthias_assets/whatever', REMOTE_ADDR=DOCKER_BRIDGE_IP
)
with self.assertRaises(Http404):
views_files.anthias_assets(request, filename='../../../etc/passwd')
def test_symlink_escape_404(self):
def test_symlink_escape_404(self) -> None:
with TemporaryDirectory() as outside_dir:
outside = Path(outside_dir) / 'outside.txt'
outside.write_text('secret')
@@ -76,13 +78,13 @@ class AnthiasAssetsViewTest(TestCase):
with self.assertRaises(Http404):
views_files.anthias_assets(request, filename='link.txt')
def test_malformed_remote_addr_403(self):
def test_malformed_remote_addr_403(self) -> None:
response = self._get('/anthias_assets/hello.txt', 'not-an-ip')
self.assertEqual(response.status_code, 403)
class StaticWithMimeViewTest(TestCase):
def setUp(self):
def setUp(self) -> None:
self.factory = RequestFactory()
self.tmp = TemporaryDirectory()
self.root = Path(self.tmp.name)
@@ -92,17 +94,19 @@ class StaticWithMimeViewTest(TestCase):
)
self.root_patch.start()
def tearDown(self):
def tearDown(self) -> None:
self.root_patch.stop()
self.tmp.cleanup()
def _call(self, filename, remote_addr, **extra):
def _call(
self, filename: str, remote_addr: str, **extra: Any
) -> HttpResponseBase:
request = self.factory.get(
f'/static_with_mime/{filename}', REMOTE_ADDR=remote_addr, **extra
)
return views_files.static_with_mime(request, filename=filename)
def test_allows_rfc1918_clients(self):
def test_allows_rfc1918_clients(self) -> None:
for ip in (LAN_IP_10, DOCKER_BRIDGE_IP, LAN_IP_192_ALLOWED):
self.assertEqual(
self._call('app.css', ip).status_code,
@@ -110,10 +114,10 @@ class StaticWithMimeViewTest(TestCase):
msg=f'expected 200 for {ip}',
)
def test_blocks_public_ip(self):
def test_blocks_public_ip(self) -> None:
self.assertEqual(self._call('app.css', PUBLIC_IP).status_code, 403)
def test_mime_override_via_query(self):
def test_mime_override_via_query(self) -> None:
request = self.factory.get(
'/static_with_mime/app.css',
data={'mime': 'application/x-tgz'},
@@ -122,7 +126,7 @@ class StaticWithMimeViewTest(TestCase):
response = views_files.static_with_mime(request, filename='app.css')
self.assertEqual(response['Content-Type'], 'application/x-tgz')
def test_mime_override_rejects_html(self):
def test_mime_override_rejects_html(self) -> None:
# text/html would let an attacker turn a stored file into XSS;
# ?mime= is allowlisted to safe download types only.
request = self.factory.get(
@@ -133,7 +137,7 @@ class StaticWithMimeViewTest(TestCase):
response = views_files.static_with_mime(request, filename='app.css')
self.assertEqual(response['Content-Type'], 'text/css')
def test_default_mime_from_extension(self):
def test_default_mime_from_extension(self) -> None:
request = self.factory.get(
'/static_with_mime/app.css', REMOTE_ADDR=LAN_IP_10
)
@@ -142,7 +146,7 @@ class StaticWithMimeViewTest(TestCase):
class HotspotViewTest(TestCase):
def setUp(self):
def setUp(self) -> None:
self.factory = RequestFactory()
self.tmp = TemporaryDirectory()
base = Path(self.tmp.name)
@@ -158,30 +162,30 @@ class HotspotViewTest(TestCase):
for p in self.patches:
p.start()
def tearDown(self):
def tearDown(self) -> None:
for p in self.patches:
p.stop()
self.tmp.cleanup()
def _get(self, remote_addr=DOCKER_BRIDGE_IP):
def _get(self, remote_addr: str = DOCKER_BRIDGE_IP) -> HttpResponseBase:
request = self.factory.get('/hotspot', REMOTE_ADDR=remote_addr)
return views_files.hotspot(request, path='')
def test_serves_when_uninitialized(self):
def test_serves_when_uninitialized(self) -> None:
response = self._get()
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'text/html')
def test_blocks_public_ip(self):
def test_blocks_public_ip(self) -> None:
self.assertEqual(self._get(PUBLIC_IP).status_code, 403)
def test_404_after_initialization(self):
def test_404_after_initialization(self) -> None:
self.initialized_flag.touch()
request = self.factory.get('/hotspot', REMOTE_ADDR=DOCKER_BRIDGE_IP)
with self.assertRaises(Http404):
views_files.hotspot(request, path='')
def test_404_when_file_missing(self):
def test_404_when_file_missing(self) -> None:
self.hotspot_file.unlink()
request = self.factory.get('/hotspot', REMOTE_ADDR=DOCKER_BRIDGE_IP)
with self.assertRaises(Http404):

View File

@@ -1,6 +1,7 @@
import ipaddress
from django.contrib import messages
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.views.decorators.http import require_http_methods
@@ -20,17 +21,22 @@ r = connect_to_redis()
@authorized
def react(request):
def react(request: HttpRequest) -> HttpResponse:
return template(request, 'react.html', {})
@require_http_methods(['GET', 'POST'])
def login(request):
def login(request: HttpRequest) -> HttpResponse:
if request.method == 'POST':
username = request.POST.get('username')
password = request.POST.get('password')
username = request.POST.get('username') or ''
password = request.POST.get('password') or ''
if settings.auth._check(username, password):
auth = settings.auth
if (
auth is not None
and hasattr(auth, '_check')
and auth._check(username, password)
):
# Store credentials in session
request.session['auth_username'] = username
request.session['auth_password'] = password
@@ -48,7 +54,7 @@ def login(request):
@require_http_methods(['GET'])
def splash_page(request):
def splash_page(request: HttpRequest) -> HttpResponse:
ip_addresses = []
for ip_address in get_node_ip().split():

View File

@@ -3,8 +3,15 @@ import mimetypes
import os
from functools import wraps
from pathlib import Path
from typing import Any, Callable
from django.http import FileResponse, Http404, HttpResponseForbidden
from django.http import (
FileResponse,
Http404,
HttpRequest,
HttpResponseForbidden,
)
from django.http.response import HttpResponseBase
from django.views.decorators.http import require_GET
# Defense-in-depth, not the real perimeter:
@@ -51,14 +58,23 @@ STATIC_WITH_MIME_ALLOWED_TYPES = frozenset(
)
def _client_ip(request):
ViewFunc = Callable[..., HttpResponseBase]
def _client_ip(
request: HttpRequest,
) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
return ipaddress.ip_address(request.META.get('REMOTE_ADDR', ''))
def require_client_in(*cidrs):
def decorator(view):
def require_client_in(
*cidrs: ipaddress.IPv4Network | ipaddress.IPv6Network,
) -> Callable[[ViewFunc], ViewFunc]:
def decorator(view: ViewFunc) -> ViewFunc:
@wraps(view)
def wrapper(request, *args, **kwargs):
def wrapper(
request: HttpRequest, *args: Any, **kwargs: Any
) -> HttpResponseBase:
try:
addr = _client_ip(request)
except ValueError:
@@ -74,7 +90,7 @@ def require_client_in(*cidrs):
@require_GET
@require_client_in(DOCKER_BRIDGE_CIDR)
def anthias_assets(request, filename):
def anthias_assets(request: HttpRequest, filename: str) -> HttpResponseBase:
# Trailing os.sep on `base` is required so e.g.
# '/data/anthias_assets_evil/...' doesn't slip past startswith().
base = os.path.realpath(ANTHIAS_ASSETS_ROOT) + os.sep
@@ -89,7 +105,7 @@ def anthias_assets(request, filename):
@require_GET
@require_client_in(*RFC1918_CIDRS)
def static_with_mime(request, filename):
def static_with_mime(request: HttpRequest, filename: str) -> HttpResponseBase:
base = os.path.realpath(STATIC_FILES_ROOT) + os.sep
target = os.path.realpath(os.path.join(base, filename))
if not target.startswith(base):
@@ -109,7 +125,7 @@ def static_with_mime(request, filename):
@require_GET
@require_client_in(DOCKER_BRIDGE_CIDR)
def hotspot(request, path=''):
def hotspot(request: HttpRequest, path: str = '') -> HttpResponseBase:
if INITIALIZED_FLAG.exists() or not HOTSPOT_FILE.is_file():
raise Http404
return FileResponse(HOTSPOT_FILE.open('rb'), content_type='text/html')

View File

@@ -12,10 +12,13 @@ import secrets
from os import getenv
from pathlib import Path
import django_stubs_ext
import pytz
from settings import settings as device_settings
django_stubs_ext.monkeypatch()
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

View File

@@ -14,7 +14,10 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from typing import Any
from django.contrib import admin
from django.http import HttpRequest, HttpResponse
from django.urls import include, path, re_path
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
@@ -24,7 +27,12 @@ from lib.auth import authorized
class APIDocView(SpectacularRedocView):
@authorized
def get(self, request, *args, **kwargs):
def get(
self,
request: HttpRequest,
*args: Any,
**kwargs: Any,
) -> HttpResponse:
return super().get(request, *args, **kwargs)

View File

@@ -1,4 +1,7 @@
def preprocessing_filter_spec(endpoints):
from typing import Any
def preprocessing_filter_spec(endpoints: list[Any]) -> list[Any]:
filtered = []
for path, path_regex, method, callback in endpoints:
if path.startswith('/api/v2'):

View File

@@ -1,4 +1,5 @@
import json
from typing import Any
from dateutil import parser as date_parser
from rest_framework import status
@@ -9,11 +10,11 @@ from anthias_app.models import Asset
class AssetCreationError(Exception):
def __init__(self, errors):
def __init__(self, errors: Any) -> None:
self.errors = errors
def update_asset(asset, data):
def update_asset(asset: dict[str, Any], data: dict[str, Any]) -> None:
for key, value in list(data.items()):
if (
key in ['asset_id', 'is_processing', 'mimetype', 'uri']
@@ -41,29 +42,35 @@ def update_asset(asset, data):
asset.update({key: value})
def custom_exception_handler(exc, context):
exception_handler(exc, context)
def custom_exception_handler(
exc: Exception, context: dict[str, Any]
) -> Response:
response = exception_handler(exc, context)
if response is not None:
# Use DRF's default response (correct 4xx status, structured body)
# for known exception types like ValidationError / NotFound / etc.
return response
return Response(
{'error': str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def get_active_asset_ids():
def get_active_asset_ids() -> list[str]:
enabled_assets = Asset.objects.filter(
is_enabled=1,
is_enabled=True,
start_date__isnull=False,
end_date__isnull=False,
)
return [asset.asset_id for asset in enabled_assets if asset.is_active()]
def save_active_assets_ordering(active_asset_ids):
def save_active_assets_ordering(active_asset_ids: list[str]) -> None:
for i, asset_id in enumerate(active_asset_ids):
Asset.objects.filter(asset_id=asset_id).update(play_order=i)
def parse_request(request):
def parse_request(request: Any) -> Any:
data = None
# For backward compatibility

View File

@@ -1,9 +1,11 @@
from datetime import timezone
from os import path
from typing import Any
from django.utils import timezone
from rest_framework.serializers import (
CharField,
DateTimeField,
Field,
IntegerField,
ModelSerializer,
Serializer,
@@ -13,7 +15,7 @@ from anthias_app.models import Asset
from lib.utils import validate_url
def get_unique_name(name):
def get_unique_name(name: str) -> str:
names = Asset.objects.values_list('name', flat=True)
if name in names:
i = 1
@@ -27,7 +29,7 @@ def get_unique_name(name):
return name
def validate_uri(uri):
def validate_uri(uri: str) -> None:
if uri.startswith('/'):
if not path.isfile(uri):
raise Exception('Invalid file path. Failed to add asset.')
@@ -36,7 +38,7 @@ def validate_uri(uri):
raise Exception('Invalid URL. Failed to add asset.')
class AssetSerializer(ModelSerializer):
class AssetSerializer(ModelSerializer[Asset]):
duration = CharField()
is_enabled = IntegerField(min_value=0, max_value=1)
is_active = IntegerField(min_value=0, max_value=1)
@@ -63,18 +65,37 @@ class AssetSerializer(ModelSerializer):
]
class UpdateAssetSerializer(Serializer):
class UpdateAssetSerializer(Serializer[Asset]):
# The fields below use `Field[Any, Any, Any, Any]` (instead of the
# narrower IntegerField/CharField) so that v2's UpdateAssetSerializerV2
# can override them with BooleanField/IntegerField. djangorestframework-
# stubs treats Field subclasses as invariant on their type parameters,
# so a narrower base type makes the override a [assignment] error. Do
# NOT widen any other field "for consistency" — only widen those that
# are actually overridden in subclasses.
name = CharField()
start_date = DateTimeField(default_timezone=timezone.utc)
end_date = DateTimeField(default_timezone=timezone.utc)
duration = CharField()
is_enabled = IntegerField(min_value=0, max_value=1)
is_processing = IntegerField(min_value=0, max_value=1, required=False)
nocache = IntegerField(min_value=0, max_value=1, required=False)
duration: Field[Any, Any, Any, Any] = CharField()
is_enabled: Field[Any, Any, Any, Any] = IntegerField(
min_value=0, max_value=1
)
is_processing: Field[Any, Any, Any, Any] = IntegerField(
min_value=0, max_value=1, required=False
)
nocache: Field[Any, Any, Any, Any] = IntegerField(
min_value=0, max_value=1, required=False
)
play_order = IntegerField(required=False)
skip_asset_check = IntegerField(min_value=0, max_value=1, required=False)
skip_asset_check: Field[Any, Any, Any, Any] = IntegerField(
min_value=0, max_value=1, required=False
)
def update(self, instance, validated_data):
def update(
self,
instance: Asset,
validated_data: dict[str, Any],
) -> Asset:
instance.name = validated_data.get('name', instance.name)
instance.start_date = validated_data.get(
'start_date', instance.start_date
@@ -94,7 +115,7 @@ class UpdateAssetSerializer(Serializer):
'skip_asset_check', instance.skip_asset_check
)
if 'video' not in instance.mimetype:
if 'video' not in (instance.mimetype or ''):
instance.duration = validated_data.get(
'duration', instance.duration
)

View File

@@ -1,6 +1,7 @@
import uuid
from inspect import cleandoc
from os import path, rename
from typing import Any
from rest_framework.serializers import CharField, Serializer
@@ -19,7 +20,14 @@ from . import (
class CreateAssetSerializerMixin:
def prepare_asset(self, data, asset_id=None, version='v2'):
unique_name: bool = False
def prepare_asset(
self,
data: dict[str, Any],
asset_id: str | None = None,
version: str = 'v2',
) -> dict[str, Any]:
ampersand_fix = '&amp;'
name = data['name'].replace(ampersand_fix, '&')
@@ -66,11 +74,17 @@ class CreateAssetSerializerMixin:
asset['uri'] = uri
if 'video' in asset['mimetype']:
if int(data.get('duration')) == 0:
duration_raw = data.get('duration')
if duration_raw is not None and int(duration_raw) == 0:
original_mimetype = data.get('mimetype')
if original_mimetype != 'youtube_asset':
duration = get_video_duration(uri).total_seconds()
video_duration = get_video_duration(uri)
if video_duration is None:
raise AssetCreationError(
f'Could not determine duration of video {uri!r}'
)
duration = video_duration.total_seconds()
asset['duration'] = (
duration if version == 'v2' else int(duration)
)
@@ -91,14 +105,17 @@ class CreateAssetSerializerMixin:
data.get('play_order') if data.get('play_order') else 0
)
skip_check_raw = data.get('skip_asset_check')
asset['skip_asset_check'] = (
int(data.get('skip_asset_check'))
if int(data.get('skip_asset_check'))
int(skip_check_raw)
if skip_check_raw is not None and int(skip_check_raw)
else 0
)
asset['start_date'] = data.get('start_date').replace(tzinfo=None)
asset['end_date'] = data.get('end_date').replace(tzinfo=None)
start_date = data['start_date']
end_date = data['end_date']
asset['start_date'] = start_date.replace(tzinfo=None)
asset['end_date'] = end_date.replace(tzinfo=None)
if not asset['skip_asset_check'] and url_fails(asset['uri']):
raise AssetCreationError(
@@ -108,7 +125,7 @@ class CreateAssetSerializerMixin:
return asset
class PlaylistOrderSerializerMixin(Serializer):
class PlaylistOrderSerializerMixin(Serializer[Any]):
ids = CharField(
write_only=True,
help_text=cleandoc(
@@ -122,13 +139,13 @@ class PlaylistOrderSerializerMixin(Serializer):
)
class BackupViewSerializerMixin(Serializer):
class BackupViewSerializerMixin(Serializer[Any]):
pass
class RebootViewSerializerMixin(Serializer):
class RebootViewSerializerMixin(Serializer[Any]):
pass
class ShutdownViewSerializerMixin(Serializer):
class ShutdownViewSerializerMixin(Serializer[Any]):
pass

View File

@@ -1,7 +1,9 @@
import uuid
from datetime import timezone
from os import path, rename
from typing import Any
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import (
BooleanField,
CharField,
@@ -23,8 +25,13 @@ from . import (
)
class CreateAssetSerializerV1_1(Serializer):
def __init__(self, *args, unique_name=False, **kwargs):
class CreateAssetSerializerV1_1(Serializer[dict[str, Any]]):
def __init__(
self,
*args: Any,
unique_name: bool = False,
**kwargs: Any,
) -> None:
self.unique_name = unique_name
super().__init__(*args, **kwargs)
@@ -40,7 +47,7 @@ class CreateAssetSerializerV1_1(Serializer):
play_order = IntegerField(required=False)
skip_asset_check = IntegerField(min_value=0, max_value=1, required=False)
def prepare_asset(self, data):
def prepare_asset(self, data: dict[str, Any]) -> dict[str, Any]:
name = data['name']
if self.unique_name:
@@ -55,7 +62,7 @@ class CreateAssetSerializerV1_1(Serializer):
'nocache': data.get('nocache', 0),
}
uri = data.get('uri')
uri: str = data['uri']
validate_uri(uri)
@@ -75,23 +82,59 @@ class CreateAssetSerializerV1_1(Serializer):
asset['uri'] = uri
if 'video' in asset['mimetype']:
if int(data.get('duration')) == 0:
asset['duration'] = int(
get_video_duration(uri).total_seconds()
duration_raw = data.get('duration')
try:
duration_int = (
None if duration_raw is None else int(duration_raw)
)
except (TypeError, ValueError):
raise ValidationError(
{'duration': 'A valid integer is required.'}
)
# `duration_int is None` (omitted) and `0` both mean
# "infer the duration from the file"; any other value is
# taken as an explicit override the caller wants persisted.
if duration_int is None or duration_int == 0:
video_duration = get_video_duration(uri)
if video_duration is None:
raise ValidationError(
{
'duration': (
'Could not determine video duration; '
'provide an explicit value.'
)
}
)
asset['duration'] = int(video_duration.total_seconds())
else:
asset['duration'] = duration_int
else:
# Crashes if it's not an int. We want that.
asset['duration'] = int(data.get('duration'))
duration_raw = data.get('duration')
if duration_raw is None:
raise ValidationError(
{
'duration': 'This field is required for non-video assets.'
}
)
try:
asset['duration'] = int(duration_raw)
except (TypeError, ValueError):
raise ValidationError(
{'duration': 'A valid integer is required.'}
)
asset['skip_asset_check'] = int(data.get('skip_asset_check', 0))
if data.get('start_date'):
asset['start_date'] = data.get('start_date').replace(tzinfo=None)
start_date = data.get('start_date')
if start_date:
asset['start_date'] = start_date.replace(tzinfo=None)
else:
asset['start_date'] = ''
if data.get('end_date'):
asset['end_date'] = data.get('end_date').replace(tzinfo=None)
end_date = data.get('end_date')
if end_date:
asset['end_date'] = end_date.replace(tzinfo=None)
else:
asset['end_date'] = ''
@@ -100,5 +143,5 @@ class CreateAssetSerializerV1_1(Serializer):
return asset
def validate(self, data):
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
return self.prepare_asset(data)

View File

@@ -1,4 +1,6 @@
from django.utils import timezone
from datetime import timezone
from typing import Any
from rest_framework.serializers import (
CharField,
DateTimeField,
@@ -9,8 +11,15 @@ from rest_framework.serializers import (
from api.serializers.mixins import CreateAssetSerializerMixin
class CreateAssetSerializerV1_2(Serializer, CreateAssetSerializerMixin):
def __init__(self, *args, unique_name=False, **kwargs):
class CreateAssetSerializerV1_2(
Serializer[dict[str, Any]], CreateAssetSerializerMixin
):
def __init__(
self,
*args: Any,
unique_name: bool = False,
**kwargs: Any,
) -> None:
self.unique_name = unique_name
super().__init__(*args, **kwargs)
@@ -28,5 +37,5 @@ class CreateAssetSerializerV1_2(Serializer, CreateAssetSerializerMixin):
play_order = IntegerField(required=False)
skip_asset_check = IntegerField(min_value=0, max_value=1, required=False)
def validate(self, data):
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
return self.prepare_asset(data, version='v1_2')

View File

@@ -1,5 +1,8 @@
from django.utils import timezone
from drf_spectacular.utils import OpenApiTypes, extend_schema_field
from datetime import timezone
from typing import Any
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework.serializers import (
BooleanField,
CharField,
@@ -16,11 +19,11 @@ from api.serializers import UpdateAssetSerializer
from api.serializers.mixins import CreateAssetSerializerMixin
class AssetSerializerV2(ModelSerializer, CreateAssetSerializerMixin):
class AssetSerializerV2(ModelSerializer[Asset], CreateAssetSerializerMixin):
is_active = SerializerMethodField()
@extend_schema_field(OpenApiTypes.BOOL)
def get_is_active(self, obj):
def get_is_active(self, obj: Asset) -> bool:
return obj.is_active()
class Meta:
@@ -42,8 +45,15 @@ class AssetSerializerV2(ModelSerializer, CreateAssetSerializerMixin):
]
class CreateAssetSerializerV2(Serializer, CreateAssetSerializerMixin):
def __init__(self, *args, unique_name=False, **kwargs):
class CreateAssetSerializerV2(
Serializer[dict[str, Any]], CreateAssetSerializerMixin
):
def __init__(
self,
*args: Any,
unique_name: bool = False,
**kwargs: Any,
) -> None:
self.unique_name = unique_name
super().__init__(*args, **kwargs)
@@ -61,7 +71,7 @@ class CreateAssetSerializerV2(Serializer, CreateAssetSerializerMixin):
play_order = IntegerField(required=False)
skip_asset_check = BooleanField(required=False)
def validate(self, data):
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
return self.prepare_asset(data, version='v2')
@@ -73,7 +83,7 @@ class UpdateAssetSerializerV2(UpdateAssetSerializer):
duration = IntegerField()
class DeviceSettingsSerializerV2(Serializer):
class DeviceSettingsSerializerV2(Serializer[Any]):
player_name = CharField()
audio_output = CharField()
default_duration = IntegerField()
@@ -88,7 +98,7 @@ class DeviceSettingsSerializerV2(Serializer):
username = CharField()
class UpdateDeviceSettingsSerializerV2(Serializer):
class UpdateDeviceSettingsSerializerV2(Serializer[Any]):
player_name = CharField(required=False, allow_blank=True)
audio_output = CharField(required=False)
default_duration = IntegerField(required=False)
@@ -113,7 +123,7 @@ class UpdateDeviceSettingsSerializerV2(Serializer):
current_password = CharField(required=False, allow_blank=True)
class IntegrationsSerializerV2(Serializer):
class IntegrationsSerializerV2(Serializer[Any]):
is_balena = BooleanField()
balena_device_id = CharField(required=False, allow_null=True)
balena_app_id = CharField(required=False, allow_null=True)

View File

@@ -2,6 +2,8 @@
Tests for asset-related API endpoints.
"""
from typing import Any
import mock
from django.test import TestCase
from django.urls import reverse
@@ -23,10 +25,10 @@ parametrize_version = parametrize(
class CRUDAssetEndpointsTest(TestCase, ParametrizedTestCase):
def setUp(self):
self.client = APIClient()
client_class = APIClient
client: APIClient
def get_assets(self, version):
def get_assets(self, version: str) -> Any:
asset_list_url = reverse(f'api:asset_list_{version}')
response = self.client.get(asset_list_url)
@@ -34,30 +36,35 @@ class CRUDAssetEndpointsTest(TestCase, ParametrizedTestCase):
return response.data
def create_asset(self, data, version):
def create_asset(self, data: dict[str, Any], version: str) -> Any:
asset_list_url = reverse(f'api:asset_list_{version}')
return self.client.post(
asset_list_url, data=get_request_data(data, version)
).data
def update_asset(self, asset_id, data, version):
def update_asset(
self,
asset_id: str,
data: dict[str, Any],
version: str,
) -> Any:
return self.client.put(
reverse(f'api:asset_detail_{version}', args=[asset_id]),
data=get_request_data(data, version),
).data
def get_asset(self, asset_id, version):
def get_asset(self, asset_id: str, version: str) -> Any:
url = reverse(f'api:asset_detail_{version}', args=[asset_id])
return self.client.get(url).data
def delete_asset(self, asset_id, version):
def delete_asset(self, asset_id: str, version: str) -> Any:
url = reverse(f'api:asset_detail_{version}', args=[asset_id])
return self.client.delete(url)
@parametrize_version
def test_get_assets_when_first_time_setup_should_initially_return_empty(
self, version
): # noqa: E501
self, version: str
) -> None: # noqa: E501
asset_list_url = reverse(f'api:asset_list_{version}')
response = self.client.get(asset_list_url)
assets = response.data
@@ -66,7 +73,7 @@ class CRUDAssetEndpointsTest(TestCase, ParametrizedTestCase):
self.assertEqual(len(assets), 0)
@parametrize_version
def test_create_asset_should_return_201(self, version):
def test_create_asset_should_return_201(self, version: str) -> None:
asset_list_url = reverse(f'api:asset_list_{version}')
response = self.client.post(
asset_list_url, data=get_request_data(ASSET_CREATION_DATA, version)
@@ -84,8 +91,8 @@ class CRUDAssetEndpointsTest(TestCase, ParametrizedTestCase):
@mock.patch('api.serializers.mixins.rename')
@mock.patch('api.serializers.mixins.validate_uri')
def test_create_video_asset_v2_with_non_zero_duration_should_fail(
self, mock_validate_uri, mock_rename
):
self, mock_validate_uri: Any, mock_rename: Any
) -> None:
"""Test that v2 rejects video assets with non-zero duration."""
mock_validate_uri.return_value = True
asset_list_url = reverse('api:asset_list_v2')
@@ -118,14 +125,16 @@ class CRUDAssetEndpointsTest(TestCase, ParametrizedTestCase):
self.assertEqual(mock_validate_uri.call_count, 1)
@parametrize_version
def test_get_assets_after_create_should_return_1_asset(self, version):
def test_get_assets_after_create_should_return_1_asset(
self, version: str
) -> None:
self.create_asset(ASSET_CREATION_DATA, version)
assets = self.get_assets(version)
self.assertEqual(len(assets), 1)
@parametrize_version
def test_get_asset_by_id_should_return_asset(self, version):
def test_get_asset_by_id_should_return_asset(self, version: str) -> None:
expected_asset = self.create_asset(ASSET_CREATION_DATA, version)
asset_id = expected_asset['asset_id']
actual_asset = self.get_asset(asset_id, version)
@@ -133,7 +142,9 @@ class CRUDAssetEndpointsTest(TestCase, ParametrizedTestCase):
self.assertEqual(expected_asset, actual_asset)
@parametrize_version
def test_update_asset_should_return_updated_asset(self, version):
def test_update_asset_should_return_updated_asset(
self, version: str
) -> None:
expected_asset = self.create_asset(ASSET_CREATION_DATA, version)
asset_id = expected_asset['asset_id']
@@ -155,7 +166,7 @@ class CRUDAssetEndpointsTest(TestCase, ParametrizedTestCase):
self.assertEqual(updated_asset['play_order'], data['play_order'])
@parametrize_version
def test_delete_asset_should_return_204(self, version):
def test_delete_asset_should_return_204(self, version: str) -> None:
asset = self.create_asset(ASSET_CREATION_DATA, version)
asset_id = asset['asset_id']

View File

@@ -3,6 +3,7 @@ Common test utilities and constants for the Anthias API tests.
"""
import json
from typing import Any
from django.urls import reverse
@@ -40,7 +41,10 @@ ASSET_UPDATE_DATA_V2 = {
}
def get_request_data(data, version):
def get_request_data(
data: dict[str, Any],
version: str,
) -> dict[str, Any]:
"""Helper function to format request data based on API version."""
if version in ['v1', 'v1_1']:
return {'model': json.dumps(data)}

View File

@@ -2,6 +2,7 @@
Tests for Info API endpoints (v1 and v2).
"""
from typing import Any
from unittest import mock
from django.test import TestCase
@@ -11,17 +12,23 @@ from rest_framework.test import APIClient
class InfoEndpointsTest(TestCase):
def setUp(self):
self.client = APIClient()
client_class = APIClient
client: APIClient
def setUp(self) -> None:
self.info_url_v1 = reverse('api:info_v1')
self.info_url_v2 = reverse('api:info_v2')
def _assert_mock_calls(self, mocks):
def _assert_mock_calls(self, mocks: list[Any]) -> None:
"""Assert that all mocks were called exactly once."""
for mock_obj in mocks:
self.assertEqual(mock_obj.call_count, 1)
def _assert_response_data(self, data, expected_data):
def _assert_response_data(
self,
data: Any,
expected_data: dict[str, Any],
) -> None:
"""Assert that the response data matches the expected data."""
for key, expected_value in expected_data.items():
self.assertEqual(data[key], expected_value)
@@ -32,8 +39,12 @@ class InfoEndpointsTest(TestCase):
@mock.patch('api.views.mixins.statvfs', mock.MagicMock())
@mock.patch('api.views.mixins.r.get', return_value='off')
def test_info_v1_endpoint(
self, redis_get_mock, size_mock, get_load_avg_mock, is_up_to_date_mock
):
self,
redis_get_mock: Any,
size_mock: Any,
get_load_avg_mock: Any,
is_up_to_date_mock: Any,
) -> None:
response = self.client.get(self.info_url_v1)
data = response.data
@@ -89,19 +100,19 @@ class InfoEndpointsTest(TestCase):
@mock.patch('api.views.v2.getenv', return_value='testuser')
def test_info_v2_endpoint(
self,
getenv_mock,
get_node_ip_mock,
mac_address_mock,
virtual_memory_mock,
get_uptime_mock,
parse_cpu_info_mock,
get_git_short_hash_mock,
get_git_branch_mock,
redis_get_mock,
size_mock,
get_load_avg_mock,
is_up_to_date_mock,
):
getenv_mock: Any,
get_node_ip_mock: Any,
mac_address_mock: Any,
virtual_memory_mock: Any,
get_uptime_mock: Any,
parse_cpu_info_mock: Any,
get_git_short_hash_mock: Any,
get_git_branch_mock: Any,
redis_get_mock: Any,
size_mock: Any,
get_load_avg_mock: Any,
is_up_to_date_mock: Any,
) -> None:
response = self.client.get(self.info_url_v2)
data = response.data

View File

@@ -4,6 +4,7 @@ Tests for V1 API endpoints.
import os
from pathlib import Path
from typing import Any
from unittest import mock
from django.conf import settings as django_settings
@@ -19,21 +20,21 @@ from settings import settings as anthias_settings
class V1EndpointsTest(TestCase, ParametrizedTestCase):
def setUp(self):
self.client = APIClient()
client_class = APIClient
client: APIClient
def tearDown(self):
def tearDown(self) -> None:
self.remove_all_asset_files()
def remove_all_asset_files(self):
def remove_all_asset_files(self) -> None:
asset_directory_path = Path(anthias_settings['assetdir'])
for file in asset_directory_path.iterdir():
file.unlink()
def get_asset_content_url(self, asset_id):
return reverse('api:asset_content_v1', args=[asset_id])
def get_asset_content_url(self, asset_id: str) -> str:
return str(reverse('api:asset_content_v1', args=[asset_id]))
def test_asset_content(self):
def test_asset_content(self) -> None:
asset = Asset.objects.create(**ASSET_CREATION_DATA)
asset_id = asset.asset_id
@@ -44,7 +45,7 @@ class V1EndpointsTest(TestCase, ParametrizedTestCase):
self.assertEqual(data['type'], 'url')
self.assertEqual(data['url'], 'https://anthias.screenly.io')
def test_file_asset(self):
def test_file_asset(self) -> None:
project_base_path = django_settings.BASE_DIR
image_path = os.path.join(
project_base_path,
@@ -63,7 +64,7 @@ class V1EndpointsTest(TestCase, ParametrizedTestCase):
self.assertTrue(os.path.exists(data['uri']))
self.assertEqual(data['ext'], '.png')
def test_playlist_order(self):
def test_playlist_order(self) -> None:
playlist_order_url = reverse('api:playlist_order_v1')
for asset_name in ['Asset #1', 'Asset #2', 'Asset #3']:
@@ -102,7 +103,9 @@ class V1EndpointsTest(TestCase, ParametrizedTestCase):
],
)
@mock.patch('api.views.v1.ZmqPublisher.send_to_viewer', return_value=None)
def test_assets_control(self, send_to_viewer_mock, command):
def test_assets_control(
self, send_to_viewer_mock: Any, command: str
) -> None:
assets_control_url = reverse('api:assets_control_v1', args=[command])
response = self.client.get(assets_control_url)
@@ -115,7 +118,7 @@ class V1EndpointsTest(TestCase, ParametrizedTestCase):
'api.views.mixins.reboot_anthias.apply_async',
side_effect=(lambda: None),
)
def test_reboot(self, reboot_anthias_mock):
def test_reboot(self, reboot_anthias_mock: Any) -> None:
reboot_url = reverse('api:reboot_v1')
response = self.client.post(reboot_url)
@@ -126,7 +129,7 @@ class V1EndpointsTest(TestCase, ParametrizedTestCase):
'api.views.mixins.shutdown_anthias.apply_async',
side_effect=(lambda: None),
)
def test_shutdown(self, shutdown_anthias_mock):
def test_shutdown(self, shutdown_anthias_mock: Any) -> None:
shutdown_url = reverse('api:shutdown_v1')
response = self.client.post(shutdown_url)
@@ -134,7 +137,7 @@ class V1EndpointsTest(TestCase, ParametrizedTestCase):
self.assertEqual(shutdown_anthias_mock.call_count, 1)
@mock.patch('api.views.v1.ZmqPublisher.send_to_viewer', return_value=None)
def test_viewer_current_asset(self, send_to_viewer_mock):
def test_viewer_current_asset(self, send_to_viewer_mock: Any) -> None:
asset = Asset.objects.create(
**{
**ASSET_CREATION_DATA,

View File

@@ -2,7 +2,7 @@
Tests for V2 API endpoints.
"""
import hashlib
from typing import Any
from unittest import mock
from unittest.mock import patch
@@ -11,14 +11,18 @@ from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from lib.auth import verify_password
class DeviceSettingsViewV2Test(TestCase):
def setUp(self):
self.client = APIClient()
client_class = APIClient
client: APIClient
def setUp(self) -> None:
self.device_settings_url = reverse('api:device_settings_v2')
@mock.patch('api.views.v2.settings')
def test_get_device_settings(self, settings_mock):
def test_get_device_settings(self, settings_mock: Any) -> None:
settings_mock.__getitem__.side_effect = lambda key: {
'player_name': 'Test Player',
'audio_output': 'hdmi',
@@ -57,7 +61,9 @@ class DeviceSettingsViewV2Test(TestCase):
self.assertEqual(response.data[key], expected_value)
@mock.patch('api.views.v2.settings')
def test_patch_device_settings_invalid_auth_backend(self, settings_mock):
def test_patch_device_settings_invalid_auth_backend(
self, settings_mock: Any
) -> None:
settings_mock.load = mock.MagicMock()
settings_mock.save = mock.MagicMock()
settings_mock.__getitem__.side_effect = lambda key: {
@@ -94,8 +100,8 @@ class DeviceSettingsViewV2Test(TestCase):
@mock.patch('api.views.v2.settings')
@mock.patch('api.views.v2.ZmqPublisher')
def test_patch_device_settings_success(
self, publisher_mock, settings_mock
):
self, publisher_mock: Any, settings_mock: Any
) -> None:
settings_mock.load = mock.MagicMock()
settings_mock.save = mock.MagicMock()
settings_mock.__getitem__.side_effect = lambda key: {
@@ -141,7 +147,9 @@ class DeviceSettingsViewV2Test(TestCase):
publisher_instance.send_to_viewer.assert_called_once_with('reload')
@mock.patch('api.views.v2.settings')
def test_patch_device_settings_validation_error(self, settings_mock):
def test_patch_device_settings_validation_error(
self, settings_mock: Any
) -> None:
data = {
'default_duration': 'not_an_integer',
'show_splash': 'not_a_boolean',
@@ -160,7 +168,9 @@ class DeviceSettingsViewV2Test(TestCase):
@mock.patch('api.views.v2.settings')
@mock.patch('api.views.v2.ZmqPublisher')
def test_enable_basic_auth(self, publisher_mock, settings_mock):
def test_enable_basic_auth(
self, publisher_mock: Any, settings_mock: Any
) -> None:
settings_mock.load = mock.MagicMock()
settings_mock.save = mock.MagicMock()
settings_mock.__getitem__.side_effect = lambda key: {
@@ -204,10 +214,6 @@ class DeviceSettingsViewV2Test(TestCase):
'password_2': 'testpass',
}
expected_hashed_password = hashlib.sha256(
'testpass'.encode('utf-8')
).hexdigest()
response = self.client.patch(
self.device_settings_url, data=data, format='json'
)
@@ -221,15 +227,29 @@ class DeviceSettingsViewV2Test(TestCase):
settings_mock.save.assert_called_once()
settings_mock.__setitem__.assert_any_call('auth_backend', 'auth_basic')
settings_mock.__setitem__.assert_any_call('user', 'testuser')
settings_mock.__setitem__.assert_any_call(
'password', expected_hashed_password
)
# PBKDF2 uses a random salt, so we can't compare the stored hash
# against a fixed expected value. Pin the algorithm via the prefix
# AND verify round-trip via verify_password — the prefix check
# guards against a regression to a weaker hasher even if
# verify_password() were broken.
password_calls = [
call
for call in settings_mock.__setitem__.call_args_list
if call.args[0] == 'password'
]
self.assertEqual(len(password_calls), 1)
stored_hash = password_calls[0].args[1]
self.assertTrue(stored_hash.startswith('pbkdf2_sha256$'))
self.assertTrue(verify_password('testpass', stored_hash))
publisher_instance.send_to_viewer.assert_called_once_with('reload')
@mock.patch('api.views.v2.settings')
@mock.patch('api.views.v2.ZmqPublisher')
def test_disable_basic_auth(self, publisher_mock, settings_mock):
def test_disable_basic_auth(
self, publisher_mock: Any, settings_mock: Any
) -> None:
settings_mock.load = mock.MagicMock()
settings_mock.save = mock.MagicMock()
settings_mock.__getitem__.side_effect = lambda key: {
@@ -287,11 +307,11 @@ class DeviceSettingsViewV2Test(TestCase):
@mock.patch('api.views.v2.remove_default_assets')
def test_patch_device_settings_default_assets(
self,
remove_default_assets_mock,
add_default_assets_mock,
publisher_mock,
settings_mock,
):
remove_default_assets_mock: Any,
add_default_assets_mock: Any,
publisher_mock: Any,
settings_mock: Any,
) -> None:
settings_mock.load = mock.MagicMock()
settings_mock.save = mock.MagicMock()
settings_mock.__getitem__.side_effect = lambda key: {
@@ -379,15 +399,17 @@ class DeviceSettingsViewV2Test(TestCase):
class TestIntegrationsViewV2(TestCase):
def setUp(self):
self.client = APIClient()
client_class = APIClient
client: APIClient
def setUp(self) -> None:
self.integrations_url = reverse('api:integrations_v2')
@patch('api.views.v2.is_balena_app')
@patch('api.views.v2.getenv')
def test_integrations_balena_environment(
self, mock_getenv, mock_is_balena
):
self, mock_getenv: Any, mock_is_balena: Any
) -> None:
# Mock Balena environment
mock_is_balena.side_effect = lambda: True
mock_getenv.side_effect = lambda x: {
@@ -415,7 +437,9 @@ class TestIntegrationsViewV2(TestCase):
)
@patch('api.views.v2.is_balena_app')
def test_integrations_non_balena_environment(self, mock_is_balena):
def test_integrations_non_balena_environment(
self, mock_is_balena: Any
) -> None:
# Mock non-Balena environment
mock_is_balena.side_effect = lambda: False

View File

@@ -1,4 +1,4 @@
from django.urls import path
from django.urls import URLPattern, URLResolver, path
from api.views.v1 import (
AssetContentViewV1,
@@ -16,7 +16,7 @@ from api.views.v1 import (
)
def get_url_patterns():
def get_url_patterns() -> list[URLPattern | URLResolver]:
return [
path('v1/assets', AssetListViewV1.as_view(), name='asset_list_v1'),
path(

View File

@@ -1,9 +1,9 @@
from django.urls import path
from django.urls import URLPattern, URLResolver, path
from api.views.v1_1 import AssetListViewV1_1, AssetViewV1_1
def get_url_patterns():
def get_url_patterns() -> list[URLPattern | URLResolver]:
return [
path(
'v1.1/assets', AssetListViewV1_1.as_view(), name='asset_list_v1_1'

View File

@@ -1,9 +1,9 @@
from django.urls import path
from django.urls import URLPattern, URLResolver, path
from api.views.v1_2 import AssetListViewV1_2, AssetViewV1_2
def get_url_patterns():
def get_url_patterns() -> list[URLPattern | URLResolver]:
return [
path(
'v1.2/assets', AssetListViewV1_2.as_view(), name='asset_list_v1_2'

View File

@@ -1,4 +1,4 @@
from django.urls import path
from django.urls import URLPattern, URLResolver, path
from api.views.v2 import (
AssetContentViewV2,
@@ -17,7 +17,7 @@ from api.views.v2 import (
)
def get_url_patterns():
def get_url_patterns() -> list[URLPattern | URLResolver]:
return [
path('v2/assets', AssetListViewV2.as_view(), name='asset_list_v2'),
path(

View File

@@ -1,12 +1,18 @@
import logging
import tarfile
import uuid
from base64 import b64encode
from inspect import cleandoc
from mimetypes import guess_extension, guess_type
from os import path, remove, statvfs
from typing import Any
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from hurry.filesize import size
from rest_framework import status
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -25,17 +31,19 @@ from lib.github import is_up_to_date
from lib.utils import connect_to_redis
from settings import ZmqPublisher, settings
logger = logging.getLogger(__name__)
r = connect_to_redis()
class DeleteAssetViewMixin:
@extend_schema(summary='Delete asset')
@authorized
def delete(self, request, asset_id):
def delete(self, request: Request, asset_id: str) -> Response:
asset = Asset.objects.get(asset_id=asset_id)
try:
if asset.uri.startswith(settings['assetdir']):
if asset.uri and asset.uri.startswith(settings['assetdir']):
remove(asset.uri)
except OSError:
pass
@@ -66,7 +74,7 @@ class BackupViewMixin(APIView):
},
)
@authorized
def post(self, request):
def post(self, request: Request) -> Response:
filename = backup_helper.create_backup(name=settings['player_name'])
return Response(filename, status=status.HTTP_201_CREATED)
@@ -94,24 +102,53 @@ class RecoverViewMixin(APIView):
},
)
@authorized
def post(self, request):
def post(self, request: Request) -> Response:
publisher = ZmqPublisher.get_instance()
file_upload = request.data.get('backup_upload')
if file_upload is None:
raise ValidationError(
{'backup_upload': 'No backup file uploaded.'}
)
filename = file_upload.name
if guess_type(filename)[0] != 'application/x-tar':
raise Exception('Incorrect file extension.')
raise ValidationError(
{'backup_upload': 'Incorrect file extension.'}
)
# Don't trust the client-supplied filename — generate a
# server-side name to avoid path traversal via crafted names
# (e.g. '../etc/passwd', absolute paths).
location = path.join('static', f'{uuid.uuid4().hex}.tar.gz')
try:
publisher.send_to_viewer('stop')
location = path.join('static', filename)
with open(location, 'wb') as f:
f.write(file_upload.read())
backup_helper.recover(location)
try:
backup_helper.recover(location)
except (
backup_helper.BackupRecoverError,
tarfile.TarError,
):
logger.exception('Backup recovery failed')
raise ValidationError(
{'backup_upload': 'Invalid backup archive.'}
)
return Response('Recovery successful.')
finally:
# recover() removes `location` on success; clean up here for
# every failure path so partial uploads / rejected archives
# don't accumulate under static/.
if path.isfile(location):
try:
remove(location)
except OSError:
logger.exception(
'Failed to remove leftover backup upload at %s',
location,
)
publisher.send_to_viewer('play')
@@ -120,7 +157,7 @@ class RebootViewMixin(APIView):
@extend_schema(summary='Reboot system')
@authorized
def post(self, request):
def post(self, request: Request) -> Response:
reboot_anthias.apply_async()
return Response(status=status.HTTP_200_OK)
@@ -130,7 +167,7 @@ class ShutdownViewMixin(APIView):
@extend_schema(summary='Shut down system')
@authorized
def post(self, request):
def post(self, request: Request) -> Response:
shutdown_anthias.apply_async()
return Response(status=status.HTTP_200_OK)
@@ -157,16 +194,17 @@ class FileAssetViewMixin(APIView):
},
)
@authorized
def post(self, request):
def post(self, request: Request) -> Response:
file_upload = request.data.get('file_upload')
if file_upload is None:
raise ValidationError({'file_upload': 'No file uploaded.'})
filename = file_upload.name
file_type = guess_type(filename)[0]
if not file_type:
raise Exception('Invalid file type.')
if file_type.split('/')[0] not in ['image', 'video']:
raise Exception('Invalid file type.')
if not file_type or file_type.split('/')[0] not in ['image', 'video']:
raise ValidationError(
{'file_upload': 'Invalid file type. Expected image or video.'}
)
file_path = (
path.join(
@@ -213,11 +251,19 @@ class AssetContentViewMixin(APIView):
},
)
@authorized
def get(self, request, asset_id, format=None):
def get(
self,
request: Request,
asset_id: str,
format: str | None = None,
) -> Response:
asset = Asset.objects.get(asset_id=asset_id)
if asset.uri is None:
raise NotFound('Asset has no content URI.')
result: dict[str, Any]
if path.isfile(asset.uri):
filename = asset.name
filename = asset.name or ''
with open(asset.uri, 'rb') as f:
content = f.read()
@@ -245,7 +291,7 @@ class PlaylistOrderViewMixin(APIView):
responses={204: None},
)
@authorized
def post(self, request):
def post(self, request: Request) -> Response:
asset_ids = request.data.get('ids', '').split(',')
save_active_assets_ordering(asset_ids)
@@ -277,7 +323,7 @@ class AssetsControlViewMixin(APIView):
],
)
@authorized
def get(self, request, command):
def get(self, request: Request, command: str) -> Response:
publisher = ZmqPublisher.get_instance()
publisher.send_to_viewer(command)
return Response('Asset switched')
@@ -307,7 +353,7 @@ class InfoViewMixin(APIView):
},
)
@authorized
def get(self, request):
def get(self, request: Request) -> Response:
viewlog = 'Not yet implemented'
# Calculate disk space

View File

@@ -5,6 +5,7 @@ from drf_spectacular.utils import (
inline_serializer,
)
from rest_framework import serializers, status
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -78,7 +79,12 @@ class AssetViewV1(APIView, DeleteAssetViewMixin):
@extend_schema(summary='Get asset')
@authorized
def get(self, request, asset_id, format=None):
def get(
self,
request: Request,
asset_id: str,
format: str | None = None,
) -> Response:
asset = Asset.objects.get(asset_id=asset_id)
return Response(AssetSerializer(asset).data)
@@ -88,7 +94,12 @@ class AssetViewV1(APIView, DeleteAssetViewMixin):
responses={201: AssetSerializer},
)
@authorized
def put(self, request, asset_id, format=None):
def put(
self,
request: Request,
asset_id: str,
format: str | None = None,
) -> Response:
asset = Asset.objects.get(asset_id=asset_id)
data = parse_request(request)
@@ -116,7 +127,7 @@ class AssetListViewV1(APIView):
summary='List assets', responses={200: AssetSerializer(many=True)}
)
@authorized
def get(self, request, format=None):
def get(self, request: Request, format: str | None = None) -> Response:
queryset = Asset.objects.all()
serializer = AssetSerializer(queryset, many=True)
return Response(serializer.data)
@@ -127,7 +138,7 @@ class AssetListViewV1(APIView):
responses={201: AssetSerializer},
)
@authorized
def post(self, request, format=None):
def post(self, request: Request, format: str | None = None) -> Response:
data = parse_request(request)
try:
@@ -183,7 +194,7 @@ class ViewerCurrentAssetViewV1(APIView):
responses={200: AssetSerializer},
)
@authorized
def get(self, request):
def get(self, request: Request) -> Response:
collector = ZmqCollector.get_instance()
publisher = ZmqPublisher.get_instance()

View File

@@ -1,5 +1,6 @@
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -20,7 +21,7 @@ class AssetListViewV1_1(APIView):
summary='List assets', responses={200: AssetSerializer(many=True)}
)
@authorized
def get(self, request):
def get(self, request: Request) -> Response:
queryset = Asset.objects.all()
serializer = AssetSerializer(queryset, many=True)
return Response(serializer.data)
@@ -31,7 +32,7 @@ class AssetListViewV1_1(APIView):
responses={201: AssetSerializer},
)
@authorized
def post(self, request):
def post(self, request: Request) -> Response:
data = parse_request(request)
try:
@@ -56,7 +57,7 @@ class AssetViewV1_1(APIView, DeleteAssetViewMixin):
},
)
@authorized
def get(self, request, asset_id):
def get(self, request: Request, asset_id: str) -> Response:
asset = Asset.objects.get(asset_id=asset_id)
return Response(AssetSerializer(asset).data)
@@ -66,7 +67,7 @@ class AssetViewV1_1(APIView, DeleteAssetViewMixin):
responses={200: AssetSerializer},
)
@authorized
def put(self, request, asset_id):
def put(self, request: Request, asset_id: str) -> Response:
asset = Asset.objects.get(asset_id=asset_id)
data = parse_request(request)

View File

@@ -1,5 +1,6 @@
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -25,7 +26,7 @@ class AssetListViewV1_2(APIView):
summary='List assets', responses={200: AssetSerializer(many=True)}
)
@authorized
def get(self, request):
def get(self, request: Request) -> Response:
queryset = Asset.objects.all()
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
@@ -36,7 +37,7 @@ class AssetListViewV1_2(APIView):
responses={201: AssetSerializer},
)
@authorized
def post(self, request):
def post(self, request: Request) -> Response:
try:
serializer = CreateAssetSerializerV1_2(
data=request.data, unique_name=True
@@ -67,12 +68,17 @@ class AssetViewV1_2(APIView, DeleteAssetViewMixin):
@extend_schema(summary='Get asset')
@authorized
def get(self, request, asset_id):
def get(self, request: Request, asset_id: str) -> Response:
asset = Asset.objects.get(asset_id=asset_id)
serializer = self.serializer_class(asset)
return Response(serializer.data)
def update(self, request, asset_id, partial=False):
def update(
self,
request: Request,
asset_id: str,
partial: bool = False,
) -> Response:
asset = Asset.objects.get(asset_id=asset_id)
serializer = UpdateAssetSerializer(
asset, data=request.data, partial=partial
@@ -108,7 +114,7 @@ class AssetViewV1_2(APIView, DeleteAssetViewMixin):
responses={200: AssetSerializer},
)
@authorized
def patch(self, request, asset_id):
def patch(self, request: Request, asset_id: str) -> Response:
return self.update(request, asset_id, partial=True)
@extend_schema(
@@ -117,5 +123,5 @@ class AssetViewV1_2(APIView, DeleteAssetViewMixin):
responses={200: AssetSerializer},
)
@authorized
def put(self, request, asset_id):
def put(self, request: Request, asset_id: str) -> Response:
return self.update(request, asset_id, partial=False)

View File

@@ -1,14 +1,15 @@
import hashlib
import ipaddress
import logging
from datetime import timedelta
from os import getenv, statvfs
from platform import machine
from typing import Any
import psutil
from drf_spectacular.utils import extend_schema
from hurry.filesize import size
from rest_framework import status
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -19,6 +20,7 @@ from api.helpers import (
get_active_asset_ids,
save_active_assets_ordering,
)
from lib.auth import hash_password
from api.serializers.v2 import (
AssetSerializerV2,
CreateAssetSerializerV2,
@@ -60,7 +62,7 @@ class AssetListViewV2(APIView):
summary='List assets', responses={200: AssetSerializerV2(many=True)}
)
@authorized
def get(self, request):
def get(self, request: Request) -> Response:
queryset = Asset.objects.all()
serializer = AssetSerializerV2(queryset, many=True)
return Response(serializer.data)
@@ -71,7 +73,7 @@ class AssetListViewV2(APIView):
responses={201: AssetSerializerV2},
)
@authorized
def post(self, request):
def post(self, request: Request) -> Response:
try:
serializer = CreateAssetSerializerV2(
data=request.data, unique_name=True
@@ -103,12 +105,17 @@ class AssetViewV2(APIView, DeleteAssetViewMixin):
@extend_schema(summary='Get asset')
@authorized
def get(self, request, asset_id):
def get(self, request: Request, asset_id: str) -> Response:
asset = Asset.objects.get(asset_id=asset_id)
serializer = self.serializer_class(asset)
return Response(serializer.data)
def update(self, request, asset_id, partial=False):
def update(
self,
request: Request,
asset_id: str,
partial: bool = False,
) -> Response:
asset = Asset.objects.get(asset_id=asset_id)
serializer = UpdateAssetSerializerV2(
asset, data=request.data, partial=partial
@@ -144,7 +151,7 @@ class AssetViewV2(APIView, DeleteAssetViewMixin):
responses={200: AssetSerializerV2},
)
@authorized
def patch(self, request, asset_id):
def patch(self, request: Request, asset_id: str) -> Response:
return self.update(request, asset_id, partial=True)
@extend_schema(
@@ -153,7 +160,7 @@ class AssetViewV2(APIView, DeleteAssetViewMixin):
responses={200: AssetSerializerV2},
)
@authorized
def put(self, request, asset_id):
def put(self, request: Request, asset_id: str) -> Response:
return self.update(request, asset_id, partial=False)
@@ -195,7 +202,7 @@ class DeviceSettingsViewV2(APIView):
responses={200: DeviceSettingsSerializerV2},
)
@authorized
def get(self, request):
def get(self, request: Request) -> Response:
try:
# Force reload of settings
settings.load()
@@ -226,7 +233,12 @@ class DeviceSettingsViewV2(APIView):
}
)
def update_auth_settings(self, data, auth_backend, current_pass_correct):
def update_auth_settings(
self,
data: dict[str, Any],
auth_backend: str,
current_pass_correct: bool | None,
) -> None:
if auth_backend == '':
return
@@ -234,10 +246,8 @@ class DeviceSettingsViewV2(APIView):
return
new_user = data.get('username', '')
new_pass = data.get('password', '').encode('utf-8')
new_pass2 = data.get('password_2', '').encode('utf-8')
new_pass = hashlib.sha256(new_pass).hexdigest() if new_pass else None
new_pass2 = hashlib.sha256(new_pass2).hexdigest() if new_pass else None
new_pass = data.get('password', '')
new_pass2 = data.get('password_2', '')
if settings['password']:
if new_user != settings['user']:
@@ -261,7 +271,7 @@ class DeviceSettingsViewV2(APIView):
if new_pass2 != new_pass:
raise ValueError('New passwords do not match!')
settings['password'] = new_pass
settings['password'] = hash_password(new_pass)
else:
if new_user:
@@ -270,7 +280,7 @@ class DeviceSettingsViewV2(APIView):
if not new_pass:
raise ValueError('Must provide password')
settings['user'] = new_user
settings['password'] = new_pass
settings['password'] = hash_password(new_pass)
else:
raise ValueError('Must provide username')
@@ -289,7 +299,7 @@ class DeviceSettingsViewV2(APIView):
},
)
@authorized
def patch(self, request):
def patch(self, request: Request) -> Response:
try:
serializer = UpdateDeviceSettingsSerializerV2(data=request.data)
if not serializer.is_valid():
@@ -310,7 +320,9 @@ class DeviceSettingsViewV2(APIView):
'Must supply current password to change '
'authentication method'
)
if not settings.auth.check_password(current_password):
if settings.auth is None or not settings.auth.check_password(
current_password
):
raise ValueError('Incorrect current password.')
prev_auth_backend = settings['auth_backend']
@@ -368,7 +380,7 @@ class DeviceSettingsViewV2(APIView):
class InfoViewV2(InfoViewMixin):
def get_anthias_version(self):
def get_anthias_version(self) -> str:
git_branch = diagnostics.get_git_branch()
git_short_hash = diagnostics.get_git_short_hash()
@@ -377,7 +389,7 @@ class InfoViewV2(InfoViewMixin):
git_short_hash,
)
def get_device_model(self):
def get_device_model(self) -> str | int | None:
device_model = device_helper.parse_cpu_info().get('model')
if device_model is None and machine() == 'x86_64':
@@ -385,14 +397,14 @@ class InfoViewV2(InfoViewMixin):
return device_model
def get_uptime(self):
def get_uptime(self) -> dict[str, int | float]:
system_uptime = timedelta(seconds=diagnostics.get_uptime())
return {
'days': system_uptime.days,
'hours': round(system_uptime.seconds / 3600, 2),
}
def get_memory(self):
def get_memory(self) -> dict[str, int]:
virtual_memory = psutil.virtual_memory()
return {
'total': virtual_memory.total >> 20,
@@ -403,7 +415,7 @@ class InfoViewV2(InfoViewMixin):
'available': virtual_memory.available >> 20,
}
def get_ip_addresses(self):
def get_ip_addresses(self) -> list[str]:
ip_addresses = []
node_ip = get_node_ip()
@@ -462,7 +474,7 @@ class InfoViewV2(InfoViewMixin):
},
)
@authorized
def get(self, request):
def get(self, request: Request) -> Response:
viewlog = 'Not yet implemented'
# Calculate disk space
@@ -496,8 +508,8 @@ class IntegrationsViewV2(APIView):
responses={200: IntegrationsSerializerV2},
)
@authorized
def get(self, request):
data = {
def get(self, request: Request) -> Response:
data: dict[str, Any] = {
'is_balena': is_balena_app(),
}

View File

@@ -1,13 +1,10 @@
from __future__ import print_function, unicode_literals
import time
from builtins import range
import sh
# wait for default route
def is_routing_up():
def is_routing_up() -> bool:
try:
sh.grep('default', _in=sh.route())
return True

View File

@@ -1,5 +1,7 @@
import logging
from datetime import timedelta
from os import getenv, path
from typing import Any
import django
import sh
@@ -23,7 +25,7 @@ except Exception:
__author__ = 'Screenly, Inc'
__copyright__ = 'Copyright 2012-2024, Screenly, Inc'
__copyright__ = 'Copyright 2012-2026, Screenly, Inc'
__license__ = 'Dual License: GPLv2 and Commercial License'
@@ -43,7 +45,7 @@ celery = Celery(
@celery.on_after_configure.connect
def setup_periodic_tasks(sender, **kwargs):
def setup_periodic_tasks(sender: Any, **kwargs: Any) -> None:
# Calls cleanup() every hour.
sender.add_periodic_task(3600, cleanup.s(), name='cleanup')
sender.add_periodic_task(
@@ -52,15 +54,22 @@ def setup_periodic_tasks(sender, **kwargs):
@celery.task(time_limit=30)
def get_display_power():
def get_display_power() -> None:
r.set('display_power', diagnostics.get_display_power())
r.expire('display_power', 3600)
@celery.task
def cleanup():
def cleanup() -> None:
# Without HOME, `path.join(..., 'anthias_assets')` would be a
# relative path and `find -delete` could chew through whatever
# directory celery happens to be running in. Bail out instead.
home = getenv('HOME')
if not home:
logging.error('cleanup() skipped: HOME is not set')
return
sh.find(
path.join(getenv('HOME'), 'anthias_assets'),
path.join(home, 'anthias_assets'),
'-name',
'*.tmp',
'-delete',
@@ -68,7 +77,7 @@ def cleanup():
@celery.task
def reboot_anthias():
def reboot_anthias() -> None:
"""
Background task to reboot Anthias
"""
@@ -84,7 +93,7 @@ def reboot_anthias():
@celery.task
def shutdown_anthias():
def shutdown_anthias() -> None:
"""
Background task to shutdown Anthias
"""

View File

@@ -1,8 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
__author__ = 'Nash Kaminski'
__license__ = 'Dual License: GPLv2 and Commercial License'
@@ -11,6 +9,7 @@ import json
import logging
import os
import subprocess
from typing import Any, Callable
import netifaces
import redis
@@ -24,9 +23,11 @@ from tenacity import (
wait_fixed,
)
REDIS_ARGS = dict(host='127.0.0.1', port=6379, db=0)
REDIS_HOST = '127.0.0.1'
REDIS_PORT = 6379
REDIS_DB = 0
# Name of redis channel to listen to
CHANNEL_NAME = b'hostcmd'
CHANNEL_NAME = 'hostcmd'
SUPPORTED_INTERFACES = (
'wlan',
'eth',
@@ -42,7 +43,7 @@ SUPPORTED_INTERFACES = (
INTERNET_PROBE_URL = 'https://1.1.1.1' # NOSONAR
def get_ip_addresses():
def get_ip_addresses() -> list[str]:
return [
ip['addr']
for interface in netifaces.interfaces()
@@ -55,8 +56,10 @@ def get_ip_addresses():
]
def set_ip_addresses():
rdb = redis.Redis(**REDIS_ARGS)
def set_ip_addresses() -> None:
rdb = redis.Redis(
host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, decode_responses=True
)
rdb.set('ip_addresses_ready', 'false')
@@ -83,15 +86,15 @@ def set_ip_addresses():
rdb.set('ip_addresses', json.dumps(ip_addresses))
# Explicit command whitelist for security reasons, keys as bytes objects
CMD_TO_ARGV = {
b'reboot': ['/usr/bin/sudo', '-n', '/usr/bin/systemctl', 'reboot'],
b'shutdown': ['/usr/bin/sudo', '-n', '/usr/bin/systemctl', 'poweroff'],
b'set_ip_addresses': set_ip_addresses,
# Explicit command whitelist for security reasons.
CMD_TO_ARGV: dict[str, list[str] | Callable[[], None]] = {
'reboot': ['/usr/bin/sudo', '-n', '/usr/bin/systemctl', 'reboot'],
'shutdown': ['/usr/bin/sudo', '-n', '/usr/bin/systemctl', 'poweroff'],
'set_ip_addresses': set_ip_addresses,
}
def execute_host_command(cmd_name):
def execute_host_command(cmd_name: str) -> None:
cmd = CMD_TO_ARGV.get(cmd_name, None)
if cmd is None:
logging.warning(
@@ -102,8 +105,10 @@ def execute_host_command(cmd_name):
'Would have executed %s but not doing so as TESTING is defined',
cmd,
)
elif cmd_name in [b'reboot', b'shutdown']:
elif cmd_name in ['reboot', 'shutdown']:
logging.info('Executing host command %s', cmd_name)
if not isinstance(cmd, list):
raise TypeError(f'Expected list for {cmd_name}, got {type(cmd)}')
phandle = subprocess.run(cmd)
logging.info(
'Host command %s (%s) returned %s',
@@ -113,32 +118,41 @@ def execute_host_command(cmd_name):
)
else:
logging.info('Calling function %s', cmd)
if not callable(cmd):
raise TypeError(
f'Expected callable for {cmd_name}, got {type(cmd)}'
)
cmd()
def process_message(message):
def process_message(message: dict[str, Any]) -> None:
if (
message.get('type', '') == 'message'
and message.get('channel', b'') == CHANNEL_NAME
and message.get('channel', '') == CHANNEL_NAME
):
execute_host_command(message.get('data', b''))
execute_host_command(message.get('data', ''))
else:
logging.info('Received unsolicited message: %s', message)
def subscriber_loop():
def subscriber_loop() -> None:
# On first boot the redis container may not yet accept connections;
# retry quietly instead of crashing the unit on every attempt.
logging.info('Connecting to redis...')
for attempt in Retrying(
retry=retry_if_exception_type(redis.exceptions.ConnectionError),
retry=retry_if_exception_type(redis.ConnectionError),
wait=wait_fixed(5),
stop=stop_after_attempt(60),
before_sleep=before_sleep_log(logging.getLogger(), logging.WARNING),
reraise=True,
):
with attempt:
rdb = redis.Redis(**REDIS_ARGS)
rdb = redis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
db=REDIS_DB,
decode_responses=True,
)
pubsub = rdb.pubsub(ignore_subscribe_messages=True)
pubsub.subscribe(CHANNEL_NAME)
rdb.set('host_agent_ready', 'true')

View File

@@ -1,37 +1,72 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import hashlib
import binascii
import os.path
import re
from abc import ABCMeta, abstractmethod
from base64 import b64decode
from builtins import object, str
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, ParamSpec, TypeVar
from future.utils import with_metaclass
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponse
P = ParamSpec('P')
R = TypeVar('R')
LINUX_USER = os.getenv('USER', 'pi')
# Legacy hashes are bare 64-char hex SHA256 digests (no algorithm prefix).
# Django's make_password() output is always prefixed (e.g. "pbkdf2_sha256$...")
# so the two formats are unambiguously distinguishable.
_LEGACY_SHA256_HEX = re.compile(r'^[0-9a-f]{64}$')
def _is_legacy_sha256(stored: str) -> bool:
return bool(_LEGACY_SHA256_HEX.match(stored))
def hash_password(password: str) -> str:
"""Hash a password using Django's default (PBKDF2-SHA256)."""
from django.contrib.auth.hashers import make_password
return str(make_password(password))
def verify_password(password: str, stored: str) -> bool:
"""Verify a password against a Django-format stored hash."""
if not stored:
return False
from django.contrib.auth.hashers import check_password
return bool(check_password(password, stored))
class Auth(metaclass=ABCMeta):
display_name: str = ''
name: str = ''
config: dict[str, Any] = {}
class Auth(with_metaclass(ABCMeta, object)):
@abstractmethod
def authenticate(self):
def authenticate(self) -> 'HttpResponse | None':
"""
Let the user authenticate himself.
:return: a Response which initiates authentication.
"""
pass
def is_authenticated(self, request):
def is_authenticated(self, request: 'HttpRequest') -> bool:
"""
See if the user is authenticated for the request.
:return: bool
"""
pass
return False
def authenticate_if_needed(self, request):
def authenticate_if_needed(
self,
request: 'HttpRequest',
) -> 'HttpResponse | None':
"""
If the user performing the request is not authenticated, initiate
authentication.
@@ -48,8 +83,13 @@ class Auth(with_metaclass(ABCMeta, object)):
return HttpResponse(
'Authorization backend is unavailable: ' + str(e), status=503
)
return None
def update_settings(self, request, current_pass_correct):
def update_settings(
self,
request: 'HttpRequest',
current_pass_correct: bool | None,
) -> None:
"""
Submit updated values from Settings page.
:param current_pass_correct: the value of "Current Password" field
@@ -60,63 +100,62 @@ class Auth(with_metaclass(ABCMeta, object)):
pass
@property
def template(self):
def template(self) -> tuple[str, dict[str, Any]] | None:
"""
Get HTML template and its context object to be displayed in
the vettings page.
:return: (template, context)
"""
pass
return None
def check_password(self, password):
def check_password(self, password: str) -> bool:
"""
Checks if password correct.
:param password: str
:return: bool
"""
pass
return False
class NoAuth(Auth):
display_name = 'Disabled'
name = ''
config = {}
config: dict[str, Any] = {}
def is_authenticated(self, request):
def is_authenticated(self, request: 'HttpRequest') -> bool:
return True
def authenticate(self):
def authenticate(self) -> None:
pass
def check_password(self, password):
def check_password(self, password: str) -> bool:
return True
class BasicAuth(Auth):
display_name = 'Basic'
name = 'auth_basic'
config = {'auth_basic': {'user': '', 'password': ''}}
config: dict[str, Any] = {'auth_basic': {'user': '', 'password': ''}}
def __init__(self, settings):
def __init__(self, settings: Any) -> None:
self.settings = settings
def _check(self, username, password):
def _check(self, username: str, password: str) -> bool:
"""
Check username/password combo against database.
:param username: str
:param password: str
:return: True if the check passes.
"""
return self.settings['user'] == username and self.check_password(
password
return bool(
self.settings['user'] == username and self.check_password(password)
)
def check_password(self, password):
hashed_password = hashlib.sha256(password.encode('utf-8')).hexdigest()
return self.settings['password'] == hashed_password
def check_password(self, password: str) -> bool:
return verify_password(password, self.settings['password'])
def is_authenticated(self, request):
def is_authenticated(self, request: 'HttpRequest') -> bool:
# First check Authorization header for API requests
authorization = request.headers.get('Authorization')
if authorization:
@@ -125,37 +164,45 @@ class BasicAuth(Auth):
auth_type = content[0]
auth_data = content[1]
if auth_type == 'Basic':
auth_data = b64decode(auth_data).decode('utf-8')
auth_data = auth_data.split(':')
if len(auth_data) == 2:
username = auth_data[0]
password = auth_data[1]
try:
decoded = b64decode(auth_data).decode('utf-8')
except (binascii.Error, UnicodeDecodeError, ValueError):
# Malformed Authorization header — treat as
# unauthenticated rather than letting the decode
# error bubble up and degrade availability.
return False
# RFC 7617 allows ':' in the password portion; split
# only on the first ':' so passwords with colons work.
username, sep, password = decoded.partition(':')
if sep:
return self._check(username, password)
# Then check session for form-based login
username = request.session.get('auth_username')
password = request.session.get('auth_password')
if username and password:
return self._check(username, password)
session_username = request.session.get('auth_username')
session_password = request.session.get('auth_password')
if session_username and session_password:
return self._check(session_username, session_password)
return False
@property
def template(self):
def template(self) -> tuple[str, dict[str, Any]]:
return 'auth_basic.html', {'user': self.settings['user']}
def authenticate(self):
def authenticate(self) -> 'HttpResponse':
from django.shortcuts import redirect
from django.urls import reverse
return redirect(reverse('anthias_app:login'))
def update_settings(self, request, current_pass_correct):
def update_settings(
self,
request: 'HttpRequest',
current_pass_correct: bool | None,
) -> None:
new_user = request.POST.get('user', '')
new_pass = request.POST.get('password', '').encode('utf-8')
new_pass2 = request.POST.get('password2', '').encode('utf-8')
new_pass = hashlib.sha256(new_pass).hexdigest() if new_pass else None
new_pass2 = hashlib.sha256(new_pass2).hexdigest() if new_pass else None
new_pass = request.POST.get('password', '')
new_pass2 = request.POST.get('password2', '')
# Handle auth components
if self.settings['password']: # if password currently set,
if new_user != self.settings['user']: # trying to change user
@@ -181,7 +228,7 @@ class BasicAuth(Auth):
if new_pass2 != new_pass: # changing password
raise ValueError('New passwords do not match!')
self.settings['password'] = new_pass
self.settings['password'] = hash_password(new_pass)
else: # no current password
if new_user: # setting username and password
@@ -190,19 +237,28 @@ class BasicAuth(Auth):
if not new_pass:
raise ValueError('Must provide password')
self.settings['user'] = new_user
self.settings['password'] = new_pass
self.settings['password'] = hash_password(new_pass)
else:
raise ValueError('Must provide username')
def authorized(orig):
def authorized(
orig: Callable[P, R],
) -> 'Callable[P, R | HttpResponse]':
# Note on the return type: when `R` is DRF's `Response` (which is itself
# an `HttpResponse` subclass), mypy collapses `Response | HttpResponse`
# to just `HttpResponse`, losing the `Response`-specific attributes
# from the static type. This mirrors Django's own `@login_required`
# decorator and is intentional — at runtime the wrapped view still
# returns its concrete type. Callers that need the narrower type
# should cast at the call site.
from django.http import HttpRequest
from rest_framework.request import Request
from settings import settings
@wraps(orig)
def decorated(*args, **kwargs):
def decorated(*args: P.args, **kwargs: P.kwargs) -> 'R | HttpResponse':
if not settings.auth:
return orig(*args, **kwargs)
@@ -216,8 +272,9 @@ def authorized(orig):
'Request object is not of type HttpRequest or Request'
)
return settings.auth.authenticate_if_needed(request) or orig(
*args, **kwargs
)
auth_response = settings.auth.authenticate_if_needed(request)
if auth_response is not None:
return auth_response
return orig(*args, **kwargs)
return decorated

View File

@@ -1,11 +1,10 @@
from __future__ import unicode_literals
import logging
import os
import sys
import tarfile
from datetime import datetime
from os import getenv, makedirs, path, remove
from typing import Any
directories = ['.anthias', 'anthias_assets']
# Tarballs created by older releases used these top-level entry names.
@@ -16,7 +15,7 @@ default_archive_name = 'anthias-backup'
static_dir = 'anthias/staticfiles'
def _safe_tar_member(member, dest_root):
def _safe_tar_member(member: tarfile.TarInfo, dest_root: str) -> bool:
"""Validate a TarInfo for safe extraction under dest_root.
Reject:
@@ -50,8 +49,12 @@ def _safe_tar_member(member, dest_root):
return True
def create_backup(name=default_archive_name):
home = getenv('HOME')
class BackupRecoverError(Exception):
"""Raised when a backup archive cannot be safely recovered."""
def create_backup(name: str = default_archive_name) -> str:
home = getenv('HOME') or ''
archive_name = '{}-{}.tar.gz'.format(
name if name else default_archive_name,
datetime.now().strftime('%Y-%m-%dT%H-%M-%S'),
@@ -76,7 +79,7 @@ def create_backup(name=default_archive_name):
return archive_name
def recover(file_path):
def recover(file_path: str) -> None:
home = getenv('HOME')
if not home:
logging.error('No HOME variable')
@@ -89,7 +92,7 @@ def recover(file_path):
new_present = all(d in names for d in directories)
legacy_present = all(d in names for d in legacy_directories)
if not new_present and not legacy_present:
raise Exception('Archive is wrong.')
raise BackupRecoverError('Archive is wrong.')
# Manually iterate so each member is validated before any
# filesystem write. Avoids tarfile.extractall's older
@@ -98,7 +101,7 @@ def recover(file_path):
# (3.11.4+/3.12+), pass `filter='data'` for belt-and-suspenders
# protection; older interpreters fall back to our own
# validation only.
extract_kwargs = {'path': home}
extract_kwargs: dict[str, Any] = {'path': home}
if hasattr(tarfile, 'data_filter'):
extract_kwargs['filter'] = 'data'
for member in tar.getmembers():

View File

@@ -1,12 +1,9 @@
from __future__ import unicode_literals
def parse_cpu_info():
def parse_cpu_info() -> dict[str, int | str]:
"""
Extracts the various Raspberry Pi related data
from the CPU.
"""
cpu_info = {'cpu_count': 0}
cpu_info: dict[str, int | str] = {'cpu_count': 0}
with open('/proc/cpuinfo', 'r') as cpuinfo:
for line in cpuinfo:
@@ -17,14 +14,16 @@ def parse_cpu_info():
pass
if key == 'processor':
cpu_info['cpu_count'] += 1
cpu_info['cpu_count'] = (
int(cpu_info.get('cpu_count', 0) or 0) + 1
)
if key in ['Serial', 'Hardware', 'Revision', 'Model']:
cpu_info[key.lower()] = value
return cpu_info
def get_device_type():
def get_device_type() -> str:
try:
with open('/proc/device-tree/model') as file:
content = file.read()

View File

@@ -1,9 +1,6 @@
#!/usr/bin/env python
from __future__ import absolute_import, unicode_literals
import os
from builtins import str
from datetime import datetime
import cec
@@ -13,11 +10,11 @@ from lib import device_helper
from . import utils
def get_display_power():
def get_display_power() -> str | bool:
"""
Queries the TV using CEC
"""
tv_status = None
tv_status: str | bool = False
try:
cec.init()
@@ -33,19 +30,19 @@ def get_display_power():
return tv_status
def get_uptime():
def get_uptime() -> float:
with open('/proc/uptime', 'r') as f:
uptime_seconds = float(f.readline().split()[0])
return uptime_seconds
def get_load_avg():
def get_load_avg() -> dict[str, float]:
"""
Returns load average rounded to two digits.
"""
load_avg = {}
load_avg: dict[str, float] = {}
get_load_avg = os.getloadavg()
load_avg['1 min'] = round(get_load_avg[0], 2)
@@ -55,19 +52,19 @@ def get_load_avg():
return load_avg
def get_git_branch():
def get_git_branch() -> str | None:
return os.getenv('GIT_BRANCH')
def get_git_short_hash():
def get_git_short_hash() -> str | None:
return os.getenv('GIT_SHORT_HASH')
def get_git_hash():
def get_git_hash() -> str | None:
return os.getenv('GIT_HASH')
def try_connectivity():
def try_connectivity() -> list[str]:
urls = [
'http://www.google.com',
'http://www.bbc.co.uk',
@@ -83,23 +80,24 @@ def try_connectivity():
return result
def get_utc_isodate():
def get_utc_isodate() -> str:
return datetime.isoformat(datetime.utcnow())
def get_debian_version():
def get_debian_version() -> str:
debian_version = '/etc/debian_version'
if os.path.isfile(debian_version):
with open(debian_version, 'r') as f:
for line in f:
return str(line).strip()
return 'Unable to get Debian version.'
else:
return 'Unable to get Debian version.'
def get_raspberry_code():
def get_raspberry_code() -> int | str:
return device_helper.parse_cpu_info().get('hardware', 'Unknown')
def get_raspberry_model():
def get_raspberry_model() -> int | str:
return device_helper.parse_cpu_info().get('model', 'Unknown')

View File

@@ -1,6 +1,3 @@
from __future__ import unicode_literals
class SigalrmError(Exception):
pass

View File

@@ -1,11 +1,8 @@
from __future__ import unicode_literals
import json
import logging
import os
import random
import string
from builtins import range, str
from requests import exceptions
from requests import get as requests_get
@@ -36,7 +33,10 @@ ANALYTICS_API_SECRET = 'G8NcBpRIS9qBsOj3ODK8gw'
DEFAULT_REQUESTS_TIMEOUT = 1 # in seconds
def handle_github_error(exc, action):
def handle_github_error(
exc: exceptions.RequestException,
action: str,
) -> None:
# After failing, dont retry until backoff timer expires
r.set('github-api-error', action)
r.expire('github-api-error', ERROR_BACKOFF_TTL)
@@ -52,7 +52,7 @@ def handle_github_error(exc, action):
)
def remote_branch_available(branch):
def remote_branch_available(branch: str | None) -> bool | None:
if not branch:
logging.error('No branch specified. Exiting.')
return None
@@ -65,7 +65,7 @@ def remote_branch_available(branch):
# Check for cached remote branch status
remote_branch_cache = r.get('remote-branch-available')
if remote_branch_cache is not None:
return remote_branch_cache == '1'
return bool(remote_branch_cache == '1')
try:
resp = requests_get(
@@ -95,7 +95,7 @@ def remote_branch_available(branch):
return found
def fetch_remote_hash():
def fetch_remote_hash() -> tuple[str | None, bool]:
"""
Returns both the hash and if the status was updated
or not.
@@ -133,7 +133,7 @@ def fetch_remote_hash():
return get_cache, False
def get_latest_docker_hub_hash(device_type):
def get_latest_docker_hub_hash(device_type: str | None) -> str | None:
"""
This function is useful for cases where latest changes pushed does not
trigger Docker image builds.
@@ -173,12 +173,12 @@ def get_latest_docker_hub_hash(device_type):
# Results are sorted by date in descending order,
# so we can just return the first one.
return reduced[0]
return str(reduced[0])
return cached_docker_hub_hash
return str(cached_docker_hub_hash) if cached_docker_hub_hash else None
def is_up_to_date():
def is_up_to_date() -> bool:
"""
Primitive update check. Checks local hash against GitHub hash for branch.
Returns True if the player is up to date.

View File

@@ -1,12 +1,9 @@
from __future__ import absolute_import, unicode_literals
import json
import logging
import os
import random
import re
import string
from builtins import range, str
from datetime import datetime, timedelta
from distutils.util import strtobool
from os import getenv, path, utime
@@ -14,6 +11,7 @@ from platform import machine
from subprocess import call, check_output
from threading import Thread
from time import sleep
from typing import Any
from urllib.parse import urlparse
import certifi
@@ -21,7 +19,6 @@ import pytz
import redis
import requests
import sh
from future import standard_library
from tenacity import (
RetryError,
Retrying,
@@ -32,37 +29,26 @@ from tenacity import (
from anthias_app.models import Asset
from settings import settings
standard_library.install_aliases()
arch = machine()
# This will only work on the Raspberry Pi,
# so let's wrap it in a try/except so that
# Travis can run.
try:
from sh import ffprobe
except ImportError:
pass
def string_to_bool(string):
def string_to_bool(string: Any) -> bool:
return bool(strtobool(str(string)))
def touch(path):
def touch(path: str) -> None:
with open(path, 'a'):
utime(path, None)
def is_ci():
def is_ci() -> bool:
"""
Returns True when run on CI.
"""
return string_to_bool(os.getenv('CI', False))
def validate_url(string):
def validate_url(string: str) -> bool:
"""Simple URL verification.
>>> validate_url("hello")
False
@@ -82,9 +68,13 @@ def validate_url(string):
)
def get_balena_supervisor_api_response(method, action, **kwargs):
def get_balena_supervisor_api_response(
method: str,
action: str,
**kwargs: Any,
) -> requests.Response:
version = kwargs.get('version', 'v1')
return getattr(requests, method)(
response: requests.Response = getattr(requests, method)(
'{}/{}/{}?apikey={}'.format(
os.getenv('BALENA_SUPERVISOR_ADDRESS'),
version,
@@ -93,31 +83,32 @@ def get_balena_supervisor_api_response(method, action, **kwargs):
),
headers={'Content-Type': 'application/json'},
)
return response
def get_balena_device_info():
def get_balena_device_info() -> requests.Response:
return get_balena_supervisor_api_response(method='get', action='device')
def shutdown_via_balena_supervisor():
def shutdown_via_balena_supervisor() -> requests.Response:
return get_balena_supervisor_api_response(method='post', action='shutdown')
def reboot_via_balena_supervisor():
def reboot_via_balena_supervisor() -> requests.Response:
return get_balena_supervisor_api_response(method='post', action='reboot')
def get_balena_supervisor_version():
def get_balena_supervisor_version() -> str:
response = get_balena_supervisor_api_response(
method='get', action='version', version='v2'
)
if response.ok:
return response.json()['version']
return str(response.json()['version'])
else:
return 'Error getting the Supervisor version'
def get_node_ip():
def get_node_ip() -> str:
"""
Returns the node's IP address.
We're using an API call to the supervisor for this on Balena
@@ -129,7 +120,7 @@ def get_node_ip():
if is_balena_app():
response = get_balena_device_info()
if response.ok:
return response.json()['ip_address']
return str(response.json()['ip_address'])
return 'Unknown'
else:
r = connect_to_redis()
@@ -183,12 +174,12 @@ def get_node_ip():
if ip_addresses:
return ' '.join(json.loads(ip_addresses))
elif os.getenv('MY_IP'):
return os.getenv('MY_IP')
return os.getenv('MY_IP') or 'Unable to retrieve IP.'
return 'Unable to retrieve IP.'
def get_node_mac_address():
def get_node_mac_address() -> str:
"""
Returns the MAC address.
"""
@@ -205,13 +196,16 @@ def get_node_mac_address():
)
if r.ok:
return r.json()['mac_address']
return str(r.json()['mac_address'])
return 'Unknown'
return os.getenv('MAC_ADDRESS', 'Unable to retrieve MAC address.')
def get_active_connections(bus, fields=None):
def get_active_connections(
bus: Any,
fields: list[str] | None = None,
) -> list[dict[str, Any]] | None:
"""
:param bus: pydbus.bus.Bus
@@ -272,7 +266,7 @@ def get_active_connections(bus, fields=None):
return connections
def remove_connection(bus, uuid):
def remove_connection(bus: Any, uuid: str) -> bool:
"""
:param bus: pydbus.bus.Bus
@@ -301,14 +295,20 @@ def remove_connection(bus, uuid):
return True
def get_video_duration(file):
def get_video_duration(file: str) -> timedelta | None:
"""
Returns the duration of a video file in timedelta.
Returns None if ffprobe is not available on the host so callers can
surface a clean validation error instead of a 500.
"""
time = None
try:
run_player = ffprobe('-i', file, _err_to_out=True)
run_player = sh.Command('ffprobe')('-i', file, _err_to_out=True)
except sh.CommandNotFound:
logging.warning('ffprobe is not installed; cannot determine duration')
return None
except sh.ErrorReturnCode_1 as err:
raise Exception('Bad video format') from err
@@ -327,7 +327,7 @@ def get_video_duration(file):
return time
def handler(obj):
def handler(obj: Any) -> str:
# Set timezone as UTC if it's datetime and format as ISO
if isinstance(obj, datetime):
with_tz = obj.replace(tzinfo=pytz.utc)
@@ -339,18 +339,24 @@ def handler(obj):
)
def json_dump(obj):
def json_dump(obj: Any) -> str:
return json.dumps(obj, default=handler)
def url_fails(url):
def url_fails(url: str) -> bool:
"""
If it is streaming
"""
if urlparse(url).scheme in ('rtsp', 'rtmp'):
run_mplayer = mplayer( # noqa: F821
'-identify', '-frames', '0', '-nosound', url
)
try:
run_mplayer = sh.Command('mplayer')(
'-identify', '-frames', '0', '-nosound', url
)
except sh.CommandNotFound:
logging.warning(
'mplayer is not installed; skipping streaming URL probe'
)
return False
for line in run_mplayer.split('\n'):
if 'Clip info:' in line:
return False
@@ -362,6 +368,7 @@ def url_fails(url):
# Use Certifi module and set to True as default so users stop
# seeing InsecureRequestWarning in logs.
verify: str | bool
if settings['verify_ssl']:
verify = certifi.where()
else:
@@ -398,8 +405,11 @@ def url_fails(url):
return True
def download_video_from_youtube(uri, asset_id):
home = getenv('HOME')
def download_video_from_youtube(
uri: str,
asset_id: str,
) -> tuple[str, str, int]:
home = getenv('HOME') or ''
name = check_output(['yt-dlp', '-O', 'title', uri])
info = json.loads(check_output(['yt-dlp', '-j', uri]))
duration = info['duration']
@@ -413,13 +423,18 @@ def download_video_from_youtube(uri, asset_id):
class YoutubeDownloadThread(Thread):
def __init__(self, location, uri, asset_id):
def __init__(
self,
location: str,
uri: str,
asset_id: str,
) -> None:
Thread.__init__(self)
self.location = location
self.uri = uri
self.asset_id = asset_id
def run(self):
def run(self) -> None:
call(
[
'yt-dlp',
@@ -433,7 +448,7 @@ class YoutubeDownloadThread(Thread):
try:
asset = Asset.objects.get(asset_id=self.asset_id)
asset.is_processing = 0
asset.is_processing = False
asset.save()
except Asset.DoesNotExist:
logging.warning('Asset %s not found', self.asset_id)
@@ -450,11 +465,11 @@ class YoutubeDownloadThread(Thread):
)
def template_handle_unicode(value):
def template_handle_unicode(value: Any) -> str:
return str(value)
def is_demo_node():
def is_demo_node() -> bool:
"""
Check if the environment variable IS_DEMO_NODE is set to 1
:return: bool
@@ -462,7 +477,10 @@ def is_demo_node():
return string_to_bool(os.getenv('IS_DEMO_NODE', False))
def generate_perfect_paper_password(pw_length=10, has_symbols=True):
def generate_perfect_paper_password(
pw_length: int = 10,
has_symbols: bool = True,
) -> str:
"""
Generates a password using 64 characters from
"Perfect Paper Password" system by Steve Gibson
@@ -481,15 +499,15 @@ def generate_perfect_paper_password(pw_length=10, has_symbols=True):
)
def connect_to_redis():
def connect_to_redis() -> 'redis.Redis':
return redis.Redis(host='redis', decode_responses=True, port=6379, db=0)
def is_docker():
def is_docker() -> bool:
return os.path.isfile('/.dockerenv')
def is_balena_app():
def is_balena_app() -> bool:
"""
Checks the application is running on Balena Cloud
:return: bool

View File

@@ -5,7 +5,7 @@ import os
import sys
def main():
def main() -> None:
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'anthias_django.settings')
try:

View File

@@ -9,7 +9,19 @@ dependencies = []
[dependency-groups]
dev-host = [
"ansible-lint==26.4.0",
"celery-types==0.26.0",
"django-stubs==6.0.3",
"djangorestframework-stubs==3.16.9",
"mypy==1.18.2",
"ruff==0.14.10",
"tenacity==9.1.2",
"types-gunicorn==25.3.0.20260408",
"types-mock==5.2.0.20260408",
"types-netifaces==0.11.0.20260408",
"types-python-dateutil==2.9.0.20260408",
"types-pytz==2026.1.1.20260408",
"types-PyYAML==6.0.12.20260408",
"types-requests==2.33.0.20260408",
]
docker-image-builder = [
"click==8.1.7",
@@ -31,6 +43,7 @@ server = [
"Django==4.2.30",
"djangorestframework==3.16.1",
"django-dbbackup==4.2.1",
"django-stubs-ext==6.0.3",
"drf-spectacular==0.29.0",
"future==1.0.0",
"hurry.filesize==0.9",
@@ -119,3 +132,56 @@ test = [
{ include-group = "viewer" },
{ include-group = "dev" },
]
# Used by the python-mypy CI job. The django-stubs plugin imports
# anthias_django.settings to introspect the app registry, so we need the
# runtime deps that settings touches — but not the heavy native-extension
# deps from the server group (cec, netifaces, etc.). We also include
# docker-image-builder so its tools (pygit2, python_on_whales) resolve.
mypy = [
{ include-group = "dev-host" },
{ include-group = "docker-image-builder" },
"channels==4.3.1",
"channels-redis==4.3.0",
"Django==4.2.30",
"django-dbbackup==4.2.1",
"django-stubs-ext==6.0.3",
"djangorestframework==3.16.1",
"drf-spectacular==0.29.0",
"pytz==2025.2",
"pyzmq==23.2.1",
"whitenoise==6.8.2",
]
[tool.mypy]
python_version = "3.11"
strict = true
follow_imports = "normal"
mypy_path = "stubs"
plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"]
exclude = [
"^\\.venv/",
"^node_modules/",
"/migrations/",
"^static/",
"^build/",
"^dist/",
]
[[tool.mypy.overrides]]
# Third-party libs without type stubs. Listed explicitly so a future stub
# release is noticed (and the override removed) instead of silently ignored.
# `channels` itself is covered by partial PEP 561 stubs in stubs/channels-stubs/;
# only the helpers we actually call are typed there.
module = [
"cec",
"channels_redis.*",
"hurry.filesize",
"pydbus",
"sh",
"splinter",
"vlc",
]
ignore_missing_imports = true
[tool.django-stubs]
django_settings_module = "anthias_django.settings"

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env python3
from __future__ import print_function, unicode_literals
import json
from typing import Any
import requests
@@ -13,15 +12,15 @@ GITHUB_HEADERS = {
}
def get_latest_tag():
def get_latest_tag() -> str:
response = requests.get(
'{}/releases/latest'.format(BASE_URL), headers=GITHUB_HEADERS
)
return response.json()['tag_name']
return str(response.json()['tag_name'])
def get_asset_list(release_tag):
def get_asset_list(release_tag: str) -> list[str]:
asset_urls = []
response = requests.get(
'{}/releases/tags/{}'.format(BASE_URL, release_tag),
@@ -36,8 +35,8 @@ def get_asset_list(release_tag):
return asset_urls
def retrieve_and_patch_json(url):
image_json = requests.get(
def retrieve_and_patch_json(url: str) -> dict[str, Any]:
image_json: dict[str, Any] = requests.get(
url.replace('.img.zst', '.json'), headers=GITHUB_HEADERS
).json()
@@ -47,10 +46,10 @@ def retrieve_and_patch_json(url):
return image_json
def main():
def main() -> None:
latest_release = get_latest_tag()
release_assets = get_asset_list(latest_release)
pi_imager_json = {'os_list': []}
pi_imager_json: dict[str, list[dict[str, Any]]] = {'os_list': []}
for url in release_assets:
pi_imager_json['os_list'].append(retrieve_and_patch_json(url))

View File

@@ -1,5 +1,3 @@
from __future__ import unicode_literals
import json
from argparse import ArgumentParser
from os import getenv
@@ -10,7 +8,7 @@ import zmq
from netifaces import AF_INET, ifaddresses, interfaces
def get_portal_url():
def get_portal_url() -> str:
gateway = getenv('PORTAL_GATEWAY', '192.168.42.1')
port = getenv('PORTAL_LISTENING_PORT', None)
@@ -20,7 +18,7 @@ def get_portal_url():
return f'{gateway}:{port}'
def get_message(action):
def get_message(action: str) -> str | None:
if action == 'setup_wifi':
data = {
'network': getenv('PORTAL_SSID'),
@@ -31,21 +29,20 @@ def get_message(action):
elif action == 'show_splash':
ip_addresses = get_ip_addresses()
return f'{action}&{json.dumps(ip_addresses)}'
return None
def get_ip_addresses():
def get_ip_addresses() -> list[str]:
return [
i['addr']
for interface_name in interfaces()
for i in ifaddresses(interface_name).setdefault(
AF_INET, [{'addr': None}]
)
for i in ifaddresses(interface_name).get(AF_INET, [])
if interface_name in ['eth0', 'wlan0']
if i['addr'] is not None
if i.get('addr') is not None
]
def is_viewer_subscriber_ready(r):
def is_viewer_subscriber_ready(r: 'redis.Redis') -> bool:
is_ready = r.get('viewer-subscriber-ready')
if is_ready is None:
return False
@@ -53,7 +50,7 @@ def is_viewer_subscriber_ready(r):
return bool(int(is_ready))
def main():
def main() -> None:
argument_parser = ArgumentParser()
argument_parser.add_argument(
'--action',

View File

@@ -1,19 +1,22 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import configparser
import hashlib
import json
import logging
from builtins import object, str
from collections import UserDict
from os import getenv, path
from time import sleep
from typing import Any, ClassVar
import zmq
from lib.auth import BasicAuth, NoAuth
from lib.auth import (
Auth,
BasicAuth,
NoAuth,
_is_legacy_sha256,
)
from lib.errors import ZmqCollectorTimeoutError
CONFIG_DIR = '.anthias/'
@@ -64,15 +67,27 @@ requests_log.setLevel(logging.WARNING)
logging.debug('Starting viewer')
class AnthiasSettings(UserDict):
class AnthiasSettings(UserDict[str, Any]):
"""Anthias' Settings."""
def __init__(self, *args, **kwargs):
def __init__(self, *args: Any, **kwargs: Any) -> None:
UserDict.__init__(self, *args, **kwargs)
self.home = getenv('HOME')
home = getenv('HOME')
if not home:
# Without HOME, all config/state paths (config file, DB,
# asset dir) would silently resolve relative to the cwd.
# Fail loudly instead of writing to unexpected locations.
raise EnvironmentError(
'HOME environment variable must be set for AnthiasSettings.'
)
self.home = home
self.conf_file = self.get_configfile()
self.auth_backends_list = [NoAuth(), BasicAuth(self)]
self.auth_backends = {}
self.auth_backends_list: list[Auth] = [NoAuth(), BasicAuth(self)]
self.auth_backends: dict[str, Auth] = {}
# Set by _get() when an insecure password was wiped during load();
# __init__ persists the cleaned state to disk so the warning isn't
# repeated on every subsequent load.
self._needs_save_after_load = False
for backend in self.auth_backends_list:
DEFAULTS.update(backend.config)
self.auth_backends[backend.name] = backend
@@ -85,8 +100,17 @@ class AnthiasSettings(UserDict):
self.save()
else:
self.load()
if self._needs_save_after_load:
self._needs_save_after_load = False
self.save()
def _get(self, config, section, field, default):
def _get(
self,
config: configparser.ConfigParser,
section: str,
field: str,
default: Any,
) -> None:
try:
if isinstance(default, bool):
self[field] = config.getboolean(section, field)
@@ -94,14 +118,39 @@ class AnthiasSettings(UserDict):
self[field] = config.getint(section, field)
else:
self[field] = config.get(section, field)
# Likely not a hashed password
if (
field == 'password'
and self[field] != ''
and len(self[field]) != 64
):
# Hash the original password.
self[field] = hashlib.sha256(self[field]).hexdigest()
if field == 'password' and self[field] != '':
# Both legacy SHA256 hashes and any non-Django-format
# value (incl. plaintext) are unsafe to keep — they
# cannot be verified by the new PBKDF2-based path. Clear
# the password and disable basic auth so the device
# stays reachable; the operator must re-set credentials
# via the UI.
#
# Note: we deliberately do NOT call hash_password() here.
# `settings.py` is imported (and AnthiasSettings()
# instantiated) before django.setup() runs in the viewer
# process, so calling Django's password hashers would
# raise ImproperlyConfigured at startup.
if (
_is_legacy_sha256(self[field])
or '$' not in self[field]
):
reason = (
'legacy SHA256 hash'
if _is_legacy_sha256(self[field])
else 'unrecognized format (possibly plaintext)'
)
logging.error(
'Insecure password (%s) detected in %s; '
'clearing it and disabling basic auth. The '
'device will accept unauthenticated requests '
'until you re-set the password via the web UI.',
reason,
self.conf_file,
)
self[field] = ''
self['auth_backend'] = ''
self._needs_save_after_load = True
except configparser.Error as e:
logging.debug(
"Could not parse setting '%s.%s': %s. "
@@ -115,7 +164,13 @@ class AnthiasSettings(UserDict):
if field in ['database', 'assetdir']:
self[field] = str(path.join(self.home, self[field]))
def _set(self, config, section, field, default):
def _set(
self,
config: configparser.ConfigParser,
section: str,
field: str,
default: Any,
) -> None:
if isinstance(default, bool):
config.set(
section, field, self.get(field, default) and 'on' or 'off'
@@ -123,7 +178,7 @@ class AnthiasSettings(UserDict):
else:
config.set(section, field, str(self.get(field, default)))
def load(self):
def load(self) -> None:
"""Loads the latest settings from anthias.conf into memory."""
logging.debug('Reading config-file...')
config = configparser.ConfigParser()
@@ -133,12 +188,12 @@ class AnthiasSettings(UserDict):
for field, default in list(defaults.items()):
self._get(config, section, field, default)
def use_defaults(self):
def use_defaults(self) -> None:
for defaults in list(DEFAULTS.items()):
for field, default in list(defaults[1].items()):
self[field] = default
def save(self):
def save(self) -> None:
# Write new settings to disk.
config = configparser.ConfigParser()
for section, defaults in list(DEFAULTS.items()):
@@ -149,26 +204,27 @@ class AnthiasSettings(UserDict):
config.write(f)
self.load()
def get_configdir(self):
def get_configdir(self) -> str:
return path.join(self.home, CONFIG_DIR)
def get_configfile(self):
def get_configfile(self) -> str:
return path.join(self.home, CONFIG_DIR, CONFIG_FILE)
@property
def auth(self):
def auth(self) -> Auth | None:
backend_name = self['auth_backend']
if backend_name in self.auth_backends:
return self.auth_backends[self['auth_backend']]
return None
settings = AnthiasSettings()
class ZmqPublisher(object):
INSTANCE = None
class ZmqPublisher:
INSTANCE: ClassVar['ZmqPublisher | None'] = None
def __init__(self):
def __init__(self) -> None:
if self.INSTANCE is not None:
raise ValueError('An instance already exists!')
@@ -179,17 +235,17 @@ class ZmqPublisher(object):
sleep(1)
@classmethod
def get_instance(cls):
def get_instance(cls) -> 'ZmqPublisher':
if cls.INSTANCE is None:
cls.INSTANCE = ZmqPublisher()
return cls.INSTANCE
def send_to_viewer(self, msg):
def send_to_viewer(self, msg: str) -> None:
self.socket.send_string('viewer {}'.format(msg))
class ZmqConsumer(object):
def __init__(self):
class ZmqConsumer:
def __init__(self) -> None:
self.context = zmq.Context()
self.socket = self.context.socket(zmq.PUSH)
@@ -198,14 +254,14 @@ class ZmqConsumer(object):
sleep(1)
def send(self, msg):
def send(self, msg: Any) -> None:
self.socket.send_json(msg, flags=zmq.NOBLOCK)
class ZmqCollector(object):
INSTANCE = None
class ZmqCollector:
INSTANCE: ClassVar['ZmqCollector | None'] = None
def __init__(self):
def __init__(self) -> None:
if self.INSTANCE is not None:
raise ValueError('An instance already exists!')
@@ -220,12 +276,12 @@ class ZmqCollector(object):
sleep(1)
@classmethod
def get_instance(cls):
def get_instance(cls) -> 'ZmqCollector':
if cls.INSTANCE is None:
cls.INSTANCE = ZmqCollector()
return cls.INSTANCE
def recv_json(self, timeout):
def recv_json(self, timeout: int) -> Any:
if self.poller.poll(timeout):
return json.loads(self.socket.recv(zmq.NOBLOCK))

View File

View File

View File

@@ -0,0 +1,21 @@
from typing import Any, Callable
class AsyncWebsocketConsumer:
channel_name: str
channel_layer: Any
groups: list[str]
@classmethod
def as_asgi(cls, **initkwargs: Any) -> Callable[..., Any]: ...
async def connect(self) -> None: ...
async def disconnect(self, code: int) -> None: ...
async def accept(self, subprotocol: str | None = ...) -> None: ...
async def send(
self,
text_data: str | None = ...,
bytes_data: bytes | None = ...,
close: bool = ...,
) -> None: ...
async def close(
self, code: int | bool = ..., reason: str | None = ...
) -> None: ...

View File

@@ -0,0 +1,3 @@
from typing import Any
def get_channel_layer(alias: str = ...) -> Any: ...

View File

@@ -0,0 +1 @@
partial

View File

@@ -0,0 +1,7 @@
from typing import Any, Iterable
class ProtocolTypeRouter:
def __init__(self, application_mapping: dict[str, Any]) -> None: ...
class URLRouter:
def __init__(self, routes: Iterable[Any]) -> None: ...

View File

View File

@@ -0,0 +1,4 @@
from typing import Any
class AllowedHostsOriginValidator:
def __init__(self, application: Any) -> None: ...

View File

View File

@@ -0,0 +1,28 @@
from typing import Any
from django.http import HttpRequest, HttpResponse
from rest_framework.views import APIView
class SpectacularAPIView(APIView):
def get(
self, request: HttpRequest, *args: Any, **kwargs: Any
) -> HttpResponse: ...
class SpectacularYAMLAPIView(SpectacularAPIView): ...
class SpectacularJSONAPIView(SpectacularAPIView): ...
class SpectacularSwaggerView(APIView):
def get(
self, request: HttpRequest, *args: Any, **kwargs: Any
) -> HttpResponse: ...
class SpectacularSwaggerSplitView(SpectacularSwaggerView): ...
class SpectacularRedocView(APIView):
url_name: str
url: str | None
template_name: str
title: str | None
def get(
self, request: HttpRequest, *args: Any, **kwargs: Any
) -> HttpResponse: ...

View File

@@ -0,0 +1,2 @@
from redis.client import PubSub as PubSub, Redis as Redis
from redis.exceptions import ConnectionError as ConnectionError

View File

@@ -0,0 +1,20 @@
from typing import Any, Iterable
class PubSub:
def __init__(self, *args: Any, **kwargs: Any) -> None: ...
def subscribe(self, *args: Any, **kwargs: Any) -> None: ...
def listen(self) -> Iterable[dict[str, Any]]: ...
# All call sites use decode_responses=True, so responses are str (not bytes).
# This stub narrows the type accordingly.
class Redis:
def __init__(self, *args: Any, **kwargs: Any) -> None: ...
def get(self, name: Any) -> str | None: ...
def set(
self, name: Any, value: Any, *args: Any, **kwargs: Any
) -> bool: ...
def expire(
self, name: Any, time: int, *args: Any, **kwargs: Any
) -> bool: ...
def publish(self, channel: Any, message: Any, **kwargs: Any) -> int: ...
def pubsub(self, **kwargs: Any) -> PubSub: ...

View File

@@ -0,0 +1,2 @@
class RedisError(Exception): ...
class ConnectionError(RedisError): ...

View File

@@ -0,0 +1 @@
partial

View File

@@ -3,6 +3,8 @@ import shutil
import tempfile
from datetime import timedelta
from time import sleep
from types import TracebackType
from typing import Any, Callable
from unittest import TestCase, skip
from django.test import tag
@@ -47,22 +49,27 @@ asset_y = {
}
class TemporaryCopy(object):
def __init__(self, original_path, base_path):
class TemporaryCopy:
def __init__(self, original_path: str, base_path: str) -> None:
self.original_path = original_path
self.base_path = base_path
def __enter__(self):
def __enter__(self) -> str:
temp_dir = tempfile.gettempdir()
self.path = os.path.join(temp_dir, self.base_path)
shutil.copy2(self.original_path, self.path)
return self.path
def __exit__(self, exc_type, exc_val, exc_tb):
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
os.remove(self.path)
def get_browser():
def get_browser() -> Any:
chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--headless')
@@ -71,7 +78,11 @@ def get_browser():
return Browser('chrome', headless=True, options=chrome_options)
def wait_for_and_do(browser, query, callback):
def wait_for_and_do(
browser: Any,
query: str,
callback: Callable[[Any], Any],
) -> None:
not_filled = True
n = 0
@@ -87,11 +98,11 @@ def wait_for_and_do(browser, query, callback):
@tag('integration')
class WebTest(TestCase):
def setUp(self):
def setUp(self) -> None:
Asset.objects.all().delete()
@skip('fixme')
def test_add_asset_url(self):
def test_add_asset_url(self) -> None:
with get_browser() as browser:
browser.visit(main_page_url)
@@ -116,6 +127,7 @@ class WebTest(TestCase):
assets = Asset.objects.all()
self.assertEqual(len(assets), 1)
asset = assets.first()
assert asset is not None
self.assertEqual(asset.name, 'https://example.com')
self.assertEqual(asset.uri, 'https://example.com')
@@ -123,8 +135,8 @@ class WebTest(TestCase):
self.assertEqual(asset.duration, settings['default_duration'])
@skip('migrate to React-based tests')
def test_edit_asset(self):
asset = Asset.objects.create(**asset_x)
def test_edit_asset(self) -> None:
Asset.objects.create(**asset_x)
with get_browser() as browser:
browser.visit(main_page_url)
@@ -153,10 +165,11 @@ class WebTest(TestCase):
assets = Asset.objects.all()
self.assertEqual(len(assets), 1)
asset = assets.first()
assert asset is not None
self.assertEqual(asset.duration, 333)
def test_add_asset_image_upload(self):
def test_add_asset_image_upload(self) -> None:
image_file = '/tmp/image.png'
with get_browser() as browser:
@@ -180,12 +193,13 @@ class WebTest(TestCase):
assets = Asset.objects.all()
self.assertEqual(len(assets), 1)
asset = assets.first()
assert asset is not None
self.assertEqual(asset.name, 'image.png')
self.assertEqual(asset.mimetype, 'image')
self.assertEqual(asset.duration, settings['default_duration'])
def test_add_asset_video_upload(self):
def test_add_asset_video_upload(self) -> None:
with TemporaryCopy(
'tests/assets/asset.mov', 'video.mov'
) as video_file:
@@ -212,12 +226,13 @@ class WebTest(TestCase):
assets = Asset.objects.all()
self.assertEqual(len(assets), 1)
asset = assets.first()
assert asset is not None
self.assertEqual(asset.name, 'video.mov')
self.assertEqual(asset.mimetype, 'video')
self.assertEqual(asset.duration, 5)
def test_add_two_assets_upload(self):
def test_add_two_assets_upload(self) -> None:
with (
TemporaryCopy('tests/assets/asset.mov', 'video.mov') as video_file,
TemporaryCopy(
@@ -261,7 +276,7 @@ class WebTest(TestCase):
self.assertEqual(assets[1].duration, 5)
@skip('fixme')
def test_add_asset_streaming(self):
def test_add_asset_streaming(self) -> None:
with get_browser() as browser:
browser.visit(main_page_url)
@@ -286,6 +301,7 @@ class WebTest(TestCase):
assets = Asset.objects.all()
self.assertEqual(len(assets), 1)
asset = assets.first()
assert asset is not None
self.assertEqual(asset.name, 'rtsp://localhost:8091/asset.mov')
self.assertEqual(asset.uri, 'rtsp://localhost:8091/asset.mov')
@@ -295,7 +311,7 @@ class WebTest(TestCase):
)
@skip('migrate to React-based tests')
def test_remove_asset(self):
def test_remove_asset(self) -> None:
Asset.objects.create(**asset_x)
with get_browser() as browser:
@@ -311,7 +327,7 @@ class WebTest(TestCase):
self.assertEqual(Asset.objects.count(), 0)
def test_enable_asset(self):
def test_enable_asset(self) -> None:
Asset.objects.create(**asset_x)
with get_browser() as browser:
@@ -348,9 +364,10 @@ class WebTest(TestCase):
self.assertEqual(len(assets), 1)
asset = assets.first()
assert asset is not None
self.assertEqual(asset.is_enabled, True)
def test_disable_asset(self):
def test_disable_asset(self) -> None:
# Clear any existing assets first
Asset.objects.all().delete()
@@ -390,10 +407,11 @@ class WebTest(TestCase):
self.assertEqual(len(assets), 1)
asset = assets.first()
assert asset is not None
self.assertEqual(asset.is_enabled, False)
@skip('migrate to React-based tests')
def test_reorder_asset(self):
def test_reorder_asset(self) -> None:
Asset.objects.create(**{**asset_x, 'is_enabled': 1})
Asset.objects.create(**asset_y)
@@ -413,7 +431,7 @@ class WebTest(TestCase):
self.assertEqual(x.play_order, 0)
self.assertEqual(y.play_order, 1)
def test_settings_page_should_work(self):
def test_settings_page_should_work(self) -> None:
with get_browser() as browser:
browser.visit(settings_url)
@@ -427,7 +445,7 @@ class WebTest(TestCase):
'5xx: not expected',
)
def test_system_info_page_should_work(self):
def test_system_info_page_should_work(self) -> None:
with get_browser() as browser:
browser.visit(system_info_url)
self.assertEqual(

View File

@@ -5,9 +5,14 @@ import tempfile
import unittest
from datetime import datetime
from os import path
from typing import Any
from unittest import mock
from lib.backup_helper import create_backup, recover, static_dir
from lib.backup_helper import (
create_backup,
recover,
static_dir,
)
class BackupHelperTest(unittest.TestCase):
@@ -16,7 +21,7 @@ class BackupHelperTest(unittest.TestCase):
~/anthias checkout or ~/.anthias config wiped by tearDown's
rmtree."""
def setUp(self):
def setUp(self) -> None:
self.tmp_home = tempfile.mkdtemp(prefix='anthias-backup-test-')
# Populate the layout create_backup() expects to tar up so the
# call has something to read.
@@ -32,20 +37,20 @@ class BackupHelperTest(unittest.TestCase):
)
self.assertFalse(path.isdir(path.join(self.tmp_home, static_dir)))
def tearDown(self):
def tearDown(self) -> None:
self._home_patch.stop()
shutil.rmtree(self.tmp_home, ignore_errors=True)
def get_patched_datetime(self):
def get_patched_datetime(self) -> Any:
return mock.patch('lib.backup_helper.datetime')
def test_get_backup_name(self):
def test_get_backup_name(self) -> None:
with self.get_patched_datetime() as mock_datetime:
mock_datetime.now.return_value = self.dt
archive_name = create_backup()
self.assertEqual(archive_name, self.expected_archive_name)
def test_recover(self):
def test_recover(self) -> None:
archive_name = create_backup()
file_path = path.join(self.tmp_home, static_dir, archive_name)
self.assertTrue(path.isfile(file_path))
@@ -58,13 +63,13 @@ class RecoverLegacyTarballTest(unittest.TestCase):
`screenly_assets` as top-level archive entries. recover() must keep
accepting them so users can still restore those backups."""
def setUp(self):
def setUp(self) -> None:
self.tmp_home = tempfile.mkdtemp(prefix='anthias-backup-legacy-test-')
def tearDown(self):
def tearDown(self) -> None:
shutil.rmtree(self.tmp_home, ignore_errors=True)
def _build_legacy_tarball(self):
def _build_legacy_tarball(self) -> str:
# Stage the legacy layout in a scratch dir, then tar it up with
# top-level `.screenly/` and `screenly_assets/` arcnames.
scratch = tempfile.mkdtemp(prefix='anthias-backup-stage-')
@@ -97,7 +102,7 @@ class RecoverLegacyTarballTest(unittest.TestCase):
shutil.rmtree(scratch, ignore_errors=True)
return archive
def test_recover_accepts_legacy_archive(self):
def test_recover_accepts_legacy_archive(self) -> None:
archive = self._build_legacy_tarball()
with mock.patch.dict(os.environ, {'HOME': self.tmp_home}):
@@ -113,7 +118,7 @@ class RecoverLegacyTarballTest(unittest.TestCase):
path.isfile(path.join(self.tmp_home, 'screenly_assets', 'a.mp4'))
)
def test_recover_rejects_unrelated_archive(self):
def test_recover_rejects_unrelated_archive(self) -> None:
archive = path.join(self.tmp_home, 'random.tar.gz')
scratch = tempfile.mkdtemp(prefix='anthias-backup-bogus-')
try:
@@ -132,7 +137,7 @@ class RecoverLegacyTarballTest(unittest.TestCase):
with self.assertRaises(Exception):
recover(archive)
def test_recover_skips_path_traversal_member(self):
def test_recover_skips_path_traversal_member(self) -> None:
"""A malicious tarball with a `..` member must not write outside
$HOME. The required top-level entries are still present, so
recover() proceeds, but the unsafe member should be skipped."""

View File

@@ -1,5 +1,3 @@
from __future__ import unicode_literals
import unittest
from os import getenv, listdir, path, system
@@ -10,7 +8,7 @@ from celery_tasks import cleanup
class CeleryTasksTestCase(unittest.TestCase):
REPO_URL = 'https://github.com/Screenly/screenly-ose'
def setUp(self):
def setUp(self) -> None:
self.image_url = f'{self.REPO_URL}/raw/master/static/img/standby.png'
celeryapp.conf.update(
CELERY_ALWAYS_EAGER=True,
@@ -18,22 +16,22 @@ class CeleryTasksTestCase(unittest.TestCase):
CELERY_BROKER_URL='',
)
def download_image(self, image_url, image_path):
def download_image(self, image_url: str, image_path: str) -> None:
system('curl {} > {}'.format(image_url, image_path))
class TestCleanup(CeleryTasksTestCase):
def setUp(self):
def setUp(self) -> None:
super(TestCleanup, self).setUp()
self.assets_path = path.join(getenv('HOME'), 'anthias_assets')
self.assets_path = path.join(getenv('HOME') or '', 'anthias_assets')
self.image_path = path.join(self.assets_path, 'image.tmp')
def test_cleanup(self):
def test_cleanup(self) -> None:
cleanup.apply()
tmp_files = [
x for x in listdir(self.assets_path) if x.endswith('.tmp')
]
self.assertEqual(len(tmp_files), 0)
def tearDown(self):
def tearDown(self) -> None:
self.download_image(self.image_url, self.image_path)

View File

@@ -1,6 +1,7 @@
import logging
import subprocess
import unittest
from typing import Any
from unittest.mock import MagicMock, patch
from viewer.media_player import (
@@ -13,11 +14,13 @@ logging.disable(logging.CRITICAL)
class TestMPVMediaPlayer(unittest.TestCase):
def setUp(self):
def setUp(self) -> None:
self.player = MPVMediaPlayer()
@patch('viewer.media_player.subprocess.Popen')
def test_play_invokes_popen_with_expected_args(self, mock_popen):
def test_play_invokes_popen_with_expected_args(
self, mock_popen: Any
) -> None:
self.player.set_asset('file:///test/video.mp4', 30)
self.player.play()
@@ -34,7 +37,9 @@ class TestMPVMediaPlayer(unittest.TestCase):
)
@patch('viewer.media_player.subprocess.Popen')
def test_is_playing_returns_true_when_process_running(self, mock_popen):
def test_is_playing_returns_true_when_process_running(
self, mock_popen: Any
) -> None:
mock_process = MagicMock()
mock_process.poll.return_value = None
mock_popen.return_value = mock_process
@@ -45,7 +50,9 @@ class TestMPVMediaPlayer(unittest.TestCase):
self.assertTrue(self.player.is_playing())
@patch('viewer.media_player.subprocess.Popen')
def test_is_playing_returns_false_when_process_finished(self, mock_popen):
def test_is_playing_returns_false_when_process_finished(
self, mock_popen: Any
) -> None:
mock_process = MagicMock()
mock_process.poll.return_value = 0
mock_popen.return_value = mock_process
@@ -55,11 +62,11 @@ class TestMPVMediaPlayer(unittest.TestCase):
self.assertFalse(self.player.is_playing())
def test_is_playing_returns_false_when_no_process(self):
def test_is_playing_returns_false_when_no_process(self) -> None:
self.assertFalse(self.player.is_playing())
@patch('viewer.media_player.subprocess.Popen')
def test_stop_terminates_process(self, mock_popen):
def test_stop_terminates_process(self, mock_popen: Any) -> None:
mock_process = MagicMock()
mock_popen.return_value = mock_process
@@ -72,7 +79,7 @@ class TestMPVMediaPlayer(unittest.TestCase):
class TestVLCMediaPlayer(unittest.TestCase):
def setUp(self):
def setUp(self) -> None:
with patch.object(VLCMediaPlayer, '__init__', return_value=None):
self.player = VLCMediaPlayer()
@@ -90,11 +97,11 @@ class TestVLCMediaPlayer(unittest.TestCase):
self.mock_settings.__getitem__.return_value = 'hdmi'
self.patch_device_type.start()
def tearDown(self):
def tearDown(self) -> None:
self.patch_settings.stop()
self.patch_device_type.stop()
def test_set_asset_invokes_parse(self):
def test_set_asset_invokes_parse(self) -> None:
self.player.set_asset('file:///test/video.mp4', 30)
self.mock_vlc_player.get_media.assert_called_once()
@@ -102,13 +109,13 @@ class TestVLCMediaPlayer(unittest.TestCase):
class TestMediaPlayerProxy(unittest.TestCase):
def setUp(self):
def setUp(self) -> None:
MediaPlayerProxy.INSTANCE = None
def tearDown(self):
def tearDown(self) -> None:
MediaPlayerProxy.INSTANCE = None
def test_get_instance_returns_vlc_for_pi_devices(self):
def test_get_instance_returns_vlc_for_pi_devices(self) -> None:
for device_type in ['pi1', 'pi2', 'pi3', 'pi4']:
with self.subTest(device_type=device_type):
MediaPlayerProxy.INSTANCE = None
@@ -122,7 +129,7 @@ class TestMediaPlayerProxy(unittest.TestCase):
instance = MediaPlayerProxy.get_instance()
self.assertIsInstance(instance, VLCMediaPlayer)
def test_get_instance_returns_mpv_for_pi5_and_x86(self):
def test_get_instance_returns_mpv_for_pi5_and_x86(self) -> None:
for device_type in ['pi5', 'x86']:
with self.subTest(device_type=device_type):
MediaPlayerProxy.INSTANCE = None
@@ -134,7 +141,7 @@ class TestMediaPlayerProxy(unittest.TestCase):
self.assertIsInstance(instance, MPVMediaPlayer)
@patch('viewer.media_player.get_device_type', return_value='pi5')
def test_get_instance_returns_same_instance(self, _):
def test_get_instance_returns_same_instance(self, _: Any) -> None:
instance1 = MediaPlayerProxy.get_instance()
instance2 = MediaPlayerProxy.get_instance()
self.assertIs(instance1, instance2)

View File

@@ -11,7 +11,7 @@ REPO_ROOT = os.path.abspath(
SCRIPT = os.path.join(REPO_ROOT, 'bin', 'migrate_legacy_paths.sh')
def run_migrate(user_home):
def run_migrate(user_home: str) -> 'subprocess.CompletedProcess[str]':
env = os.environ.copy()
env['USER_HOME'] = user_home
# Slim PATH so the helper resolves only standard binaries. The
@@ -30,13 +30,13 @@ def run_migrate(user_home):
class MigrateLegacyPathsTest(unittest.TestCase):
def setUp(self):
def setUp(self) -> None:
self.home = tempfile.mkdtemp(prefix='anthias-migrate-test-')
def tearDown(self):
def tearDown(self) -> None:
shutil.rmtree(self.home, ignore_errors=True)
def _populate_legacy_layout(self):
def _populate_legacy_layout(self) -> None:
os.makedirs(os.path.join(self.home, 'screenly', '.git'))
os.makedirs(os.path.join(self.home, 'screenly_assets'))
os.makedirs(os.path.join(self.home, '.screenly', 'backups'))
@@ -57,7 +57,7 @@ class MigrateLegacyPathsTest(unittest.TestCase):
) as f:
f.write(b'video-stub')
def test_full_migration(self):
def test_full_migration(self) -> None:
self._populate_legacy_layout()
run_migrate(self.home)
@@ -116,7 +116,7 @@ class MigrateLegacyPathsTest(unittest.TestCase):
# Relative target so the link is portable across mounts.
self.assertEqual(os.readlink(link), expected_target)
def test_conf_rewrite_handles_absolute_paths(self):
def test_conf_rewrite_handles_absolute_paths(self) -> None:
os.makedirs(os.path.join(self.home, '.screenly'))
# User customised their conf with absolute paths.
with open(
@@ -136,7 +136,7 @@ class MigrateLegacyPathsTest(unittest.TestCase):
self.assertIn(f'database = {self.home}/.anthias/anthias.db', body)
self.assertNotIn('.screenly', body)
def test_idempotent_rerun(self):
def test_idempotent_rerun(self) -> None:
self._populate_legacy_layout()
run_migrate(self.home)
# Second run must not raise and must leave the layout intact.
@@ -147,7 +147,7 @@ class MigrateLegacyPathsTest(unittest.TestCase):
)
self.assertTrue(os.path.islink(os.path.join(self.home, 'screenly')))
def test_fresh_install_noop(self):
def test_fresh_install_noop(self) -> None:
# No legacy paths and no new paths → script should still succeed.
run_migrate(self.home)
self.assertFalse(os.path.exists(os.path.join(self.home, 'anthias')))

View File

@@ -1,6 +1,7 @@
import logging
import os
from datetime import timedelta
from typing import Any
import time_machine
from django.test import TestCase
@@ -79,26 +80,28 @@ FAKE_DB_PATH = '/tmp/fakedb'
class SchedulerTest(TestCase):
def tearDown(self):
def tearDown(self) -> None:
settings['shuffle_playlist'] = False
def create_assets(self, assets):
def create_assets(self, assets: list[dict[str, Any]]) -> None:
for asset in assets:
Asset.objects.create(**asset)
def test_generate_asset_list_assets_should_return_list_sorted_by_play_order(
self,
): # noqa: E501
) -> None: # noqa: E501
self.create_assets([ASSET_X, ASSET_Y])
assets, _ = generate_asset_list()
self.assertEqual(assets, [ASSET_Y, ASSET_X])
def test_generate_asset_list_check_deadline_if_both_active(self):
def test_generate_asset_list_check_deadline_if_both_active(self) -> None:
self.create_assets([ASSET_X, ASSET_Y])
_, deadline = generate_asset_list()
self.assertEqual(deadline, ASSET_Y['end_date'])
def test_generate_asset_list_check_deadline_if_asset_scheduled(self):
def test_generate_asset_list_check_deadline_if_asset_scheduled(
self,
) -> None:
"""If ASSET_X is active and ASSET_X[end_date] == (now + 3) and
ASSET_TOMORROW will be active tomorrow then deadline should be
ASSET_TOMORROW[start_date]
@@ -107,7 +110,7 @@ class SchedulerTest(TestCase):
_, deadline = generate_asset_list()
self.assertEqual(deadline, ASSET_TOMORROW['start_date'])
def test_get_next_asset_should_be_y_and_x(self):
def test_get_next_asset_should_be_y_and_x(self) -> None:
self.create_assets([ASSET_X, ASSET_Y])
scheduler = Scheduler()
@@ -116,7 +119,7 @@ class SchedulerTest(TestCase):
self.assertEqual([expected_y, expected_x], [ASSET_Y, ASSET_X])
def test_keep_same_position_on_playlist_update(self):
def test_keep_same_position_on_playlist_update(self) -> None:
self.create_assets([ASSET_X, ASSET_Y])
scheduler = Scheduler()
scheduler.get_next_asset()
@@ -126,7 +129,7 @@ class SchedulerTest(TestCase):
self.assertEqual(scheduler.index, 1)
def test_counter_should_increment_after_full_asset_loop(self):
def test_counter_should_increment_after_full_asset_loop(self) -> None:
settings['shuffle_playlist'] = True
self.create_assets([ASSET_X, ASSET_Y])
scheduler = Scheduler()
@@ -138,16 +141,17 @@ class SchedulerTest(TestCase):
self.assertEqual(scheduler.counter, 1)
def test_check_get_db_mtime(self):
def test_check_get_db_mtime(self) -> None:
settings['database'] = FAKE_DB_PATH
with open(FAKE_DB_PATH, 'a'):
os.utime(FAKE_DB_PATH, (0, 0))
self.assertEqual(0, Scheduler().get_db_mtime())
def test_playlist_should_be_updated_after_deadline_reached(self):
def test_playlist_should_be_updated_after_deadline_reached(self) -> None:
self.create_assets([ASSET_X, ASSET_Y])
_, deadline = generate_asset_list()
assert deadline is not None
traveller = time_machine.travel(deadline + timedelta(seconds=1))
traveller.start()

View File

@@ -1,10 +1,10 @@
from __future__ import unicode_literals
import os
import shutil
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from unittest import TestCase
from typing import Any
from unittest import TestCase, mock
user_home_dir = os.getenv('HOME')
@@ -46,20 +46,26 @@ CONFIG_FILE = CONFIG_DIR + 'anthias.conf'
@contextmanager
def fake_settings(raw):
def fake_settings(raw: str) -> Iterator[tuple[Any, Any]]:
with open(CONFIG_FILE, mode='w+') as f:
f.write(raw)
# Force a re-import so AnthiasSettings() is instantiated against the
# CONFIG_FILE we just wrote. Without this, a prior test that imported
# `settings` cleanly would leave the module cached, and `import
# settings` here would skip __init__ entirely — silently accepting
# any config (including the broken-by-design fixture).
sys.modules.pop('settings', None)
try:
import settings
yield (settings, settings.settings)
del sys.modules['settings']
finally:
sys.modules.pop('settings', None)
os.remove(CONFIG_FILE)
def getenv(k, default=None):
def getenv(k: str, default: Any = None) -> Any:
try:
return '/tmp' if k == 'HOME' else os.environ[k]
except KeyError:
@@ -67,18 +73,19 @@ def getenv(k, default=None):
class SettingsTest(TestCase):
def setUp(self):
def setUp(self) -> None:
if not os.path.exists(CONFIG_DIR):
os.mkdir(CONFIG_DIR)
self.orig_getenv = os.getenv
self._getenv_patcher = mock.patch.object(
os, 'getenv', side_effect=getenv
)
self._getenv_patcher.start()
os.getenv = getenv
def tearDown(self):
def tearDown(self) -> None:
shutil.rmtree(CONFIG_DIR)
os.getenv = self.orig_getenv
self._getenv_patcher.stop()
def test_parse_settings(self):
def test_parse_settings(self) -> None:
with fake_settings(settings1) as (mod_settings, settings):
self.assertEqual(settings['player_name'], 'new player')
self.assertEqual(settings['show_splash'], False)
@@ -86,7 +93,7 @@ class SettingsTest(TestCase):
self.assertEqual(settings['debug_logging'], True)
self.assertEqual(settings['default_duration'], 45)
def test_default_settings(self):
def test_default_settings(self) -> None:
with fake_settings(empty_settings) as (mod_settings, settings):
self.assertEqual(
settings['player_name'],
@@ -109,12 +116,12 @@ class SettingsTest(TestCase):
mod_settings.DEFAULTS['viewer']['default_duration'],
)
def broken_settings_should_raise_value_error(self):
def test_broken_settings_should_raise_value_error(self) -> None:
with self.assertRaises(ValueError):
with fake_settings(broken_settings) as (mod_settings, settings):
pass
def test_save_settings(self):
def test_save_settings(self) -> None:
with fake_settings(settings1) as (mod_settings, settings):
settings.conf_file = CONFIG_DIR + '/new.conf'
settings['default_duration'] = 35

View File

@@ -1,7 +1,6 @@
from __future__ import unicode_literals
import logging
import os
from typing import Any
import mock
from unittest_parametrize import ParametrizedTestCase, parametrize
@@ -24,7 +23,7 @@ class UpdateTest(ParametrizedTestCase):
)
def test__if_git_branch_env_does_not_exist__is_up_to_date_should_return_true(
self,
): # noqa: E501
) -> None: # noqa: E501
self.assertEqual(is_up_to_date(), True)
@parametrize(
@@ -73,8 +72,8 @@ class UpdateTest(ParametrizedTestCase):
mock.MagicMock(return_value='master'),
)
def test_is_up_to_date_should_return_value_depending_on_git_hashes(
self, hashes, expected
):
self, hashes: dict[str, Any], expected: bool
) -> None:
os.environ['GIT_BRANCH'] = 'master'
os.environ['DEVICE_TYPE'] = 'pi4'

View File

@@ -13,24 +13,24 @@ uri_ = '/home/user/file'
class UtilsTest(unittest.TestCase):
def test_unicode_correctness_in_bottle_templates(self):
def test_unicode_correctness_in_bottle_templates(self) -> None:
self.assertEqual(template_handle_unicode('hello'), 'hello')
self.assertEqual(
template_handle_unicode('Привет'),
'\u041f\u0440\u0438\u0432\u0435\u0442',
'Привет',
)
def test_json_tz(self):
def test_json_tz(self) -> None:
json_str = handler(datetime(2016, 7, 19, 12, 42))
self.assertEqual(json_str, '2016-07-19T12:42:00+00:00')
class URLHelperTest(TestCase):
def test_url_1(self):
def test_url_1(self) -> None:
self.assertTrue(url_fails(url_fail))
def test_url_2(self):
def test_url_2(self) -> None:
self.assertFalse(url_fails(url_redir))
def test_url_3(self):
def test_url_3(self) -> None:
self.assertFalse(url_fails(uri_))

View File

@@ -5,6 +5,7 @@ import logging
import os
import unittest
from time import sleep
from typing import Any
import mock
@@ -15,7 +16,7 @@ logging.disable(logging.CRITICAL)
class ViewerTestCase(unittest.TestCase):
def setUp(self):
def setUp(self) -> None:
self.original_splash_delay = viewer.SPLASH_DELAY
viewer.SPLASH_DELAY = 0
@@ -45,34 +46,34 @@ class ViewerTestCase(unittest.TestCase):
self.m_loadb = mock.Mock(name='load_browser')
self.p_loadb = mock.patch.object(self.u, 'load_browser', self.m_loadb)
def tearDown(self):
def tearDown(self) -> None:
self.u.SPLASH_DELAY = self.original_splash_delay
def noop(*a, **k):
def noop(*a: Any, **k: Any) -> None:
return None
class TestEmptyPl(ViewerTestCase):
@mock.patch('viewer.constants.SERVER_WAIT_TIMEOUT', 0)
def test_empty(self):
def test_empty(self) -> None:
m_asset_list = mock.Mock()
m_asset_list.return_value = ([], None)
with mock.patch('viewer.scheduling.generate_asset_list', m_asset_list):
self.u.scheduler = Scheduler()
setattr(self.u, 'scheduler', Scheduler())
m_asset_list.assert_called_once()
class TestLoadBrowser(ViewerTestCase):
@mock.patch('pydbus.SessionBus', mock.MagicMock())
def test_setup(self):
def test_setup(self) -> None:
self.p_loadb.start()
self.u.setup()
self.p_loadb.stop()
def test_load_browser(self):
def test_load_browser(self) -> None:
self.m_cmd.return_value.return_value.process.stdout = (
b'Screenly service start'
)
@@ -83,7 +84,7 @@ class TestLoadBrowser(ViewerTestCase):
class TestWatchdog(ViewerTestCase):
def test_watchdog_should_create_file_if_not_exists(self):
def test_watchdog_should_create_file_if_not_exists(self) -> None:
try:
os.remove(self.u.utils.WATCHDOG_PATH)
except OSError:
@@ -91,7 +92,7 @@ class TestWatchdog(ViewerTestCase):
self.u.watchdog()
self.assertEqual(os.path.exists(self.u.utils.WATCHDOG_PATH), True)
def test_watchdog_should_update_mtime(self):
def test_watchdog_should_update_mtime(self) -> None:
# for watchdog file creation
self.u.watchdog()
mtime = os.path.getmtime(self.u.utils.WATCHDOG_PATH)

View File

@@ -215,7 +215,7 @@ def main(
clean_build: bool,
build_target: str,
target_platform: str,
service,
service: tuple[str, ...],
disable_cache_mounts: bool,
environment: str,
push: bool,

View File

@@ -1,3 +1,5 @@
from typing import Any
import click
import requests
from jinja2 import Environment, FileSystemLoader
@@ -5,7 +7,7 @@ from jinja2 import Environment, FileSystemLoader
from tools.image_builder.constants import GITHUB_REPO_URL
def get_build_parameters(build_target: str) -> dict:
def get_build_parameters(build_target: str) -> dict[str, Any]:
default_build_parameters = {
'board': 'x86',
'base_image': 'debian',
@@ -64,7 +66,7 @@ def get_docker_tag(git_branch: str, board: str, platform: str) -> str:
return f'{git_branch}-{result_board}'
def generate_dockerfile(service: str, context: dict) -> None:
def generate_dockerfile(service: str, context: dict[str, Any]) -> None:
templating_environment = Environment(loader=FileSystemLoader('docker/'))
templating_environment.lstrip_blocks = True
templating_environment.trim_blocks = True
@@ -76,7 +78,7 @@ def generate_dockerfile(service: str, context: dict) -> None:
f.write(dockerfile)
def get_uv_builder_context(service: str) -> dict:
def get_uv_builder_context(service: str) -> dict[str, Any]:
service_to_group = {
'server': 'server',
'celery': 'server',
@@ -105,7 +107,7 @@ def get_uv_builder_context(service: str) -> dict:
}
def get_test_context() -> dict:
def get_test_context() -> dict[str, Any]:
chrome_dl_url = (
'https://storage.googleapis.com/chrome-for-testing-public/'
'123.0.6312.86/linux64/chrome-linux64.zip'
@@ -131,7 +133,7 @@ def get_test_context() -> dict:
}
def get_viewer_context(board: str) -> dict:
def get_viewer_context(board: str) -> dict[str, Any]:
releases_url = f'{GITHUB_REPO_URL}/releases/download'
webview_git_hash = 'd7a7e2c'
@@ -288,7 +290,7 @@ def get_viewer_context(board: str) -> dict:
}
def get_wifi_connect_context(target_platform: str) -> dict:
def get_wifi_connect_context(target_platform: str) -> dict[str, Any]:
if target_platform == 'linux/arm/v6':
architecture = 'rpi'
elif target_platform in ['linux/arm/v7', 'linux/arm/v8']:

View File

@@ -5,6 +5,7 @@ import sys
import traceback
from inspect import cleandoc
from textwrap import shorten
from typing import Any
import click
import requests
@@ -12,13 +13,13 @@ from requests.auth import HTTPBasicAuth
from requests.exceptions import RequestException
from tenacity import retry
HOME = os.getenv('HOME')
HOME = os.getenv('HOME') or ''
BASE_API_SCREENLY_URL = 'https://api.screenlyapp.com'
ASSETS_ANTHIAS_API = 'http://127.0.0.1/api/v1.1/assets'
MAX_ASSET_NAME_LENGTH = 40
PORT = 80
token = None
token: str | None = None
#############
@@ -26,7 +27,12 @@ token = None
#############
def progress_bar(count, total, asset_name='', previous_asset_name=''):
def progress_bar(
count: int,
total: int,
asset_name: str = '',
previous_asset_name: str = '',
) -> None:
"""
This simple console progress bar
For display progress asset uploads
@@ -44,7 +50,7 @@ def progress_bar(count, total, asset_name='', previous_asset_name=''):
sys.stdout.flush()
def set_token(value):
def set_token(value: str) -> None:
global token
token = f'Token {value}'
@@ -54,7 +60,8 @@ def set_token(value):
############
def get_assets_by_anthias_api():
def get_assets_by_anthias_api() -> list[dict[str, Any]]:
auth: HTTPBasicAuth | None
if click.confirm('Do you need authentication to access Anthias API?'):
login = click.prompt('Login')
password = click.prompt('Password', hide_input=True)
@@ -64,7 +71,8 @@ def get_assets_by_anthias_api():
response = requests.get(ASSETS_ANTHIAS_API, timeout=10, auth=auth)
response.raise_for_status()
return response.json()
result: list[dict[str, Any]] = response.json()
return result
############
@@ -73,11 +81,11 @@ def get_assets_by_anthias_api():
@retry
def get_post_response(endpoint_url, **kwargs):
def get_post_response(endpoint_url: str, **kwargs: Any) -> requests.Response:
return requests.post(endpoint_url, **kwargs)
def send_asset(asset):
def send_asset(asset: dict[str, Any]) -> bool:
endpoint_url = f'{BASE_API_SCREENLY_URL}/api/v4/assets'
asset_uri = asset['uri']
post_kwargs = {
@@ -109,7 +117,7 @@ def send_asset(asset):
return True
def check_validate_token(api_key):
def check_validate_token(api_key: str) -> str | None:
endpoint_url = f'{BASE_API_SCREENLY_URL}/api/v4/assets'
headers = {'Authorization': f'Token {api_key}'}
response = requests.get(endpoint_url, headers=headers)
@@ -124,12 +132,12 @@ def check_validate_token(api_key):
########
def start_migration():
def start_migration() -> None:
if click.confirm('Do you want to start assets migration?'):
assets_migration()
def assets_migration():
def assets_migration() -> None:
try:
assets = get_assets_by_anthias_api()
except RequestException as error:
@@ -182,7 +190,7 @@ def assets_migration():
),
type=click.Choice(['1', '2']),
)
def main(method):
def main(method: str) -> None:
try:
valid_token = None

282
uv.lock generated
View File

@@ -105,7 +105,19 @@ dev = [
]
dev-host = [
{ name = "ansible-lint" },
{ name = "celery-types" },
{ name = "django-stubs" },
{ name = "djangorestframework-stubs" },
{ name = "mypy" },
{ name = "ruff" },
{ name = "tenacity" },
{ name = "types-gunicorn" },
{ name = "types-mock" },
{ name = "types-netifaces" },
{ name = "types-python-dateutil" },
{ name = "types-pytz" },
{ name = "types-pyyaml" },
{ name = "types-requests" },
]
docker-image-builder = [
{ name = "click" },
@@ -127,6 +139,37 @@ local = [
{ name = "requests" },
{ name = "tenacity" },
]
mypy = [
{ name = "ansible-lint" },
{ name = "celery-types" },
{ name = "channels" },
{ name = "channels-redis" },
{ name = "click" },
{ name = "django" },
{ name = "django-dbbackup" },
{ name = "django-stubs" },
{ name = "django-stubs-ext" },
{ name = "djangorestframework" },
{ name = "djangorestframework-stubs" },
{ name = "drf-spectacular" },
{ name = "jinja2" },
{ name = "mypy" },
{ name = "pygit2" },
{ name = "python-on-whales" },
{ name = "pytz" },
{ name = "pyzmq" },
{ name = "requests" },
{ name = "ruff" },
{ name = "tenacity" },
{ name = "types-gunicorn" },
{ name = "types-mock" },
{ name = "types-netifaces" },
{ name = "types-python-dateutil" },
{ name = "types-pytz" },
{ name = "types-pyyaml" },
{ name = "types-requests" },
{ name = "whitenoise" },
]
server = [
{ name = "cec" },
{ name = "celery" },
@@ -139,6 +182,7 @@ server = [
{ name = "cython" },
{ name = "django" },
{ name = "django-dbbackup" },
{ name = "django-stubs-ext" },
{ name = "djangorestframework" },
{ name = "drf-spectacular" },
{ name = "future" },
@@ -180,6 +224,7 @@ test = [
{ name = "cython" },
{ name = "django" },
{ name = "django-dbbackup" },
{ name = "django-stubs-ext" },
{ name = "djangorestframework" },
{ name = "drf-spectacular" },
{ name = "future" },
@@ -266,7 +311,19 @@ dev = [
]
dev-host = [
{ name = "ansible-lint", specifier = "==26.4.0" },
{ name = "celery-types", specifier = "==0.26.0" },
{ name = "django-stubs", specifier = "==6.0.3" },
{ name = "djangorestframework-stubs", specifier = "==3.16.9" },
{ name = "mypy", specifier = "==1.18.2" },
{ name = "ruff", specifier = "==0.14.10" },
{ name = "tenacity", specifier = "==9.1.2" },
{ name = "types-gunicorn", specifier = "==25.3.0.20260408" },
{ name = "types-mock", specifier = "==5.2.0.20260408" },
{ name = "types-netifaces", specifier = "==0.11.0.20260408" },
{ name = "types-python-dateutil", specifier = "==2.9.0.20260408" },
{ name = "types-pytz", specifier = "==2026.1.1.20260408" },
{ name = "types-pyyaml", specifier = "==6.0.12.20260408" },
{ name = "types-requests", specifier = "==2.33.0.20260408" },
]
docker-image-builder = [
{ name = "click", specifier = "==8.1.7" },
@@ -288,6 +345,37 @@ local = [
{ name = "requests", specifier = "==2.33.1" },
{ name = "tenacity", specifier = "==9.1.2" },
]
mypy = [
{ name = "ansible-lint", specifier = "==26.4.0" },
{ name = "celery-types", specifier = "==0.26.0" },
{ name = "channels", specifier = "==4.3.1" },
{ name = "channels-redis", specifier = "==4.3.0" },
{ name = "click", specifier = "==8.1.7" },
{ name = "django", specifier = "==4.2.30" },
{ name = "django-dbbackup", specifier = "==4.2.1" },
{ name = "django-stubs", specifier = "==6.0.3" },
{ name = "django-stubs-ext", specifier = "==6.0.3" },
{ name = "djangorestframework", specifier = "==3.16.1" },
{ name = "djangorestframework-stubs", specifier = "==3.16.9" },
{ name = "drf-spectacular", specifier = "==0.29.0" },
{ name = "jinja2", specifier = "==3.1.6" },
{ name = "mypy", specifier = "==1.18.2" },
{ name = "pygit2", specifier = "==1.19.1" },
{ name = "python-on-whales", specifier = "==0.79.0" },
{ name = "pytz", specifier = "==2025.2" },
{ name = "pyzmq", specifier = "==23.2.1" },
{ name = "requests", specifier = "==2.33.1" },
{ name = "ruff", specifier = "==0.14.10" },
{ name = "tenacity", specifier = "==9.1.2" },
{ name = "types-gunicorn", specifier = "==25.3.0.20260408" },
{ name = "types-mock", specifier = "==5.2.0.20260408" },
{ name = "types-netifaces", specifier = "==0.11.0.20260408" },
{ name = "types-python-dateutil", specifier = "==2.9.0.20260408" },
{ name = "types-pytz", specifier = "==2026.1.1.20260408" },
{ name = "types-pyyaml", specifier = "==6.0.12.20260408" },
{ name = "types-requests", specifier = "==2.33.0.20260408" },
{ name = "whitenoise", specifier = "==6.8.2" },
]
server = [
{ name = "cec", specifier = "==0.2.8" },
{ name = "celery", specifier = "==5.2.2" },
@@ -300,6 +388,7 @@ server = [
{ name = "cython", specifier = "==3.2.4" },
{ name = "django", specifier = "==4.2.30" },
{ name = "django-dbbackup", specifier = "==4.2.1" },
{ name = "django-stubs-ext", specifier = "==6.0.3" },
{ name = "djangorestframework", specifier = "==3.16.1" },
{ name = "drf-spectacular", specifier = "==0.29.0" },
{ name = "future", specifier = "==1.0.0" },
@@ -341,6 +430,7 @@ test = [
{ name = "cython", specifier = "==3.2.4" },
{ name = "django", specifier = "==4.2.30" },
{ name = "django-dbbackup", specifier = "==4.2.1" },
{ name = "django-stubs-ext", specifier = "==6.0.3" },
{ name = "djangorestframework", specifier = "==3.16.1" },
{ name = "drf-spectacular", specifier = "==0.29.0" },
{ name = "future", specifier = "==1.0.0" },
@@ -534,6 +624,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/a9/57e261eb3d95c74faf6a0ab7b21b4a875bfe3f71d60b1788ddafdef71f37/celery-5.2.2-py3-none-any.whl", hash = "sha256:5a68a351076cfac4f678fa5ffd898105c28825a2224902da006970005196d061", size = 405080, upload-time = "2021-12-26T14:31:56.465Z" },
]
[[package]]
name = "celery-types"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/38/813dd7534e41682684d3a5c2cc4a8710e3acc51b364920b9c4d747c7b18f/celery_types-0.26.0.tar.gz", hash = "sha256:fa318136fdad83f83f1531deecd9fe664b5dfffff29f3c31e9120a46b8e3908f", size = 106210, upload-time = "2026-03-12T23:06:49.941Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/e5/c5ec98f7fd7817d077c9a5a5e705d54f74d4ca08ee3f14dee881c93c0511/celery_types-0.26.0-py3-none-any.whl", hash = "sha256:eb9da76f461786091970df466ec647d9a27956399852542cb6cab9309970f950", size = 211260, upload-time = "2026-03-12T23:06:48.588Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
@@ -910,6 +1012,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/56/269824e8ae46772c3506c72ece58148f13e65a2ffaed42c5bd428960a729/django_dbbackup-4.2.1-py3-none-any.whl", hash = "sha256:b23265600ead0780ca781b1b4b594949aaa8a20d74f08701f91ee9d7eb1f08cd", size = 59133, upload-time = "2024-08-23T22:18:03.825Z" },
]
[[package]]
name = "django-stubs"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-stubs-ext" },
{ name = "types-pyyaml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/0c/8d0d875af79bf774c1c3997c84aa118dba3a77be12086b9c14e130e8ec72/django_stubs-6.0.3.tar.gz", hash = "sha256:ee895f403c373608eeb50822f0733f9d9ec5ab12731d4ab58956053bb95fdd9e", size = 278214, upload-time = "2026-04-18T15:11:22.327Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/a3/6751b7684d20fc4f228bdd3dd8341d382ab3faaf65d3d050c0d59ab0a1b0/django_stubs-6.0.3-py3-none-any.whl", hash = "sha256:5fee22bcbbad59a78c727a820b6f4e68ff442ca76a922b7002e57c25dd7cb390", size = 541570, upload-time = "2026-04-18T15:11:20.711Z" },
]
[[package]]
name = "django-stubs-ext"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/e6/5dcdaa785ec3eed5fc196c7e68fb7ad9d9fe6d5acccea4690e65f2546417/django_stubs_ext-6.0.3.tar.gz", hash = "sha256:3307d42132bc295d5744de6276bc5fdf6896efc70f891e21c0ae8bdf529d2762", size = 6663, upload-time = "2026-04-18T15:10:53.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/fa/0a3a05c29d6295dbd52fa3cb4047a95de11ba4f2696072d6f3f2c1e6f370/django_stubs_ext-6.0.3-py3-none-any.whl", hash = "sha256:9e4105955419ae310d7da9cfd808e039d4dae3092c628f021057bb4f2c237f8f", size = 10354, upload-time = "2026-04-18T15:10:52.395Z" },
]
[[package]]
name = "djangorestframework"
version = "3.16.1"
@@ -922,6 +1052,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
]
[[package]]
name = "djangorestframework-stubs"
version = "3.16.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django-stubs" },
{ name = "types-pyyaml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/84/fa0e31f763ee35152a418c2a456efdd8047a9da0f5909110147b70382191/djangorestframework_stubs-3.16.9.tar.gz", hash = "sha256:b1abb97490c90c85eabcd09b8ecbadae1b9360f21ad3021abf830227c0129697", size = 32798, upload-time = "2026-03-31T22:40:23.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/be/e53e3b89eaa30c21e036ae4d2ee88a92ef8cb43678400901748ddad870c5/djangorestframework_stubs-3.16.9-py3-none-any.whl", hash = "sha256:27b3e245d5f9c22ff6988d9e54388249f98f88608cc2b365b71e9f39dd096958", size = 57239, upload-time = "2026-03-31T22:40:22.314Z" },
]
[[package]]
name = "drf-spectacular"
version = "0.29.0"
@@ -1240,6 +1384,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
]
[[package]]
name = "mypy"
version = "1.18.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" },
{ url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" },
{ url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" },
{ url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" },
{ url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" },
{ url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" },
{ url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" },
{ url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" },
{ url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" },
{ url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" },
{ url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" },
{ url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" },
{ url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" },
{ url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" },
{ url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" },
{ url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" },
{ url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" },
{ url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
@@ -2161,6 +2343,106 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" },
]
[[package]]
name = "types-gevent"
version = "26.4.0.20260409"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-greenlet" },
{ name = "types-psutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/ea/17fa935aa62d45cb9f67947e93c3c0c1ed97a76d579b12e1623cd348b68a/types_gevent-26.4.0.20260409.tar.gz", hash = "sha256:6b029c599fe4ec0efce8cd2bf5e5ae958d9808aa5b2f7bdfcb9b9eb42d91cc6a", size = 38333, upload-time = "2026-04-09T04:22:42.334Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/17/04671a7e3de8c0fdd4c39dc43830b496ad68998d37cff38e0d9701f77a67/types_gevent-26.4.0.20260409-py3-none-any.whl", hash = "sha256:f5f5eb7365a9b8b738787a2dc93c509ee0ca919c6d4388504f2cd09e476d4066", size = 55491, upload-time = "2026-04-09T04:22:41.226Z" },
]
[[package]]
name = "types-greenlet"
version = "3.4.0.20260409"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/27/a6/668751bc864efe820e1eb12c2a77f9e62537f433cc002e483ad01badb04b/types_greenlet-3.4.0.20260409.tar.gz", hash = "sha256:81d2cf628934a16856bb9e54136def8de5356e934f0ad5d5474f219a0c5cb205", size = 8976, upload-time = "2026-04-09T04:22:31.693Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/3f/c8a4d8782f78fccb4b5fe91c5eae2efce6648072754bc7096b1e3b5407ad/types_greenlet-3.4.0.20260409-py3-none-any.whl", hash = "sha256:cbceadb4594eccd95b57b3f7fa8a9b851488f5e6c05026f4a3db9aac02ec8333", size = 8812, upload-time = "2026-04-09T04:22:30.734Z" },
]
[[package]]
name = "types-gunicorn"
version = "25.3.0.20260408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-gevent" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/94/1da5bcee084a5c33af582d46540ad3bb153852c138fc0c63832448fd6b10/types_gunicorn-25.3.0.20260408.tar.gz", hash = "sha256:51596970b1aff649530d74e50e7a055b09531954752a4498185f48bf37517b79", size = 34002, upload-time = "2026-04-08T04:37:25.323Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/75/e06c1c644c6af5ead6963470c637a043c4b1c0179858bf73a9e1b3056878/types_gunicorn-25.3.0.20260408-py3-none-any.whl", hash = "sha256:5bfd757504f932256c3303135e1cd2968859c8196c6c3ad8b0a66f67aea80b02", size = 52677, upload-time = "2026-04-08T04:37:24.082Z" },
]
[[package]]
name = "types-mock"
version = "5.2.0.20260408"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/4f/a17fd8f0bdeb8d97842e4a825907e7ef7d0360e29728b7a0442d245e28e9/types_mock-5.2.0.20260408.tar.gz", hash = "sha256:0bca3ca21943ba25bca6888b2383b91938f5978a189c840656e255073d53868d", size = 11494, upload-time = "2026-04-08T04:31:46.301Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/f8/dc90b73af9c04a9ff89eb785d4b63482ac74c365668a22a0b609a8e90ce9/types_mock-5.2.0.20260408-py3-none-any.whl", hash = "sha256:7754cdaadb81127124804acf2abea413705c1ac22f7bd2bd8417710d2e507216", size = 10500, upload-time = "2026-04-08T04:31:45.404Z" },
]
[[package]]
name = "types-netifaces"
version = "0.11.0.20260408"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/12/dc9ecb086c06c533206ccd21de7525873c4daa5a563854a90f0d0b2530fd/types_netifaces-0.11.0.20260408.tar.gz", hash = "sha256:5237ed095d79067d9c02f7618511c745332b8bce5bb2a217ff22c49614a80a6b", size = 7352, upload-time = "2026-04-08T04:32:57.855Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d6/29bc5d805a02938a5804a2ed3351b569c59aefd8dce404487470bed35358/types_netifaces-0.11.0.20260408-py3-none-any.whl", hash = "sha256:e34d79960e820fb279c053d16727b1bad400763ec3f4f399c4651ccab536aa8d", size = 7719, upload-time = "2026-04-08T04:32:57.028Z" },
]
[[package]]
name = "types-psutil"
version = "7.2.2.20260408"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/14/279fd5defebbd560ede04aecd38f7651cccee7336f2264d0889d8c9a9d43/types_psutil-7.2.2.20260408.tar.gz", hash = "sha256:e8053450685965b8cd52afb62569073d00ea9967ae78bb45dff5f606847f97f2", size = 26556, upload-time = "2026-04-08T04:27:44.349Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/40/2fd92a4a1ee088c4dbcc44c977908d9869838d9cd2a2fa2e001352f56694/types_psutil-7.2.2.20260408-py3-none-any.whl", hash = "sha256:0c334f6f6bc9e9c24fca5c7d1f0b6971c961a0a2e3956dc5ce704722c01f9762", size = 32861, upload-time = "2026-04-08T04:27:42.929Z" },
]
[[package]]
name = "types-python-dateutil"
version = "2.9.0.20260408"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/f3/2427775f80cd5e19a0a71ba8e5ab7645a01a852f43a5fd0ffc24f66338e0/types_python_dateutil-2.9.0.20260408.tar.gz", hash = "sha256:8b056ec01568674235f64ecbcef928972a5fac412f5aab09c516dfa2acfbb582", size = 16981, upload-time = "2026-04-08T04:28:10.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/c6/eeba37bfee282a6a97f889faef9352d6172c6a5088eb9a4daf570d9d748d/types_python_dateutil-2.9.0.20260408-py3-none-any.whl", hash = "sha256:473139d514a71c9d1fbd8bb328974bedcb1cc3dba57aad04ffa4157f483c216f", size = 18437, upload-time = "2026-04-08T04:28:10.095Z" },
]
[[package]]
name = "types-pytz"
version = "2026.1.1.20260408"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/b7/33f5a4f29b1f285b99ff79a607751a7996194cbb98705e331dab7a2daa28/types_pytz-2026.1.1.20260408.tar.gz", hash = "sha256:89b6a34b9198ea2a4b98a9d15cbca987053f52a105fd44f7ce3789cae4349408", size = 10788, upload-time = "2026-04-08T04:28:14.54Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/90/12c059e6bb330a22d9cc97daf027ac7fb7f50fbf518e4d88185b4d39120e/types_pytz-2026.1.1.20260408-py3-none-any.whl", hash = "sha256:c7e4dec76221fb7d0c97b91ad8561d689bebe39b6bcb7b728387e7ffd8cde788", size = 10124, upload-time = "2026-04-08T04:28:13.353Z" },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20260408"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/73/b759b1e413c31034cc01ecdfb96b38115d0ab4db55a752a3929f0cd449fd/types_pyyaml-6.0.12.20260408.tar.gz", hash = "sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307", size = 17735, upload-time = "2026-04-08T04:30:50.974Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" },
]
[[package]]
name = "types-requests"
version = "2.33.0.20260408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/b8/78fd6c037de4788c040fdd323b3369804400351b7827473920f6c1d03c10/types_requests-2.33.0.20260408-py3-none-any.whl", hash = "sha256:81f31d5ea4acb39f03be7bc8bed569ba6d5a9c5d97e89f45ac43d819b68ca50f", size = 20739, upload-time = "2026-04-08T04:34:48.325Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"

View File

@@ -1,32 +1,27 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import json
import logging
import sys
from builtins import range
from os import getenv, path
from signal import SIGALRM, signal
from time import sleep
from typing import Any
import django
import pydbus
import sh
from future import standard_library
import sh as sh
from jinja2 import Template
from tenacity import Retrying, stop_after_attempt, wait_fixed
from settings import LISTEN, PORT, ZmqConsumer, settings
from viewer.constants import (
BALENA_IP_RETRY_DELAY,
EMPTY_PL_DELAY,
MAX_BALENA_IP_RETRIES,
SERVER_WAIT_TIMEOUT,
SPLASH_DELAY,
SPLASH_PAGE_URL,
STANDBY_SCREEN,
)
from viewer.constants import BALENA_IP_RETRY_DELAY as BALENA_IP_RETRY_DELAY
from viewer.constants import EMPTY_PL_DELAY as EMPTY_PL_DELAY
from viewer.constants import MAX_BALENA_IP_RETRIES as MAX_BALENA_IP_RETRIES
from viewer.constants import SERVER_WAIT_TIMEOUT as SERVER_WAIT_TIMEOUT
from viewer.constants import SPLASH_DELAY as SPLASH_DELAY
from viewer.constants import SPLASH_PAGE_URL as SPLASH_PAGE_URL
from viewer.constants import STANDBY_SCREEN as STANDBY_SCREEN
from viewer.media_player import MediaPlayerProxy
from viewer.playback import navigate_to_asset, play_loop, skip_asset, stop_loop
from viewer.utils import (
@@ -55,31 +50,31 @@ try:
except Exception:
pass
standard_library.install_aliases()
__author__ = 'Screenly, Inc'
__copyright__ = 'Copyright 2012-2024, Screenly, Inc'
__copyright__ = 'Copyright 2012-2026, Screenly, Inc'
__license__ = 'Dual License: GPLv2 and Commercial License'
current_browser_url = None
browser = None
loop_is_stopped = False
browser_bus = None
current_browser_url: str | None = None
browser: Any = None
loop_is_stopped: bool = False
browser_bus: Any = None
r = connect_to_redis()
HOME = None
HOME: str | None = None
scheduler = None
scheduler: Any = None
load_screen_displayed: bool = False
mq_data: Any = None
def send_current_asset_id_to_server():
def send_current_asset_id_to_server() -> None:
consumer = ZmqConsumer()
consumer.send({'current_asset_id': scheduler.current_asset_id})
def show_hotspot_page(data):
def show_hotspot_page(data: str) -> None:
global loop_is_stopped
uri = 'http://{0}:{1}/hotspot'.format(LISTEN, PORT)
@@ -104,7 +99,7 @@ def show_hotspot_page(data):
view_webpage(uri)
def setup_wifi(data):
def setup_wifi(data: str) -> None:
global load_screen_displayed, mq_data
if not load_screen_displayed:
mq_data = data
@@ -113,7 +108,7 @@ def setup_wifi(data):
show_hotspot_page(data)
def show_splash(data):
def show_splash(data: str) -> None:
global loop_is_stopped
if is_balena_app():
@@ -150,7 +145,7 @@ commands = {
}
def load_browser():
def load_browser() -> None:
global browser
logging.info('Loading browser...')
@@ -161,7 +156,7 @@ def load_browser():
sleep(1)
def view_webpage(uri):
def view_webpage(uri: str) -> None:
global current_browser_url
if browser is None or not browser.is_alive():
@@ -172,7 +167,7 @@ def view_webpage(uri):
logging.info('Current url is {0}'.format(current_browser_url))
def view_image(uri):
def view_image(uri: str) -> None:
global current_browser_url
if browser is None or not browser.is_alive():
@@ -186,7 +181,7 @@ def view_image(uri):
logging.info(browser.process.stdout)
def view_video(uri, duration):
def view_video(uri: str, duration: int | str) -> None:
logging.debug('Displaying video %s for %s ', uri, duration)
media_player = MediaPlayerProxy.get_instance()
@@ -212,7 +207,7 @@ def view_video(uri, duration):
media_player.stop()
def load_settings():
def load_settings() -> None:
"""
Load settings and set the log level.
"""
@@ -222,7 +217,7 @@ def load_settings():
)
def asset_loop(scheduler):
def asset_loop(scheduler: Any) -> None:
asset = scheduler.get_next_asset()
if asset is None:
@@ -288,7 +283,7 @@ def asset_loop(scheduler):
pass
def setup():
def setup() -> None:
global HOME, browser_bus
HOME = getenv('HOME')
if not HOME:
@@ -308,7 +303,7 @@ def setup():
browser_bus = bus.get('screenly.webview', '/Screenly')
def wait_for_node_ip(seconds):
def wait_for_node_ip(seconds: int) -> None:
for _ in range(seconds):
try:
get_node_ip()
@@ -317,7 +312,7 @@ def wait_for_node_ip(seconds):
sleep(1)
def start_loop():
def start_loop() -> None:
global loop_is_stopped
logging.debug('Entering infinite loop.')
@@ -329,7 +324,7 @@ def start_loop():
asset_loop(scheduler)
def main():
def main() -> None:
global scheduler
global load_screen_displayed, mq_data

View File

@@ -1,7 +1,6 @@
from __future__ import unicode_literals
import logging
import subprocess
from typing import ClassVar
import vlc
@@ -12,38 +11,39 @@ VIDEO_TIMEOUT = 20 # secs
class MediaPlayer:
def __init__(self):
def __init__(self) -> None:
pass
def set_asset(self, uri, duration):
def set_asset(self, uri: str, duration: int | str) -> None:
raise NotImplementedError
def play(self):
def play(self) -> None:
raise NotImplementedError
def stop(self):
def stop(self) -> None:
raise NotImplementedError
def is_playing(self):
def is_playing(self) -> bool:
raise NotImplementedError
class MPVMediaPlayer(MediaPlayer):
def __init__(self):
def __init__(self) -> None:
MediaPlayer.__init__(self)
self.process = None
self.process: subprocess.Popen[bytes] | None = None
self.uri: str = ''
def set_asset(self, uri, duration):
def set_asset(self, uri: str, duration: int | str) -> None:
self.uri = uri
def play(self):
def play(self) -> None:
self.process = subprocess.Popen(
['mpv', '--no-terminal', '--vo=drm', '--', self.uri],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def stop(self):
def stop(self) -> None:
try:
if self.process:
self.process.terminate()
@@ -51,14 +51,14 @@ class MPVMediaPlayer(MediaPlayer):
except Exception as e:
logging.error(f'Exception in stop(): {e}')
def is_playing(self):
def is_playing(self) -> bool:
if self.process:
return self.process.poll() is None
return False
class VLCMediaPlayer(MediaPlayer):
def __init__(self):
def __init__(self) -> None:
MediaPlayer.__init__(self)
options = self.__get_options()
@@ -67,7 +67,7 @@ class VLCMediaPlayer(MediaPlayer):
self.player.audio_output_set('alsa')
def get_alsa_audio_device(self):
def get_alsa_audio_device(self) -> str:
if settings['audio_output'] == 'local':
if get_device_type() == 'pi5':
return 'default:CARD=vc4hdmi0'
@@ -81,12 +81,12 @@ class VLCMediaPlayer(MediaPlayer):
else:
return 'default:CARD=HID'
def __get_options(self):
def __get_options(self) -> list[str]:
return [
f'--alsa-audio-device={self.get_alsa_audio_device()}',
]
def set_asset(self, uri, duration):
def set_asset(self, uri: str, duration: int | str) -> None:
self.player.set_mrl(uri)
settings.load()
self.player.audio_output_device_set(
@@ -98,13 +98,13 @@ class VLCMediaPlayer(MediaPlayer):
# startup gap we're trying to reduce.
self.player.get_media().parse()
def play(self):
def play(self) -> None:
self.player.play()
def stop(self):
def stop(self) -> None:
self.player.stop()
def is_playing(self):
def is_playing(self) -> bool:
return self.player.get_state() in [
vlc.State.Playing,
vlc.State.Buffering,
@@ -113,10 +113,10 @@ class VLCMediaPlayer(MediaPlayer):
class MediaPlayerProxy:
INSTANCE = None
INSTANCE: ClassVar[MediaPlayer | None] = None
@classmethod
def get_instance(cls):
def get_instance(cls) -> MediaPlayer:
if cls.INSTANCE is None:
if get_device_type() in ['pi1', 'pi2', 'pi3', 'pi4']:
cls.INSTANCE = VLCMediaPlayer()

View File

@@ -1,24 +1,25 @@
import threading
from typing import Any
# Global event for instant asset switching
skip_event = threading.Event()
def skip_asset(scheduler, back=False):
def skip_asset(scheduler: Any, back: bool = False) -> None:
if back is True:
scheduler.reverse = True
skip_event.set()
def navigate_to_asset(scheduler, asset_id):
def navigate_to_asset(scheduler: Any, asset_id: str) -> None:
scheduler.extra_asset = asset_id
skip_event.set()
def stop_loop(scheduler):
def stop_loop(scheduler: Any) -> bool:
skip_asset(scheduler)
return True
def play_loop():
def play_loop() -> bool:
return False

View File

@@ -1,6 +1,8 @@
import logging
from datetime import datetime
from os import path
from random import shuffle
from typing import Any
from django.utils import timezone
@@ -8,16 +10,17 @@ from anthias_app.models import Asset
from settings import settings
def get_specific_asset(asset_id):
def get_specific_asset(asset_id: str) -> dict[str, Any] | None:
logging.info('Getting specific asset')
try:
return Asset.objects.get(asset_id=asset_id).__dict__
result: dict[str, Any] = Asset.objects.get(asset_id=asset_id).__dict__
return result
except Asset.DoesNotExist:
logging.debug('Asset %s not found in database', asset_id)
return None
def generate_asset_list():
def generate_asset_list() -> tuple[list[dict[str, Any]], datetime | None]:
"""Choose deadline via:
1. Map assets to deadlines with rule: if asset is active then
'end_date' else 'start_date'
@@ -26,8 +29,10 @@ def generate_asset_list():
logging.info('Generating asset-list...')
assets = Asset.objects.all()
deadlines = [
asset.end_date if asset.is_active() else asset.start_date
d
for asset in assets
if (d := asset.end_date if asset.is_active() else asset.start_date)
is not None
]
enabled_assets = Asset.objects.filter(
@@ -50,19 +55,20 @@ def generate_asset_list():
return playlist, deadline
class Scheduler(object):
def __init__(self, *args, **kwargs):
class Scheduler:
def __init__(self, *args: Any, **kwargs: Any) -> None:
logging.debug('Scheduler init')
self.assets = []
self.counter = 0
self.current_asset_id = None
self.deadline = None
self.extra_asset = None
self.index = 0
self.reverse = 0
self.assets: list[dict[str, Any]] = []
self.counter: int = 0
self.current_asset_id: str | None = None
self.deadline: datetime | None = None
self.extra_asset: str | None = None
self.index: int = 0
self.reverse: bool = False
self.last_update_db_mtime: float = 0
self.update_playlist()
def get_next_asset(self):
def get_next_asset(self) -> dict[str, Any] | None:
logging.debug('get_next_asset')
if self.extra_asset is not None:
@@ -101,7 +107,7 @@ class Scheduler(object):
self.current_asset_id = current_asset.get('asset_id')
return current_asset
def refresh_playlist(self):
def refresh_playlist(self) -> None:
logging.debug('refresh_playlist')
time_cur = timezone.now()
@@ -120,7 +126,7 @@ class Scheduler(object):
elif self.deadline and self.deadline <= time_cur:
self.update_playlist()
def update_playlist(self):
def update_playlist(self) -> None:
logging.debug('update_playlist')
self.last_update_db_mtime = self.get_db_mtime()
(new_assets, new_deadline) = generate_asset_list()
@@ -142,7 +148,7 @@ class Scheduler(object):
self.deadline,
)
def get_db_mtime(self):
def get_db_mtime(self) -> float:
# get database file last modification time
try:
return path.getmtime(settings['database'])

View File

@@ -1,6 +1,9 @@
import logging
import threading
from os import path, utime
from time import sleep
from types import FrameType
from typing import Any
import requests
@@ -10,14 +13,14 @@ from settings import LISTEN, PORT
WATCHDOG_PATH = '/tmp/anthias.watchdog'
def sigalrm(signum, frame):
def sigalrm(signum: int, frame: FrameType | None) -> None:
"""
Signal just throw an SigalrmError
"""
raise SigalrmError('SigalrmError')
def get_skip_event():
def get_skip_event() -> threading.Event:
"""
Get the global skip event for instant asset switching.
"""
@@ -26,11 +29,11 @@ def get_skip_event():
return skip_event
def command_not_found():
def command_not_found(*args: Any, **kwargs: Any) -> None:
logging.error('Command not found')
def watchdog():
def watchdog() -> None:
"""Notify the watchdog file to be used with the watchdog-device."""
if not path.isfile(WATCHDOG_PATH):
open(WATCHDOG_PATH, 'w').close()
@@ -38,7 +41,7 @@ def watchdog():
utime(WATCHDOG_PATH, None)
def wait_for_server(retries, wt=1):
def wait_for_server(retries: int, wt: int = 1) -> None:
for _ in range(retries):
try:
response = requests.get(f'http://{LISTEN}:{PORT}/splash-page')

View File

@@ -1,5 +1,5 @@
from builtins import bytes
from threading import Thread
from typing import Any, Callable
import zmq
@@ -9,11 +9,11 @@ ZMQ_HOST_PUB_URL = 'tcp://host.docker.internal:10001'
class ZmqSubscriber(Thread):
def __init__(
self,
redis_connection,
commands,
publisher_url,
topic='viewer',
):
redis_connection: Any,
commands: dict[str, Callable[[str | None], Any]],
publisher_url: str,
topic: str = 'viewer',
) -> None:
Thread.__init__(self)
self.context = zmq.Context()
self.publisher_url = publisher_url
@@ -21,7 +21,7 @@ class ZmqSubscriber(Thread):
self.commands = commands
self.redis_connection = redis_connection
def run(self):
def run(self) -> None:
socket = self.context.socket(zmq.SUB)
socket.connect(self.publisher_url)
socket.setsockopt(zmq.SUBSCRIBE, bytes(self.topic, encoding='utf-8'))
@@ -39,4 +39,6 @@ class ZmqSubscriber(Thread):
command = parts[0]
parameter = parts[1] if len(parts) > 1 else None
self.commands.get(command, self.commands.get('unknown'))(parameter)
handler = self.commands.get(command, self.commands.get('unknown'))
if handler is not None:
handler(parameter)