Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/lint_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Lint archivist

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
lint:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Install dependencies
run: uv sync --python 3.11 --group dev

- name: Run lint
run: |
uv run ruff check

- name: Run lint
run: |
uv run ruff format --check --diff

- name: Run isort
run: |
uv run isort . --check --diff
24 changes: 24 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Release archivist

on:
release:
types: [published]

jobs:
deploy:
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Build package
run: uv build --python 3.11

- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
36 changes: 36 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Test archivist

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
test:
runs-on: ubuntu-latest

strategy:
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Install dependencies
run: uv sync --python ${{ matrix.python-version }} --group dev

- name: Run tests
run: |
uv run pytest

- name: Upload coverage report
if: matrix.python-version == '3.11'
run: uv run coveralls
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Archivist

[![Coverage Status](https://coveralls.io/repos/github/simonsobs/archivist/badge.svg?branch=main)](https://coveralls.io/github/simonsobs/archivist?branch=main)

Tools for offline data storage compatible with the Librarian.
13 changes: 11 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ name = "archivist"
dynamic = ["version"]
description = "Archival data storage for the Librarian"
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.11"
license = "MIT"
license-files = ["LICENSE"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = [
Expand All @@ -36,6 +37,7 @@ write_to = "src/archivist/_version.py"
dev = [
"coverage>=7.10.7",
"coveralls>=4.0.1",
"httpx>=0.28.1",
"isort>=6.1.0",
"pre-commit>=4.3.0",
"pytest>=8.4.2",
Expand Down Expand Up @@ -113,3 +115,10 @@ mark-parentheses = false
profile = "black"
line_length = 120
skip = ["_version.py"]

[tool.pytest.ini_options]
addopts = "--random-order --cov=archivist --cov-report=term-missing --cov-report=xml"

[tool.coverage.run]
source = ["archivist"]
branch = true
4 changes: 2 additions & 2 deletions src/archivist/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def _status_worker_loop():


@asynccontextmanager
async def slack_post_at_startup_shutdown(app: FastAPI):
async def startup_shutdown_server(app: FastAPI):
"""
Lifespan event that posts to the slack hook once
the FastAPI server starts up and shuts down.
Expand Down Expand Up @@ -71,7 +71,7 @@ def main() -> FastAPI:
title=server_settings.displayed_site_name,
description=server_settings.displayed_site_description,
openapi_url="/api/v2/openapi.json" if server_settings.debug else None,
lifespan=slack_post_at_startup_shutdown,
lifespan=startup_shutdown_server,
)

logger.debug("Adding API router.")
Expand Down
129 changes: 129 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Copyright (c) 2025-2026 Simons Observatory.
# Full license can be found in the top level "LICENSE" file.
from datetime import datetime, timezone

import pytest

import archivist.database as database
import archivist.queue as queue_module
import archivist.settings as settings_module
from archivist.settings import Settings
from archivist.storage.storage_disk import StorageDisk


@pytest.fixture(autouse=True)
def _reset_module_singletons():
"""
Several modules cache lazily-created global state (the DB engine,
session maker, loaded settings, the in-memory status queue, and the
shared StorageDisk thread pool). Reset all of it before and after
every test so tests don't leak state into one another.
"""

def _reset():
if database._engine is not None:
database._engine.dispose()
database._engine = None
database._SessionMaker = None
settings_module._settings = None
queue_module._status_queue = None
StorageDisk._executor = None

_reset()
yield
_reset()


@pytest.fixture
def archive_root(tmp_path):
path = tmp_path / "archive_root"
path.mkdir()
return path


@pytest.fixture
def local_root(tmp_path):
path = tmp_path / "local_root"
path.mkdir()
return path


@pytest.fixture
def settings(tmp_path, archive_root, local_root) -> Settings:
"""A Settings instance backed by a throwaway on-disk sqlite database."""

return Settings(
database_driver="sqlite",
database=str(tmp_path / "archivist_test.db"),
archive_type="posix",
archive_root=str(archive_root),
local_root=str(local_root),
)


@pytest.fixture
def config_path(settings, tmp_path):
"""`settings` serialised to a JSON file, as the CLI/server would be given."""

path = tmp_path / "test_config.json"
path.write_text(settings.model_dump_json())
return path


@pytest.fixture
def use_settings(monkeypatch, settings, config_path):
"""
Make `get_settings()` return `settings`, regardless of whether the
caller does `import archivist.settings` or
`from archivist.settings import get_settings` (the latter binds its
own reference, so patching the module attribute alone would miss it).
We instead drive this through the same env-var + cache mechanism
`get_settings()` itself uses.
"""

monkeypatch.setenv("ARCHIVIST_CONFIG_PATH", str(config_path))
monkeypatch.setattr(settings_module, "_settings", None)

return settings_module.get_settings()


@pytest.fixture
def db_session(use_settings):
"""A real database session against a freshly created schema."""

database.create_all()
session = database.get_session()
try:
yield session
finally:
session.close()


def make_manifest_entry(**overrides):
"""Build a dict-form manifest entry, suitable for `ManifestEntry(**entry)`."""

entry = {
"name": "file.txt",
"create_time": datetime(2026, 1, 1, tzinfo=timezone.utc),
"size": 1024,
"checksum": "0" * 64,
"uploader": "test-uploader",
"source": "/source",
"instance_path": "/source/file.txt",
"instance_create_time": datetime(2026, 1, 1, tzinfo=timezone.utc),
"instance_available": True,
"outgoing_transfer_id": 0,
}
entry.update(overrides)
return entry


def make_manifest_request(json_safe=False, **overrides):
"""A one-entry manifest request body. Set `json_safe=True` for a raw HTTP JSON body."""

entry = make_manifest_entry(**overrides)
if json_safe:
for key in ("create_time", "instance_create_time"):
entry[key] = entry[key].isoformat()

return {"librarian_name": "test-librarian", "store_files": [entry]}
66 changes: 66 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Copyright (c) 2025-2026 Simons Observatory.
# Full license can be found in the top level "LICENSE" file.

"""
Tests for the FastAPI routes in archivist.api.

These build a bare FastAPI app that includes the same routers as the real
server (`archivist.server.main`), but skip the lifespan handler -- which
starts busy-looping background worker threads -- since it isn't relevant
to exercising the HTTP endpoints themselves.
"""

import pytest
from conftest import make_manifest_request
from fastapi import FastAPI
from fastapi.testclient import TestClient

from archivist.api import health_router
from archivist.api import router as api_router
from archivist.database import yield_session
from archivist.orm.archivequeue import ArchiveQueue
from archivist.settings import get_settings


@pytest.fixture
def client(db_session, use_settings):
app = FastAPI()
app.include_router(api_router)
app.include_router(health_router)

def _yield_session():
yield db_session

app.dependency_overrides[get_settings] = lambda: use_settings
app.dependency_overrides[yield_session] = _yield_session

return TestClient(app)


def test_health(client, use_settings):
response = client.get("/health")

assert response.status_code == 200
assert response.json() == {"status": "ok", "name": use_settings.displayed_site_name}


def test_archive_queues_the_manifest_and_returns_its_id(client, db_session):
"""The route enqueues rather than archiving synchronously; the queued row is the contract."""

response = client.post("/api/v1/archive", json=make_manifest_request(json_safe=True))

assert response.status_code == 200
manifest_id = response.json()["manifest_id"]

item = db_session.query(ArchiveQueue).filter_by(manifest_id=manifest_id).one()
assert not item.consumed
assert not item.completed


def test_archive_rejects_manifests_over_the_size_limit(client, use_settings):
use_settings.maximal_size_bytes = 100

response = client.post("/api/v1/archive", json=make_manifest_request(json_safe=True, size=1_000_000))

assert response.status_code == 400
assert "error" in response.json()
16 changes: 0 additions & 16 deletions tests/test_classes.py

This file was deleted.

Loading