From 0c0cfd3cf6dd7e2c8c94d4f9bd6b765ea51077aa Mon Sep 17 00:00:00 2001 From: Ioannis Paraskevakos Date: Mon, 22 Jun 2026 14:39:25 -0400 Subject: [PATCH 1/3] feat: setting up the API --- .gitignore | 2 +- .pre-commit-config.yaml | 36 ++++++++++++++++ pyproject.toml | 91 ++++++++++++++++++++++++++++++++++++--- src/archivist/__init__.py | 6 +-- src/archivist/__main__.py | 61 ++++++++++++++++++++++++++ src/archivist/api.py | 16 +++++++ src/archivist/archive.py | 2 +- src/archivist/config.py | 3 ++ src/archivist/core.py | 71 ++++++++++++++++++++++++++++++ src/archivist/registry.py | 5 ++- src/archivist/storage.py | 9 ++-- tests/test_classes.py | 12 +----- 12 files changed, 287 insertions(+), 27 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 src/archivist/__main__.py create mode 100644 src/archivist/api.py create mode 100644 src/archivist/config.py create mode 100644 src/archivist/core.py diff --git a/.gitignore b/.gitignore index ecd41cb..15779c2 100644 --- a/.gitignore +++ b/.gitignore @@ -101,7 +101,7 @@ ipython_config.py # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. -#uv.lock +uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b35a7a5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +repos: + - repo: https://github.com/pycqa/isort + rev: 6.0.1 + hooks: + - id: isort + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.17 + hooks: + - id: ruff-check + - id: ruff-format + args: [--check, --diff] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-added-large-files + - id: check-merge-conflict + - id: debug-statements + +# Replace commitsar with conventional commit checker + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v2.1.1 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [] # default: [feat, fix, ci, chore, docs, style, refactor, perf, test, build, revert] + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: python-check-blanket-noqa + - id: python-no-eval diff --git a/pyproject.toml b/pyproject.toml index 60115e7..80d6890 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,11 +13,11 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -dependencies = ["numpy"] - -[project.optional-dependencies] -dev = [ - "pytest" +dependencies = [ + "click>=8.1.8", + "fastapi>=0.128.8", + "numpy", + "pydantic>=2.13.4", ] [project.urls] @@ -25,3 +25,84 @@ Homepage = "https://github.com/simonsobs/archivist" [tool.setuptools_scm] write_to = "src/archivist/_version.py" + +[dependency-groups] +dev = [ + "coverage>=7.10.7", + "coveralls>=4.0.1", + "isort>=6.1.0", + "pre-commit>=4.3.0", + "pytest>=8.4.2", + "pytest-cov>=7.1.0", + "pytest-random-order>=1.2.0", + "ruff>=0.15.17", +] + +[project.scripts] +archivist = "archivist.__main__:main" + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +ignore = ["ANN001", + "SIM118", + "T201", + "D100", + "INP001", + "D212", + "PTH119", + "D415", + "D103", + "D411", + "D410", + "COM812", + "D205", + "S101", + "D202", + "SLF001", + "D401", + "D400", + "FBT001", + "ANN202", + "PTH123", + "FBT002", + "ANN201", + "F401", + "F841" +] + +[tool.ruff.format] +docstring-code-format = true +docstring-code-line-length = 80 + +[tool.ruff.lint.per-file-ignores] +"**/scripts/*" = [ + "INP001", + "T201", +] +"**/tests/**/*" = [ + "PLC1901", + "PLR2004", + "PLR6301", + "S", + "TID252", + "T201", + "DTZ005", + "N802", + "ARG005" +] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.lint.isort] +known-first-party = ["archivist"] + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false +mark-parentheses = false + +[tool.isort] +line_length = 120 +skip = ["_version.py"] diff --git a/src/archivist/__init__.py b/src/archivist/__init__.py index c5e352d..3fc3ad7 100644 --- a/src/archivist/__init__.py +++ b/src/archivist/__init__.py @@ -1,11 +1,9 @@ # Copyright (c) 2025-2026 Simons Observatory. # Full license can be found in the top level "LICENSE" file. -"""Archive tools for data. -""" +"""Archive tools for data.""" from ._version import __version__ - from .archive import Archive -from .registry import RegistrySqlite, RegistryLibrarian +from .registry import RegistryLibrarian, RegistrySqlite from .storage import StorageDisk, StorageHPSS diff --git a/src/archivist/__main__.py b/src/archivist/__main__.py new file mode 100644 index 0000000..0b84386 --- /dev/null +++ b/src/archivist/__main__.py @@ -0,0 +1,61 @@ +import sys + +import click + +import Archivist + + +@click.group() +@click.option( + "--config", + "-c", + type=click.Path(exists=True), + required=False, + help="Path to the Archivist configuration file", +) +@click.pass_context +def main(ctx, config): + if config is None: + click.echo("Archivist requires a configuration file", err=True) + click.echo("See readme for more information", err=True) + sys.exit(1) + ctx.ensure_object(dict) + ctx.obj["archivist"] = Archivist(config) + + +@main.command() +@click.option("--source-path", type=str, help="The source path of the file to archive") +@click.option( + "--dest-path", + type=str, + help="The destination path where the file should be archived", +) +@click.pass_context +def archive(ctx, source_path, dest_path): + """Command to archive a file. + For example: `archivist archive "--source-path /path/to/source --dest-path /path/to/dest"` + """ + pass + + +@main.command() +@click.argument("cmd", nargs=1) +@click.option("--archive", type=str, help="Name of the job") +@click.option( + "--dest-path", + type=str, + help="The destination path where the file should be restored", +) +@click.pass_context +def extract(ctx, archive, dest_path): + """Command to record a job. + For example: `slurmise record "-o 2 -i 3 -m fast"` + """ + pass + + +@main.command() +@click.pass_context +def start_server(ctx): + """Command to check the status of the archivist.""" + pass diff --git a/src/archivist/api.py b/src/archivist/api.py new file mode 100644 index 0000000..73c42d8 --- /dev/null +++ b/src/archivist/api.py @@ -0,0 +1,16 @@ +class Archivist: + def __init__(self, config): + self.config = config + self.archivist = None + + def start_server(self): + # Initialize the archivist with the provided configuration + self.archivist = self.config.get("archivist", {}) + + def archive(self, source_path, dest_path): + # Implementation for archiving a file + pass + + def extract(self, archive, dest_path): + # Implementation for extracting an archive + pass diff --git a/src/archivist/archive.py b/src/archivist/archive.py index 1fe5b1e..a9abcd0 100644 --- a/src/archivist/archive.py +++ b/src/archivist/archive.py @@ -20,6 +20,7 @@ class Archive(object): for all objects. """ + def __init__(self, manifest=None, paths=None, root=None): pass @@ -34,4 +35,3 @@ def root(self): @property def checksum(self): pass - diff --git a/src/archivist/config.py b/src/archivist/config.py new file mode 100644 index 0000000..dfe602f --- /dev/null +++ b/src/archivist/config.py @@ -0,0 +1,3 @@ +class ArchivistConfiguration: + def __init__(self, config): + self.config = config diff --git a/src/archivist/core.py b/src/archivist/core.py new file mode 100644 index 0000000..75adc63 --- /dev/null +++ b/src/archivist/core.py @@ -0,0 +1,71 @@ +""" +Pydantic modems for the admin endpoints +""" + +from datetime import datetime + +from pydantic import BaseModel + + +class ManifestEntry(BaseModel): + name: str + "The name of the file." + create_time: datetime + "The time the file was created." + size: int + "The size of the file in bytes." + checksum: str + "The checksum of the file." + uploader: str + "The uploader of the file." + source: str + "The source of the file." + + instance_path: str + "The path to the instance on the store." + instance_create_time: datetime + "The time the instance was created." + instance_available: bool + "Whether the instance is available or not. If not, no outgoing transfer is created." + + outgoing_transfer_id: int + "The ID of the outgoing transfer, if it exists." + + +class ManifestRequest(BaseModel): + store_name: str + "The name of the store to get the manifest for." + + create_outgoing_transfers: bool = False + "Whether to create outgoing transfers for the files in the manifest." + + destination_librarian: str = "" + "The name of the librarian to send the files to, if create_outgoing_transfers is true." + + disable_store: bool = False + "Whether to disable the store after creating the outgoing transfers." + + mark_local_instances_as_unavailable: bool = False + "Mark the local instances as unavailable after creating the outgoing transfers." + + +class ManifestResponse(BaseModel): + librarian_name: str + "The name of the librarian that generated this manifest." + + store_name: str + "The name of the store." + + store_files: list[ManifestEntry] + "The files on the store." + + +class Archive(BaseModel): + manifest: str + "Path to a manifest file listing the files / directories contained in the archive." + + paths: list[str] + "Alternatively list the full set of files / directories contained in the archive." + + root: str + "The common root filesystem location for building relative paths for all objects." diff --git a/src/archivist/registry.py b/src/archivist/registry.py index 52ba84c..a85569b 100644 --- a/src/archivist/registry.py +++ b/src/archivist/registry.py @@ -4,9 +4,8 @@ class Registry(object): - """Base class representing a registry of archives. + """Base class representing a registry of archives.""" - """ def __init__(self): pass @@ -44,6 +43,7 @@ def remove(self, archive_name): class RegistrySqlite(Registry): """Simple class which registers archives in an SQLite DB.""" + def __init__(self): super().__init__() @@ -56,6 +56,7 @@ def _remove(self, archive_name): class RegistryLibrarian(Registry): """Store archive metadata with with the librarian.""" + def __init__(self): super().__init__() diff --git a/src/archivist/storage.py b/src/archivist/storage.py index e48bba4..c4f24e4 100644 --- a/src/archivist/storage.py +++ b/src/archivist/storage.py @@ -4,8 +4,8 @@ class Storage(object): - """Base class representing an archive storage system. - """ + """Base class representing an archive storage system.""" + def __init__(self): pass @@ -61,6 +61,7 @@ class StorageDisk(Storage): directory (str): The top-level location for storing archives. """ + def __init__(self, directory): self._directory = directory super().__init__() @@ -75,8 +76,8 @@ def _extract(self, archive_name, storage_info, outdir, paths=None): class StorageHPSS(Storage): - """Store archives to HPSS tapes. - """ + """Store archives to HPSS tapes.""" + def __init__(self): super().__init__() diff --git a/tests/test_classes.py b/tests/test_classes.py index b24d1a6..2db294e 100644 --- a/tests/test_classes.py +++ b/tests/test_classes.py @@ -4,15 +4,8 @@ """Test package imports.""" import archivist -from archivist import __version__ as pkg_version - -from archivist import ( - Archive, - RegistrySqlite, - RegistryLibrarian, - StorageDisk, - StorageHPSS, -) +from archivist import Archive, RegistryLibrarian, RegistrySqlite, StorageDisk, StorageHPSS +from archivist import _version as pkg_version def test_construction(): @@ -21,4 +14,3 @@ def test_construction(): reg_sql = RegistrySqlite() store_disk = StorageDisk(".") store_hpss = StorageHPSS() - From 05bec5b46a3f237fec0cb0e0132b74d5fd4dd260 Mon Sep 17 00:00:00 2001 From: Ioannis Paraskevakos Date: Mon, 22 Jun 2026 19:24:15 -0400 Subject: [PATCH 2/3] feat: creating the archive router --- src/archivist/__init__.py | 6 +-- src/archivist/api/__init__.py | 3 ++ src/archivist/api/archive.py | 47 +++++++++++++++++++++++ src/archivist/core/__init__.py | 0 src/archivist/{ => core}/archive.py | 0 src/archivist/{core.py => core/models.py} | 31 ++++++--------- src/archivist/{ => core}/registry.py | 0 src/archivist/{ => core}/storage.py | 0 8 files changed, 65 insertions(+), 22 deletions(-) create mode 100644 src/archivist/api/__init__.py create mode 100644 src/archivist/api/archive.py create mode 100644 src/archivist/core/__init__.py rename src/archivist/{ => core}/archive.py (100%) rename src/archivist/{core.py => core/models.py} (69%) rename src/archivist/{ => core}/registry.py (100%) rename src/archivist/{ => core}/storage.py (100%) diff --git a/src/archivist/__init__.py b/src/archivist/__init__.py index 3fc3ad7..9e2db2a 100644 --- a/src/archivist/__init__.py +++ b/src/archivist/__init__.py @@ -4,6 +4,6 @@ """Archive tools for data.""" from ._version import __version__ -from .archive import Archive -from .registry import RegistryLibrarian, RegistrySqlite -from .storage import StorageDisk, StorageHPSS +from .core.archive import Archive +from .core.registry import RegistryLibrarian, RegistrySqlite +from .core.storage import StorageDisk, StorageHPSS diff --git a/src/archivist/api/__init__.py b/src/archivist/api/__init__.py new file mode 100644 index 0000000..91129c5 --- /dev/null +++ b/src/archivist/api/__init__.py @@ -0,0 +1,3 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/api/v1") diff --git a/src/archivist/api/archive.py b/src/archivist/api/archive.py new file mode 100644 index 0000000..3ac800d --- /dev/null +++ b/src/archivist/api/archive.py @@ -0,0 +1,47 @@ +import json +import uuid + +from fastapi import Depends, Response + +from archivist.api import router +from archivist.core.models import Archive, ManifestFailedResponse, ManifestRequest, ManifestResponse + + +@router.post("/archive", response_model=ManifestResponse | ManifestFailedResponse) +def archive( + manifest_request: ManifestRequest, + response: Response, + queue: "Queue" = Depends(get_queue), # noqa: F821 + settings: "Settings" = Depends(get_settings), # noqa: F821 +): + """ + Endpoint to archive a file. + """ + # Here you would implement the logic to handle the archiving process + # For now, we will just return a dummy response + + # TODO: Implement the actual archiving logic here. + + # TODO: get the basic settings to know the type of archival store and the size of the + # archive manifest + + total_size = sum(entry.size for entry in manifest_request.store_files) + if total_size > 1000000000: # Example size limit of 1GB + response.status_code = 400 + return ManifestFailedResponse(error="The total size of the files exceeds the allowed limit.") + + manifest_id = uuid.uuid4() + + archive = Archive( + manifest_id=str(manifest_id), + manifest=json.dumps(manifest_request), + paths=[entry.instance_path for entry in manifest_request.store_files], + root=settings.archive_root, # Example root path + ) + + # TODO: Add the archive to a queue for processing + queue.enqueue_archive(archive) + + return ManifestResponse( + manifest_id=str(manifest_id), + ) diff --git a/src/archivist/core/__init__.py b/src/archivist/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archivist/archive.py b/src/archivist/core/archive.py similarity index 100% rename from src/archivist/archive.py rename to src/archivist/core/archive.py diff --git a/src/archivist/core.py b/src/archivist/core/models.py similarity index 69% rename from src/archivist/core.py rename to src/archivist/core/models.py index 75adc63..c8c3cd9 100644 --- a/src/archivist/core.py +++ b/src/archivist/core/models.py @@ -33,34 +33,27 @@ class ManifestEntry(BaseModel): class ManifestRequest(BaseModel): - store_name: str - "The name of the store to get the manifest for." - - create_outgoing_transfers: bool = False - "Whether to create outgoing transfers for the files in the manifest." - - destination_librarian: str = "" - "The name of the librarian to send the files to, if create_outgoing_transfers is true." - - disable_store: bool = False - "Whether to disable the store after creating the outgoing transfers." + librarian_name: str + "The name of the librarian that generated this manifest." - mark_local_instances_as_unavailable: bool = False - "Mark the local instances as unavailable after creating the outgoing transfers." + store_files: list[ManifestEntry] + "The files on the store." class ManifestResponse(BaseModel): - librarian_name: str - "The name of the librarian that generated this manifest." + manifest_id: str + "The ID of the manifest." - store_name: str - "The name of the store." - store_files: list[ManifestEntry] - "The files on the store." +class ManifestFailedResponse(BaseModel): + error: str + "The error message indicating why the manifest request failed." class Archive(BaseModel): + manifest_id: str + "The ID of the manifest associated with this archive." + manifest: str "Path to a manifest file listing the files / directories contained in the archive." diff --git a/src/archivist/registry.py b/src/archivist/core/registry.py similarity index 100% rename from src/archivist/registry.py rename to src/archivist/core/registry.py diff --git a/src/archivist/storage.py b/src/archivist/core/storage.py similarity index 100% rename from src/archivist/storage.py rename to src/archivist/core/storage.py From 870645d9886d4557b3502dc3248a09717abf6f52 Mon Sep 17 00:00:00 2001 From: Ioannis Paraskevakos Date: Tue, 30 Jun 2026 14:29:10 -0400 Subject: [PATCH 3/3] feat:Archivist API --- pyproject.toml | 10 ++- src/archivist/api/archive.py | 15 ++-- src/archivist/api/extract.py | 25 ++++++ src/archivist/core/__init__.py | 3 + src/archivist/core/archive.py | 27 ++++-- src/archivist/queue.py | 60 +++++++++++++ src/archivist/settings.py | 152 +++++++++++++++++++++++++++++++++ 7 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 src/archivist/api/extract.py create mode 100644 src/archivist/queue.py create mode 100644 src/archivist/settings.py diff --git a/pyproject.toml b/pyproject.toml index 80d6890..4236756 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,10 +14,14 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "click>=8.1.8", - "fastapi>=0.128.8", + "click", + "fastapi", "numpy", - "pydantic>=2.13.4", + "pydantic", + "pydantic-settings", + "sqlalchemy", + "sqlalchemy-utils", + "loguru", ] [project.urls] diff --git a/src/archivist/api/archive.py b/src/archivist/api/archive.py index 3ac800d..16d09e4 100644 --- a/src/archivist/api/archive.py +++ b/src/archivist/api/archive.py @@ -5,14 +5,16 @@ from archivist.api import router from archivist.core.models import Archive, ManifestFailedResponse, ManifestRequest, ManifestResponse +from archivist.queue import ArchiveQueue, get_queue +from archivist.settings import Settings, get_settings @router.post("/archive", response_model=ManifestResponse | ManifestFailedResponse) def archive( manifest_request: ManifestRequest, response: Response, - queue: "Queue" = Depends(get_queue), # noqa: F821 - settings: "Settings" = Depends(get_settings), # noqa: F821 + queue: ArchiveQueue = Depends(get_queue), + settings: Settings = Depends(get_settings), ): """ Endpoint to archive a file. @@ -20,13 +22,8 @@ def archive( # Here you would implement the logic to handle the archiving process # For now, we will just return a dummy response - # TODO: Implement the actual archiving logic here. - - # TODO: get the basic settings to know the type of archival store and the size of the - # archive manifest - total_size = sum(entry.size for entry in manifest_request.store_files) - if total_size > 1000000000: # Example size limit of 1GB + if total_size > settings.maximal_size_bytes: # Example size limit of 1GB response.status_code = 400 return ManifestFailedResponse(error="The total size of the files exceeds the allowed limit.") @@ -36,7 +33,7 @@ def archive( manifest_id=str(manifest_id), manifest=json.dumps(manifest_request), paths=[entry.instance_path for entry in manifest_request.store_files], - root=settings.archive_root, # Example root path + root=settings.storage_root, # Example root path ) # TODO: Add the archive to a queue for processing diff --git a/src/archivist/api/extract.py b/src/archivist/api/extract.py new file mode 100644 index 0000000..a203e89 --- /dev/null +++ b/src/archivist/api/extract.py @@ -0,0 +1,25 @@ +import json +import uuid + +from fastapi import Depends, Response + +from archivist.api import router +from archivist.core.models import Archive, ManifestFailedResponse, ManifestRequest, ManifestResponse +from archivist.queue import ExtractQueue, get_extract_queue +from archivist.settings import Settings, get_settings + + +@router.post("/extract", response_model=ManifestResponse | ManifestFailedResponse) +def extract( + manifest_request: ManifestRequest, + response: Response, + queue: ExtractQueue = Depends(get_extract_queue), + settings: Settings = Depends(get_settings), +): + """ + Endpoint to extract a file. + """ + # Here you would implement the logic to handle the extraction process + # For now, we will just return a dummy response + + pass diff --git a/src/archivist/core/__init__.py b/src/archivist/core/__init__.py index e69de29..ee43863 100644 --- a/src/archivist/core/__init__.py +++ b/src/archivist/core/__init__.py @@ -0,0 +1,3 @@ +from .storage import StorageDisk, StorageHPSS + +storage_factory = {"hpss:": StorageHPSS, "posix": StorageDisk} diff --git a/src/archivist/core/archive.py b/src/archivist/core/archive.py index a9abcd0..b8c9198 100644 --- a/src/archivist/core/archive.py +++ b/src/archivist/core/archive.py @@ -2,6 +2,8 @@ # Full license can be found in the top level "LICENSE" file. """Classes for working with archives.""" +import subprocess + class Archive(object): """Class representing a single archive in a storage system. @@ -21,17 +23,32 @@ class Archive(object): """ - def __init__(self, manifest=None, paths=None, root=None): - pass + def __init__(self, manifest=None, paths=None, root=None, type=None): + self._manifest = manifest + self._paths = paths + self._root = root + self._type = type + self._checksum = None # Placeholder for checksum value @property def paths(self): - pass + return self._paths @property def root(self): - pass + return self._root @property def checksum(self): - pass + """Return the checksum of the archive.""" + + def create_archive(self): + """Create the archive based on the manifest or paths.""" + # Placeholder for archive creation logic + if self._type == "posix": + # Implement logic for creating a POSIX archive + pass + elif self._type == "hpss": + # Implement logic for creating an HPSS archive + # Call subprocess to create the archive using HPSS commands + pass diff --git a/src/archivist/queue.py b/src/archivist/queue.py new file mode 100644 index 0000000..f6ed0e3 --- /dev/null +++ b/src/archivist/queue.py @@ -0,0 +1,60 @@ +import queue + +from archivist.core.models import Archive + +_archive_queue: "ArchiveQueue | None" = None +_extract_queue: "ExtractQueue | None" = None + + +class ArchiveQueue: + """Thread-safe queue for Archive jobs awaiting processing.""" + + def __init__(self) -> None: + self._q: queue.Queue[Archive] = queue.Queue() + + def enqueue_archive(self, archive: Archive) -> None: + self._q.put(archive) + + def dequeue_archive(self, block: bool = True, timeout: float | None = None) -> Archive: + return self._q.get(block=block, timeout=timeout) + + def task_done(self) -> None: + self._q.task_done() + + @property + def size(self) -> int: + return self._q.qsize() + + +class ExtractQueue: + """Thread-safe queue for Extract jobs awaiting processing.""" + + def __init__(self) -> None: + self._q: queue.Queue[Archive] = queue.Queue() + + def enqueue_archive(self, archive: Archive) -> None: + self._q.put(archive) + + def dequeue_archive(self, block: bool = True, timeout: float | None = None) -> Archive: + return self._q.get(block=block, timeout=timeout) + + def task_done(self) -> None: + self._q.task_done() + + @property + def size(self) -> int: + return self._q.qsize() + + +def get_archive_queue() -> ArchiveQueue: + global _archive_queue + if _archive_queue is None: + _archive_queue = ArchiveQueue() + return _archive_queue + + +def get_extract_queue() -> ExtractQueue: + global _extract_queue + if _extract_queue is None: + _extract_queue = ExtractQueue() + return _extract_queue diff --git a/src/archivist/settings.py b/src/archivist/settings.py new file mode 100644 index 0000000..fb2eb5f --- /dev/null +++ b/src/archivist/settings.py @@ -0,0 +1,152 @@ +import datetime +import os +from pathlib import Path + +from pydantic import BaseModel, Field, ValidationError, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from sqlalchemy import URL + + +class Settings(BaseSettings): + """ + Settings for the archivist . Note that because this is a BaseSettings + object, you can overwrite the values in the config file with environment + variables. + """ + + # Top level name of the server. Should be unique. + name: str = "archivist_server" + + # Whether to enable debugging features, like the API docs and OpenAPI schema. + debug: bool = False + + # Database settings. + database_driver: str = "sqlite" + database_user: str | None = None + database_password: str | None = None + database_host: str | None = None + database_port: int | None = None + database: str | None = None + + database_password_file: Path | None = None + + log_level: str = "DEBUG" + + # Display name and description of the site, used in UI only. + displayed_site_name: str = "Untitled Archivist Server" + displayed_site_description: str = "No description set." + + # Host and port to bind to. + host: str = "0.0.0.0" + port: int = 8080 + + # storage options + storage_type: str = "posix" + storage_root: str = "/tmp/archivist_storage" + + # Database migration settings + alembic_config_path: str = "." + alembic_path: str = "alembic" + + # Client restraints + max_search_results: int = 64 + maximal_size_bytes: int = 1_000_000_000_000 # 1 TB + + # Globus integration; by default disable this. This contains a client ID and + # login secret (for authenticating with Globus as a service), whether this + # client is a "native app" or not, as well as the UUID for the local Globus + # endpoint. That endpoint ID should be duplicated in any async transfer manager + # definitions we want to use a source. We also save the "local root" for the Globus + # endpoint, which is the root directory presented to the Globus endpoint and + # all file transfers using Globus are relative to. For example, if the + # endpoint is configured so that `/mnt/globus` is the local root, then the + # path to `/mnt/globus/file.txt` should be given as `/~/file.txt`. This is + # important for forming paths that Globus can understand. + globus_enable: bool = False + globus_client_id: str = "" + globus_client_native_app: bool = False + globus_local_endpoint_id: str = "" + globus_local_root: str | None = None + globus_encrypt_transfers: bool = True + + globus_client_secret: str | None = None + globus_client_secret_file: Path | None = None + + # Checksumming options + checksum_threads: int = 4 + checksum_timeout: datetime.timedelta = datetime.timedelta(days=1) + + def model_post_init(__context, *args, **kwargs): + """ + Read sensitive data from their appropriate files. + """ + + if __context.globus_client_secret_file is not None: + with open(__context.globus_client_secret_file, "r") as handle: + __context.globus_client_secret = handle.read().strip() + + if __context.database_password_file is not None: + with open(__context.database_password_file, "r") as handle: + __context.database_password = handle.read().strip() + + @property + def sqlalchemy_database_uri(self) -> URL: + """ + The SQLAlchemy database URI. + """ + + return URL.create( + self.database_driver, + username=self.database_user, + password=self.database_password, + host=self.database_host, + port=self.database_port, + database=self.database, + ) + + @classmethod + def from_file(cls, config_path: Path | str) -> "Settings": + """ + Loads the settings from the given path. + """ + + with open(config_path, "r") as handle: + return cls.model_validate_json(handle.read()) + + +# Automatically create a variable, server_settings, from the environment variable +# on _use_! +_settings = None + + +def get_settings() -> "Settings": + """ + Dependency to get the application settings. + """ + global _settings + + try_paths = [ + os.environ.get("LIBRARIAN_CONFIG_PATH", None), + ] + + for path in try_paths: + if path is not None: + path = Path(path) + else: + continue + + if path.exists(): + try: + _settings = Settings.from_file(path) + except ValidationError as e: + print(f"Error loading settings from {path}: {e}") + raise e + + return _settings + try: + _settings = Settings() + except ValidationError as e: + print(f"Not all settings have defaults: {e}") + raise e + + return _settings