Reduce audit noise #251
15 changed files with 231 additions and 94 deletions
|
|
@ -162,8 +162,8 @@ class AdminTokenConfig(StrictModel):
|
||||||
|
|
||||||
|
|
||||||
class Configuration(StrictModel):
|
class Configuration(StrictModel):
|
||||||
type: str = Literal['collections']
|
type: Literal['collections']
|
||||||
version: int = Literal[2]
|
version: Literal[2]
|
||||||
collections: dict[str, CollectionConfig] = {}
|
collections: dict[str, CollectionConfig] = {}
|
||||||
tokens: dict[str, TokenConfig] = {}
|
tokens: dict[str, TokenConfig] = {}
|
||||||
admin_tokens: dict[str, AdminTokenConfig] = {}
|
admin_tokens: dict[str, AdminTokenConfig] = {}
|
||||||
|
|
@ -267,18 +267,21 @@ def store_config(
|
||||||
store_path,
|
store_path,
|
||||||
config: Configuration,
|
config: Configuration,
|
||||||
):
|
):
|
||||||
|
from dump_things_service.utils import order_dict # noqa PLC0415 -- global import leads to circular imports
|
||||||
|
|
||||||
global g_abstract_configuration # noqa PLW0603 -- this function updates a globally referenced instance
|
global g_abstract_configuration # noqa PLW0603 -- this function updates a globally referenced instance
|
||||||
|
|
||||||
config_backend, audit_backend = get_config_backends(store_path)
|
config_backend, audit_backend = get_config_backends(store_path)
|
||||||
json_object = config.model_dump(mode='json', exclude_none=True, by_alias=True)
|
json_object = config.model_dump(mode='json', exclude_none=True, by_alias=True)
|
||||||
json_object['pid'] = dump_things_config_iri
|
json_object['pid'] = dump_things_config_iri
|
||||||
|
sorted_json_object = order_dict(json_object)
|
||||||
config_backend.add_record(
|
config_backend.add_record(
|
||||||
iri=dump_things_config_iri,
|
iri=dump_things_config_iri,
|
||||||
class_name='DumpThingsConfig',
|
class_name='DumpThingsConfig',
|
||||||
json_object=json_object,
|
json_object=sorted_json_object,
|
||||||
)
|
)
|
||||||
audit_backend.add_record(
|
audit_backend.add_record(
|
||||||
record=json_object,
|
record=sorted_json_object,
|
||||||
committer_id='__dump_things_server__',
|
committer_id='__dump_things_server__',
|
||||||
)
|
)
|
||||||
g_abstract_configuration = config
|
g_abstract_configuration = config
|
||||||
|
|
@ -377,7 +380,7 @@ def get_token_infos_for_collection(
|
||||||
yield from {
|
yield from {
|
||||||
(token_name, token_config, token_collection_config)
|
(token_name, token_config, token_collection_config)
|
||||||
for token_name, token_config in abstract_config.tokens.items()
|
for token_name, token_config in abstract_config.tokens.items()
|
||||||
for token_collection_config in token_config.collections.get(collection_name)
|
for token_collection_config in token_config.collections.get(collection_name, [])
|
||||||
if token_config is not None
|
if token_config is not None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -470,7 +473,7 @@ def mapping_after_last_colon(pid: str, suffix: str) -> Path:
|
||||||
return Path(escaped_result + '.' + suffix)
|
return Path(escaped_result + '.' + suffix)
|
||||||
|
|
||||||
|
|
||||||
mapping_functions = {
|
mapping_functions: dict[MappingMethod, Callable] = {
|
||||||
MappingMethod.digest_md5: partial(mapping_digest, hashlib.md5),
|
MappingMethod.digest_md5: partial(mapping_digest, hashlib.md5),
|
||||||
MappingMethod.digest_md5_p3: partial(mapping_digest_p3, hashlib.md5),
|
MappingMethod.digest_md5_p3: partial(mapping_digest_p3, hashlib.md5),
|
||||||
MappingMethod.digest_md5_p3_p3: partial(mapping_digest_p3_p3, hashlib.md5),
|
MappingMethod.digest_md5_p3_p3: partial(mapping_digest_p3_p3, hashlib.md5),
|
||||||
|
|
|
||||||
|
|
@ -223,14 +223,11 @@ class GitAuditBackend(AuditBackend):
|
||||||
author_id: str,
|
author_id: str,
|
||||||
record: dict,
|
record: dict,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
from dump_things_service.utils import json2yaml # noqa PLC0415 -- global import leads to circular imports
|
||||||
|
|
||||||
existing_record = self._read_record_from_repo_path(location[1])
|
existing_record = self._read_record_from_repo_path(location[1])
|
||||||
if existing_record != record:
|
if existing_record != record:
|
||||||
self.current_change_set[location[1]] = yaml.dump(
|
self.current_change_set[location[1]] = json2yaml(record)
|
||||||
data=record,
|
|
||||||
sort_keys=False,
|
|
||||||
allow_unicode=True,
|
|
||||||
default_flow_style=False,
|
|
||||||
)
|
|
||||||
self._add_log_entry(location[2], committer_id, author_id)
|
self._add_log_entry(location[2], committer_id, author_id)
|
||||||
self._add_index_entry(record_id)
|
self._add_index_entry(record_id)
|
||||||
return True
|
return True
|
||||||
|
|
@ -249,7 +246,7 @@ class GitAuditBackend(AuditBackend):
|
||||||
'author_id': author_id,
|
'author_id': author_id,
|
||||||
}
|
}
|
||||||
log_content = self._read_from_repo_path(log_location).decode()
|
log_content = self._read_from_repo_path(log_location).decode()
|
||||||
log_content += json.dumps(entry, ensure_ascii=False) + '\n'
|
log_content += json.dumps(entry, ensure_ascii=False, sort_keys=True) + '\n'
|
||||||
self.current_change_set[log_location] = log_content
|
self.current_change_set[log_location] = log_content
|
||||||
|
|
||||||
def _add_index_entry(
|
def _add_index_entry(
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class InvalidTokenError(AuthenticationError):
|
||||||
class AuthenticationInfo:
|
class AuthenticationInfo:
|
||||||
token_permission: TokenPermission
|
token_permission: TokenPermission
|
||||||
user_id: str
|
user_id: str
|
||||||
incoming_label: str | None
|
incoming_label: str
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationSource(metaclass=abc.ABCMeta):
|
class AuthenticationSource(metaclass=abc.ABCMeta):
|
||||||
|
|
|
||||||
|
|
@ -61,12 +61,14 @@ class RecordDirResultList(BackendResultList):
|
||||||
:param path: The path where the record is stored
|
:param path: The path where the record is stored
|
||||||
:return: A RecordInfo object.
|
:return: A RecordInfo object.
|
||||||
"""
|
"""
|
||||||
|
from dump_things_service.utils import order_dict # noqa PLC0415 -- global import leads to circular imports
|
||||||
|
|
||||||
with path.open('r') as f:
|
with path.open('r') as f:
|
||||||
json_object = yaml.load(f, Loader=yaml.SafeLoader)
|
json_object = yaml.load(f, Loader=yaml.SafeLoader)
|
||||||
return RecordInfo(
|
return RecordInfo(
|
||||||
iri=iri,
|
iri=iri,
|
||||||
class_name=class_name,
|
class_name=class_name,
|
||||||
json_object=json_object,
|
json_object=order_dict(json_object),
|
||||||
sort_key=sort_key,
|
sort_key=sort_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -111,6 +113,8 @@ class _RecordDirStore(StorageBackend):
|
||||||
class_name: str,
|
class_name: str,
|
||||||
json_object: dict,
|
json_object: dict,
|
||||||
):
|
):
|
||||||
|
from dump_things_service.utils import json2yaml # noqa PLC0415 -- global import leads to circular imports
|
||||||
|
|
||||||
pid = json_object['pid']
|
pid = json_object['pid']
|
||||||
|
|
||||||
# Generate the class directory, apply the mapping function to the record
|
# Generate the class directory, apply the mapping function to the record
|
||||||
|
|
@ -132,12 +136,7 @@ class _RecordDirStore(StorageBackend):
|
||||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Convert the record object into a YAML object
|
# Convert the record object into a YAML object
|
||||||
data = yaml.dump(
|
data = json2yaml(json_object)
|
||||||
data=json_object,
|
|
||||||
sort_keys=False,
|
|
||||||
allow_unicode=True,
|
|
||||||
default_flow_style=False,
|
|
||||||
)
|
|
||||||
storage_path.write_text(data, encoding='utf-8')
|
storage_path.write_text(data, encoding='utf-8')
|
||||||
|
|
||||||
# Add the IRI to the index.
|
# Add the IRI to the index.
|
||||||
|
|
@ -219,7 +218,7 @@ class _RecordDirStore(StorageBackend):
|
||||||
|
|
||||||
|
|
||||||
# Ensure that there is only one store per root directory.
|
# Ensure that there is only one store per root directory.
|
||||||
_existing_stores = {}
|
_existing_stores: dict[Path, _RecordDirStore] = {}
|
||||||
|
|
||||||
|
|
||||||
def RecordDirStore( # noqa: N802
|
def RecordDirStore( # noqa: N802
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ if TYPE_CHECKING:
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'IndexEntry',
|
'IndexEntry',
|
||||||
'RecordDirIndex',
|
'RecordDirIndex',
|
||||||
|
'index_file_name',
|
||||||
]
|
]
|
||||||
|
|
||||||
index_file_name = '.directory_dir_index.db'
|
index_file_name = '.directory_dir_index.db'
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ if TYPE_CHECKING:
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'SQLiteBackend',
|
'SQLiteBackend',
|
||||||
'_SQLiteBackend',
|
'_SQLiteBackend',
|
||||||
|
'record_file_name',
|
||||||
]
|
]
|
||||||
|
|
||||||
logger = logging.getLogger('dump_things_service')
|
logger = logging.getLogger('dump_things_service')
|
||||||
|
|
@ -305,7 +306,7 @@ class _SQLiteBackend(StorageBackend):
|
||||||
|
|
||||||
|
|
||||||
# Ensure that there is only one SQL-backend per database file.
|
# Ensure that there is only one SQL-backend per database file.
|
||||||
_existing_sqlite_backends = {}
|
_existing_sqlite_backends: dict[Path, _SQLiteBackend] = {}
|
||||||
|
|
||||||
|
|
||||||
def SQLiteBackend( # noqa: N802
|
def SQLiteBackend( # noqa: N802
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ from dump_things_service.utils import (
|
||||||
create_store,
|
create_store,
|
||||||
get_token_store,
|
get_token_store,
|
||||||
join_default_token_permissions,
|
join_default_token_permissions,
|
||||||
|
order_dict,
|
||||||
var_escape,
|
var_escape,
|
||||||
wrap_http_exception,
|
wrap_http_exception,
|
||||||
)
|
)
|
||||||
|
|
@ -665,9 +666,10 @@ def store_record(
|
||||||
):
|
):
|
||||||
instance_state.validators[collection].validate(record)
|
instance_state.validators[collection].validate(record)
|
||||||
|
|
||||||
|
sorted_record = order_dict(record)
|
||||||
with wrap_http_exception(CurieResolutionError):
|
with wrap_http_exception(CurieResolutionError):
|
||||||
stored_records = store.store_object(
|
stored_records = store.store_object(
|
||||||
obj=record,
|
obj=sorted_record,
|
||||||
submitter=user_id if add_submission_tag else None,
|
submitter=user_id if add_submission_tag else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -690,4 +692,4 @@ def store_record(
|
||||||
),
|
),
|
||||||
media_type='text/turtle',
|
media_type='text/turtle',
|
||||||
)
|
)
|
||||||
return JSONResponse([record for _, record in stored_records])
|
return JSONResponse([order_dict(record) for _, record in stored_records])
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ import sys
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import yaml
|
|
||||||
from starlette.status import HTTP_300_MULTIPLE_CHOICES
|
from starlette.status import HTTP_300_MULTIPLE_CHOICES
|
||||||
|
|
||||||
|
from dump_things_service.utils import json2yaml
|
||||||
|
|
||||||
parser = ArgumentParser(
|
parser = ArgumentParser(
|
||||||
prog='Download a complete configuration of a running service',
|
prog='Download a complete configuration of a running service',
|
||||||
description='Read a configuration from dump-things endpoints and create a '
|
description='Read a configuration from dump-things endpoints and create a '
|
||||||
|
|
@ -76,14 +77,7 @@ def main():
|
||||||
if arguments.format == 'json':
|
if arguments.format == 'json':
|
||||||
print(json.dumps(configuration, indent=2, sort_keys=False)) # noqa T201 -- cli result output
|
print(json.dumps(configuration, indent=2, sort_keys=False)) # noqa T201 -- cli result output
|
||||||
elif arguments.format == 'yaml':
|
elif arguments.format == 'yaml':
|
||||||
print( # noqa T201 -- cli result output
|
print(json2yaml(configuration)) # noqa T201 -- cli result output
|
||||||
yaml.dump(
|
|
||||||
data=configuration,
|
|
||||||
sort_keys=False,
|
|
||||||
allow_unicode=True,
|
|
||||||
default_flow_style=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import yaml
|
||||||
from starlette.status import HTTP_300_MULTIPLE_CHOICES
|
from starlette.status import HTTP_300_MULTIPLE_CHOICES
|
||||||
|
|
||||||
from dump_things_service.instance_state import get_record_dir_config
|
from dump_things_service.instance_state import get_record_dir_config
|
||||||
|
from dump_things_service.utils import json2yaml
|
||||||
|
|
||||||
CONFIG_VERSION = 2
|
CONFIG_VERSION = 2
|
||||||
|
|
||||||
|
|
@ -129,14 +130,7 @@ def main():
|
||||||
if file_type == 'json':
|
if file_type == 'json':
|
||||||
print(json.dumps(configuration, indent=2, sort_keys=False)) # noqa T201 -- cli result output
|
print(json.dumps(configuration, indent=2, sort_keys=False)) # noqa T201 -- cli result output
|
||||||
elif file_type == 'yaml':
|
elif file_type == 'yaml':
|
||||||
print( # noqa T201 -- cli result output
|
print(json2yaml(configuration)) # noqa T201 -- cli result output
|
||||||
yaml.dump(
|
|
||||||
data=configuration,
|
|
||||||
sort_keys=False,
|
|
||||||
allow_unicode=True,
|
|
||||||
default_flow_style=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from fastapi_pagination import (
|
||||||
add_pagination,
|
add_pagination,
|
||||||
paginate,
|
paginate,
|
||||||
)
|
)
|
||||||
|
from starlette.status import HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
from dump_things_service import (
|
from dump_things_service import (
|
||||||
HTTP_401_UNAUTHORIZED,
|
HTTP_401_UNAUTHORIZED,
|
||||||
|
|
@ -26,12 +27,16 @@ from dump_things_service.abstract_config import (
|
||||||
from dump_things_service.api_key import api_key_header_scheme
|
from dump_things_service.api_key import api_key_header_scheme
|
||||||
from dump_things_service.backends.schema_type_layer import _SchemaTypeLayer
|
from dump_things_service.backends.schema_type_layer import _SchemaTypeLayer
|
||||||
from dump_things_service.exceptions import CurieResolutionError
|
from dump_things_service.exceptions import CurieResolutionError
|
||||||
from dump_things_service.instance_state import get_instance_state
|
from dump_things_service.instance_state import (
|
||||||
|
check_instance_state_collection,
|
||||||
|
get_instance_state,
|
||||||
|
)
|
||||||
from dump_things_service.lazy_list import ModifierList
|
from dump_things_service.lazy_list import ModifierList
|
||||||
from dump_things_service.utils import (
|
from dump_things_service.utils import (
|
||||||
authenticate_token,
|
authenticate_token,
|
||||||
check_bounds,
|
check_bounds,
|
||||||
cleaned_json,
|
cleaned_json,
|
||||||
|
order_dict,
|
||||||
wrap_http_exception,
|
wrap_http_exception,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -56,7 +61,7 @@ async def {name}(
|
||||||
repr(author_id),
|
repr(author_id),
|
||||||
repr({model_var_name}),
|
repr({model_var_name}),
|
||||||
)
|
)
|
||||||
return await store_curated_record(
|
return store_curated_record(
|
||||||
'{collection}',
|
'{collection}',
|
||||||
data,
|
data,
|
||||||
'{class_name}',
|
'{class_name}',
|
||||||
|
|
@ -83,6 +88,7 @@ async def read_curated_records_of_type(
|
||||||
matching: str | None = None,
|
matching: str | None = None,
|
||||||
):
|
):
|
||||||
instance_state = get_instance_state()
|
instance_state = get_instance_state()
|
||||||
|
check_instance_state_collection(instance_state, collection)
|
||||||
if class_name not in instance_state.collections[collection].active_classes:
|
if class_name not in instance_state.collections[collection].active_classes:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTP_404_NOT_FOUND,
|
status_code=HTTP_404_NOT_FOUND,
|
||||||
|
|
@ -111,6 +117,7 @@ async def read_curated_records_of_type_paginated(
|
||||||
matching: str | None = None,
|
matching: str | None = None,
|
||||||
) -> Page[dict]:
|
) -> Page[dict]:
|
||||||
instance_state = get_instance_state()
|
instance_state = get_instance_state()
|
||||||
|
check_instance_state_collection(instance_state, collection)
|
||||||
if class_name not in instance_state.collections[collection].active_classes:
|
if class_name not in instance_state.collections[collection].active_classes:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTP_404_NOT_FOUND,
|
status_code=HTTP_404_NOT_FOUND,
|
||||||
|
|
@ -218,6 +225,13 @@ async def _read_curated_records(
|
||||||
if record_info:
|
if record_info:
|
||||||
return record_info.json_object
|
return record_info.json_object
|
||||||
return None
|
return None
|
||||||
|
# TODO: check whether HTTP_404_NOT_FOUND should be raised instead of
|
||||||
|
# returning an empty HTTP_200_OK result. This will influence on how the
|
||||||
|
# endpoint is used by the clients.
|
||||||
|
# raise HTTPException(
|
||||||
|
# status_code=HTTP_404_NOT_FOUND,
|
||||||
|
# detail=f"no record with pid '{pid}' in curated area of collection '{collection}'",
|
||||||
|
# )
|
||||||
if class_name:
|
if class_name:
|
||||||
result_list = backend.get_records_of_classes([class_name], matching)
|
result_list = backend.get_records_of_classes([class_name], matching)
|
||||||
else:
|
else:
|
||||||
|
|
@ -276,7 +290,7 @@ def _get_store_and_backend(
|
||||||
permissions = auth_info.token_permission
|
permissions = auth_info.token_permission
|
||||||
if permissions.curated_write is False:
|
if permissions.curated_write is False:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTP_401_UNAUTHORIZED,
|
status_code=HTTP_403_FORBIDDEN,
|
||||||
detail=f'no write access to curated area of collection `{collection}`',
|
detail=f'no write access to curated area of collection `{collection}`',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -292,8 +306,8 @@ def store_curated_record(
|
||||||
collection: str,
|
collection: str,
|
||||||
data: BaseModel,
|
data: BaseModel,
|
||||||
class_name: str,
|
class_name: str,
|
||||||
author_id: str | None = None,
|
author_id: str | None,
|
||||||
api_key: str | None = Depends(api_key_header_scheme),
|
api_key: str | None,
|
||||||
):
|
):
|
||||||
instance_state = get_instance_state()
|
instance_state = get_instance_state()
|
||||||
with wrap_http_exception(
|
with wrap_http_exception(
|
||||||
|
|
@ -311,16 +325,17 @@ def store_curated_record(
|
||||||
remove_keys=('@type',),
|
remove_keys=('@type',),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sorted_json_object = order_dict(json_object)
|
||||||
with wrap_http_exception(CurieResolutionError):
|
with wrap_http_exception(CurieResolutionError):
|
||||||
backend.add_record(
|
backend.add_record(
|
||||||
model_store.pid_to_iri(pid),
|
model_store.pid_to_iri(pid),
|
||||||
class_name,
|
class_name,
|
||||||
json_object,
|
sorted_json_object,
|
||||||
)
|
)
|
||||||
|
|
||||||
for audit_backend in instance_state.audit_backends[collection]:
|
for audit_backend in instance_state.audit_backends[collection]:
|
||||||
audit_backend.add_record(
|
audit_backend.add_record(
|
||||||
record=json_object,
|
record=sorted_json_object,
|
||||||
committer_id=auth_info.user_id,
|
committer_id=auth_info.user_id,
|
||||||
author_id=author_id,
|
author_id=author_id,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING, Annotated
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
|
|
@ -13,6 +12,7 @@ from fastapi_pagination import (
|
||||||
add_pagination,
|
add_pagination,
|
||||||
paginate,
|
paginate,
|
||||||
)
|
)
|
||||||
|
from starlette.status import HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
from dump_things_service import (
|
from dump_things_service import (
|
||||||
HTTP_401_UNAUTHORIZED,
|
HTTP_401_UNAUTHORIZED,
|
||||||
|
|
@ -28,7 +28,11 @@ from dump_things_service.abstract_config import (
|
||||||
from dump_things_service.api_key import api_key_header_scheme
|
from dump_things_service.api_key import api_key_header_scheme
|
||||||
from dump_things_service.backends.schema_type_layer import _SchemaTypeLayer
|
from dump_things_service.backends.schema_type_layer import _SchemaTypeLayer
|
||||||
from dump_things_service.exceptions import CurieResolutionError
|
from dump_things_service.exceptions import CurieResolutionError
|
||||||
from dump_things_service.instance_state import get_instance_state
|
from dump_things_service.instance_state import (
|
||||||
|
InstanceState,
|
||||||
|
check_instance_state_collection,
|
||||||
|
get_instance_state,
|
||||||
|
)
|
||||||
from dump_things_service.lazy_list import ModifierList
|
from dump_things_service.lazy_list import ModifierList
|
||||||
from dump_things_service.utils import (
|
from dump_things_service.utils import (
|
||||||
authenticate_token,
|
authenticate_token,
|
||||||
|
|
@ -36,6 +40,7 @@ from dump_things_service.utils import (
|
||||||
cleaned_json,
|
cleaned_json,
|
||||||
create_token_store,
|
create_token_store,
|
||||||
get_on_disk_labels,
|
get_on_disk_labels,
|
||||||
|
order_dict,
|
||||||
wrap_http_exception,
|
wrap_http_exception,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -47,7 +52,6 @@ if TYPE_CHECKING:
|
||||||
from dump_things_service.store.model_store import _ModelStore
|
from dump_things_service.store.model_store import _ModelStore
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('dump_things_service')
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
add_pagination(router)
|
add_pagination(router)
|
||||||
|
|
||||||
|
|
@ -69,7 +73,7 @@ async def incoming_read_labels(
|
||||||
on_disk_labels = get_on_disk_labels(
|
on_disk_labels = get_on_disk_labels(
|
||||||
instance_state.store_path, get_config(), collection
|
instance_state.store_path, get_config(), collection
|
||||||
)
|
)
|
||||||
return list(configured_labels.union(on_disk_labels))
|
return sorted(configured_labels.union(on_disk_labels))
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -85,11 +89,7 @@ async def incoming_read_records_of_type(
|
||||||
matching: str | None = None,
|
matching: str | None = None,
|
||||||
):
|
):
|
||||||
instance_state = get_instance_state()
|
instance_state = get_instance_state()
|
||||||
if class_name not in instance_state.collections[collection].active_classes:
|
_check_collection_and_class(instance_state, collection, class_name)
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"No '{class_name}'-class in collection '{collection}'.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return await _incoming_read_records(
|
return await _incoming_read_records(
|
||||||
collection=collection,
|
collection=collection,
|
||||||
|
|
@ -115,11 +115,7 @@ async def incoming_read_records_of_type_paginated(
|
||||||
matching: str | None = None,
|
matching: str | None = None,
|
||||||
) -> Page[dict]:
|
) -> Page[dict]:
|
||||||
instance_state = get_instance_state()
|
instance_state = get_instance_state()
|
||||||
if class_name not in instance_state.collections[collection].active_classes:
|
_check_collection_and_class(instance_state, collection, class_name)
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"No '{class_name}'-class in collection '{collection}'.",
|
|
||||||
)
|
|
||||||
|
|
||||||
record_list = await _incoming_read_records(
|
record_list = await _incoming_read_records(
|
||||||
collection=collection,
|
collection=collection,
|
||||||
|
|
@ -128,10 +124,24 @@ async def incoming_read_records_of_type_paginated(
|
||||||
pid=None,
|
pid=None,
|
||||||
matching=matching,
|
matching=matching,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
|
upper_bound=None,
|
||||||
)
|
)
|
||||||
return paginate(record_list)
|
return paginate(record_list)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_collection_and_class(
|
||||||
|
instance_state: InstanceState,
|
||||||
|
collection: str,
|
||||||
|
class_name: str,
|
||||||
|
):
|
||||||
|
check_instance_state_collection(instance_state, collection)
|
||||||
|
if class_name not in instance_state.collections[collection].active_classes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"No '{class_name}'-class in collection '{collection}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
'/{collection}/incoming/{label}/records/',
|
'/{collection}/incoming/{label}/records/',
|
||||||
tags=['Incoming area: read records'],
|
tags=['Incoming area: read records'],
|
||||||
|
|
@ -223,7 +233,7 @@ async def _incoming_read_records(
|
||||||
pid: str | None,
|
pid: str | None,
|
||||||
matching: str | None = None,
|
matching: str | None = None,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
upper_bound: int = 1000,
|
upper_bound: int | None = 1000,
|
||||||
) -> LazyList | dict | None:
|
) -> LazyList | dict | None:
|
||||||
model_store, backend = await _get_store_and_backend(collection, label, api_key)
|
model_store, backend = await _get_store_and_backend(collection, label, api_key)
|
||||||
|
|
||||||
|
|
@ -232,6 +242,13 @@ async def _incoming_read_records(
|
||||||
if record_info:
|
if record_info:
|
||||||
return record_info.json_object
|
return record_info.json_object
|
||||||
return None
|
return None
|
||||||
|
# TODO: check whether HTTP_404_NOT_FOUND should be raised instead of
|
||||||
|
# returning an empty HTTP_200_OK result. This will influence on how the
|
||||||
|
# endpoint is used by the clients.
|
||||||
|
# raise HTTPException(
|
||||||
|
# status_code=HTTP_404_NOT_FOUND,
|
||||||
|
# detail=f'no record with pid: {pid}',
|
||||||
|
# )
|
||||||
if class_name:
|
if class_name:
|
||||||
result_list = backend.get_records_of_classes([class_name], matching)
|
result_list = backend.get_records_of_classes([class_name], matching)
|
||||||
else:
|
else:
|
||||||
|
|
@ -242,9 +259,9 @@ async def _incoming_read_records(
|
||||||
len(result_list),
|
len(result_list),
|
||||||
upper_bound,
|
upper_bound,
|
||||||
collection,
|
collection,
|
||||||
f'/incoming/{label}/records/p/{class_name}'
|
f'/incoming/{label}/records/{class_name}'
|
||||||
if class_name
|
if class_name
|
||||||
else f'/incoming/{label}/records/p/',
|
else f'/incoming/{label}/records/',
|
||||||
)
|
)
|
||||||
|
|
||||||
return ModifierList(
|
return ModifierList(
|
||||||
|
|
@ -256,7 +273,7 @@ async def _incoming_read_records(
|
||||||
async def _incoming_delete_record(
|
async def _incoming_delete_record(
|
||||||
collection: str,
|
collection: str,
|
||||||
label: str,
|
label: str,
|
||||||
pid: str | None,
|
pid: str,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
model_store, backend = await _get_store_and_backend(collection, label, api_key)
|
model_store, backend = await _get_store_and_backend(collection, label, api_key)
|
||||||
|
|
@ -274,10 +291,10 @@ async def _incoming_delete_record(
|
||||||
async def _get_store_and_backend(
|
async def _get_store_and_backend(
|
||||||
collection: str,
|
collection: str,
|
||||||
label: str,
|
label: str,
|
||||||
plain_token: str | None,
|
api_key: str | None,
|
||||||
) -> tuple[_ModelStore, StorageBackend]:
|
) -> tuple[_ModelStore, StorageBackend]:
|
||||||
# Authorize api_key
|
# Authorize api_key
|
||||||
await authorize_zones(collection, plain_token)
|
await authorize_zones(collection, api_key)
|
||||||
|
|
||||||
# Check that the incoming zone exists
|
# Check that the incoming zone exists
|
||||||
instance_state = get_instance_state()
|
instance_state = get_instance_state()
|
||||||
|
|
@ -327,7 +344,7 @@ async def authorize_zones(
|
||||||
permissions = auth_info.token_permission
|
permissions = auth_info.token_permission
|
||||||
if permissions.zones_access is False:
|
if permissions.zones_access is False:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTP_401_UNAUTHORIZED,
|
status_code=HTTP_403_FORBIDDEN,
|
||||||
detail=f'no access to incoming zones of collection `{collection}`',
|
detail=f'no access to incoming zones of collection `{collection}`',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -337,7 +354,7 @@ async def store_incoming_record(
|
||||||
label: str,
|
label: str,
|
||||||
data: BaseModel,
|
data: BaseModel,
|
||||||
class_name: str,
|
class_name: str,
|
||||||
api_key: str | None = Depends(api_key_header_scheme),
|
api_key: str | None,
|
||||||
):
|
):
|
||||||
instance_state = get_instance_state()
|
instance_state = get_instance_state()
|
||||||
with wrap_http_exception(
|
with wrap_http_exception(
|
||||||
|
|
@ -359,9 +376,10 @@ async def store_incoming_record(
|
||||||
remove_keys=('@type',),
|
remove_keys=('@type',),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sorted_json_object = order_dict(json_object)
|
||||||
with wrap_http_exception(CurieResolutionError):
|
with wrap_http_exception(CurieResolutionError):
|
||||||
backend.add_record(
|
backend.add_record(
|
||||||
model_store.pid_to_iri(pid),
|
model_store.pid_to_iri(pid),
|
||||||
class_name,
|
class_name,
|
||||||
json_object,
|
sorted_json_object,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ from typing import (
|
||||||
)
|
)
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
from fastapi import HTTPException
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from yaml.scanner import ScannerError
|
from yaml.scanner import ScannerError
|
||||||
|
|
||||||
|
from dump_things_service import HTTP_404_NOT_FOUND
|
||||||
from dump_things_service.abstract_config import (
|
from dump_things_service.abstract_config import (
|
||||||
MappingMethod,
|
MappingMethod,
|
||||||
RecordDirConfigFileContent,
|
RecordDirConfigFileContent,
|
||||||
|
|
@ -155,3 +157,14 @@ def get_mapping_function_by_name(mapping_function_name: str) -> Callable:
|
||||||
|
|
||||||
def get_mapping_function(collection_config: RecordDirConfigFileContent):
|
def get_mapping_function(collection_config: RecordDirConfigFileContent):
|
||||||
return mapping_functions[collection_config.idfx]
|
return mapping_functions[collection_config.idfx]
|
||||||
|
|
||||||
|
|
||||||
|
def check_instance_state_collection(
|
||||||
|
instance_state: InstanceState,
|
||||||
|
collection: str,
|
||||||
|
):
|
||||||
|
if collection not in instance_state.collections:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"No such collection: '{collection}'.",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,7 @@ def fastapi_client_simple(fastapi_app_simple):
|
||||||
response = test_client.post(
|
response = test_client.post(
|
||||||
'/collections',
|
'/collections',
|
||||||
json=collection_config.model_dump(
|
json=collection_config.model_dump(
|
||||||
|
exclude_none=True,
|
||||||
exclude_unset=True,
|
exclude_unset=True,
|
||||||
mode='json',
|
mode='json',
|
||||||
by_alias=True,
|
by_alias=True,
|
||||||
|
|
@ -407,7 +408,11 @@ def fastapi_client_simple(fastapi_app_simple):
|
||||||
for token_config in g_default_tokens:
|
for token_config in g_default_tokens:
|
||||||
response = test_client.post(
|
response = test_client.post(
|
||||||
'/tokens',
|
'/tokens',
|
||||||
json=token_config.model_dump(exclude_unset=True, mode='json'),
|
json=token_config.model_dump(
|
||||||
|
exclude_none=True,
|
||||||
|
exclude_unset=True,
|
||||||
|
mode='json',
|
||||||
|
),
|
||||||
headers={'x-dumpthings-token': admin_token},
|
headers={'x-dumpthings-token': admin_token},
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
|
||||||
56
dump_things_service/tests/test_canonical.py
Normal file
56
dump_things_service/tests/test_canonical.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dump_things_service import HTTP_200_OK
|
||||||
|
from dump_things_service.abstract_config import (
|
||||||
|
MappingMethod,
|
||||||
|
mapping_functions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonicalization(fastapi_client_simple):
|
||||||
|
test_client, store_path, _admin_token = fastapi_client_simple
|
||||||
|
|
||||||
|
pid = 'http://example.com/test_canonicalization/1'
|
||||||
|
record_a = {
|
||||||
|
'pid': pid,
|
||||||
|
'given_name': 'Alice',
|
||||||
|
}
|
||||||
|
|
||||||
|
record_b = {
|
||||||
|
'given_name': 'Bob',
|
||||||
|
'pid': pid,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure that the two records are represented in the same order on disk.
|
||||||
|
# (The current implementation should guarantee that because the pydantic
|
||||||
|
# objects that are instantiated from the JSON input have a fixed order
|
||||||
|
# of attributes. Once the implementation is changed, this test here might
|
||||||
|
# catch regressions).
|
||||||
|
file_content = []
|
||||||
|
for record in (record_a, record_b):
|
||||||
|
response = test_client.post(
|
||||||
|
'/collection_1/record/Person',
|
||||||
|
headers={'x-dumpthings-token': 'token-1'},
|
||||||
|
json=record,
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTP_200_OK
|
||||||
|
file_content.append(
|
||||||
|
_get_record_dir_record(
|
||||||
|
store_path,
|
||||||
|
'incoming/collection_1/in_token_1/Person',
|
||||||
|
pid,
|
||||||
|
MappingMethod.digest_md5,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert file_content[0] == file_content[1].replace('Bob', 'Alice')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_record_dir_record(
|
||||||
|
store_path: Path,
|
||||||
|
path_part: str | Path,
|
||||||
|
pid: str,
|
||||||
|
mapping_method: MappingMethod,
|
||||||
|
) -> str:
|
||||||
|
file_name = mapping_functions[mapping_method](pid, 'yaml')
|
||||||
|
with (store_path / path_part / file_name).open('rt') as f:
|
||||||
|
return f.read()
|
||||||
|
|
@ -15,9 +15,11 @@ from contextlib import contextmanager
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
|
cast,
|
||||||
)
|
)
|
||||||
|
|
||||||
import fsspec
|
import fsspec
|
||||||
|
import yaml
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from rdflib import Graph
|
from rdflib import Graph
|
||||||
|
|
||||||
|
|
@ -30,6 +32,7 @@ from dump_things_service import (
|
||||||
)
|
)
|
||||||
from dump_things_service.abstract_config import (
|
from dump_things_service.abstract_config import (
|
||||||
Configuration,
|
Configuration,
|
||||||
|
RecordDirBackendConfig,
|
||||||
TokenModes,
|
TokenModes,
|
||||||
TokenPermission,
|
TokenPermission,
|
||||||
check_collection,
|
check_collection,
|
||||||
|
|
@ -89,12 +92,14 @@ def cleaned_json(data: JSON, remove_keys: tuple[str, ...] = ('@type',)) -> JSON:
|
||||||
|
|
||||||
def combine_ttl(documents: list[str]) -> str:
|
def combine_ttl(documents: list[str]) -> str:
|
||||||
graphs = [Graph().parse(data=doc, format='ttl') for doc in documents]
|
graphs = [Graph().parse(data=doc, format='ttl') for doc in documents]
|
||||||
|
if not graphs:
|
||||||
|
return ''
|
||||||
return reduce(lambda g1, g2: g1 + g2, graphs).serialize(format='ttl')
|
return reduce(lambda g1, g2: g1 + g2, graphs).serialize(format='ttl')
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def wrap_http_exception(
|
def wrap_http_exception(
|
||||||
exception_class: type[BaseException] = ValueError,
|
exception_class: type[Exception] = ValueError,
|
||||||
status_code: int = HTTP_400_BAD_REQUEST,
|
status_code: int = HTTP_400_BAD_REQUEST,
|
||||||
header: str = '',
|
header: str = '',
|
||||||
):
|
):
|
||||||
|
|
@ -154,7 +159,12 @@ def get_on_disk_labels(
|
||||||
) -> set[str]:
|
) -> set[str]:
|
||||||
check_collection(abstract_config, collection)
|
check_collection(abstract_config, collection)
|
||||||
|
|
||||||
incoming_path = store_path / abstract_config.collections[collection].incoming
|
if abstract_config.collections[collection].incoming is None:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
incoming_path = store_path / cast(
|
||||||
|
'Path', abstract_config.collections[collection].incoming
|
||||||
|
)
|
||||||
if not incoming_path or not incoming_path.exists():
|
if not incoming_path or not incoming_path.exists():
|
||||||
return set()
|
return set()
|
||||||
|
|
||||||
|
|
@ -221,31 +231,26 @@ def get_token_store(
|
||||||
abstract_config: Configuration,
|
abstract_config: Configuration,
|
||||||
instance_state: InstanceState,
|
instance_state: InstanceState,
|
||||||
collection_name: str,
|
collection_name: str,
|
||||||
token_representation: str | None,
|
token_representation: str,
|
||||||
*,
|
*,
|
||||||
is_token_name: bool = False,
|
is_token_name: bool = False,
|
||||||
) -> tuple[_ModelStore, TokenPermission, str] | tuple[None, None, None, None]:
|
) -> tuple[_ModelStore, TokenPermission, str] | tuple[None, None, None]:
|
||||||
# If a token representation is provided, try to authenticate the token
|
# If a token representation is provided, try to authenticate the token
|
||||||
# with the authentication providers that are associated with the collection.
|
# with the authentication providers that are associated with the collection.
|
||||||
if not is_token_name:
|
if not is_token_name:
|
||||||
if token_representation is None:
|
|
||||||
msg = 'get_token_store: token_representation is None and is_token_name is False. This is a calling error!'
|
|
||||||
logger.error(msg)
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
auth_info = authenticate_token(
|
auth_info = authenticate_token(
|
||||||
instance_state,
|
instance_state,
|
||||||
collection_name,
|
collection_name,
|
||||||
token_representation,
|
token_representation,
|
||||||
)
|
)
|
||||||
|
token_key = token_representation
|
||||||
else:
|
else:
|
||||||
auth_info = get_default_token_auth_info(
|
auth_info = get_default_token_auth_info(
|
||||||
abstract_config=abstract_config,
|
abstract_config=abstract_config,
|
||||||
collection_name=collection_name,
|
collection_name=collection_name,
|
||||||
token_name=token_representation,
|
token_name=token_representation,
|
||||||
)
|
)
|
||||||
token_representation = None
|
token_key = None
|
||||||
|
|
||||||
if not auth_info:
|
if not auth_info:
|
||||||
detail = f"invalid token for collection '{collection_name}'"
|
detail = f"invalid token for collection '{collection_name}'"
|
||||||
|
|
@ -258,12 +263,12 @@ def get_token_store(
|
||||||
# If the token has no incoming-read or incoming-write permissions, we do not
|
# If the token has no incoming-read or incoming-write permissions, we do not
|
||||||
# need to create a store.
|
# need to create a store.
|
||||||
if not permissions.incoming_read and not permissions.incoming_write:
|
if not permissions.incoming_read and not permissions.incoming_write:
|
||||||
instance_state.incoming_stores[collection_name][token_representation] = (
|
instance_state.incoming_stores[collection_name][token_key] = (
|
||||||
None,
|
None,
|
||||||
permissions,
|
permissions,
|
||||||
auth_info.user_id,
|
auth_info.user_id,
|
||||||
)
|
)
|
||||||
return instance_state.incoming_stores[collection_name][token_representation]
|
return instance_state.incoming_stores[collection_name][token_key]
|
||||||
|
|
||||||
# Check whether the collection has an incoming definition
|
# Check whether the collection has an incoming definition
|
||||||
incoming = abstract_config.collections[collection_name].incoming
|
incoming = abstract_config.collections[collection_name].incoming
|
||||||
|
|
@ -274,9 +279,7 @@ def get_token_store(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check whether a store for this collection and token does already exist.
|
# Check whether a store for this collection and token does already exist.
|
||||||
store_info = instance_state.incoming_stores[collection_name].get(
|
store_info = instance_state.incoming_stores[collection_name].get(token_key)
|
||||||
token_representation
|
|
||||||
)
|
|
||||||
if store_info:
|
if store_info:
|
||||||
return store_info
|
return store_info
|
||||||
|
|
||||||
|
|
@ -288,12 +291,12 @@ def get_token_store(
|
||||||
store_dir=store_dir,
|
store_dir=store_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
instance_state.incoming_stores[collection_name][token_representation] = (
|
instance_state.incoming_stores[collection_name][token_key] = (
|
||||||
token_store,
|
token_store,
|
||||||
permissions,
|
permissions,
|
||||||
auth_info.user_id,
|
auth_info.user_id,
|
||||||
)
|
)
|
||||||
return instance_state.incoming_stores[collection_name][token_representation]
|
return instance_state.incoming_stores[collection_name][token_key]
|
||||||
|
|
||||||
|
|
||||||
def create_store(
|
def create_store(
|
||||||
|
|
@ -353,7 +356,9 @@ def create_token_store(
|
||||||
store_dir=store_dir,
|
store_dir=store_dir,
|
||||||
order_by=instance_state.order_by,
|
order_by=instance_state.order_by,
|
||||||
schema_uri=schema_uri,
|
schema_uri=schema_uri,
|
||||||
mapping_function=backend_config.mapping_method,
|
mapping_function=cast(
|
||||||
|
'RecordDirBackendConfig', backend_config
|
||||||
|
).mapping_method,
|
||||||
suffix='yaml',
|
suffix='yaml',
|
||||||
)
|
)
|
||||||
elif backend_name == 'sqlite':
|
elif backend_name == 'sqlite':
|
||||||
|
|
@ -449,7 +454,7 @@ def create_sqlite_token_store_backend(
|
||||||
def check_bounds(
|
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:
|
if length is not None and length > max_length:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTP_413_CONTENT_TOO_LARGE,
|
status_code=HTTP_413_CONTENT_TOO_LARGE,
|
||||||
detail=f"Too many records found in collection '{collection}'. "
|
detail=f"Too many records found in collection '{collection}'. "
|
||||||
|
|
@ -477,7 +482,14 @@ async def process_token(
|
||||||
abstract_config,
|
abstract_config,
|
||||||
instance_state,
|
instance_state,
|
||||||
collection,
|
collection,
|
||||||
api_key,
|
token_representation=api_key,
|
||||||
|
is_token_name=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if token_permissions is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=f"No read access to curated or incoming data in collection '{collection}'.",
|
||||||
)
|
)
|
||||||
|
|
||||||
final_permissions = join_default_token_permissions(
|
final_permissions = join_default_token_permissions(
|
||||||
|
|
@ -527,3 +539,30 @@ def var_escape(
|
||||||
name: str,
|
name: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
return name.replace('_', '___').replace('-', '_0_')
|
return name.replace('_', '___').replace('-', '_0_')
|
||||||
|
|
||||||
|
|
||||||
|
def json2yaml(
|
||||||
|
json_object: dict,
|
||||||
|
) -> str:
|
||||||
|
return yaml.dump(
|
||||||
|
data=json_object,
|
||||||
|
sort_keys=True,
|
||||||
|
allow_unicode=True,
|
||||||
|
default_flow_style=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def order_dict(
|
||||||
|
d: dict,
|
||||||
|
) -> dict:
|
||||||
|
return cast('dict', _order_dict(d))
|
||||||
|
|
||||||
|
|
||||||
|
def _order_dict(
|
||||||
|
d: dict | list | str | int | None,
|
||||||
|
) -> dict | list | str | int | None:
|
||||||
|
if isinstance(d, dict):
|
||||||
|
return {k: order_dict(d[k]) for k in sorted(d)}
|
||||||
|
if isinstance(d, list):
|
||||||
|
return [order_dict(e) for e in d]
|
||||||
|
return d
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue