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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:

strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
mongodb-version: ['5.0', '6.0', '7.0', '8.0']
dserver-search-plugin-mongo-version: ['0.4.2']
dserver-retrieve-plugin-mongo-version: ['0.4.2']
Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@ CHANGELOG
This project uses `semantic versioning <http://semver.org/>`_.
This change log uses principles from `keep a changelog <http://keepachangelog.com/>`_.

[0.23.0] - 2025-12-08
---------------------

Added
^^^^^

- Health check endpoint ``GET /config/health`` for container orchestration
(does not require authorization)
- Tag manipulation routes:

- ``GET /tags/<uri>`` - Get dataset tags
- ``PUT /tags/<uri>`` - Set all dataset tags (replaces existing)
- ``POST /tags/<uri>/<tag>`` - Add a single tag
- ``DELETE /tags/<uri>/<tag>`` - Remove a single tag

- Annotation manipulation routes:

- ``GET /annotations/<uri>`` - Get dataset annotations
- ``PUT /annotations/<uri>`` - Set all annotations (replaces existing)
- ``PUT /annotations/<uri>/<name>`` - Set a single annotation
- ``DELETE /annotations/<uri>/<name>`` - Delete a single annotation

- README manipulation routes:

- ``GET /readmes/<uri>`` - Get dataset README
- ``PUT /readmes/<uri>`` - Update dataset README

Changed
^^^^^^^

- Switched build system to flit
- Tags and annotations are now updated both in the database and in storage
(previously only updated in database)

Removed
^^^^^^^

- Removed unused legacy code


[0.22.0]
------------

Expand Down
1 change: 0 additions & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
include README.rst
include LICENSE
include dserver/templates/*
64 changes: 64 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,57 @@ URI ``s3://dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db``::
http://localhost:5000/manifests/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db


Modifying dataset tags
~~~~~~~~~~~~~~~~~~~~~~

Add a single tag to a dataset::

$ curl -H "$HEADER" -X POST \
http://localhost:5000/tags/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db/new-tag

Replace all tags on a dataset::

$ curl -H "$HEADER" -H "Content-Type: application/json" \
-X PUT -d '{"tags": ["tag1", "tag2"]}' \
http://localhost:5000/tags/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db

Remove a tag from a dataset::

$ curl -H "$HEADER" -X DELETE \
http://localhost:5000/tags/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db/old-tag


Modifying dataset annotations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Set a single annotation on a dataset::

$ curl -H "$HEADER" -H "Content-Type: application/json" \
-X PUT -d '{"value": "some value"}' \
http://localhost:5000/annotations/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db/my-annotation

Replace all annotations on a dataset::

$ curl -H "$HEADER" -H "Content-Type: application/json" \
-X PUT -d '{"annotations": {"key1": "value1", "key2": 42}}' \
http://localhost:5000/annotations/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db

Delete an annotation from a dataset::

$ curl -H "$HEADER" -X DELETE \
http://localhost:5000/annotations/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db/my-annotation


Modifying dataset README
~~~~~~~~~~~~~~~~~~~~~~~~

Update the README of a dataset::

$ curl -H "$HEADER" -H "Content-Type: application/json" \
-X PUT -d '{"readme": "---\ndescription: Updated README content\n"}' \
http://localhost:5000/readmes/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db


Getting information about one's own permissions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down Expand Up @@ -727,6 +778,19 @@ and extension plugins with their versions, i.e.::

This request does not require any authorization.

The request::

$ curl http://localhost:5000/config/health

will return a simple health check status for container orchestration::

{
"status": "healthy"
}

This request does not require any authorization and can be used for
Kubernetes liveness/readiness probes or Docker health checks.

Creating a plugin
-----------------

Expand Down
28 changes: 10 additions & 18 deletions dservercore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,24 +42,16 @@
__version__ = None


# Python version-dependent treatment of entry points
if sys.version_info >= (3, 8):
from importlib.metadata import entry_points
eps = entry_points()
if sys.version_info >= (3, 10):
search_entrypoints_iterator = eps.select(group="dservercore.search")
retrieve_entrypoints_iterator = eps.select(group="dservercore.retrieve")
extension_entrypoints_iterator = eps.select(group="dservercore.extension")
else:
search_entrypoints_iterator = eps.get("dservercore.search", [])
retrieve_entrypoints_iterator = eps.get("dservercore.retrieve", [])
extension_entrypoints_iterator = eps.get("dservercore.extension", [])
else: # Python version < 3.8
from pkg_resources import iter_entry_points

search_entrypoints_iterator = iter_entry_points("dservercore.search")
retrieve_entrypoints_iterator = iter_entry_points("dservercore.retrieve")
extension_entrypoints_iterator = iter_entry_points("dservercore.extension")
from importlib.metadata import entry_points
eps = entry_points()
if sys.version_info >= (3, 10):
search_entrypoints_iterator = eps.select(group="dservercore.search")
retrieve_entrypoints_iterator = eps.select(group="dservercore.retrieve")
extension_entrypoints_iterator = eps.select(group="dservercore.extension")
else:
search_entrypoints_iterator = eps.get("dservercore.search", [])
retrieve_entrypoints_iterator = eps.get("dservercore.retrieve", [])
extension_entrypoints_iterator = eps.get("dservercore.extension", [])


class ValidationError(ValueError):
Expand Down
105 changes: 95 additions & 10 deletions dservercore/annotations_routes.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,45 @@
"""Route for retrieving dataset annotations by URI."""
"""Routes for retrieving and modifying dataset annotations by URI."""
from flask import (
abort,
jsonify,
current_app
current_app,
request
)
from dservercore.utils_auth import (
jwt_required,
get_jwt_identity,
)

from dservercore import UnknownURIError
from dservercore import UnknownURIError, AuthorizationError
from dservercore.blueprint import Blueprint
from dservercore.schemas import AnnotationSchema
from dservercore.schemas import AnnotationSchema, SingleAnnotationSchema
import dservercore.utils_auth
from dservercore.utils import (
url_suffix_to_uri,
get_annotations_from_uri_by_user
get_annotations_from_uri_by_user,
set_annotations_for_uri_by_user,
set_annotation_for_uri_by_user,
delete_annotation_for_uri_by_user
)

bp = Blueprint("annotations", __name__, url_prefix="/annotations")


@bp.route("/<path:uri>", methods=["GET"])
@bp.response(200, AnnotationSchema)
@bp.alt_response(401, description="Not registered")
@bp.alt_response(401, description="Unauthorized")
@bp.alt_response(403, description="No permissions")
@bp.alt_response(400, description="Unknown URI")
@bp.alt_response(404, description="Not found")
@jwt_required()
def annotations(uri):
def get_annotations(uri):
"""Request the dataset annotations."""
username = get_jwt_identity()
if not dservercore.utils_auth.user_exists(username):
# Unregistered users should see 401.
abort(401)

uri = url_suffix_to_uri(uri)

if not dservercore.utils_auth.may_access(username, uri):
# Authorization errors should return 403.
abort(403)

try:
Expand All @@ -46,4 +48,87 @@ def annotations(uri):
current_app.logger.info("UnknownURIError")
abort(404)

return {"annotations": annotations}


@bp.route("/<path:uri>", methods=["PUT"])
@bp.arguments(AnnotationSchema)
@bp.response(200, AnnotationSchema)
@bp.alt_response(401, description="Unauthorized")
@bp.alt_response(403, description="No permissions")
@bp.alt_response(404, description="Not found")
@jwt_required()
def set_annotations(data, uri):
"""Set all dataset annotations (replaces existing annotations)."""
username = get_jwt_identity()
if not dservercore.utils_auth.user_exists(username):
abort(401)

uri = url_suffix_to_uri(uri)

try:
annotations = set_annotations_for_uri_by_user(
username, uri, data.get("annotations", {})
)
except AuthorizationError:
abort(403)
except UnknownURIError:
current_app.logger.info("UnknownURIError")
abort(404)

return {"annotations": annotations}


@bp.route("/<path:uri>/<annotation_name>", methods=["PUT"])
@bp.arguments(SingleAnnotationSchema)
@bp.response(200, AnnotationSchema)
@bp.alt_response(401, description="Unauthorized")
@bp.alt_response(403, description="No permissions")
@bp.alt_response(404, description="Not found")
@jwt_required()
def set_annotation(data, uri, annotation_name):
"""Set a single annotation (creates or updates)."""
username = get_jwt_identity()
if not dservercore.utils_auth.user_exists(username):
abort(401)

uri = url_suffix_to_uri(uri)

try:
annotations = set_annotation_for_uri_by_user(
username, uri, annotation_name, data.get("value")
)
except AuthorizationError:
abort(403)
except UnknownURIError:
current_app.logger.info("UnknownURIError")
abort(404)

return {"annotations": annotations}


@bp.route("/<path:uri>/<annotation_name>", methods=["DELETE"])
@bp.response(200, AnnotationSchema)
@bp.alt_response(401, description="Unauthorized")
@bp.alt_response(403, description="No permissions")
@bp.alt_response(404, description="Not found")
@jwt_required()
def delete_annotation(uri, annotation_name):
"""Delete a single annotation."""
username = get_jwt_identity()
if not dservercore.utils_auth.user_exists(username):
abort(401)

uri = url_suffix_to_uri(uri)

try:
annotations = delete_annotation_for_uri_by_user(
username, uri, annotation_name
)
except AuthorizationError:
abort(403)
except UnknownURIError:
current_app.logger.info("UnknownURIError")
abort(404)

return {"annotations": annotations}
13 changes: 12 additions & 1 deletion dservercore/config_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import dservercore
import dservercore.utils_auth
from dservercore.blueprint import Blueprint
from dservercore.schemas import ConfigSchema, VersionSchema
from dservercore.schemas import ConfigSchema, HealthSchema, VersionSchema
from dservercore.utils import versions_to_dict, obj_to_lowercase_key_dict


Expand Down Expand Up @@ -45,3 +45,14 @@ def server_versions():
This does not require authorization."""

return jsonify({"versions": versions_to_dict()})


@bp.route("/health", methods=["GET"])
@bp.response(200, HealthSchema)
def health():
"""Health check endpoint for container orchestration.

Returns a simple status indicating the service is running.
This does not require authorization."""

return jsonify({"status": "healthy"}), 200
18 changes: 13 additions & 5 deletions dservercore/date_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
"""Validation utility functions."""

from datetime import datetime
from datetime import datetime, timezone


def _naive_utc_from_timestamp(timestamp):
"""Return a naive UTC datetime for a Unix timestamp.

Naive UTC datetimes are used throughout dservercore because
dtoolcore.utils.timestamp() only accepts naive datetimes.
"""
return datetime.fromtimestamp(
float(timestamp), timezone.utc).replace(tzinfo=None)


def extract_created_at_as_datetime(admin_metadata):
Expand All @@ -12,11 +22,9 @@ def extract_created_at_as_datetime(admin_metadata):
created_at = admin_metadata["created_at"]
except KeyError:
created_at = admin_metadata["frozen_at"]
created_at = float(created_at)
return datetime.utcfromtimestamp(created_at)
return _naive_utc_from_timestamp(created_at)


def extract_frozen_at_as_datetime(admin_metadata):
frozen_at = admin_metadata["frozen_at"]
frozen_at = float(frozen_at)
return datetime.utcfromtimestamp(frozen_at)
return _naive_utc_from_timestamp(frozen_at)
2 changes: 1 addition & 1 deletion dservercore/manifest_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

@bp.route("/<path:uri>", methods=["GET"])
@bp.response(200, ManifestSchema)
@bp.alt_response(1, description=2)
@bp.alt_response(401, description="Not registered")
@bp.alt_response(403, description="No permissions")
@bp.alt_response(404, description="Not found")
@jwt_required()
Expand Down
Loading
Loading