Files
Anthias/tests/test_settings.py
Viktor Petersson 133ec78ff0 refactor(packaging): adopt src/ layout with split server/viewer packages (#2817)
* refactor(packaging): adopt src/ layout with split server/viewer packages

Move all Python source under src/ following modern packaging conventions.
Server, viewer, host-agent, and shared common code now live as four
top-level packages with clear excision boundaries — anthias_viewer can
be removed wholesale when the rewrite-out-of-Python lands without
touching the server.

  src/anthias_common/         shared: errors, utils, internal_auth, device_helper
  src/anthias_server/         Django app, REST API, Celery tasks, manage.py
    lib/                      server-only: auth, backup_helper, diagnostics, github, telemetry
  src/anthias_viewer/         player runtime (was viewer/)
  src/anthias_host_agent/     systemd-driven host shim (was host_agent.py)
  tools/raspberry_pi_imager/  moved from repo root
  tests/conftest.py           moved from repo root

pyproject.toml gets [build-system], setuptools src/ discovery, and an
anthias-manage console script. Django AppConfigs keep label='anthias_app'
and label='api' so existing migration dependency tuples don't move.
BASE_DIR computed from parents[3] to keep templates/static at repo root.
mypy_path set to ["src", "stubs"] with explicit_package_bases.

Dockerfile templates set PYTHONPATH=/usr/src/app/src; bin/start_*.sh
and CI workflows use python -m anthias_server.manage / python -m
anthias_viewer instead of bare ./manage.py and python -m viewer.
Ansible host-agent unit invokes python -m anthias_host_agent.

Verified end-to-end in the docker test container:
  - 430 unit tests pass (matches baseline)
  - 7 integration tests pass, 5 skipped (matches baseline)
  - ruff, mypy clean

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

* style: ruff format the new src/ tree

The longer post-rename module paths (anthias_common.internal_auth vs
lib.internal_auth, etc.) pushed several import lines past 79 chars, so
ruff format had to wrap them. Apply that formatting and split the one
multi-import in anthias_viewer/__init__.py into per-symbol lines so the
existing # noqa: E402 sits on the `from` line where ruff expects it,
without needing a re-anchor when format wraps the parens.

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

* chore: realign sonar + gitignore comment to src/ layout

sonar-project.properties still pointed at the pre-refactor top-level
packages (anthias_app, anthias_django, api, lib, viewer, ...) and
their old per-file coverage.exclusions paths, which would have
produced empty Sonar runs and stale exclusions. Collapse sources to
`src` and rewrite the exclusions to the new src/anthias_*/ paths.

Also fix the stale path reference in .gitignore's comment for the
test DB (now src/anthias_server/django_project/settings.py).

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

* chore: gitignore .claude/ and untrack the lock file I just leaked

Previous commit accidentally pulled in .claude/scheduled_tasks.lock
because .claude was in .dockerignore but not .gitignore. Add the
pattern to .gitignore and drop the file from the index.

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

* chore(dockerignore): exclude pytest cache, __pycache__ dirs, and the local test DB

Three entries that were missing relative to the new src/ layout:

- .anthias-test.db (and -journal/-wal/-shm siblings) — created at the
  repo root by src/anthias_server/django_project/settings.py when a
  developer runs the host pytest suite. Without this exclude, the
  next docker build COPY . bakes the file into /usr/src/app/.
- **/__pycache__ — *.py[co] only matched the .pyc/.pyo files, leaving
  the empty cache directories to ship.
- .pytest_cache — host-side, regenerable.

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

* fix(urls): preserve 'anthias_app' URL namespace, not just the app label

Copilot caught that the import-rewrite swept up the URL namespace too:
app_name in src/anthias_server/app/urls.py changed from 'anthias_app'
to 'anthias_server.app', which leaves templates/login.html's
{% url 'anthias_app:login' %} pointing at a namespace that no longer
exists — NoReverseMatch at render time when an unauthenticated request
hits the login page.

The namespace is the same kind of stable user-facing identifier as the
AppConfig label (which we already kept as 'anthias_app'). Restore it,
and revert the two reverse() callers in lib/auth.py and app/views.py
that the rewrite changed in lockstep.

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

* fix(ci): update --confcutdir to the new tools/raspberry_pi_imager path

Copilot caught that the earlier sweep missed --confcutdir=raspberry_pi_imager
(no trailing slash) — replace_all of "raspberry_pi_imager/" only matched
path-with-slash forms. Without confcutdir, pytest walks back up looking
for conftests and discovers the repo-root tests/conftest.py, which
applies the Anthias-specific Django/Redis stubs to the rpi-imager test
run on the website-deploy workflow.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:08:32 +01:00

160 lines
4.7 KiB
Python

import os
import shutil
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from unittest import mock
import pytest
user_home_dir = os.getenv('HOME')
settings1 = """
[viewer]
player_name = new player
show_splash = off
audio_output = hdmi
shuffle_playlist = on
verify_ssl = off
debug_logging = on
resolution = 1920x1080
default_duration = 45
[main]
assetdir = "{}/anthias_assets".format(user_home_dir)
database = "{}/.anthias/anthias.db".format(user_home_dir)
use_ssl = False
"""
empty_settings = """
[viewer]
[main]
"""
broken_settings = """
[viewer]
show_splash = offf
[main]
"""
# Each xdist worker gets its own /tmp root so the four tests in this
# module — which all rewrite the same on-disk config file — don't race
# against one another. Without the worker suffix, worker A's file
# write/cleanup interleaves with worker B's import/remove and tests
# fail intermittently with FileNotFoundError.
_WORKER_ID = os.environ.get('PYTEST_XDIST_WORKER', 'main')
_TMP_HOME = f'/tmp/.anthias-test-{_WORKER_ID}'
CONFIG_DIR = f'{_TMP_HOME}/.anthias/'
CONFIG_FILE = CONFIG_DIR + 'anthias.conf'
@contextmanager
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).
# Force a fresh import: pop the submodule from sys.modules AND
# delete the cached attribute on the parent package, otherwise
# `from anthias_server import settings` returns the stale module
# object via the parent's namespace and __init__ never re-runs.
sys.modules.pop('anthias_server.settings', None)
import anthias_server as _anthias_server
if hasattr(_anthias_server, 'settings'):
del _anthias_server.settings
try:
from anthias_server import settings
yield (settings, settings.settings)
finally:
sys.modules.pop('anthias_server.settings', None)
if hasattr(_anthias_server, 'settings'):
del _anthias_server.settings
os.remove(CONFIG_FILE)
def getenv(k: str, default: Any = None) -> Any:
try:
return _TMP_HOME if k == 'HOME' else os.environ[k]
except KeyError:
return default
@pytest.fixture
def settings_env() -> Iterator[None]:
os.makedirs(CONFIG_DIR, exist_ok=True)
getenv_patcher = mock.patch.object(os, 'getenv', side_effect=getenv)
getenv_patcher.start()
try:
yield
finally:
shutil.rmtree(_TMP_HOME, ignore_errors=True)
getenv_patcher.stop()
def test_parse_settings(settings_env: None) -> None:
with fake_settings(settings1) as (mod_settings, settings):
assert settings['player_name'] == 'new player'
assert settings['show_splash'] is False
assert settings['shuffle_playlist'] is True
assert settings['debug_logging'] is True
assert settings['default_duration'] == 45
def test_default_settings(settings_env: None) -> None:
with fake_settings(empty_settings) as (mod_settings, settings):
assert (
settings['player_name']
== mod_settings.DEFAULTS['viewer']['player_name']
)
assert (
settings['show_splash']
== mod_settings.DEFAULTS['viewer']['show_splash']
)
assert (
settings['shuffle_playlist']
== mod_settings.DEFAULTS['viewer']['shuffle_playlist']
)
assert (
settings['debug_logging']
== mod_settings.DEFAULTS['viewer']['debug_logging']
)
assert (
settings['default_duration']
== mod_settings.DEFAULTS['viewer']['default_duration']
)
def test_broken_settings_should_raise_value_error(settings_env: None) -> None:
with pytest.raises(ValueError):
with fake_settings(broken_settings) as (mod_settings, settings):
pass
def test_save_settings(settings_env: None) -> None:
with fake_settings(settings1) as (mod_settings, settings):
settings.conf_file = CONFIG_DIR + '/new.conf'
settings['default_duration'] = 35
settings['verify_ssl'] = True
settings.save()
with open(CONFIG_DIR + '/new.conf') as f:
saved = f.read()
with fake_settings(saved) as (mod_settings, settings):
# changes saved?
assert settings['default_duration'] == 35
assert settings['verify_ssl'] is True
# no out of thin air changes?
assert settings['audio_output'] == 'hdmi'