Packaging/dev-tools/docs update #244

Merged
cmo merged 19 commits from packaging into master 2026-07-01 09:00:58 +00:00
70 changed files with 1518 additions and 1327 deletions

View file

@ -2,7 +2,7 @@
---
name: Codespell
on: workflow_dispatch
on: [push, pull_request, workflow_dispatch]
permissions:
contents: read
@ -10,13 +10,13 @@ permissions:
jobs:
codespell:
name: Check for spelling errors
runs-on: ubuntu-latest
runs-on: debian-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Codespell
uses: codespell-project/actions-codespell@v2
uses: https://github.com/codespell-project/actions-codespell@v2
with:
ignore_words_list: crate

View file

@ -0,0 +1,36 @@
name: Type annotation (PR)
on:
pull_request:
paths:
- 'dump_things_service/**.py'
- '!**/tests/**.py'
jobs:
check:
runs-on: debian-latest
steps:
- name: Checkout project
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install hatch
run: uv tool install hatch
- name: Get Python changed files
id: changed-py-files
uses: https://github.com/tj-actions/changed-files@v46
with:
files: |
*.py
**/*.py
- name: Type check changed files
if: steps.changed-py-files.outputs.any_changed == 'true'
run: |
# get any type stubs that mypy thinks it needs
# run mypy on the modified files only, and do not even follow imports.
# this results is a fairly superficial test, but given the overall
# state of annotations, we strive to become more correct incrementally
# with focused error reports, rather than barfing a huge complaint
# that is unrelated to the changeset someone has been working on.
# run on the oldest supported Python version
hatch run types:mypy --install-types --non-interactive --python-version 3.11 --follow-imports skip --pretty --show-error-context ${{ steps.changed-py-files.outputs.all_changed_files }}

View file

@ -0,0 +1,17 @@
name: Ruff
on: [push, pull_request]
jobs:
ruff:
name: Code linting
runs-on: debian-latest
steps:
- name: Checkout project
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install hatch
run: uv tool install hatch
- name: Check code
run: hatch check code
- name: Check formatting
run: hatch check fmt

View file

@ -12,9 +12,6 @@ jobs:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
- name: Install uv
uses: astral-sh/setup-uv@v6
@ -23,14 +20,14 @@ jobs:
- name: Run tests
run: |
hatch run tests:run \
hatch test \
--ignore=dump_things_service/tests/test_generators.py \
--ignore=dump_things_service/tests/test_ifabsent_patch.py
- name: Run generator tests
run: |
hatch run tests:run dump_things_service/tests/test_generators.py
hatch test dump_things_service/tests/test_generators.py
- name: Run ifabsent-patch tests
run: |
hatch run tests:run dump_things_service/tests/test_ifabsent_patch.py
hatch test dump_things_service/tests/test_ifabsent_patch.py

2
.gitignore vendored
View file

@ -3,3 +3,5 @@ dist/**
tmp/**
**/__pycache__
**/.hypothesis
.*.swp
dump_things_service/_version.py

31
.readthedocs.yaml Normal file
View file

@ -0,0 +1,31 @@
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the OS, Python version and other tools you might need
build:
os: ubuntu-24.04
tools:
python: "3.12"
# Build documentation in the "docs/" directory with Sphinx
sphinx:
configuration: docs/conf.py
# Optionally build your docs in additional formats such as PDF and ePub
# formats:
# - pdf
# - epub
# Optional but recommended, declare the Python requirements required
# to build your documentation
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
python:
install:
- method: pip
path: .
extra_requirements:
- docs

View file

@ -123,7 +123,7 @@
3. The top-level mapping `admin_tokens` was added.
- Configuration files are no longer read when the service is started. Instead
the service reads its configuration from the store, if it is present. Thw tool
the service reads its configuration from the store, if it is present. The tool
(`dump-things-load-config`) can read an existing configuration
file and manifest the described configuration on a running dump-things server.
It supports pre version 6 config files and converts them to the new

View file

@ -594,7 +594,7 @@ Most endpoints require a *collection*. These correspond to the names of the "dat
The service provides the following user endpoints (In addition to user endpoints, there exist endpoints for curators. To view them, check the `/docs`-path in an installed service):
- `POST /maintenance`: this endpoint allows to set a collection into mantenance mode.
- `POST /maintenance`: this endpoint allows to set a collection into maintenance mode.
In maintenance mode, only tokens with curator-privileges can access the collection.
The posted data is a JSON that contains the name of the collection and whether the maintenance state should be active or not, for example:
```json

2
docs/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
_build
generated

20
docs/Makefile Normal file
View file

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?= --fail-on-warning
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

0
docs/_static/.gitkeep vendored Normal file
View file

48
docs/conf.py Normal file
View file

@ -0,0 +1,48 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import dump_things_service
project = 'dump-things-server'
copyright = '2025-2026, Christian Mönch'
author = 'Christian Mönch'
release = dump_things_service.__version__
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
'sphinx.ext.autosummary',
'sphinx.ext.autodoc',
'sphinx_autodoc_typehints',
'sphinx.ext.viewcode',
]
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
primary_domain = 'py'
autoclass_content = "both"
typehints_use_signature = True
typehints_use_signature_return = True
# we build some docstrings from loguru. define some no-op substitutions
# to avoid errors
rst_prolog = """
.. |Logger| replace:: Logger
.. |add| replace:: add
.. |sys.stderr| replace:: sys.stderr
.. |str.format| replace:: str.format
"""
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinx_rtd_theme'
html_static_path = ['_static']

10
docs/index.rst Normal file
View file

@ -0,0 +1,10 @@
The `dump-thing-server` documentation
=====================================
HERE BE CONTENT...
Indices and tables
==================
* :ref:`genindex`
* :ref:`search`

View file

@ -20,8 +20,9 @@ from starlette.status import (
HTTP_503_SERVICE_UNAVAILABLE,
)
from dump_things_service._version import __version__
__all__ = [
'Format',
'HTTP_200_OK',
'HTTP_201_CREATED',
'HTTP_300_MULTIPLE_CHOICES',
@ -37,6 +38,8 @@ __all__ = [
'HTTP_503_SERVICE_UNAVAILABLE',
'JSON',
'YAML',
'Format',
'__version__',
'config_file_name',
'reserved_collection_names',
]

View file

@ -1,14 +1,13 @@
import enum
import hashlib
import logging
from collections.abc import Callable, Iterable
from functools import partial
from pathlib import (
Path,
PurePosixPath,
)
from typing import (
Callable,
Iterable,
Literal,
cast,
)
@ -17,7 +16,8 @@ from fastapi import HTTPException
from pydantic import (
BaseModel,
ConfigDict,
Field, ValidationError,
Field,
ValidationError,
)
from yaml.scanner import ScannerError
@ -27,12 +27,11 @@ from dump_things_service import (
)
from dump_things_service.audit.gitaudit import GitAuditBackend
from dump_things_service.backends.record_dir import (
_RecordDirStore,
RecordDirStore,
_RecordDirStore,
)
from dump_things_service.exceptions import ConfigError
logger = logging.getLogger('dump_things_service')
g_abstract_configuration = None
@ -103,7 +102,9 @@ class CollectionConfig(BaseModel):
curated: PurePosixPath
schema_location: str = Field(alias='schema')
incoming: PurePosixPath | None = None
backend: RecordDirBackendConfig | SQLiteBackendConfig = RecordDirBackendConfig(type='record_dir+stl')
backend: RecordDirBackendConfig | SQLiteBackendConfig = RecordDirBackendConfig(
type='record_dir+stl'
)
auth_sources: list[ForgejoAuthSpec | ConfigAuthSpec] = [ConfigAuthSpec()]
audit_backends: list[GitAuditBackendConfig] = []
submission_tags: TagSpec = TagSpec()
@ -211,9 +212,7 @@ def get_config_backends(
if config_backend is None:
config_backend = RecordDirStore(
config_path,
mapping_functions[MappingMethod.digest_md5],
'yaml'
config_path, mapping_functions[MappingMethod.digest_md5], 'yaml'
)
audit_path = store_path / config_audit_path
@ -259,7 +258,7 @@ def get_config() -> Configuration:
if not g_abstract_configuration:
msg = 'Configuration not yet loaded'
raise RuntimeError(msg)
return cast(Configuration, g_abstract_configuration)
return cast('Configuration', g_abstract_configuration)
def store_config(
@ -274,7 +273,7 @@ def store_config(
config_backend.add_record(
iri=dump_things_config_iri,
class_name='DumpThingsConfig',
json_object=json_object
json_object=json_object,
)
audit_backend.add_record(
record=json_object,
@ -314,10 +313,9 @@ def check_label(
from dump_things_service.utils import get_on_disk_labels
"""Check that a label exists in a collection configuration or on disk"""
if (
label not in get_config_labels(abstract_config, collection)
and label not in get_on_disk_labels(store_path, abstract_config, collection)
):
if label not in get_config_labels(
abstract_config, collection
) and label not in get_on_disk_labels(store_path, abstract_config, collection):
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"No incoming label: '{label}' in collection: '{collection}'.",
@ -336,10 +334,7 @@ def get_config_labels(
}
def get_default_token_name(
abstract_config: Configuration,
collection: str
) -> str:
def get_default_token_name(abstract_config: Configuration, collection: str) -> str:
check_collection(abstract_config, collection)
return abstract_config.collections[collection].default_token
@ -377,7 +372,6 @@ def get_token_infos_for_collection(
abstract_config: Configuration,
collection_name: str,
) -> Iterable[tuple[str, TokenConfig, TokenCollectionConfig]]:
yield from {
(token_name, token_config, token_collection_config)
for token_name, token_config in abstract_config.tokens.items()
@ -391,7 +385,6 @@ def get_token_config_for_representation_and_collection(
collection_name: str,
token_representation: str,
) -> tuple[str, TokenConfig, TokenCollectionConfig] | None:
token_info = get_token_info_by_representation(
abstract_config=abstract_config,
token_representation=token_representation,
@ -421,7 +414,6 @@ def get_default_token_config(
abstract_config: Configuration,
collection: str,
) -> TokenConfig | None:
default_token_name = get_collection_config_by_name(
abstract_config,
collection,

View file

@ -9,7 +9,6 @@ from dump_things_service.abstract_config import (
)
from dump_things_service.instance_state import InstanceState
logger = logging.getLogger('dump_things_service')

View file

@ -6,6 +6,7 @@ committed.
Changes are annotated with a time stamp and a user-id
"""
from __future__ import annotations
import hashlib
@ -23,12 +24,11 @@ import yaml
from datalad_core.git_utils import apply_changeset
from datalad_core.repo import Repo
from datalad_core.runners import (
call_git,
CommandError,
call_git,
)
from . import AuditBackend
from dump_things_service.audit import AuditBackend
index_file_name = 'gitaudit_index.log'
@ -56,7 +56,6 @@ class FlushingThread(Thread):
class GitAuditBackend(AuditBackend):
def __init__(
self,
path: Path,
@ -69,7 +68,8 @@ class GitAuditBackend(AuditBackend):
self.lock = Lock()
self.last_flush_time = 0
if auto_flush_timeout < 1:
raise ValueError('auto_flush_timeout must be greater or equal to 1')
msg = 'auto_flush_timeout must be greater or equal to 1'
raise ValueError(msg)
self.flushing_thread = FlushingThread(self, auto_flush_timeout)
self.flushing_thread.start()
self._init_repo()
@ -125,32 +125,42 @@ class GitAuditBackend(AuditBackend):
# the records
changes = []
yaml_location, log_location = map(str, self._get_location_for(record_id)[1:])
commit_hashes = call_git(
commit_hashes = (
call_git(
['log', '--format=%H', '--', log_location],
cwd=self.path,
capture_output=True,
).decode().splitlines()
)
.decode()
.splitlines()
)
for commit_hash in commit_hashes:
log_diff_lines = call_git(
log_diff_lines = (
call_git(
['show', '--format=%b', commit_hash, '--', log_location],
cwd=self.path,
capture_output=True,
).decode().splitlines()
)
.decode()
.splitlines()
)
# Get the log entry
log_line = tuple(
filter(
log_line = next(filter(
lambda l: not l.startswith('+++') and l.startswith('+'),
log_diff_lines,
)
)[0][1:]
))[1:]
log_entry = json.loads(log_line)
# Get the YAML diff
yaml_diff_lines = call_git(
yaml_diff_lines = (
call_git(
['show', '--format=%b', commit_hash, '--', yaml_location],
cwd=self.path,
capture_output=True,
).decode().splitlines()
)
.decode()
.splitlines()
)
yaml_diff = '\n'.join(filter(lambda l: l != '', yaml_diff_lines)) + '\n'
# Get the YAML content
@ -247,7 +257,7 @@ class GitAuditBackend(AuditBackend):
) -> bytes:
try:
return call_git(
['cat-file', '-p', f'master:{str(path)}'],
['cat-file', '-p', f'master:{path!s}'],
cwd=self.path,
capture_output=True,
)
@ -290,7 +300,7 @@ class GitAuditBackend(AuditBackend):
record_id: str,
) -> tuple[str, Path, Path]:
base = hashlib.sha1(record_id.encode()).hexdigest()
dir_1, dir_2, name = base[0:3], base[3:6], base[6:]
dir_1, dir_2, _name = base[0:3], base[3:6], base[6:]
location_dir = Path(dir_1) / Path(dir_2)
return (
base,
@ -321,8 +331,8 @@ class GitAuditBackend(AuditBackend):
if not self.index_path.exists():
self._rebuild_index()
with open(self.index_path, 'rt') as f:
self.index = set(line.strip() for line in f.readlines())
with open(self.index_path) as f:
self.index = {line.strip() for line in f}
def _add_to_index(
self,
@ -333,16 +343,20 @@ class GitAuditBackend(AuditBackend):
self.index.add(record_id)
def _rebuild_index(self):
tree_entries = call_git(
tree_entries = (
call_git(
['ls-tree', '-r', 'master:'],
cwd=self.path,
capture_output=True,
).decode().splitlines()
with open(self.index_path, 'wt') as f:
)
.decode()
.splitlines()
)
with open(self.index_path, 'w') as f:
for line in tree_entries:
if not line.endswith('.yaml'):
continue
flag, object_type, object_hash, file_name = line.split(maxsplit=3)
_flag, _object_type, object_hash, _file_name = line.split(maxsplit=3)
record = yaml.safe_load(
call_git(
['show', object_hash],

View file

@ -19,7 +19,7 @@ def _get_audit_log_lines(backend: GitAuditBackend, record_id: str) -> list[str]:
def test_gitaudit_basic(tmp_path_factory):
tmp_path = tmp_path_factory.mktemp("gitaudit_backend")
tmp_path = tmp_path_factory.mktemp('gitaudit_backend')
backend = GitAuditBackend(tmp_path)
@ -44,14 +44,13 @@ def test_gitaudit_basic(tmp_path_factory):
# Check that the changes are reported
changes = backend.get_audit_log(record_id)
assert len(changes) == 4
assert tuple(map(lambda e: e[0:2], changes.values())) == tuple(
(f'committer_{100 + i}@x.org', f'author_{i}@y.org')
for i in range(4)
assert tuple(e[0:2] for e in changes.values()) == tuple(
(f'committer_{100 + i}@x.org', f'author_{i}@y.org') for i in range(4)
)
def test_gitaudit_identical_change(tmp_path_factory):
tmp_path = tmp_path_factory.mktemp("gitaudit_backend")
tmp_path = tmp_path_factory.mktemp('gitaudit_backend')
backend = GitAuditBackend(tmp_path)
@ -83,7 +82,7 @@ def test_gitaudit_identical_change(tmp_path_factory):
def test_gitaudit_huge_log(tmp_path_factory):
tmp_path = tmp_path_factory.mktemp("gitaudit_backend")
tmp_path = tmp_path_factory.mktemp('gitaudit_backend')
backend = GitAuditBackend(tmp_path)

View file

@ -8,6 +8,7 @@ determine:
- the incoming_label to be used with the token
"""
from __future__ import annotations
import abc

View file

@ -1,15 +1,15 @@
"""Use configuration information to fetch token permissions, ids, and incoming_label"""
from dump_things_service.abstract_config import Configuration
from dump_things_service.abstract_config import (
Configuration,
get_token_config_for_representation_and_collection,
get_token_permissions,
)
from dump_things_service.auth import (
AuthenticationInfo,
AuthenticationSource,
InvalidTokenError,
)
from dump_things_service.abstract_config import (
get_token_permissions,
get_token_config_for_representation_and_collection,
)
class ConfigAuthenticationSource(AuthenticationSource):
@ -25,7 +25,6 @@ class ConfigAuthenticationSource(AuthenticationSource):
self,
token_representation: str,
) -> AuthenticationInfo:
result = get_token_config_for_representation_and_collection(
self.abstract_configuration,
self.collection_name,

View file

@ -7,13 +7,14 @@ Note: for some reason, the request:
does not require a token. If the owner and the repo are known, the request
will emit a complete repository-record including the complete owner-record.
"""
from __future__ import annotations
import hashlib
import logging
import time
from functools import wraps
from typing import Callable
from typing import TYPE_CHECKING
import requests
from requests.exceptions import Timeout
@ -22,13 +23,16 @@ from dump_things_service import (
HTTP_300_MULTIPLE_CHOICES,
HTTP_401_UNAUTHORIZED,
)
from dump_things_service.abstract_config import TokenPermission
from dump_things_service.auth import (
AuthenticationError,
AuthenticationInfo,
AuthenticationSource,
InvalidTokenError,
)
from dump_things_service.abstract_config import TokenPermission
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger('dump_things_service')
@ -47,6 +51,7 @@ class MethodCache:
duration: int = 300,
) -> Callable:
"""Cache results for a given time (default: 300 seconds)"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
@ -56,12 +61,15 @@ class MethodCache:
if cached_data is None or time.time() - cached_data[0] > duration:
self.__cached_data[key] = (time.time(), func(*args, **kwargs))
return self.__cached_data[key][1]
return wrapper
return decorator
class RemoteAuthenticationError(AuthenticationError):
"""Exception for remote authentication errors."""
def __init__(self, status: int, message: str):
self.status = status
self.message = message
@ -133,7 +141,8 @@ class ForgejoAuthenticationSource(AuthenticationSource, MethodCache):
) from e
if r.status_code >= HTTP_300_MULTIPLE_CHOICES:
msg = f'invalid token: ({r.status_code}): {r.text}'
cleaned_text = r.text.replace(token, '***')
msg = f'invalid token: ({r.status_code}): {cleaned_text}'
raise InvalidTokenError(msg)
return r.json()
@ -197,11 +206,7 @@ class ForgejoAuthenticationSource(AuthenticationSource, MethodCache):
zones_access=is_curator,
)
def _get_unit_content(
self,
team: dict,
unit_name: str
) -> str:
def _get_unit_content(self, team: dict, unit_name: str) -> str:
permissions = team['units_map'].get(unit_name)
if not permissions:
logger.debug(f'no unit `repo.actions` in team {self.team}')
@ -216,23 +221,22 @@ class ForgejoAuthenticationSource(AuthenticationSource, MethodCache):
return permissions
def _instance_label(self) -> str:
return self.instance_id or hashlib.md5(
self.api_url.encode()
).hexdigest()
return self.instance_id or hashlib.md5(self.api_url.encode()).hexdigest()
@MethodCache.cache_temporary(duration=60)
def authenticate(
self,
token: str,
) -> AuthenticationInfo:
logger.debug(f'starting Forgejo authentication: {self.api_url}, {self.organization}, {self.team}')
logger.debug(
f'starting Forgejo authentication: {self.api_url}, {self.organization}, {self.team}'
)
user_teams = self._get_teams_for_user(token)
logger.debug(f'user_teams: {user_teams}')
if self.team not in user_teams:
logger.debug(f'{self.team} not in user\'s teams')
logger.debug(f"{self.team} not in user's teams")
msg = f'token user is not member of team `{self.team}`'
raise RemoteAuthenticationError(
status=HTTP_401_UNAUTHORIZED,
@ -281,8 +285,7 @@ class ForgejoAuthenticationSource(AuthenticationSource, MethodCache):
action_permissions,
),
user_id=user_info['email'],
incoming_label=
f'forgejo-{self._instance_label()}-team-{organization["name"]}-{team["name"]}'
incoming_label=f'forgejo-{self._instance_label()}-team-{organization["name"]}-{team["name"]}'
if self.label_type == 'team'
else f'forgejo-{self._instance_label()}-user-{user_info["login"]}',
)

View file

@ -1,48 +1,27 @@
from __future__ import annotations
import logging
from itertools import count
from typing import TYPE_CHECKING
from fastapi import (
APIRouter,
Depends,
FastAPI,
HTTPException,
)
from fastapi_pagination import (
Page,
add_pagination,
paginate,
)
from dump_things_service import (
HTTP_401_UNAUTHORIZED,
HTTP_404_NOT_FOUND,
HTTP_422_UNPROCESSABLE_CONTENT, abstract_config,
)
from dump_things_service.abstract_config import (
check_collection,
read_config,
)
from dump_things_service.api_key import api_key_header_scheme
from dump_things_service.auth import AuthenticationInfo
from dump_things_service.backends.schema_type_layer import _SchemaTypeLayer
from dump_things_service.exceptions import CurieResolutionError
from dump_things_service.instance_state import get_instance_state
from dump_things_service.lazy_list import ModifierList
from dump_things_service.utils import (
authenticate_token,
check_bounds,
cleaned_json,
wrap_http_exception,
)
if TYPE_CHECKING:
from pydantic import BaseModel
from dump_things_service.auth import AuthenticationInfo
from dump_things_service.backends import StorageBackend
from dump_things_service.lazy_list import LazyList
from dump_things_service.store.model_store import _ModelStore
@ -50,7 +29,6 @@ def get_store_and_backend(
collection: str,
plain_token: str | None,
) -> tuple[_ModelStore, StorageBackend, AuthenticationInfo]:
# A token is required
if plain_token is None:
raise HTTPException(

View file

@ -111,9 +111,7 @@ class StorageBackend(metaclass=ABCMeta):
self.order_by = order_by or ['pid']
@abstractmethod
def get_uri(
self
) -> str:
def get_uri(self) -> str:
raise NotImplementedError
@abstractmethod

View file

@ -10,7 +10,6 @@ import logging
from pathlib import Path
from typing import (
TYPE_CHECKING,
Callable,
)
import yaml
@ -26,12 +25,12 @@ from dump_things_service.backends import (
from dump_things_service.backends.record_dir_index import RecordDirIndex
if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Callable, Iterable
__all__ = [
'_RecordDirStore',
'RecordDirStore',
'_RecordDirStore',
]
ignored_files = {'.', '..', config_file_name}
@ -91,9 +90,7 @@ class _RecordDirStore(StorageBackend):
self.suffix = suffix
self.index = RecordDirIndex(root, suffix)
def get_uri(
self
) -> str:
def get_uri(self) -> str:
return f'file://{self.root!s}'
def build_index(

View file

@ -34,8 +34,8 @@ if TYPE_CHECKING:
__all__ = [
'_SchemaTypeLayer',
'SchemaTypeLayer',
'_SchemaTypeLayer',
]
@ -81,9 +81,7 @@ class _SchemaTypeLayer(StorageBackend):
self.backend = backend
self.schema_model = get_schema_model_for_schema(schema)
def get_uri(
self
) -> str:
def get_uri(self) -> str:
return self.backend.get_uri()
def add_record(
@ -96,8 +94,7 @@ class _SchemaTypeLayer(StorageBackend):
# don't want to store it in the files. We add `schema_type` after
# reading the record from disk. The value of `schema_type` is determined
# by the class name of the record, which is stored in the path.
if 'schema_type' in json_object:
del json_object['schema_type']
json_object.pop('schema_type', None)
self.backend.add_record(
iri=iri,
class_name=class_name,

View file

@ -62,8 +62,8 @@ if TYPE_CHECKING:
__all__ = [
'_SQLiteBackend',
'SQLiteBackend',
'_SQLiteBackend',
]
logger = logging.getLogger('dump_things_service')
@ -139,9 +139,7 @@ class _SQLiteBackend(StorageBackend):
self.engine = create_engine('sqlite:///' + str(db_path), echo=echo)
Base.metadata.create_all(self.engine)
def get_uri(
self
) -> str:
def get_uri(self) -> str:
return f'sqlite://{self.db_path}'
def perform_file_name_conversion(self):
@ -152,7 +150,9 @@ class _SQLiteBackend(StorageBackend):
logger.info('converting old style name %s', str(old_path))
# Create a backup copy
old_backup_path = (self.db_path.parent / (old_record_file_name + '.backup')).absolute()
old_backup_path = (
self.db_path.parent / (old_record_file_name + '.backup')
).absolute()
logger.info('copying %s to %s', old_path, old_backup_path)
shutil.copyfile(str(old_path), str(old_backup_path))
@ -240,21 +240,20 @@ class _SQLiteBackend(StorageBackend):
class_names: Iterable[str],
pattern: str | None = None,
) -> SQLResultList:
class_list = ', '.join(f"'{cn}'" for cn in class_names)
if pattern is None:
statement = text(
'select distinct thing.iri, thing.class_name, thing.sort_key, thing.id '
'from thing '
f"where thing.class_name in ({class_list}) "
"ORDER BY thing.sort_key"
f'where thing.class_name in ({class_list}) '
'ORDER BY thing.sort_key'
)
else:
statement = text(
'select distinct thing.iri, thing.class_name, thing.sort_key, thing.id '
'from thing, json_tree(thing.object) '
'where lower(json_tree.value) like lower(:pattern) '
f"and thing.class_name in ({class_list}) "
f'and thing.class_name in ({class_list}) '
"and json_tree.type = 'text' ORDER BY thing.sort_key"
)
@ -278,7 +277,7 @@ class _SQLiteBackend(StorageBackend):
statement = text(
'select distinct thing.iri, thing.class_name, thing.sort_key, thing.id '
'from thing '
"ORDER BY thing.sort_key"
'ORDER BY thing.sort_key'
)
else:
statement = text(

View file

@ -20,9 +20,7 @@ def test_add_and_delete_record(tmp_path):
record_dir_store.build_index(str(schema_path))
record_dir_store.add_record(
iri=iri,
class_name='Object',
json_object={'pid': 'some-pid'}
iri=iri, class_name='Object', json_object={'pid': 'some-pid'}
)
record = record_dir_store.get_record_by_iri(iri=iri)

View file

@ -2,13 +2,19 @@ import logging
import os
import shutil
from pathlib import Path
from typing import Any
# This following lines are required for dynamic endpoint generation
from typing import (
Annotated, # noqa: F401 -- used by autogenerated code
Any,
)
from datalad_core.runners import (
call_git_oneline,
CommandError,
call_git_oneline,
)
from fastapi import (
Body, # noqa: F401 -- used by autogenerated code
Depends,
FastAPI,
HTTPException,
@ -24,40 +30,49 @@ from starlette.responses import (
)
from dump_things_service import (
Format,
HTTP_400_BAD_REQUEST,
HTTP_403_FORBIDDEN,
HTTP_422_UNPROCESSABLE_CONTENT,
Format,
)
from dump_things_service.abstract_config import (
CollectionConfig,
Configuration,
ConfigAuthSpec,
Configuration,
ForgejoAuthSpec,
RecordDirBackendConfig,
SQLiteBackendConfig,
read_config,
check_collection,
read_config,
)
from dump_things_service.api_key import (
api_key_header_scheme,
)
from dump_things_service.audit.gitaudit import GitAuditBackend
from dump_things_service.auth.config import ConfigAuthenticationSource
from dump_things_service.auth.forgejo import ForgejoAuthenticationSource
from dump_things_service.backends.record_dir_index import index_file_name
from dump_things_service.backends.sqlite import record_file_name as sqlite_db_filename
from dump_things_service.converter import FormatConverter
from dump_things_service.curated import (
store_curated_record, # noqa: F401 -- used by autogenerated code
)
from dump_things_service.exceptions import (
ConfigCollisionError,
ConfigError,
CurieResolutionError,
)
from dump_things_service.incoming import (
store_incoming_record, # noqa: F401 -- used by autogenerated code
)
from dump_things_service.instance_state import (
InstanceState,
InstanceStateCollectionInfo,
get_record_dir_config,
get_instance_state,
get_record_dir_config,
get_schema_info,
record_dir_config_file_name,
)
from dump_things_service.converter import FormatConverter
from dump_things_service.exceptions import (
ConfigError,
ConfigCollisionError,
CurieResolutionError,
)
from dump_things_service.model import get_model_for_schema
from dump_things_service.utils import (
combine_ttl,
@ -67,16 +82,9 @@ from dump_things_service.utils import (
var_escape,
wrap_http_exception,
)
# This following lines are required for dynamic endpoint generation
from typing import Annotated # noqa 401 -- used by autogenerated code
from fastapi import Body # noqa 401 -- used by autogenerated code
from dump_things_service.api_key import api_key_header_scheme # noqa 401 -- used by autogenerated code
from dump_things_service.curated import store_curated_record # noqa 401 -- used by autogenerated code
from dump_things_service.incoming import store_incoming_record # noqa 401 -- used by autogenerated code
from dump_things_service.validate import validate_record # noqa 401 -- used by autogenerated code
from dump_things_service.validate import (
validate_record, # noqa: F401 -- used by autogenerated code
)
logger = logging.getLogger('dump_things_service')
@ -191,7 +199,7 @@ def create_collection(
audit_path.mkdir(parents=True)
created_directories.append(audit_path)
except ConfigError as e:
except ConfigError:
# Delete all directories that were created in this
for directory in created_directories:
shutil.rmtree(directory)
@ -222,7 +230,7 @@ def create_collection(
active_classes -= set(collection_configuration.ignore_classes)
instance_state.collections[collection_name] = InstanceStateCollectionInfo(
active_classes=active_classes,
tag_info=dict(),
tag_info={},
)
# Create a validator for the collection
@ -301,7 +309,8 @@ def write_record_dir_config(
record_dir_config_file_path = path / record_dir_config_file_name
if not record_dir_config_file_path.exists():
record_dir_config_file_path.write_text(f"""# RecordDir Config
record_dir_config_file_path.write_text(
f"""# RecordDir Config
type: records
version: 1
schema: {schema}
@ -340,20 +349,20 @@ def check_record_dir_compatibility(
backend_config: RecordDirBackendConfig,
schema: str,
):
# Non-existing or empty record_dir-directories are compatible
if not store_path.exists():
return
# A record_dir-directory is considered to be empty, if it contains no
# files or only an record_dir-index file
files_in_dir = tuple(map(lambda dir_entry: dir_entry.name, os.scandir(store_path)))
files_in_dir = tuple(dir_entry.name for dir_entry in os.scandir(store_path))
if files_in_dir in ((), (index_file_name,)):
return
record_dir_config = get_record_dir_config(store_path)
if record_dir_config.schema_location != schema:
raise ConfigCollisionError(f"Existing backend uses a different schema: '{record_dir_config.schema_location}'")
msg = f"Existing backend uses a different schema: '{record_dir_config.schema_location}'"
raise ConfigCollisionError(msg)
stored_mapping_method = record_dir_config.idfx.value
if stored_mapping_method != backend_config.mapping_method:
@ -367,8 +376,8 @@ def check_sqlite_compatibility(
):
sqlite_db_path = Path(store_path / sqlite_db_filename)
if not sqlite_db_path.exists():
raise ConfigError('No sqlite database found in existing store')
return
msg = 'No sqlite database found in existing store'
raise ConfigError(msg)
def check_git_audit_compatibility(
@ -394,9 +403,11 @@ def check_git_audit_compatibility(
force_c_locale=True,
)
except CommandError as ce:
raise ConfigError(f'No git repository in gitaudit-path: {audit_path}') from ce
msg = f'No git repository in gitaudit-path: {audit_path}'
raise ConfigError(msg) from ce
if result.strip().lower() != 'true':
raise ConfigError(f'No bare git repository in gitaudit-path: {audit_path}')
msg = f'No bare git repository in gitaudit-path: {audit_path}'
raise ConfigError(msg)
return
@ -413,7 +424,7 @@ def create_endpoint(
app: FastAPI,
):
logger.info(
f'Creating %s-endpoints for collection: "%s"',
'Creating %s-endpoints for collection: "%s"',
operation_name,
collection_name,
)
@ -421,12 +432,16 @@ def create_endpoint(
instance_state.collections[collection_name].tag_info[tag_group] = tag_name
# TODO: get schema_info from instance_state!?
model, classes, model_var_name = get_model_for_schema(collection_config.schema_location)
model, _classes, model_var_name = get_model_for_schema(
collection_config.schema_location
)
globals()[model_var_name] = model
active_classes = instance_state.collections[collection_name].active_classes
for class_name in active_classes:
endpoint_name = f'_endpoint_{var_escape(collection_name)}_{operation_name}_{class_name}'
endpoint_name = (
f'_endpoint_{var_escape(collection_name)}_{operation_name}_{class_name}'
)
endpoint_source = template.format(
name=endpoint_name,
model_var_name=model_var_name,
@ -435,7 +450,7 @@ def create_endpoint(
info=f"'{operation_name} {collection_name}/{class_name} objects'",
handler=handler,
)
exec(endpoint_source, globals()) # noqa S102
exec(endpoint_source, globals()) # noqa: S102
# Create an API route for the endpoint
app.add_api_route(
@ -444,7 +459,7 @@ def create_endpoint(
methods=['POST'],
name=f'{operation_name} "{class_name}" object (schema: {model.linkml_meta["id"]})',
response_model=None,
tags=[tag_name]
tags=[tag_name],
)
logger.info(
@ -468,10 +483,38 @@ def create_endpoints_for_collection(
tag_group,
tag_name,
) in (
('store', 'record', _endpoint_template, 'store_record', 'write', f'Write records to collection "{collection_name}"'),
('validate', 'validate/record', _endpoint_template, 'validate_record', 'validate', f'Validate records for collection "{collection_name}"'),
('curated', 'curated/record', _endpoint_curated_template, 'store_curated_record', 'curated_write', f'Curated area: store records in curated area of collection "{collection_name}"'),
('incoming', 'incoming/{label}/record', _endpoint_incoming_template, 'store_incoming_record', 'incoming_write', f'Incoming area: store records in incoming area "{{label}}" of collection "{collection_name}"'),
(
'store',
'record',
_endpoint_template,
'store_record',
'write',
f'Write records to collection "{collection_name}"',
),
(
'validate',
'validate/record',
_endpoint_template,
'validate_record',
'validate',
f'Validate records for collection "{collection_name}"',
),
(
'curated',
'curated/record',
_endpoint_curated_template,
'store_curated_record',
'curated_write',
f'Curated area: store records in curated area of collection "{collection_name}"',
),
(
'incoming',
'incoming/{label}/record',
_endpoint_incoming_template,
'store_incoming_record',
'incoming_write',
f'Incoming area: store records in incoming area "{{label}}" of collection "{collection_name}"',
),
):
create_endpoint(
operation_name=operation_name,
@ -491,14 +534,13 @@ def delete_endpoints_for_collection(
instance_state: InstanceState,
collection_name: str,
):
active_classes = instance_state.collections[collection_name].active_classes
for operation_path in (
'record',
'validate/record',
'curated/record',
'incoming/{label}/record'
'incoming/{label}/record',
):
delete_endpoint(
collection_name=collection_name,
@ -516,10 +558,10 @@ def delete_endpoint(
):
from fastapi.routing import _IncludedRouter
remove_paths_set = set(
remove_paths_set = {
f'/{collection_name}/{operation_path}/{class_name}'
for class_name in active_classes
)
}
remove_indices = [
index
@ -584,18 +626,32 @@ def store_record(
)
if input_format == Format.ttl:
with wrap_http_exception(ValueError, status_code=HTTP_422_UNPROCESSABLE_CONTENT, header='Conversion error'):
with wrap_http_exception(
ValueError,
status_code=HTTP_422_UNPROCESSABLE_CONTENT,
header='Conversion error',
):
json_object = FormatConverter(
abstract_config.collections[collection].schema_location,
input_format=Format.ttl,
output_format=Format.json,
).convert(data, class_name)
with wrap_http_exception(ValidationError, status_code=HTTP_422_UNPROCESSABLE_CONTENT, header='Validation error'):
record = TypeAdapter(getattr(model, class_name)).validate_python(json_object)
with wrap_http_exception(
ValidationError,
status_code=HTTP_422_UNPROCESSABLE_CONTENT,
header='Validation error',
):
record = TypeAdapter(getattr(model, class_name)).validate_python(
json_object
)
else:
record = data
with wrap_http_exception(ValueError, status_code=HTTP_422_UNPROCESSABLE_CONTENT, header='Validation error'):
with wrap_http_exception(
ValueError,
status_code=HTTP_422_UNPROCESSABLE_CONTENT,
header='Validation error',
):
instance_state.validators[collection].validate(record)
with wrap_http_exception(CurieResolutionError):

View file

@ -3,7 +3,7 @@ from pathlib import (
Path,
PurePosixPath,
)
from typing import Literal
from typing import Annotated, Literal
from urllib.parse import quote
from fastapi import (
@ -22,19 +22,19 @@ from dump_things_service import (
reserved_collection_names,
)
from dump_things_service.abstract_config import (
Configuration,
CollectionConfig,
Configuration,
get_config,
get_token_permissions,
store_config,
get_config, get_token_permissions,
)
from dump_things_service.admin import authenticate_admin
from dump_things_service.api_key import api_key_header_scheme
from dump_things_service.instance_state import get_instance_state, InstanceState
from dump_things_service.manifest import manifest_configuration
from dump_things_service.exceptions import ConfigError
from dump_things_service.instance_state import InstanceState, get_instance_state
from dump_things_service.manifest import manifest_configuration
from dump_things_service.utils import wrap_http_exception
logger = logging.getLogger('dump_things_service')
router = APIRouter()
@ -71,7 +71,7 @@ class CollectionRequest(CollectionConfig):
async def create_collection(
response: Response,
body: CollectionRequest,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
):
await create_or_replace_collection(body, api_key, allow_replace=False)
response.headers['Location'] = f'/collections/{quote(body.name)}'
@ -86,7 +86,7 @@ async def create_collection(
async def replace_collection(
response: Response,
body: CollectionRequest,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
):
await create_or_replace_collection(body, api_key, allow_replace=True)
response.headers['Location'] = f'/collections/{quote(body.name)}'
@ -97,7 +97,6 @@ async def create_or_replace_collection(
api_key: str,
allow_replace: bool,
):
instance_state = get_instance_state()
abstract_config = get_config()
@ -165,9 +164,8 @@ async def create_or_replace_collection(
name='Get existing collections',
)
async def get_collections(
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
) -> list[CollectionRequest]:
instance_state = get_instance_state()
abstract_config = get_config()
@ -177,7 +175,7 @@ async def get_collections(
CollectionRequest(
**{
'name': collection_name,
**collection_info.model_dump(mode='json', by_alias=True)
**collection_info.model_dump(mode='json', by_alias=True),
}
)
for collection_name, collection_info in abstract_config.collections.items()
@ -191,9 +189,8 @@ async def get_collections(
)
async def get_collection_with_name(
collection_name: str,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
) -> CollectionConfig:
instance_state = get_instance_state()
abstract_config = get_config()
@ -215,9 +212,8 @@ async def get_collection_with_name(
)
async def delete_collection(
collection_name: str,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
):
instance_state = get_instance_state()
abstract_config = get_config()
@ -252,7 +248,9 @@ def ensure_unique_directory(
abs_existing_dir = (instance_state.store_path / Path(existing_dir)).absolute()
for collection_name, collection_config in abstract_config.collections.items():
for collection_dir in collection_config.curated, collection_config.incoming:
abs_collection_dir = (instance_state.store_path / Path(collection_dir)).absolute()
abs_collection_dir = (
instance_state.store_path / Path(collection_dir)
).absolute()
if abs_collection_dir == abs_existing_dir:
raise HTTPException(
status_code=HTTP_409_CONFLICT,
@ -273,7 +271,7 @@ def validate_incoming_paths(
detail = (
f"Cannot add collection '{collection_request.name}' without "
f"`incoming` path, because at least token '{token_name}' "
f" has write access to the collection"
f' has write access to the collection'
)
raise HTTPException(
status_code=HTTP_406_NOT_ACCEPTABLE,

View file

@ -2,8 +2,8 @@ from __future__ import annotations
import sys
from argparse import ArgumentParser
from collections.abc import Iterable
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import FastAPI
@ -16,12 +16,16 @@ from dump_things_service.backends.sqlite import _SQLiteBackend
from dump_things_service.exceptions import CurieResolutionError
from dump_things_service.instance_state import create_instance_state
from dump_things_service.manifest import manifest_configuration
from dump_things_service.store.model_store import _ModelStore
from dump_things_service.utils import (
create_token_store,
get_on_disk_labels,
)
if TYPE_CHECKING:
from collections.abc import Iterable
from dump_things_service.store.model_store import _ModelStore
parser = ArgumentParser(
prog='Check pids for resolvability',
description='This command checks for pids that are in CURIE format and '
@ -33,19 +37,7 @@ parser.add_argument(
)
def show_backend(model_store: _ModelStore):
backend = model_store.backend
if isinstance(backend, _SchemaTypeLayer):
backend = backend.backend
if isinstance(backend, _SQLiteBackend):
print(f'Checking: {backend.db_path}', file=sys.stderr)
else:
print(f'Checking: {backend.root}', file=sys.stderr)
def check_pids_in_stores(
stores: Iterable[_ModelStore]
) -> int:
def check_pids_in_stores(stores: Iterable[_ModelStore]) -> int:
result = 0
for store in stores:
print('checking', store.get_uri(), file=sys.stderr)
@ -55,8 +47,6 @@ def check_pids_in_stores(
store.pid_to_iri(pid)
except CurieResolutionError:
result += 1
print(pid, store.get_uri())
return result
@ -94,7 +84,7 @@ def check_pids(
abstract_config,
instance_state,
collection,
instance_state.store_path / collection_info.incoming / label
instance_state.store_path / collection_info.incoming / label,
)
for label in all_labels
]

View file

@ -5,6 +5,7 @@ from argparse import ArgumentParser
from pathlib import Path
from typing import TYPE_CHECKING
from dump_things_service.abstract_config import get_backend_and_extension
from dump_things_service.backends.record_dir import (
RecordDirStore,
_RecordDirStore,
@ -17,7 +18,6 @@ from dump_things_service.backends.sqlite import (
from dump_things_service.backends.sqlite import (
record_file_name as sqlite_record_file_name,
)
from dump_things_service.abstract_config import get_backend_and_extension
if TYPE_CHECKING:
from dump_things_service.backends import StorageBackend

View file

@ -5,21 +5,16 @@ import yaml
from linkml_runtime.utils.schemaview import SchemaView
# Patch linkml
from dump_things_service.patches import enabled # noqa F401 -- patches LinkML
from dump_things_service.patches import enabled # noqa: F401 -- patches LinkML
parser = ArgumentParser(
prog='Create a static schema with all imported schemas integrated',
)
parser.add_argument(
'schema',
help='File containing a schema definition'
)
parser.add_argument('schema', help='File containing a schema definition')
def update_uris_for_elements(
all_elements: dict,
attribute_name: str,
prefix_index: dict
all_elements: dict, attribute_name: str, prefix_index: dict
):
for name, info in all_elements.items():
uri = getattr(info, attribute_name)

View file

@ -8,7 +8,6 @@ from argparse import ArgumentParser
import requests
import yaml
parser = ArgumentParser(
prog='Download a complete configuration of a running service',
description='Read a configuration from dump-things endpoints and create a '
@ -23,22 +22,24 @@ parser.add_argument(
help='The base URL of the server API.',
)
parser.add_argument(
'--entities', '-e',
'--entities',
'-e',
action='append',
choices=['admin_tokens', 'collections', 'tokens'],
help='Specify for which entities the configuration should be downloaded. '
' Possible values are `admin_tokens`, `collections`, or `tokens` '
'(repeat to download configuration for more than one entity). If this '
'option is not provided, configurations for all entities will be '
'downloaded.'
'downloaded.',
)
parser.add_argument(
'--format', '-f',
'--format',
'-f',
nargs='?',
default='yaml',
choices=['json', 'yaml'],
help='Specify the format of the output. Possible values are `json` '
'and `yaml` (the default is `yaml`).'
'and `yaml` (the default is `yaml`).',
)
@ -90,7 +91,6 @@ def get_configuration(
admin_token: str,
entities: list[str],
) -> dict:
result = {}
if 'collections' in entities:
@ -115,7 +115,8 @@ def list_to_dict_on_key(
) -> dict:
return {
element[extract_key]: {
element_key: value for element_key, value in element.items()
element_key: value
for element_key, value in element.items()
if element_key != extract_key
}
for element in elements

View file

@ -6,14 +6,12 @@ from pathlib import Path
from dump_things_service.audit.gitaudit import GitAuditBackend
parser = ArgumentParser(
prog='Rebuild the index of a `gitaudit`-database',
description='This command rebuilds the index of a `gitaudit`-database.'
description='This command rebuilds the index of a `gitaudit`-database.',
)
parser.add_argument(
'audit_store',
help='The directory in which the `gitaudit`-database is located.'
'audit_store', help='The directory in which the `gitaudit`-database is located.'
)

View file

@ -8,7 +8,6 @@ from pathlib import Path
from dump_things_service.audit.gitaudit import GitAuditBackend
parser = ArgumentParser(
prog='Report audit information for a PID',
description='Report the audit information that was stored for a specific '

View file

@ -5,7 +5,6 @@ from argparse import ArgumentParser
from dump_things_service.abstract_config import hash_token_representation
parser = ArgumentParser(
prog='Hash a plain text token to create a hashed token in a dump-things server',
description='Hash a token and print the calculated hash value. The hash value '
@ -23,12 +22,13 @@ def main():
arguments = parser.parse_args()
token = arguments.token.strip()
if any(map(lambda s: s.isspace(), token)):
if any(s.isspace() for s in token):
print('Whitespace are not allowed in token', file=sys.stderr, flush=True)
return 1
print(hash_token_representation(token))
return 0
if __name__ == '__main__':
sys.exit(main())

View file

@ -7,9 +7,8 @@ from pathlib import Path
import yaml
from dump_things_service import config_file_name
from dump_things_service.backends.record_dir_index import RecordDirIndex
from dump_things_service.abstract_config import RecordDirConfigFileContent
from dump_things_service.backends.record_dir_index import RecordDirIndex
parser = ArgumentParser(
prog='Rebuild the index of a `record_dir`-store',

View file

@ -12,7 +12,6 @@ import yaml
from dump_things_service.instance_state import get_record_dir_config
parser = ArgumentParser(
prog='Establish a configuration in a running service',
description='Read a configuration from a dump-things configuration-file '
@ -27,12 +26,13 @@ parser.add_argument(
help='The path to the config file',
)
parser.add_argument(
'--format', '-f',
'--format',
'-f',
nargs='?',
choices=['json', 'yaml'],
help='Specify the format of the input file. Possible values are `json` '
'and `yaml`. If this option is given, the '
'suffix of the configuration file is ignored.'
'suffix of the configuration file is ignored.',
)
parser.add_argument(
'--send-to',
@ -85,8 +85,7 @@ def main():
if arguments.old_format:
configuration = convert_config_1_to_config_2(configuration, arguments.store)
else:
if arguments.store:
elif arguments.store:
print(
'Warning: ignoring `--store` option because `--old-format` '
'is not provided.',
@ -94,7 +93,9 @@ def main():
flush=True,
)
assert configuration['type'] == 'collections', '`type: collections` missing in config-file'
assert configuration['type'] == 'collections', (
'`type: collections` missing in config-file'
)
assert configuration['version'] == 2, '`version: 2` missing in config-file'
if arguments.send_to:
@ -110,9 +111,7 @@ def main():
try:
establish_configuration(
configuration,
arguments.send_to[:-1]
if arguments.send_to.endswith('/')
else arguments.send_to,
arguments.send_to.removesuffix('/'),
admin_token,
)
return 0
@ -138,7 +137,6 @@ def convert_config_1_to_config_2(
old_configuration: dict,
store_path: str | Path,
) -> dict:
old_version = old_configuration.get('version')
if old_version != 1:
msg = f'`Unknown old configuration format: {old_version}'
@ -154,9 +152,11 @@ def convert_config_1_to_config_2(
f'token_{next(counter)}': {
**old_token_config.copy(),
'representation': token_representation,
'hashed': False
'hashed': False,
}
for token_representation, old_token_config in old_configuration['tokens'].items()
for token_representation, old_token_config in old_configuration[
'tokens'
].items()
}
old_to_new_token_mapping = {
@ -165,7 +165,7 @@ def convert_config_1_to_config_2(
}
store_path = Path(store_path) if store_path else None
for collection_name, collection_config in old_configuration['collections'].items():
for collection_config in old_configuration['collections'].values():
backend = collection_config.get('backend')
if backend and backend['type'].startswith('sqlite'):
collection_config['schema'] = backend['schema']
@ -174,23 +174,26 @@ def convert_config_1_to_config_2(
if store_path is None:
msg = '--store <path> has to be provided to convert collection with record_dir-backends'
raise ValueError(msg)
record_dir_config = get_record_dir_config(store_path / collection_config['curated'])
record_dir_config = get_record_dir_config(
store_path / collection_config['curated']
)
collection_config['schema'] = record_dir_config.schema_location
backend = {
'type': 'record_dir+stl' if not backend else backend['type'],
'mapping_method': record_dir_config.idfx.value
'mapping_method': record_dir_config.idfx.value,
}
collection_config['backend'] = backend
collection_config['default_token'] = old_to_new_token_mapping[collection_config['default_token']]
collection_config['default_token'] = old_to_new_token_mapping[
collection_config['default_token']
]
new_configuration = {
return {
'type': 'collections',
'version': 2,
'tokens': new_tokens_dict,
'collections': old_configuration['collections'],
'admin_tokens': {},
}
return new_configuration
def establish_configuration(
@ -264,7 +267,11 @@ def _post_data(
content_class: str,
content_name: str,
):
result = requests.put(url, headers={'x-dumpthings-token': token}, json=data,)
result = requests.put(
url,
headers={'x-dumpthings-token': token},
json=data,
)
if result.status_code >= 300:
msg = f'Error uploading {content_class}: {content_name}: {result.text}'
raise RuntimeError(msg)

View file

@ -6,10 +6,8 @@ from json import loads as json_loads
from typing import (
TYPE_CHECKING,
Any,
Callable,
)
from linkml_runtime import SchemaView
from linkml.utils.datautils import (
get_dumper,
get_loader,
@ -29,10 +27,11 @@ from dump_things_service.model import (
)
from dump_things_service.utils import cleaned_json
if TYPE_CHECKING:
from collections.abc import Callable
from types import ModuleType
from linkml_runtime import SchemaView
from pydantic import BaseModel
from dump_things_service.backends import RecordInfo
@ -47,10 +46,7 @@ class TypeValidator:
self.type_name = type_name
self.matcher = None if pattern is None else re.compile(pattern)
def validate(
self,
value: str
) -> str:
def validate(self, value: str) -> str:
if self.matcher:
match = self.matcher.match(value)
if not match:
@ -238,10 +234,7 @@ def _convert_format(
)
except Exception as e: # BLE001
if load_only:
msg = (
f'Validation error for instance of {target_class}: {e}, '
f'data:\n{data}'
)
msg = f'Validation error for instance of {target_class}: {e}, data:\n{data}'
else:
msg = (
f'Conversion {input_format} -> {output_format}. Error: {e}, '

View file

@ -1,13 +1,11 @@
from __future__ import annotations
import logging
from itertools import count
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Annotated
from fastapi import (
APIRouter,
Depends,
FastAPI,
HTTPException,
)
from fastapi_pagination import (
@ -19,14 +17,13 @@ from fastapi_pagination import (
from dump_things_service import (
HTTP_401_UNAUTHORIZED,
HTTP_404_NOT_FOUND,
HTTP_422_UNPROCESSABLE_CONTENT, abstract_config,
HTTP_422_UNPROCESSABLE_CONTENT,
)
from dump_things_service.abstract_config import (
check_collection,
read_config,
)
from dump_things_service.api_key import api_key_header_scheme
from dump_things_service.auth import AuthenticationInfo
from dump_things_service.backends.schema_type_layer import _SchemaTypeLayer
from dump_things_service.exceptions import CurieResolutionError
from dump_things_service.instance_state import get_instance_state
@ -41,6 +38,7 @@ from dump_things_service.utils import (
if TYPE_CHECKING:
from pydantic import BaseModel
from dump_things_service.auth import AuthenticationInfo
from dump_things_service.backends import StorageBackend
from dump_things_service.lazy_list import LazyList
from dump_things_service.store.model_store import _ModelStore
@ -76,7 +74,7 @@ add_pagination(router)
@router.get(
'/{collection}/curated/records/{class_name}',
tags=['Curated area: read records'],
name='Read all records of the given class from the curated area'
name='Read all records of the given class from the curated area',
)
async def read_curated_records_of_type(
collection: str,
@ -104,7 +102,7 @@ async def read_curated_records_of_type(
@router.get(
'/{collection}/curated/records/p/{class_name}',
tags=['Curated area: read records'],
name='Read all records of the given class from the curated area with pagination'
name='Read all records of the given class from the curated area with pagination',
)
async def read_curated_records_of_type_paginated(
collection: str,
@ -112,7 +110,6 @@ async def read_curated_records_of_type_paginated(
matching: str | None = None,
api_key: str | None = Depends(api_key_header_scheme),
) -> Page[dict]:
instance_state = get_instance_state()
if class_name not in instance_state.collections[collection].active_classes:
raise HTTPException(
@ -133,7 +130,7 @@ async def read_curated_records_of_type_paginated(
@router.get(
'/{collection}/curated/records/',
tags=['Curated area: read records'],
name='Read all records from the curated area'
name='Read all records from the curated area',
)
async def read_curated_all_records(
collection: str,
@ -153,7 +150,7 @@ async def read_curated_all_records(
@router.get(
'/{collection}/curated/records/p/',
tags=['Curated area: read records'],
name='Read all records from the curated area with pagination'
name='Read all records from the curated area with pagination',
)
async def read_curated_all_records_paginated(
collection: str,
@ -174,12 +171,12 @@ async def read_curated_all_records_paginated(
@router.get(
'/{collection}/curated/record',
tags=['Curated area: read records'],
name='Read the record with the given pid from the curated area'
name='Read the record with the given pid from the curated area',
)
async def read_curated_record_with_pid(
collection: str,
pid: str,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
):
return await _read_curated_records(
collection=collection,
@ -192,12 +189,12 @@ async def read_curated_record_with_pid(
@router.delete(
'/{collection}/curated/record',
tags=['Curated area: delete records'],
name='Delete the record with the given pid from the curated area of the given collection'
name='Delete the record with the given pid from the curated area of the given collection',
)
async def delete_curated_record_with_pid(
collection: str,
pid: str,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
):
return await _delete_curated_record(
collection=collection,
@ -214,7 +211,6 @@ async def _read_curated_records(
api_key: str | None = None,
upper_bound: int | None = 1000,
) -> LazyList | dict | None:
model_store, backend, _ = _get_store_and_backend(collection, api_key)
if pid:
@ -232,9 +228,7 @@ async def _read_curated_records(
len(result_list),
upper_bound,
collection,
f'/curated/records/p/{class_name}'
if class_name
else '/curated/records/p/',
f'/curated/records/p/{class_name}' if class_name else '/curated/records/p/',
)
return ModifierList(
@ -264,7 +258,6 @@ def _get_store_and_backend(
collection: str,
plain_token: str | None,
) -> tuple[_ModelStore, StorageBackend, AuthenticationInfo]:
# A token is required
if plain_token is None:
raise HTTPException(
@ -303,7 +296,11 @@ def store_curated_record(
api_key: str | None = Depends(api_key_header_scheme),
):
instance_state = get_instance_state()
with wrap_http_exception(ValueError, status_code=HTTP_422_UNPROCESSABLE_CONTENT, header='Validation error'):
with wrap_http_exception(
ValueError,
status_code=HTTP_422_UNPROCESSABLE_CONTENT,
header='Validation error',
):
instance_state.validators[collection].validate(data)
pid = data.pid

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Annotated
from fastapi import (
APIRouter,
@ -22,8 +22,8 @@ from dump_things_service import (
from dump_things_service.abstract_config import (
check_collection,
check_label,
get_config_labels,
get_config,
get_config_labels,
)
from dump_things_service.api_key import api_key_header_scheme
from dump_things_service.backends.schema_type_layer import _SchemaTypeLayer
@ -55,25 +55,27 @@ add_pagination(router)
@router.get(
'/{collection}/incoming/',
tags=['Incoming area: read labels'],
name='Get all incoming labels for the given collection'
name='Get all incoming labels for the given collection',
)
async def incoming_read_labels(
collection: str,
api_key: str | None = Depends(api_key_header_scheme),
api_key: Annotated[str | None, Depends(api_key_header_scheme)],
) -> list[str]:
# Authorize api_key
await authorize_zones(collection, api_key)
instance_state = get_instance_state()
configured_labels = get_config_labels(get_config(), collection)
on_disk_labels = get_on_disk_labels(instance_state.store_path, get_config(), collection)
on_disk_labels = get_on_disk_labels(
instance_state.store_path, get_config(), collection
)
return list(configured_labels.union(on_disk_labels))
@router.get(
'/{collection}/incoming/{label}/records/{class_name}',
tags=['Incoming area: read records'],
name='Read all records of the given class from the given incoming area'
name='Read all records of the given class from the given incoming area',
)
async def incoming_read_records_of_type(
collection: str,
@ -103,7 +105,7 @@ async def incoming_read_records_of_type(
@router.get(
'/{collection}/incoming/{label}/records/p/{class_name}',
tags=['Incoming area: read records'],
name='Read all records of the given class from the given incoming area with pagination'
name='Read all records of the given class from the given incoming area with pagination',
)
async def incoming_read_records_of_type_paginated(
collection: str,
@ -112,7 +114,6 @@ async def incoming_read_records_of_type_paginated(
matching: str | None = None,
api_key: str | None = Depends(api_key_header_scheme),
) -> Page[dict]:
instance_state = get_instance_state()
if class_name not in instance_state.collections[collection].active_classes:
raise HTTPException(
@ -134,7 +135,7 @@ async def incoming_read_records_of_type_paginated(
@router.get(
'/{collection}/incoming/{label}/records/',
tags=['Incoming area: read records'],
name='Read all records from the given incoming area'
name='Read all records from the given incoming area',
)
async def incoming_read_all_records(
collection: str,
@ -156,7 +157,7 @@ async def incoming_read_all_records(
@router.get(
'/{collection}/incoming/{label}/records/p/',
tags=['Incoming area: read records'],
name='Read all records from the given incoming area with pagination'
name='Read all records from the given incoming area with pagination',
)
async def incoming_read_all_records_paginated(
collection: str,
@ -179,13 +180,13 @@ async def incoming_read_all_records_paginated(
@router.get(
'/{collection}/incoming/{label}/record',
tags=['Incoming area: read records'],
name='Read the record with the given PID from the given incoming area'
name='Read the record with the given PID from the given incoming area',
)
async def incoming_read_record_with_pid(
collection: str,
label: str,
pid: str,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
):
return await _incoming_read_records(
collection=collection,
@ -199,13 +200,13 @@ async def incoming_read_record_with_pid(
@router.delete(
'/{collection}/incoming/{label}/record',
tags=['Incoming area: delete records'],
name='Delete the record with the given PID from the given incoming area'
name='Delete the record with the given PID from the given incoming area',
)
async def incoming_delete_record_with_pid(
collection: str,
label: str,
pid: str,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
):
return await _incoming_delete_record(
collection=collection,
@ -224,7 +225,6 @@ async def _incoming_read_records(
api_key: str | None = None,
upper_bound: int = 1000,
) -> LazyList | dict | None:
model_store, backend = await _get_store_and_backend(collection, label, api_key)
if pid:
@ -244,7 +244,7 @@ async def _incoming_read_records(
collection,
f'/incoming/{label}/records/p/{class_name}'
if class_name
else f'/incoming/{label}/records/p/'
else f'/incoming/{label}/records/p/',
)
return ModifierList(
@ -276,7 +276,6 @@ async def _get_store_and_backend(
label: str,
plain_token: str | None,
) -> tuple[_ModelStore, StorageBackend]:
# Authorize api_key
await authorize_zones(collection, plain_token)
@ -301,33 +300,6 @@ async def _get_store_and_backend(
store_dir=store_dir,
)
xxx = """
# For consistency, associate the store with all matching tokens from the
# configuration file. That means with all tokens that have the same
# input
matching_tokens = [
token_name
for token_name, token_info in abstract_config.tokens.items()
if (collection, label) in [
(collection_name, token_collection_info.incoming_label)
for collection_name, token_collection_info in token_info.items()
]
]
for matching_token in matching_tokens:
# Associate the store with all matching tokens in the configuration.
# Note: there are stores that are not associated with a token in
# the abstract configuration. These are stores that belong to a token
# that is authenticated with an external authentication source.
token_info = instance_state.tokens[collection][matching_token]
instance_state.token_stores[collection][matching_token] = (
model_store,
matching_token,
token_info['permissions'],
token_info['user_id'],
)
"""
backend = model_store.backend
if isinstance(backend, _SchemaTypeLayer):
return model_store, backend.backend
@ -367,9 +339,12 @@ async def store_incoming_record(
class_name: str,
api_key: str | None = Depends(api_key_header_scheme),
):
instance_state = get_instance_state()
with wrap_http_exception(ValueError, status_code=HTTP_422_UNPROCESSABLE_CONTENT, header='Validation error'):
with wrap_http_exception(
ValueError,
status_code=HTTP_422_UNPROCESSABLE_CONTENT,
header='Validation error',
):
instance_state.validators[collection].validate(data)
pid = data.pid

View file

@ -3,25 +3,20 @@ from __future__ import annotations
import dataclasses
import logging
from functools import cache
from pathlib import Path
from types import ModuleType
from typing import (
TYPE_CHECKING,
Any,
Callable,
)
import yaml
from fastapi import FastAPI
from linkml_runtime import SchemaView
from pydantic import ValidationError
from yaml.scanner import ScannerError
from dump_things_service.abstract_config import (
RecordDirConfigFileContent,
MappingMethod,
RecordDirConfigFileContent,
mapping_functions,
)
from dump_things_service.converter import get_conversion_objects
from dump_things_service.exceptions import ConfigError
from dump_things_service.model import (
@ -30,6 +25,13 @@ from dump_things_service.model import (
get_schema_view,
)
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
from types import ModuleType
from fastapi import FastAPI
from linkml_runtime import SchemaView
logger = logging.getLogger('dump_things_service')
@ -86,7 +88,9 @@ class InstanceState:
maintenance_mode: set = dataclasses.field(default_factory=set)
# Created based on abstract configuration
collections: dict[str, InstanceStateCollectionInfo] = dataclasses.field(default_factory=dict)
collections: dict[str, InstanceStateCollectionInfo] = dataclasses.field(
default_factory=dict
)
tokens: dict = dataclasses.field(default_factory=dict)
auth_sources: dict[str, list] = dataclasses.field(default_factory=dict)
audit_backends: dict[str, list] = dataclasses.field(default_factory=dict)

View file

@ -27,10 +27,9 @@ from abc import (
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from typing import (
Any,
Callable,
)

View file

@ -5,13 +5,14 @@ import logging
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Annotated
from dump_things_service.abstract_config import store_config
from dump_things_service.commands.upload_config import convert_config_1_to_config_2
from dump_things_service.manifest import manifest_configuration
# Perform the patching before importing any third-party libraries
from dump_things_service.patches import enabled # noqa F401 -- used by generated code
from dump_things_service.patches import enabled # noqa: F401 -- used by generated code
import yaml
import uvicorn
@ -57,8 +58,7 @@ from dump_things_service.converter import (
from dump_things_service.curated import router as curated_router
from dump_things_service.exceptions import CurieResolutionError
from dump_things_service.incoming import router as incoming_router
from dump_things_service.instance_state import create_instance_state, \
InstanceState
from dump_things_service.instance_state import create_instance_state, InstanceState
from dump_things_service.lazy_list import (
PriorityList,
ModifierList,
@ -106,7 +106,7 @@ logger = logging.getLogger('dump_things_service')
parser = argparse.ArgumentParser()
parser.add_argument('--host', default='0.0.0.0') # noqa S104
parser.add_argument('--host', default='0.0.0.0') # noqa: S104
parser.add_argument('--port', default=8000, type=int)
parser.add_argument('--origins', action='append', default=[])
parser.add_argument(
@ -124,8 +124,8 @@ parser.add_argument(
'--config',
metavar='CONFIG_FILE',
help="Read the configuration from 'CONFIG_FILE' if no persisted "
"configuration is found in the data store root directory, and "
"initialize the persistent configuration and the service state with "
'configuration is found in the data store root directory, and '
'initialize the persistent configuration and the service state with '
"the values in 'CONFIG_FILE'.",
)
parser.add_argument(
@ -141,10 +141,10 @@ parser.add_argument(
parser.add_argument(
'--ignore-default-config-file',
action='store_true',
help="If the persisted configuration is empty, do not try to initialize it "
"from an existing default-config file, i.e., do not read the file "
"`<store>/.dumpthings.yaml`. That means the configuration be empty "
"collections and tokens are added via the API.",
help='If the persisted configuration is empty, do not try to initialize it '
'from an existing default-config file, i.e., do not read the file '
'`<store>/.dumpthings.yaml`. That means the configuration be empty '
'collections and tokens are added via the API.',
)
parser.add_argument(
'store',
@ -182,9 +182,8 @@ if not arguments.admin_token_hash:
arguments.admin_token_hash = hash_token_representation(
os.environ.get('DTS_ADMIN_TOKEN', ''),
)
else:
# Validate the hash token format
if not hash_matcher.match(arguments.admin_token_hash):
elif not hash_matcher.match(arguments.admin_token_hash):
print(
'Hashed admin token is not a 64-digits hex-number',
file=sys.stderr,
@ -280,11 +279,11 @@ if not (
):
if arguments.config:
config_file = arguments.config
else:
if arguments.ignore_default_config_file:
elif arguments.ignore_default_config_file:
config_file = None
else:
from dump_things_service import config_file_name
config_file = g_instance_state.store_path / config_file_name
if not config_file.exists():
config_file = None
@ -310,11 +309,10 @@ if not (
g_configuration.admin_tokens
or g_configuration.collections
or g_configuration.tokens
):
if not g_instance_state.bootstrap_token:
) and not g_instance_state.bootstrap_token:
print(
'The server has an empty configuration and requires a bootstrap '
'token (use `--admin-token-hash` to provide one).',
'token (use `--admin-token-hash` to provide one)',
file=sys.stderr,
flush=True,
)
@ -337,11 +335,7 @@ async def root() -> RedirectResponse:
return RedirectResponse('/docs')
@app.get(
'/server',
tags=['Server management'],
name='get server information'
)
@app.get('/server', tags=['Server management'], name='get server information')
async def server() -> ServerResponse:
return ServerResponse(
version=__version__,
@ -349,26 +343,28 @@ async def server() -> ServerResponse:
ServerCollectionResponse(
name=collection_name,
schema=g_configuration.collections[collection_name].schema_location,
classes=g_instance_state.schema_info[g_configuration.collections[collection_name].schema_location].classes,
classes=g_instance_state.schema_info[
g_configuration.collections[collection_name].schema_location
].classes,
)
for collection_name in g_configuration.collections
]
],
)
@app.post(
'/maintenance',
tags=['Server management'],
name='put a collection in maintenance mode'
name='put a collection in maintenance mode',
)
async def maintenance(
body: MaintenanceRequest,
api_key: str | None = Depends(api_key_header_scheme),
api_key: Annotated[str | None, Depends(api_key_header_scheme)],
):
if api_key is None:
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail=f'Token required for this operation',
detail='Token required for this operation',
)
collection = body.collection
@ -387,14 +383,13 @@ async def maintenance(
):
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail=f'Curator permissions required for this operation',
detail='Curator permissions required for this operation',
)
if active:
g_instance_state.maintenance_mode.add(collection)
else:
g_instance_state.maintenance_mode.remove(collection)
return
@app.get(
@ -405,7 +400,7 @@ async def maintenance(
async def read_record_with_pid(
collection: str,
pid: str,
format: Format = Format.json, # noqa A002
format: Format = Format.json, # noqa: A002
api_key: str = Depends(api_key_header_scheme),
):
check_collection(g_configuration, collection)
@ -448,7 +443,7 @@ async def read_record_with_pid(
async def read_all_records(
collection: str,
matching: str | None = None,
format: Format = Format.json, # noqa A002
format: Format = Format.json, # noqa: A002
api_key: str = Depends(api_key_header_scheme),
):
return await _read_all_records(
@ -471,7 +466,7 @@ async def read_all_records(
async def read_all_records_paginated(
collection: str,
matching: str | None = None,
format: Format = Format.json, # noqa A002
format: Format = Format.json, # noqa: A002
api_key: str = Depends(api_key_header_scheme),
) -> Page[dict | str]:
result_list = await _read_all_records(
@ -493,7 +488,7 @@ async def read_records_of_type(
collection: str,
class_name: str,
matching: str | None = None,
format: Format = Format.json, # noqa A002
format: Format = Format.json, # noqa: A002
api_key: str = Depends(api_key_header_scheme),
):
return await _read_records_of_type(
@ -518,7 +513,7 @@ async def read_records_of_type_paginated(
collection: str,
class_name: str,
matching: str | None = None,
format: Format = Format.json, # noqa A002
format: Format = Format.json, # noqa: A002
api_key: str = Depends(api_key_header_scheme),
) -> Page[dict | str]:
result_list = await _read_records_of_type(
@ -535,11 +530,10 @@ async def read_records_of_type_paginated(
async def _read_all_records(
collection: str,
matching: str | None = None,
format: Format = Format.json, # noqa A002
format: Format = Format.json, # noqa: A002
api_key: str = Depends(api_key_header_scheme),
bound: int | None = None,
) -> LazyList:
def convert_to_http_exception(e: BaseException):
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
@ -591,7 +585,7 @@ async def _read_records_of_type(
collection: str,
class_name: str,
matching: str | None = None,
format: Format = Format.json, # noqa A002
format: Format = Format.json, # noqa: A002
api_key: str = Depends(api_key_header_scheme),
bound: int | None = None,
) -> LazyList:
@ -622,7 +616,9 @@ async def _read_records_of_type(
matching=matching,
)
if bound:
check_bounds(len(token_store_list), bound, collection, f'/records/p/{class_name}')
check_bounds(
len(token_store_list), bound, collection, f'/records/p/{class_name}'
)
result_list.add_list(token_store_list)
if final_permissions.curated_read:
@ -634,7 +630,12 @@ async def _read_records_of_type(
matching=matching,
)
if bound:
check_bounds(len(curated_store_list), bound, collection, f'/records/p/{class_name}')
check_bounds(
len(curated_store_list),
bound,
collection,
f'/records/p/{class_name}',
)
result_list.add_list(curated_store_list)
# Sort the result list.
@ -664,7 +665,7 @@ async def _read_records_of_type(
async def delete_record(
collection: str,
pid: str,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
):
check_collection(g_configuration, collection)
final_permissions, token_store = await process_token(
@ -682,7 +683,7 @@ async def delete_record(
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"Could not remove record with PID '{pid}' from the "
"token associated incoming area of collection "
'token associated incoming area of collection '
f"'{collection}'.",
)
return True

View file

@ -12,7 +12,6 @@ from dump_things_service.collection import (
)
from dump_things_service.instance_state import InstanceState
logger = logging.getLogger('dump_things_service')
tag_groups = [
@ -58,7 +57,6 @@ openapi_tags_template = [
]
def manifest_configuration(
configuration: Configuration,
instance_state: InstanceState,
@ -190,7 +188,6 @@ def create_openapi_tags(
instance_state: InstanceState,
openapi_tags_template: list[dict | str],
) -> list[dict]:
# Collect tag name lists for all tag groups that we have defined.
tag_group_info = {
tag_group: sorted(
@ -198,7 +195,7 @@ def create_openapi_tags(
{'name': collection_info.tag_info[tag_group]}
for collection_info in instance_state.collections.values()
],
key=lambda x: x['name']
key=lambda x: x['name'],
)
for tag_group in tag_groups
}

View file

@ -1,6 +1,6 @@
from __future__ import annotations
import dataclasses # noqa F401 -- used by generated code
import dataclasses # noqa: F401 -- used by generated code
import logging
import sys
from functools import cache
@ -11,9 +11,9 @@ from typing import (
)
from urllib.parse import urlparse
import annotated_types # noqa F401 -- used by generated code
import pydantic # noqa F401 -- used by generated code
import pydantic_core # noqa F401 -- used by generated code
import annotated_types # noqa: F401 -- used by generated code
import pydantic # noqa: F401 -- used by generated code
import pydantic_core # noqa: F401 -- used by generated code
from linkml.generators import (
PydanticGenerator,
PythonGenerator,
@ -22,7 +22,7 @@ from linkml_runtime import SchemaView
from pydantic._internal._model_construction import ModelMetaclass
# Ensure linkml is patched
import dump_things_service.patches.enabled # noqa F401 -- apply patches
import dump_things_service.patches.enabled # noqa: F401 -- apply patches
if TYPE_CHECKING:
from types import ModuleType
@ -67,7 +67,7 @@ def get_subclasses(
# TODO: shall we use the following code?
# The code below would use schema-definitions to determine classes and not
# go through thw pydantic module generation.
# go through the pydantic module generation.
@cache
def get_subclasses_2(
collection_name: str,
@ -88,8 +88,8 @@ def compile_module_with_increasing_recursion_limit(
module = None
module_name = (
urlparse(schema_location).path
.replace('/', '_')
urlparse(schema_location)
.path.replace('/', '_')
.replace('-', '_')
.replace('.', '_')
)

View file

@ -17,8 +17,8 @@ if TYPE_CHECKING:
from pydantic import BaseModel
from dump_things_service.backends import (
_RecordInfo,
StorageBackend,
_RecordInfo,
)
from dump_things_service.lazy_list import LazyList
@ -28,12 +28,7 @@ submitter_namespace = 'http://purl.obolibrary.org/obo/'
class _ModelStore:
def __init__(
self,
schema: str,
backend: StorageBackend,
tags: dict[str, str]
):
def __init__(self, schema: str, backend: StorageBackend, tags: dict[str, str]):
self.schema = schema
self.model = get_model_for_schema(self.schema)[0]
self.backend = backend
@ -47,7 +42,9 @@ class _ModelStore:
obj: BaseModel,
submitter: str | None,
) -> Iterable[tuple[str, dict]]:
if obj.__class__.__name__ == 'Thing' and 'dlthings:placeholder' in (obj.annotations or dict()):
if obj.__class__.__name__ == 'Thing' and 'dlthings:placeholder' in (
obj.annotations or {}
):
return []
# Extract inlined records from the object, store individual records
@ -146,7 +143,8 @@ class _ModelStore:
# Do not extract 'empty'-Thing records with an
# `dlthings:placeholder` annotation. These records are just
# placeholders for already extracted records.
if sub_record != self.model.Thing(
if sub_record
!= self.model.Thing(
pid=sub_record.pid,
annotations={
'dlthings:placeholder': sub_record.pid,
@ -165,7 +163,7 @@ class _ModelStore:
pid=sub_record_pid,
annotations={
'dlthings:placeholder': sub_record_pid,
}
},
)
for sub_record_pid in record.relations
}
@ -252,9 +250,8 @@ def ModelStore( # noqa: N802
# We store a pointer to the backend in the value to ensure that the
# backend object exists while we use its `id` as a key.
_existing_model_stores[id(backend)] = existing_model_store, backend
else:
# Check that the schemas are compatible, if the backend is reused.
if existing_model_store.schema != schema:
elif existing_model_store.schema != schema:
msg = 'Backend is already used in a ModelStore with a different schema'
raise ValueError(msg)

View file

@ -4,18 +4,19 @@ from typing import TYPE_CHECKING
import yaml
from dump_things_service.backends.record_dir import RecordDirStore
from dump_things_service.backends.sqlite import (
SQLiteBackend,
record_file_name as sqlite_record_file_name,
)
from dump_things_service.abstract_config import (
RecordDirBackendConfig,
CollectionConfig,
Configuration,
MappingMethod,
RecordDirBackendConfig,
mapping_functions,
)
from dump_things_service.backends.sqlite import (
SQLiteBackend,
)
from dump_things_service.backends.sqlite import (
record_file_name as sqlite_record_file_name,
)
from dump_things_service.model import get_model_for_schema
from dump_things_service.resolve_curie import resolve_curie

View file

@ -11,20 +11,23 @@ import yaml
from dump_things_service.abstract_config import (
GitAuditBackendConfig,
SQLiteBackendConfig,
TagSpec,
TokenCollectionConfig,
TokenModes, hash_token_representation, TagSpec,
TokenModes,
hash_token_representation,
)
from dump_things_service.backends import StorageBackend
from dump_things_service.backends.record_dir import RecordDirStore
from dump_things_service.backends.sqlite import (
SQLiteBackend,
)
from dump_things_service.backends.sqlite import (
record_file_name as sqlite_db_filename,
)
from dump_things_service.collection_endpoints import CollectionRequest
from dump_things_service.instance_state import get_mapping_function_by_name
from dump_things_service.model import get_model_for_schema
from dump_things_service.resolve_curie import resolve_curie
from dump_things_service.token_endpoints import TokenRequest
from dump_things_service.tests.create_store import (
pid,
pid_curated,
@ -33,7 +36,7 @@ from dump_things_service.tests.create_store import (
test_record_curated,
test_record_trr,
)
from dump_things_service.token_endpoints import TokenRequest
# String representation of curated- and incoming-path
curated = 'curated'
@ -41,7 +44,9 @@ incoming = 'incoming'
# Path to a local simple test schema
test_schema_location = str((Path(__file__).parent / 'testschema.yaml').absolute())
flat_social_schema_location = 'https://concepts.datalad.org/s/flat-social/unreleased.yaml'
flat_social_schema_location = (
'https://concepts.datalad.org/s/flat-social/unreleased.yaml'
)
# The test store is created empty and collections are added via the admin
@ -64,7 +69,7 @@ g_default_collections[6].submission_tags = TagSpec(
g_default_collections.append(
CollectionRequest(
name=f'collection_8',
name='collection_8',
default_token='test_default_token',
curated=PurePosixPath(f'{curated}/collection_8'),
schema=test_schema_location,
@ -75,11 +80,12 @@ g_default_collections.append(
submission_tags=TagSpec(
submitter_id_tag='no_default_id_tag',
submission_time_tag='no_default_time_tag',
)
),
)
)
g_default_collections.extend([
g_default_collections.extend(
[
CollectionRequest(
name='collection_dlflatsocial-1',
schema=flat_social_schema_location,
@ -106,7 +112,8 @@ g_default_collections.extend([
'Project',
],
),
])
]
)
g_default_tokens = [
TokenRequest(
@ -152,7 +159,7 @@ g_default_tokens = [
hashed=False,
representation='token-2',
collections={
f'collection_2': TokenCollectionConfig(
'collection_2': TokenCollectionConfig(
mode=TokenModes.WRITE_COLLECTION,
incoming_label='in_token-2',
)
@ -164,7 +171,7 @@ g_default_tokens = [
hashed=False,
representation='token-8',
collections={
f'collection_8': TokenCollectionConfig(
'collection_8': TokenCollectionConfig(
mode=TokenModes.WRITE_COLLECTION,
incoming_label='test_user_8',
)
@ -235,7 +242,7 @@ g_default_tokens = [
mode=TokenModes.WRITE_COLLECTION,
incoming_label='modes',
),
}
},
),
TokenRequest(
name='Test 0X000 (READ_SUBMISSIONS)',
@ -354,7 +361,8 @@ def fastapi_app_simple(dump_stores_simple):
old_sys_argv = sys.argv
sys.argv = [
'test-runner',
'--admin-token-hash', hash_token_representation(admin_token),
'--admin-token-hash',
hash_token_representation(admin_token),
'--ignore-default-config-file',
str(tmp_path),
]

View file

@ -15,11 +15,7 @@ user_1 = {
'@type': 'user',
}
org_1 = {
'id': 1,
'name': 'org_1',
'@type': 'org'
}
org_1 = {'id': 1, 'name': 'org_1', '@type': 'org'}
repo_1 = {
'id': 3,
@ -46,10 +42,18 @@ team_3 = json.loads(team_template.format(id=3, action='write'))
def setup_http_server(http_server) -> None:
for instance in ('1', '2'):
http_server.expect_request(f'/api/v{instance}/user').respond_with_json(user_1)
http_server.expect_request(f'/api/v{instance}/user/teams').respond_with_json([team_1, team_3])
http_server.expect_request(f'/api/v{instance}/orgs/org_1').respond_with_json(org_1)
http_server.expect_request(f'/api/v{instance}/orgs/org_1/teams').respond_with_json([team_1, team_2, team_3])
http_server.expect_request(f'/api/v{instance}/repos/org_1/repo_1/teams').respond_with_json([team_1, team_2, team_3])
http_server.expect_request(f'/api/v{instance}/user/teams').respond_with_json(
[team_1, team_3]
)
http_server.expect_request(f'/api/v{instance}/orgs/org_1').respond_with_json(
org_1
)
http_server.expect_request(
f'/api/v{instance}/orgs/org_1/teams'
).respond_with_json([team_1, team_2, team_3])
http_server.expect_request(
f'/api/v{instance}/repos/org_1/repo_1/teams'
).respond_with_json([team_1, team_2, team_3])
@pytest.mark.parametrize('repository', ['repo_1', None])

View file

@ -1,7 +1,3 @@
import pytest # F401
from . import schema_file
from .. import (
HTTP_200_OK,
HTTP_400_BAD_REQUEST,
@ -11,14 +7,13 @@ from .. import (
HTTP_503_SERVICE_UNAVAILABLE,
)
from ..__about__ import __version__
from ..utils import cleaned_json
from . import schema_file
from .create_store import (
given_name,
pid,
)
from .test_utils import basic_write_locations
extra_record = {
'schema_type': 'abc:Person',
'pid': 'abc:aaaa',
@ -298,7 +293,7 @@ def test_funky_pid(fastapi_client_simple):
def test_token_store_priority(fastapi_client_simple):
test_client, store_dir, _ = fastapi_client_simple
test_client, _store_dir, _ = fastapi_client_simple
# Post a record with the same pid as the global store's test record, but
# with different content.
@ -393,7 +388,8 @@ def test_server(fastapi_client_simple):
'classes': test_schema_classes,
}
for i in range(1, 9)
] + [
]
+ [
{
'name': f'collection_dlflatsocial-{i}',
'schema': 'https://concepts.datalad.org/s/flat-social/unreleased.yaml',

View file

@ -6,10 +6,10 @@ from pathlib import (
from starlette.testclient import TestClient
from dump_things_service import (
HTTP_201_CREATED,
HTTP_200_OK,
HTTP_404_NOT_FOUND,
HTTP_201_CREATED,
HTTP_401_UNAUTHORIZED,
HTTP_404_NOT_FOUND,
)
from dump_things_service.abstract_config import (
GitAuditBackendConfig,
@ -19,10 +19,9 @@ from dump_things_service.abstract_config import (
)
from dump_things_service.collection_endpoints import CollectionRequest
from dump_things_service.token_endpoints import (
TokenRequest,
AdminTokenRequest,
TokenRequest,
)
from dump_things_service.utils import cleaned_json
# String representation of curated- and incoming-path
curated = 'admin_test_curated'
@ -69,10 +68,7 @@ def _name_in_openapi_paths(
) -> bool:
response = test_client.get('/openapi.json')
open_api = response.json()
for path in open_api['paths'].keys():
if name in path:
return True
return False
return any(name in path for path in open_api['paths'])
def test_collection_adding(fastapi_client_simple):
@ -100,7 +96,9 @@ def test_collection_adding(fastapi_client_simple):
headers={'x-dumpthings-token': admin_token},
)
assert response.status_code == HTTP_200_OK
new_collection_config = new_collection_request.model_dump(mode='json', by_alias=True)
new_collection_config = new_collection_request.model_dump(
mode='json', by_alias=True
)
del new_collection_config['name']
assert response.json() == new_collection_config
@ -123,7 +121,7 @@ def test_collection_adding(fastapi_client_simple):
'user_id': new_token_request.user_id,
'collections': new_token_request.model_dump(mode='json')['collections'],
'hashed': new_token_request.hashed,
'representation': new_token_request.representation
'representation': new_token_request.representation,
}
new_record = {
@ -204,7 +202,7 @@ def test_collection_putting(fastapi_client_simple, tmp_path):
path=Path(tmp_path),
auto_flush_timeout=2,
)
]
],
)
# Check that the collection does not yet exist
@ -259,7 +257,7 @@ def test_collection_reading(fastapi_client_simple):
# Check that the new admin token is not yet working
response = test_client.get(
f'/collections',
'/collections',
headers={'x-dumpthings-token': admin_token},
)
assert response.status_code == HTTP_200_OK
@ -273,7 +271,7 @@ def test_admin_token_management(fastapi_client_simple):
# Check that the new admin token is not yet working
response = test_client.get(
f'/collections/collection_1',
'/collections/collection_1',
headers={'x-dumpthings-token': plain_new_admin_token},
)
assert response.status_code == HTTP_401_UNAUTHORIZED
@ -288,14 +286,14 @@ def test_admin_token_management(fastapi_client_simple):
# Try the new token
response = test_client.get(
f'/collections/collection_1',
'/collections/collection_1',
headers={'x-dumpthings-token': plain_new_admin_token},
)
assert response.status_code == HTTP_200_OK
# Check that the token shows up in the token list
response = test_client.get(
f'/admin_tokens',
'/admin_tokens',
headers={'x-dumpthings-token': plain_new_admin_token},
)
assert response.status_code == HTTP_200_OK
@ -310,7 +308,7 @@ def test_admin_token_management(fastapi_client_simple):
assert response.status_code == HTTP_200_OK
response = test_client.get(
f'/admin_tokens',
'/admin_tokens',
headers={'x-dumpthings-token': admin_token},
)
assert response.status_code == HTTP_200_OK

View file

@ -22,13 +22,12 @@ from dump_things_service.exceptions import ConfigError
from dump_things_service.tests import schema_file
from dump_things_service.token_endpoints import TokenRequest
collection_request_pattern = CollectionRequest(
name='',
schema=str(schema_file),
default_token='test_default_token',
curated=PurePosixPath('curate_dir'),
incoming=PurePosixPath(f'incoming_dir'),
incoming=PurePosixPath('incoming_dir'),
)
@ -42,7 +41,7 @@ def test_illegal_collection_name_detection(fastapi_client_simple):
dump_things_private_collection_name,
):
response = test_client.post(
f'/collections',
'/collections',
json={
**collection_request_pattern.model_dump(mode='json', by_alias=True),
'name': name,
@ -52,7 +51,9 @@ def test_illegal_collection_name_detection(fastapi_client_simple):
assert response.status_code == HTTP_409_CONFLICT
@pytest.mark.skip(reason='Reuse detection is disabled to support existing old configurations')
@pytest.mark.skip(
reason='Reuse detection is disabled to support existing old configurations'
)
def test_collection_dir_reuse_detection(fastapi_client_simple):
test_client, _, admin_token = fastapi_client_simple
@ -62,7 +63,7 @@ def test_collection_dir_reuse_detection(fastapi_client_simple):
('curated/collection_1', 'incoming/collection_2'),
):
response = test_client.post(
f'/collections',
'/collections',
json={
**collection_request_pattern.model_dump(mode='json', by_alias=True),
'curated': curated_path,
@ -76,15 +77,17 @@ def test_collection_dir_reuse_detection(fastapi_client_simple):
def test_scanner_error_detection(tmp_path_factory):
tmp_path = tmp_path_factory.mktemp('config_scanner_test')
config_backend, audit_backend = get_config_backends(tmp_path)
config_backend, _audit_backend = get_config_backends(tmp_path)
config_backend.add_record(
iri=dump_things_config_iri,
class_name='DumpThingsConfig',
json_object={'pid': dump_things_config_iri}
json_object={'pid': dump_things_config_iri},
)
md5_hexdigest = hashlib.md5(dump_things_config_iri.encode()).hexdigest()
config_file_path = config_backend.root / 'DumpThingsConfig' / f'{md5_hexdigest}.yaml'
config_file_path = (
config_backend.root / 'DumpThingsConfig' / f'{md5_hexdigest}.yaml'
)
config_file_path.write_text('collections: ::: -\n sdsdfsdf: xxx')
with pytest.raises(ConfigError):
read_config(tmp_path, force_reload=True)
@ -93,15 +96,17 @@ def test_scanner_error_detection(tmp_path_factory):
def test_structure_error_detection(tmp_path_factory):
tmp_path = tmp_path_factory.mktemp('config_scanner_test')
config_backend, audit_backend = get_config_backends(tmp_path)
config_backend, _audit_backend = get_config_backends(tmp_path)
config_backend.add_record(
iri=dump_things_config_iri,
class_name='DumpThingsConfig',
json_object={'pid': dump_things_config_iri}
json_object={'pid': dump_things_config_iri},
)
md5_hexdigest = hashlib.md5(dump_things_config_iri.encode()).hexdigest()
config_file_path = config_backend.root / 'DumpThingsConfig' / f'{md5_hexdigest}.yaml'
config_file_path = (
config_backend.root / 'DumpThingsConfig' / f'{md5_hexdigest}.yaml'
)
config_file_path.write_text('type: 1\n')
with pytest.raises(ConfigError):
read_config(tmp_path, force_reload=True)
@ -135,7 +140,7 @@ def test_missing_incoming_detection(fastapi_client_simple):
mode=TokenModes.CURATOR,
incoming_label='',
)
}
},
)
# Check that a write token for a collection without incoming path cannot
@ -155,7 +160,9 @@ def test_missing_incoming_detection(fastapi_client_simple):
assert response.status_code == HTTP_200_OK
# Add a collection with incoming path
collection_request.incoming = PurePosixPath('missing_incoming_detection_test_incoming')
collection_request.incoming = PurePosixPath(
'missing_incoming_detection_test_incoming'
)
response = test_client.post(
'/collections',
json=collection_request.model_dump(mode='json', by_alias=True),
@ -173,10 +180,12 @@ def test_missing_incoming_detection(fastapi_client_simple):
assert response.status_code == HTTP_406_NOT_ACCEPTABLE
# Check that a write token for a collection with an incoming path can be created
token_request.collections['missing_incoming_detection_test'] = TokenCollectionConfig(
token_request.collections['missing_incoming_detection_test'] = (
TokenCollectionConfig(
mode=TokenModes.CURATOR,
incoming_label='test_incoming_label',
)
)
response = test_client.post(
'/tokens',
json=token_request.model_dump(mode='json', by_alias=True),

View file

@ -1,17 +1,17 @@
from __future__ import annotations
import pytest
import time
import yaml
from itertools import count
import pytest
import yaml
from dump_things_service import (
HTTP_200_OK,
HTTP_404_NOT_FOUND,
)
from dump_things_service.instance_state import get_instance_state
delete_record = {
'schema_type': 'abc:Person',
'pid': 'abc:delete-me',
@ -19,8 +19,8 @@ delete_record = {
}
@pytest.mark.parametrize('paginate', ('', 'p/'))
@pytest.mark.parametrize('class_name', ('', 'Person'))
@pytest.mark.parametrize('paginate', ['', 'p/'])
@pytest.mark.parametrize('class_name', ['', 'Person'])
def test_read_curated_records(
fastapi_client_simple,
paginate,
@ -54,10 +54,6 @@ def test_read_curated_records(
assert len(json_object) == count
pytest.mark.parametrize(
'pid',
('abc:mode_test', 'abc:some_timee@x.com', 'abc:curated'),
)
def test_read_curated_records_by_pid(fastapi_client_simple):
test_client, _, _ = fastapi_client_simple
@ -185,5 +181,6 @@ def test_audit_backend_auto_flush(fastapi_client_simple):
break
i += 1
if i == 10:
raise ValueError(f'auto flush did not trigger within 10 seconds')
msg = 'auto flush did not trigger within 10 seconds'
raise ValueError(msg)
time.sleep(1)

View file

@ -113,7 +113,10 @@ empty_inlined_json_record = cleaned_json(dataclasses.asdict(empty_inlined_object
tree = (
('dlflatsocial:test_extract_1', ('dlflatsocial:test_extract_1_1', 'dlflatsocial:test_extract_1_2')),
(
'dlflatsocial:test_extract_1',
('dlflatsocial:test_extract_1_1', 'dlflatsocial:test_extract_1_2'),
),
('dlflatsocial:test_extract_1_1', ('dlflatsocial:test_extract_1_1_1',)),
('dlflatsocial:test_extract_1_2', ()),
('dlflatsocial:test_extract_1_1_1', ()),
@ -184,7 +187,7 @@ def test_inline_extraction_locally():
tags={
'id': 'abc:id',
'time': 'abc:time',
}
},
)
store.model = MockedModule()
records = store.extract_inlined(inlined_object)
@ -216,7 +219,7 @@ def test_dont_extract_empty_things_locally():
tags={
'id': 'https://id',
'time': 'https://time',
}
},
)
store.model = MockedModule()
records = store.extract_inlined(empty_inlined_object)
@ -257,7 +260,10 @@ def test_inline_extraction_on_service(fastapi_client_simple):
# Check that individual record classes were recognized
for class_name, pids in (
('Person', ('dlflatsocial:test_extract_1', 'dlflatsocial:test_extract_1_1')),
(
'Person',
('dlflatsocial:test_extract_1', 'dlflatsocial:test_extract_1_1'),
),
('Agent', ('dlflatsocial:test_extract_1_1_1',)),
('InstantaneousEvent', ('dlflatsocial:test_extract_1_2',)),
):
@ -301,7 +307,10 @@ def test_inline_ttl_processing(fastapi_client_simple):
# Check that individual record classes were recognized
for class_name, pids in (
('Person', ('dlflatsocial:test_ttl_inline_1', 'dlflatsocial:test_ttl_inline_1_1')),
(
'Person',
('dlflatsocial:test_ttl_inline_1', 'dlflatsocial:test_ttl_inline_1_1'),
),
('Agent', ('dlflatsocial:test_ttl_inline_1_1_1',)),
('InstantaneousEvent', ('dlflatsocial:test_ttl_inline_1_2',)),
):
@ -339,7 +348,7 @@ def _check_result_json(
# That breaks the tests. They assume that Person.relations has range Thing.
@pytest.mark.xfail
def test_dont_extract_empty_things_on_service(fastapi_client_simple):
test_client, store = fastapi_client_simple
test_client, _store = fastapi_client_simple
for i in range(1, 3):
# Deposit JSON record
@ -352,7 +361,7 @@ def test_dont_extract_empty_things_on_service(fastapi_client_simple):
def test_store_things(fastapi_client_simple):
test_client, store, _ = fastapi_client_simple
test_client, _store, _ = fastapi_client_simple
simple_thing = {
'pid': 'http://test.simple.thing/1',
@ -375,7 +384,7 @@ def test_store_things(fastapi_client_simple):
def test_store_complex_things(fastapi_client_simple):
test_client, store, _ = fastapi_client_simple
test_client, _store, _ = fastapi_client_simple
complex_thing = {
'pid': 'http://test.complex.thing/1',
@ -386,9 +395,9 @@ def test_store_complex_things(fastapi_client_simple):
'http://test.complex.thing/1.1.1': {
'pid': 'http://test.complex.thing/1.1.1',
}
},
}
}
}
},
}
# Deposit JSON record

View file

@ -6,7 +6,6 @@ import linkml.generators.common.ifabsent_processor as if_abs_proc
import dump_things_service.patches.ifabsent_processing
# Path to a local simple test schema
schema_dir = Path(__file__).parent / 'assets'
@ -17,10 +16,11 @@ def _original_uri_for(self, s: str) -> str:
def test_ifabsent_patch():
# Patch in the faulty, original code and check for its result
if_abs_proc.IfAbsentProcessor._uri_for = _original_uri_for
gen1 = linkml.generators.PydanticGenerator(str(schema_dir / 'schema-ifabsent-error.yaml'))
gen1 = linkml.generators.PydanticGenerator(
str(schema_dir / 'schema-ifabsent-error.yaml')
)
x = gen1.serialize()
assert 'default=XSD["04fa4r544"]' in x
@ -28,6 +28,8 @@ def test_ifabsent_patch():
reload(dump_things_service.patches.ifabsent_processing)
# Check for proper code generation
gen2 = linkml.generators.PydanticGenerator(str(schema_dir / 'schema-ifabsent-error.yaml'))
gen2 = linkml.generators.PydanticGenerator(
str(schema_dir / 'schema-ifabsent-error.yaml')
)
y = gen2.serialize()
assert 'XSD' not in y

View file

@ -30,6 +30,7 @@ def test_incoming_labels(fastapi_client_simple):
zones_filled = False
def fill_zones(test_client):
global zones_filled
@ -53,15 +54,15 @@ def fill_zones(test_client):
json={
'pid': f'abc:test_incoming-collection_{collection_id}-{token}',
'given_name': f'collection_{collection_id}-{token}',
}
},
)
assert result.status_code == HTTP_200_OK
zones_filled = True
@pytest.mark.parametrize('paginate', ('', 'p/'))
@pytest.mark.parametrize('class_name', ('', 'Person'))
@pytest.mark.parametrize('paginate', ['', 'p/'])
@pytest.mark.parametrize('class_name', ['', 'Person'])
def test_read_incoming_records(
fastapi_client_simple,
paginate: str,
@ -87,7 +88,9 @@ def test_read_incoming_records(
f'/collection_{collection_id}/incoming/{label}/records/{paginate}{class_name}',
headers={'x-dumpthings-token': 'token_curator'},
)
assert response.status_code == HTTP_200_OK, f'failed on collection: {collection_id}, label: {label}, class: {class_name}'
assert response.status_code == HTTP_200_OK, (
f'failed on collection: {collection_id}, label: {label}, class: {class_name}'
)
# We don't know the exact number of entries in each zone, because
# it depends on the tests that ran before.
@ -103,22 +106,15 @@ def test_read_incoming_records(
)
assert response.status_code == HTTP_200_OK
json_object = response.json()
if 'items' in json_object:
result = json_object['items']
else:
result = json_object
result = json_object['items'] if 'items' in json_object else json_object
matching = [
json_object
for json_object in result
if json_object['pid'] == pattern
json_object for json_object in result if json_object['pid'] == pattern
]
assert len(matching) == expected_length, f'did not find {expected_length} record: collection_{collection_id}, {label}, {result}'
pytest.mark.parametrize(
'pid',
('abc:mode_test', 'abc:some_timee@x.com', 'abc:curated'),
assert len(matching) == expected_length, (
f'did not find {expected_length} record: collection_{collection_id}, {label}, {result}'
)
def test_read_incoming_records_by_pid(fastapi_client_simple):
test_client, _, _ = fastapi_client_simple

View file

@ -50,7 +50,7 @@ def verify_modes(
def test_token_modes(fastapi_client_simple):
test_client, store_dir, _ = fastapi_client_simple
test_client, _store_dir, _ = fastapi_client_simple
# Post a record to incoming of collections `collection_1`. We use it to
# validate read/write permissions on class-base

View file

@ -1,5 +1,5 @@
import freezegun
import pytest # noqa F401
import pytest # noqa: F401
from .. import HTTP_200_OK
from ..utils import cleaned_json

View file

@ -1,6 +1,5 @@
import pytest # noqa F401
import freezegun
import pytest # noqa: F401
from .. import HTTP_200_OK
from ..utils import cleaned_json
@ -144,7 +143,9 @@ def test_ttl_json_ttl_dlflatsocial(fastapi_client_simple):
},
data=ttl_input_record,
)
assert response.status_code == HTTP_200_OK, 'Response content: ' + response.content.decode()
assert response.status_code == HTTP_200_OK, (
'Response content: ' + response.content.decode()
)
# Retrieve JSON records
response = test_client.get(
@ -172,8 +173,12 @@ def test_ttl_json_ttl_dlflatsocial(fastapi_client_simple):
assert response.status_code == HTTP_200_OK
assert (
response.text.strip()
== ttl_output_record_a.replace('dlflatsocial:test_john_ttl', new_json_pid).strip()
== ttl_output_record_a.replace(
'dlflatsocial:test_john_ttl', new_json_pid
).strip()
) or (
response.text.strip()
== ttl_output_record_b.replace('dlflatsocial:test_john_ttl', new_json_pid).strip()
== ttl_output_record_b.replace(
'dlflatsocial:test_john_ttl', new_json_pid
).strip()
)

View file

@ -11,14 +11,11 @@ def test_token_creation(fastapi_client_simple):
'user_id': 'u_a',
'representation': '8bb6805ff10bcb1c2ca49dcd4bfef94d',
'collections': {
'collection_1': {
'mode': 'WRITE_COLLECTION',
'incoming_label': 'i_a'
}
}
'collection_1': {'mode': 'WRITE_COLLECTION', 'incoming_label': 'i_a'}
},
}
# Create a token eith name 'a'
# Create a token with name 'a'
response = test_client.post(
'/tokens',
headers={'x-dumpthings-token': admin_token},
@ -34,7 +31,7 @@ def test_token_creation(fastapi_client_simple):
)
assert response.status_code == HTTP_409_CONFLICT
# Try to create another token eith name 'b' and the same representation
# Try to create another token with name 'b' and the same representation
# as 'a', should result in a 4ß9-error
json_record['name'] = 'b'
response = test_client.post(

View file

@ -2,7 +2,6 @@ from pathlib import Path
from .. import HTTP_200_OK
# Path to a local simple test schema
schema_file = Path(__file__).parent / 'testschema.yaml'
@ -33,7 +32,7 @@ def test_unicode_iri(fastapi_client_simple):
headers={'x-dumpthings-token': 'token-1'},
json={
'pid': 'https://en.wikipedia.org/wiki/Universita_degli_Studi_eCampus',
'given_name': 'Università degli Studi eCampus (Italy)',
}
'given_name': 'Università degli Studi eCampus (Italy)', # codespell:ignore
},
)
assert response.status_code == HTTP_200_OK

View file

@ -1,9 +1,11 @@
from dump_things_service import HTTP_422_UNPROCESSABLE_CONTENT
json_records = [
({'name': 'Henry', 'pid': 'unknown_prefix:henry'}, HTTP_422_UNPROCESSABLE_CONTENT),
({'given_name': 'Henry', 'pid': 'unknown_prefix:henry'}, HTTP_422_UNPROCESSABLE_CONTENT),
(
{'given_name': 'Henry', 'pid': 'unknown_prefix:henry'},
HTTP_422_UNPROCESSABLE_CONTENT,
),
({'given_name': 'Henry', 'pid': 'xyz:henry'}, 200),
]

View file

@ -14,7 +14,7 @@ pids = ('', '--------', '&&&&&', 'abc', 'abc&', 'abc&format=ttl')
@pytest.mark.parametrize(
'collection_name,class_name,query,format_name', # noqa PT006
'collection_name,class_name,query,format_name', # noqa: PT006
tuple(product(*(collection_names, class_names, queries, format_names))),
)
def test_web_interface_post_errors(
@ -35,7 +35,7 @@ def test_web_interface_post_errors(
@pytest.mark.parametrize(
'collection_name,class_name,query,format_name', # noqa PT006
'collection_name,class_name,query,format_name', # noqa: PT006
tuple(product(*(collection_names, class_names, queries, format_names))),
)
def test_web_interface_get_class_errors(
@ -60,7 +60,7 @@ def test_web_interface_get_class_errors(
@pytest.mark.parametrize(
'collection_name,pid,query,format_name', # noqa PT006
'collection_name,pid,query,format_name', # noqa: PT006
tuple(product(*(collection_names, pids, queries, format_names))),
)
def test_web_interface_get_pid_errors(

View file

@ -1,6 +1,7 @@
import logging
import random
import re
from typing import Annotated
from urllib.parse import quote
from fastapi import (
@ -30,12 +31,11 @@ from dump_things_service.abstract_config import (
)
from dump_things_service.admin import authenticate_admin
from dump_things_service.api_key import api_key_header_scheme
from dump_things_service.instance_state import get_instance_state
from dump_things_service.exceptions import ConfigError
from dump_things_service.instance_state import get_instance_state
from dump_things_service.manifest import manifest_configuration
from dump_things_service.utils import wrap_http_exception
logger = logging.getLogger('dump_things_service')
router = APIRouter()
@ -73,9 +73,8 @@ def get_token_parts(token: str) -> list[str]:
async def create_token(
response: Response,
body: TokenRequest,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
) -> TokenRequest:
token_request = create_or_replace_token(body, api_key, allow_replace=False)
response.headers['Location'] = f'/tokens/{quote(body.name)}'
return token_request
@ -90,9 +89,8 @@ async def create_token(
async def replace_token(
response: Response,
body: TokenRequest,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
) -> TokenRequest:
token_request = create_or_replace_token(body, api_key, allow_replace=True)
response.headers['Location'] = f'/tokens/{quote(body.name)}'
return token_request
@ -104,7 +102,6 @@ def create_or_replace_token(
*,
allow_replace: bool,
) -> TokenRequest:
instance_state = get_instance_state()
abstract_config = read_config(store_path=instance_state.store_path)
@ -126,12 +123,11 @@ def create_or_replace_token(
# Check that incoming areas are defined if the token allows writing.
token_permissions = get_token_permissions(token_collection_info.mode)
if token_permissions.incoming_write or token_permissions.zones_access:
# Check for incoming definition in collection config
collection_info = abstract_config.collections[collection_name]
if not collection_info.incoming:
detail = (
f"Cannot add token with write access to collection "
f'Cannot add token with write access to collection '
f"'{collection_name}' without `incoming`."
)
raise HTTPException(
@ -154,7 +150,7 @@ def create_or_replace_token(
token_representation=body.representation,
)
if existing_token_info:
detail= f"Token with identical representation already exists."
detail = 'Token with identical representation already exists.'
raise HTTPException(status_code=HTTP_409_CONFLICT, detail=detail)
else:
# Generate a random representation that does not yet exist.
@ -203,9 +199,8 @@ def create_or_replace_token(
name='Get existing tokens',
)
async def get_tokens(
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
) -> list[TokenRequest]:
instance_state = get_instance_state()
abstract_config = read_config(store_path=instance_state.store_path)
@ -230,9 +225,8 @@ async def get_tokens(
)
async def get_token_with_name(
token_name: str,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
) -> TokenRequest:
instance_state = get_instance_state()
abstract_config = get_config()
@ -260,9 +254,8 @@ async def get_token_with_name(
)
async def delete_token_with_name(
token_name: str,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
):
instance_state = get_instance_state()
abstract_config = get_config()
@ -295,7 +288,7 @@ async def delete_token_with_name(
)
async def create_admin_token(
body: AdminTokenRequest,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
):
return create_or_replace_admin_token(body, api_key, allow_replace=False)
@ -308,7 +301,7 @@ async def create_admin_token(
)
async def replace_admin_token(
body: AdminTokenRequest,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
):
return create_or_replace_admin_token(body, api_key, allow_replace=True)
@ -369,7 +362,7 @@ def create_or_replace_admin_token(
name='Get admin token names',
)
async def get_admin_token(
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
) -> list[dict]:
instance_state = get_instance_state()
abstract_config = read_config(store_path=instance_state.store_path)
@ -377,10 +370,7 @@ async def get_admin_token(
authenticate_admin(instance_state, abstract_config, api_key)
return [
{
'name': token_name,
**(token_value.model_dump(mode='json', by_alias=True))
}
{'name': token_name, **(token_value.model_dump(mode='json', by_alias=True))}
for token_name, token_value in abstract_config.admin_tokens.items()
] + (
[]
@ -401,9 +391,8 @@ async def get_admin_token(
)
async def delete_admin_token(
token_name: str,
api_key: str = Depends(api_key_header_scheme),
api_key: Annotated[str, Depends(api_key_header_scheme)],
):
instance_state = get_instance_state()
abstract_config = read_config(store_path=instance_state.store_path)

View file

@ -6,6 +6,7 @@ To speed up processing, multiple indices could be introduced, e.g.:
- token representation -> token name
"""
from __future__ import annotations
import logging
@ -31,12 +32,10 @@ from dump_things_service.abstract_config import (
Configuration,
TokenModes,
TokenPermission,
mode_mapping,
check_collection,
get_collection_config_by_name,
get_default_token_config,
get_mapping_function_by_name,
get_token_config_for_representation_and_collection,
mode_mapping,
)
from dump_things_service.auth import (
AuthenticationError,
@ -83,7 +82,7 @@ def cleaned_json(data: JSON, remove_keys: tuple[str, ...] = ('@type',)) -> JSON:
return {
key: cleaned_json(value, remove_keys)
for key, value in data.items()
if key not in remove_keys and data[key] is not None
if key not in remove_keys and value is not None
}
return data
@ -97,7 +96,7 @@ def combine_ttl(documents: list[str]) -> str:
def wrap_http_exception(
exception_class: type[BaseException] = ValueError,
status_code: int = HTTP_400_BAD_REQUEST,
header: str = ''
header: str = '',
):
"""Wrap exceptions of class `exception_class` into HTTP exceptions"""
try:
@ -115,7 +114,6 @@ def join_default_token_permissions(
permissions: TokenPermission,
collection: str,
) -> TokenPermission:
result = permissions.model_copy()
# Get the default token name. If a default token is not defined, return
@ -134,7 +132,9 @@ def join_default_token_permissions(
if collection not in abstract_configuration.tokens[default_token_name].collections:
return result
default_token_mode = abstract_configuration.tokens[default_token_name].collections[collection].mode
default_token_mode = (
abstract_configuration.tokens[default_token_name].collections[collection].mode
)
default_token_permissions = mode_mapping[TokenModes(default_token_mode)]
result.curated_read = (
permissions.curated_read | default_token_permissions.curated_read
@ -155,17 +155,11 @@ def get_on_disk_labels(
) -> set[str]:
check_collection(abstract_config, collection)
incoming_path = (
store_path / abstract_config.collections[collection].incoming
)
incoming_path = store_path / abstract_config.collections[collection].incoming
if not incoming_path or not incoming_path.exists():
return set()
return {
path.name
for path in incoming_path.iterdir()
if path.is_dir()
}
return {path.name for path in incoming_path.iterdir() if path.is_dir()}
def authenticate_token(
@ -173,7 +167,6 @@ def authenticate_token(
collection_name: str,
token_representation: str,
) -> AuthenticationInfo:
# Try to authenticate the token with the authentication providers that
# are associated with the collection.
auth_info = None
@ -233,7 +226,6 @@ def get_token_store(
*,
is_token_name: bool = False,
) -> tuple[_ModelStore, TokenPermission, str] | tuple[None, None, None, None]:
# If a token representation is provided, try to authenticate the token
# with the authentication providers that are associated with the collection.
if not is_token_name:
@ -279,11 +271,13 @@ def get_token_store(
if not incoming:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail='No incoming area for collection ' + collection_name
detail='No incoming area for collection ' + collection_name,
)
# Check whether a store for this collection and token does already exist.
store_info = instance_state.incoming_stores[collection_name].get(token_representation)
store_info = instance_state.incoming_stores[collection_name].get(
token_representation
)
if store_info:
return store_info
@ -308,7 +302,9 @@ def create_store(
instance_state: InstanceState,
collection_name: str,
) -> _ModelStore:
collection_curated_path = abstract_configuration.collections[collection_name].curated
collection_curated_path = abstract_configuration.collections[
collection_name
].curated
return create_token_store(
abstract_configuration=abstract_configuration,
instance_state=instance_state,
@ -323,8 +319,8 @@ def create_token_store(
collection_name: str,
store_dir: Path,
) -> _ModelStore:
from dump_things_service.backends.schema_type_layer import SchemaTypeLayer
from dump_things_service.abstract_config import get_backend_and_extension
from dump_things_service.backends.schema_type_layer import SchemaTypeLayer
from dump_things_service.exceptions import ConfigError
from dump_things_service.store.model_store import ModelStore
@ -354,7 +350,6 @@ def create_token_store(
backend_config = abstract_configuration.collections[collection_name].backend
backend_name, extension = get_backend_and_extension(backend_config.type)
if backend_name == 'record_dir':
backend = create_record_dir_token_store_backend(
store_dir=store_dir,
order_by=instance_state.order_by,
@ -376,7 +371,9 @@ def create_token_store(
if extension == 'stl':
backend = SchemaTypeLayer(backend=backend, schema=schema_uri)
submission_tags = abstract_configuration.collections[collection_name].submission_tags
submission_tags = abstract_configuration.collections[
collection_name
].submission_tags
return ModelStore(
schema=schema_uri,
backend=backend,
@ -394,8 +391,8 @@ def create_record_dir_token_store_backend(
mapping_function: str,
suffix: str,
) -> _RecordDirStore:
from dump_things_service.instance_state import record_dir_config_file_name
from dump_things_service.backends.record_dir import RecordDirStore
from dump_things_service.instance_state import record_dir_config_file_name
# Write the configuration to the store, if it does not yet exist.
if not (store_dir / record_dir_config_file_name).exists():
@ -424,7 +421,8 @@ def write_record_dir_config(
record_dir_config_file_path = path / record_dir_config_file_name
if not record_dir_config_file_path.exists():
record_dir_config_file_path.write_text(f"""# RecordDir Config
record_dir_config_file_path.write_text(
f"""# RecordDir Config
type: records
version: 1
schema: {schema}
@ -450,10 +448,7 @@ def create_sqlite_token_store_backend(
def check_bounds(
length: int | None,
max_length: int,
collection: str,
alternative_url: str
length: int | None, max_length: int, collection: str, alternative_url: str
):
if length > max_length:
raise HTTPException(
@ -469,7 +464,6 @@ async def process_token(
api_key: str | None,
collection: str,
) -> tuple[TokenPermission, _ModelStore]:
if api_key is None:
collection_config = get_collection_config_by_name(abstract_config, collection)
token_store, token_permissions, user_id = get_token_store(
@ -480,7 +474,7 @@ async def process_token(
is_token_name=True,
)
else:
token_store, token_permissions, user_id = get_token_store(
token_store, token_permissions, _user_id = get_token_store(
abstract_config,
instance_state,
collection,
@ -492,8 +486,7 @@ async def process_token(
)
# Check for maintenance mode
if collection in instance_state.maintenance_mode:
if not (
if collection in instance_state.maintenance_mode and not (
final_permissions.curated_read
and final_permissions.curated_write
and final_permissions.zones_access
@ -515,12 +508,7 @@ def get_required_incoming_labels(
abstract_config: Configuration,
collection_name: str,
) -> set[str]:
return set(
map(
lambda x: x[1],
get_required_incoming_info(abstract_config, collection_name),
)
)
return {x[1] for x in get_required_incoming_info(abstract_config, collection_name)}
def get_required_incoming_info(
@ -531,9 +519,8 @@ def get_required_incoming_info(
(token_name, this_collection_info.incoming_label)
for token_name, token_info in abstract_config.tokens.items()
for this_collection_name, this_collection_info in token_info.collections.items()
if this_collection_name == collection_name and mode_mapping[
TokenModes(this_collection_info.mode)
].incoming_write is True
if this_collection_name == collection_name
and mode_mapping[TokenModes(this_collection_info.mode)].incoming_write is True
}

View file

@ -41,7 +41,6 @@ def validate_record(
_: bool,
api_key: str | None = Depends(api_key_header_scheme),
) -> JSONResponse:
instance_state = get_instance_state()
abstract_config = get_config()
@ -63,7 +62,7 @@ def validate_record(
else api_key
)
store, token_permissions, user_id = get_token_store(
_store, token_permissions, _user_id = get_token_store(
abstract_config,
instance_state,
collection,
@ -82,18 +81,30 @@ def validate_record(
)
if input_format == Format.ttl:
with wrap_http_exception(ValueError, status_code=HTTP_422_UNPROCESSABLE_CONTENT, header='Conversion error'):
with wrap_http_exception(
ValueError,
status_code=HTTP_422_UNPROCESSABLE_CONTENT,
header='Conversion error',
):
json_object = FormatConverter(
abstract_config.collections[collection].schema_location,
input_format=Format.ttl,
output_format=Format.json,
).convert(data, class_name)
with wrap_http_exception(ValidationError, status_code=HTTP_422_UNPROCESSABLE_CONTENT, header='Validation error'):
with wrap_http_exception(
ValidationError,
status_code=HTTP_422_UNPROCESSABLE_CONTENT,
header='Validation error',
):
TypeAdapter(getattr(model, class_name)).validate_python(json_object)
else:
# Try to convert it into TTL to detect potential errors before storing
# the record
with wrap_http_exception(ValueError, status_code=HTTP_422_UNPROCESSABLE_CONTENT, header='Validation error'):
with wrap_http_exception(
ValueError,
status_code=HTTP_422_UNPROCESSABLE_CONTENT,
header='Validation error',
):
instance_state.validators[collection].validate(data)
return JSONResponse(True)

View file

@ -1,5 +1,8 @@
[build-system]
requires = ["hatchling"]
requires = [
"hatchling",
"hatch-vcs",
]
build-backend = "hatchling.build"
[project]
@ -17,9 +20,7 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
@ -42,9 +43,20 @@ dependencies = [
]
[project.urls]
Documentation = "https://hub.psychoinformatics.de/datalink/dump-things-server"
Documentation = "https://hub.psychoinformatics.de/orinoco/dump-things-server"
Issues = "https://codeberg.org/datalink/dump-things-server/issues"
Source = "https://hub.psychoinformatics.de/datalink/dump-things-server"
Source = "https://hub.psychoinformatics.de/orinoco/dump-things-server"
Changelog = "https://hub.psychoinformatics.de/orinoco/dump-things-server/src/branch/master/CHANGELOG.md"
[project.optional-dependencies]
# this is what readthedocs consumes to decide what needs to be installed
# for compiling the docs
docs = [
"pytest",
"sphinx",
"sphinx_rtd_theme",
"sphinx_autodoc_typehints",
]
[project.scripts]
dump-things-service = "dump_things_service.main:main"
@ -75,14 +87,36 @@ only-include = [
]
[tool.hatch.version]
path = "dump_things_service/__about__.py"
source = "vcs"
[tool.hatch.build.hooks.vcs]
version-file = "dump_things_service/_version.py"
[tool.hatch.envs.types]
extra-dependencies = [
"mypy>=1.0.0",
]
[tool.hatch.envs.types.scripts]
check = "mypy --install-types --non-interactive {args:src tests}"
check = "mypy --install-types --non-interactive --python-version 3.11 --follow-imports skip --pretty --show-error-context {args:dump_things_service}"
[tool.hatch.envs.docs]
description = "build Sphinx-based docs"
# also see project.optional-dependencies.docs!
# this is not considered by readthedocs
extra-dependencies = [
"pytest",
"sphinx",
"sphinx_rtd_theme",
"sphinx-autodoc-typehints",
]
[tool.hatch.envs.docs.scripts]
build = [
"make -C docs html",
]
clean = [
"rm -rf docs/generated",
"make -C docs clean",
]
[tool.coverage.run]
source_pkgs = ["dump_things_service"]
@ -106,21 +140,19 @@ description = "fastapi dev environment"
[tool.hatch.envs.fastapi.scripts]
run = "python -m dump_things_service.main {args}"
[[tool.hatch.envs.tests.matrix]]
[[tool.hatch.envs.hatch-test.matrix]]
python = ["3.11", "3.12"]
[tool.hatch.envs.tests]
[tool.hatch.envs.hatch-test]
default-args = ["dump_things_service"]
extra-dependencies = [
"freezegun",
"httpx",
"httpx2",
"pytest",
"pytest-cov",
"pytest-httpserver",
]
[tool.hatch.envs.tests.scripts]
run = 'python -m pytest {args}'
[tool.ruff]
extend-exclude = [
# sphinx
@ -130,7 +162,7 @@ extend-exclude = [
]
line-length = 88
indent-width = 4
target-version = "py39"
target-version = "py311"
[tool.ruff.format]
# Prefer single quotes over double quotes.
quote-style = "single"
@ -152,3 +184,6 @@ skip = '.git*'
check-hidden = true
# ignore-regex = ''
# ignore-words-list = ''
[tool.mypy]
disable_error_code = ["import-untyped"]