mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-10 09:08:09 -04:00
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:
60
.github/workflows/python-mypy.yaml
vendored
Normal file
60
.github/workflows/python-mypy.yaml
vendored
Normal 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 .
|
||||
@@ -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',
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 = '&'
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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')
|
||||
|
||||
159
lib/auth.py
159
lib/auth.py
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
|
||||
class SigalrmError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
122
lib/utils.py
122
lib/utils.py
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
|
||||
132
settings.py
132
settings.py
@@ -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))
|
||||
|
||||
|
||||
0
stubs/channels-stubs/__init__.pyi
Normal file
0
stubs/channels-stubs/__init__.pyi
Normal file
0
stubs/channels-stubs/generic/__init__.pyi
Normal file
0
stubs/channels-stubs/generic/__init__.pyi
Normal file
21
stubs/channels-stubs/generic/websocket.pyi
Normal file
21
stubs/channels-stubs/generic/websocket.pyi
Normal 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: ...
|
||||
3
stubs/channels-stubs/layers.pyi
Normal file
3
stubs/channels-stubs/layers.pyi
Normal file
@@ -0,0 +1,3 @@
|
||||
from typing import Any
|
||||
|
||||
def get_channel_layer(alias: str = ...) -> Any: ...
|
||||
1
stubs/channels-stubs/py.typed
Normal file
1
stubs/channels-stubs/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
partial
|
||||
7
stubs/channels-stubs/routing.pyi
Normal file
7
stubs/channels-stubs/routing.pyi
Normal 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: ...
|
||||
0
stubs/channels-stubs/security/__init__.pyi
Normal file
0
stubs/channels-stubs/security/__init__.pyi
Normal file
4
stubs/channels-stubs/security/websocket.pyi
Normal file
4
stubs/channels-stubs/security/websocket.pyi
Normal file
@@ -0,0 +1,4 @@
|
||||
from typing import Any
|
||||
|
||||
class AllowedHostsOriginValidator:
|
||||
def __init__(self, application: Any) -> None: ...
|
||||
0
stubs/drf_spectacular-stubs/__init__.pyi
Normal file
0
stubs/drf_spectacular-stubs/__init__.pyi
Normal file
28
stubs/drf_spectacular-stubs/views.pyi
Normal file
28
stubs/drf_spectacular-stubs/views.pyi
Normal 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: ...
|
||||
2
stubs/redis-stubs/__init__.pyi
Normal file
2
stubs/redis-stubs/__init__.pyi
Normal file
@@ -0,0 +1,2 @@
|
||||
from redis.client import PubSub as PubSub, Redis as Redis
|
||||
from redis.exceptions import ConnectionError as ConnectionError
|
||||
20
stubs/redis-stubs/client.pyi
Normal file
20
stubs/redis-stubs/client.pyi
Normal 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: ...
|
||||
2
stubs/redis-stubs/exceptions.pyi
Normal file
2
stubs/redis-stubs/exceptions.pyi
Normal file
@@ -0,0 +1,2 @@
|
||||
class RedisError(Exception): ...
|
||||
class ConnectionError(RedisError): ...
|
||||
1
stubs/redis-stubs/py.typed
Normal file
1
stubs/redis-stubs/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
partial
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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_))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']:
|
||||
|
||||
@@ -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
282
uv.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user