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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ dependencies = [
"sqlalchemy",
"sqlalchemy-utils",
"loguru",
"uvicorn",
"requests>=2.32.5",
]

[project.urls]
Expand Down
98 changes: 90 additions & 8 deletions src/archivist/__main__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import os
import sys
from datetime import datetime
from pathlib import Path

import click
import requests

import Archivist
from archivist.core.models import ManifestEntry, ManifestFailedResponse, ManifestRequest, ManifestResponse
from archivist.settings import get_settings
from archivist.utils import _checksum


@click.group()
Expand All @@ -19,23 +25,88 @@ def main(ctx, config):
click.echo("Archivist requires a configuration file", err=True)
click.echo("See readme for more information", err=True)
sys.exit(1)

os.environ["ARCHIVIST_CONFIG_PATH"] = config
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(
"--source-path", type=click.Path(exists=True), required=True, help="The source path of the file to archive"
)
@click.option(
"--dest-path",
type=str,
type=click.Path(exists=False),
required=True,
help="The destination path where the file should be archived",
)
@click.option(
"--librarian-name",
type=str,
required=False,
help="The name of the librarian archiving the file",
default="librarian",
)
@click.pass_context
def archive(ctx, source_path, dest_path):
def archive(ctx, source_path, dest_path, librarian_name):
"""Command to archive a file.
For example: `archivist archive "--source-path /path/to/source --dest-path /path/to/dest"`
For example: `archivist -c config archive "--source-path /path/to/source --dest-path /path/to/dest"`
"""
pass
# Here you would implement the logic to handle the archiving process
# For now, we will just print the source and destination paths
click.echo(f"Archiving file from {source_path} to {dest_path}")

settings = get_settings()

source_root = Path(source_path)
dest_root = Path(dest_path) if dest_path is not None else "source_root"

if source_root.is_file():
files = [source_root]
walk_root = source_root.parent
else:
files = [Path(dirpath) / filename for dirpath, _, filenames in os.walk(source_root) for filename in filenames]
walk_root = source_root

uploader = librarian_name
store_files = []

for file_path in files:
stat = file_path.stat()
relative_path = file_path.relative_to(walk_root)

store_files.append(
ManifestEntry(
name=file_path.name,
create_time=datetime.fromtimestamp(stat.st_ctime),
size=stat.st_size,
checksum=_checksum(file_path),
uploader=uploader,
source=str(source_root),
instance_path=str(dest_root / relative_path),
instance_create_time=datetime.now(),
instance_available=True,
outgoing_transfer_id=0,
)
)

manifest_request = ManifestRequest(librarian_name=librarian_name, store_files=store_files)
try:
req = requests.post(
f"http://{settings.host}:{settings.port}/api/v1/archive", json=manifest_request.model_dump(mode="json")
)
except (TimeoutError, requests.exceptions.ConnectionError) as ex:
raise ex

click.echo(f"Status code: {req.status_code}")

if req.status_code == 200:
response = ManifestResponse.model_validate(req.json())
click.echo(f"Archive succeeded, manifest_id={response.manifest_id}")
else:
response = ManifestFailedResponse.model_validate(req.json())
click.secho("[ERROR]", fg="red", nl=False, err=True)
click.echo(f" Archive failed: {response.error}", err=True)


@main.command()
Expand All @@ -58,4 +129,15 @@ def extract(ctx, archive, dest_path):
@click.pass_context
def start_server(ctx):
"""Command to check the status of the archivist."""
pass

import uvicorn

from .settings import server_settings

uvicorn.run(
"archivist.server:main",
host=server_settings.host,
port=server_settings.port,
log_level=server_settings.log_level.lower(),
factory=True,
)
3 changes: 3 additions & 0 deletions src/archivist/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from fastapi import APIRouter

router = APIRouter(prefix="/api/v1")
health_router = APIRouter()

from . import archive, extract, health # noqa: E402,F401
17 changes: 12 additions & 5 deletions src/archivist/api/archive.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import json
import uuid

from fastapi import Depends, Response
from loguru import logger

from archivist.api import router
from archivist.core.models import Archive, ManifestFailedResponse, ManifestRequest, ManifestResponse
from archivist.queue import ArchiveQueue, get_queue
from archivist.queue import ArchiveQueue, get_archive_queue
from archivist.settings import Settings, get_settings


@router.post("/archive", response_model=ManifestResponse | ManifestFailedResponse)
def archive(
manifest_request: ManifestRequest,
response: Response,
queue: ArchiveQueue = Depends(get_queue),
queue: ArchiveQueue = Depends(get_archive_queue),
settings: Settings = Depends(get_settings),
):
"""
Expand All @@ -25,19 +25,26 @@ def archive(
total_size = sum(entry.size for entry in manifest_request.store_files)
if total_size > settings.maximal_size_bytes: # Example size limit of 1GB
response.status_code = 400
logger.error(
f"Archiving manifest from librarian '{manifest_request.librarian_name}' failed: total size {total_size} exceeds limit of {settings.maximal_size_bytes} bytes"
)
return ManifestFailedResponse(error="The total size of the files exceeds the allowed limit.")

manifest_id = uuid.uuid4()

logger.info(
f"Archiving manifest {manifest_id} from librarian '{manifest_request.librarian_name}': {len(manifest_request.store_files)} file(s), {total_size} bytes total"
)

archive = Archive(
manifest_id=str(manifest_id),
manifest=json.dumps(manifest_request),
manifest=manifest_request.model_dump_json(),
paths=[entry.instance_path for entry in manifest_request.store_files],
root=settings.storage_root, # Example root path
)

# TODO: Add the archive to a queue for processing
queue.enqueue_archive(archive)
# queue.enqueue_archive(archive)

return ManifestResponse(
manifest_id=str(manifest_id),
Expand Down
20 changes: 20 additions & 0 deletions src/archivist/api/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import Any

from fastapi import Depends

from archivist.api import health_router
from archivist.settings import Settings, get_settings


@health_router.get("/health")
def health(
settings: Settings = Depends(get_settings),
) -> dict[str, Any]:
"""
Health check endpoint used by orchestrators/load balancers to confirm
the server is up and responding.
"""

return_dict = {"name": settings.displayed_site_name, "status": "ok"}

return return_dict
46 changes: 46 additions & 0 deletions src/archivist/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""
The Archivist v2.0 server.
"""

from contextlib import asynccontextmanager

from fastapi import FastAPI

from .settings import server_settings


@asynccontextmanager
async def slack_post_at_startup_shutdown(app: FastAPI):
"""
Lifespan event that posts to the slack hook once
the FastAPI server starts up and shuts down.
"""
from loguru import logger

logger.info("Archivist server starting up")
yield
logger.info("Archivist server shutting down")


def main() -> FastAPI:
from loguru import logger

logger.info("Starting Archivist server.")
logger.debug("Creating FastAPI app instance.")

app = 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,
)

logger.debug("Adding API router.")

from .api import health_router
from .api import router as api_router

app.include_router(api_router)
app.include_router(health_router)

return app
18 changes: 17 additions & 1 deletion src/archivist/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def get_settings() -> "Settings":
global _settings

try_paths = [
os.environ.get("LIBRARIAN_CONFIG_PATH", None),
os.environ.get("ARCHIVIST_CONFIG_PATH", None),
]

for path in try_paths:
Expand All @@ -150,3 +150,19 @@ def get_settings() -> "Settings":
raise e

return _settings


def __getattr__(name) -> Settings:
"""
Try to load the settings if they haven't been loaded yet.
"""

if name == "server_settings":
global _settings

if _settings is not None:
return _settings

return get_settings()

raise AttributeError(f"module '{__name__}' has no attribute '{name}'")