diff --git a/Procfile b/Procfile index 37cc9ad44..2a7a88653 100644 --- a/Procfile +++ b/Procfile @@ -6,6 +6,6 @@ web: gunicorn --config gunicorn.conf.py dandiapi.wsgi # This is OK for now because of how lightweight all high priority tasks currently are, # but we may need to switch back to a dedicated worker in the future. # The queue `celery` is the default queue. -worker: REMAP_SIGTERM=SIGQUIT celery --app dandiapi.celery worker --loglevel INFO --without-mingle --without-heartbeat --without-gossip --queues celery --beat +worker: REMAP_SIGTERM=SIGQUIT celery --app dandiapi.celery worker --loglevel INFO --without-mingle --without-heartbeat --without-gossip --queues celery,doi --beat # The checksum-worker calculates blob checksums and updates zarr checksum files checksum-worker: REMAP_SIGTERM=SIGQUIT celery --app dandiapi.celery worker --loglevel INFO --without-mingle --without-heartbeat --without-gossip --queues calculate_sha256,ingest_zarr_archive diff --git a/dandiapi/api/doi.py b/dandiapi/api/doi.py deleted file mode 100644 index 2869d358a..000000000 --- a/dandiapi/api/doi.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -from dandischema.conf import get_instance_config -from django.conf import settings -import requests - -if TYPE_CHECKING: - from dandiapi.api.models import Version - -# All of the required DOI configuration settings -DANDI_DOI_SETTINGS = [ - (settings.DANDI_DOI_API_URL, 'DANDI_DOI_API_URL'), - (settings.DANDI_DOI_API_USER, 'DANDI_DOI_API_USER'), - (settings.DANDI_DOI_API_PASSWORD, 'DANDI_DOI_API_PASSWORD'), - (settings.DANDI_DOI_API_PREFIX, 'DANDI_DOI_API_PREFIX'), -] - -logger = logging.getLogger(__name__) - - -def doi_configured() -> bool: - return all(setting is not None for setting, _ in DANDI_DOI_SETTINGS) - - -def _generate_doi_data(version: Version): - from dandischema.datacite import to_datacite - - publish = settings.DANDI_DOI_PUBLISH - # Use the DANDI test datacite instance as a placeholder if PREFIX isn't set - prefix = settings.DANDI_DOI_API_PREFIX - instance_name: str = get_instance_config().instance_name - dandiset_id = version.dandiset.identifier - version_id = version.version - doi = f'{prefix}/{instance_name.lower()}.{dandiset_id}/{version_id}' - metadata = version.metadata - metadata['doi'] = doi - return (doi, to_datacite(metadata, publish=publish)) - - -def create_doi(version: Version) -> str: - doi, request_body = _generate_doi_data(version) - # If DOI isn't configured, skip the API call - if doi_configured(): - try: - requests.post( - settings.DANDI_DOI_API_URL, - json=request_body, - auth=requests.auth.HTTPBasicAuth( - settings.DANDI_DOI_API_USER, - settings.DANDI_DOI_API_PASSWORD, - ), - timeout=30, - ).raise_for_status() - except requests.exceptions.HTTPError as e: - logger.exception('Failed to create DOI %s', doi) - logger.exception(request_body) - if e.response: - logger.exception(e.response.text) - raise - return doi - - -def delete_doi(doi: str) -> None: - # If DOI isn't configured, skip the API call - if doi_configured(): - doi_url = settings.DANDI_DOI_API_URL.rstrip('/') + '/' + doi - with requests.Session() as s: - s.auth = (settings.DANDI_DOI_API_USER, settings.DANDI_DOI_API_PASSWORD) - try: - r = s.get(doi_url, headers={'Accept': 'application/vnd.api+json'}) - r.raise_for_status() - except requests.exceptions.HTTPError as e: - if e.response and e.response.status_code == requests.codes.not_found: - logger.warning('Tried to get data for nonexistent DOI %s', doi) - return - logger.exception('Failed to fetch data for DOI %s', doi) - raise - if r.json()['data']['attributes']['state'] == 'draft': - try: - s.delete(doi_url).raise_for_status() - except requests.exceptions.HTTPError: - logger.exception('Failed to delete DOI %s', doi) - raise - else: - logger.debug('Skipping DOI deletion for %s since not configured', doi) diff --git a/dandiapi/api/management/commands/check_doi_health.py b/dandiapi/api/management/commands/check_doi_health.py new file mode 100644 index 000000000..d470108ae --- /dev/null +++ b/dandiapi/api/management/commands/check_doi_health.py @@ -0,0 +1,88 @@ +""" +Management command to check DOI health — find stuck or failed DOI states. + +Run periodically (e.g., via cron or Celery beat) to detect DOIs that +are stuck in 'pending' or 'failed' state. + +Note: This command was AI-generated (Claude Code). +""" + +from __future__ import annotations + +import datetime + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from dandiapi.api.models import Version +from dandiapi.api.models.dandiset import Dandiset + + +class Command(BaseCommand): + help = 'Check for DOIs stuck in pending or failed state.' + + def add_arguments(self, parser): + parser.add_argument( + '--threshold-minutes', + type=int, + default=30, + help='Consider pending DOIs stuck after this many minutes (default: 30).', + ) + + def handle(self, *args, **options): + threshold = timezone.now() - datetime.timedelta(minutes=options['threshold_minutes']) + + # Find versions stuck in 'pending' longer than threshold + stuck_pending = Version.objects.filter( + doi_state='pending', + modified__lt=threshold, + ).select_related('dandiset') + + # Find versions in 'failed' state + failed = Version.objects.filter( + doi_state='failed', + ).select_related('dandiset') + + # Find dandisets without concept_doi + missing_concept = Dandiset.objects.filter( + concept_doi__isnull=True, + embargo_status=Dandiset.EmbargoStatus.OPEN, + ) + + self.stdout.write('\n--- DOI Health Check ---') + self.stdout.write(f'Threshold: {options["threshold_minutes"]} minutes\n') + + if stuck_pending.exists(): + self.stdout.write( + self.style.WARNING(f'STUCK PENDING: {stuck_pending.count()} versions') + ) + for v in stuck_pending[:20]: + self.stdout.write( + f' {v.dandiset.identifier}/{v.version} doi={v.doi} modified={v.modified}' + ) + else: + self.stdout.write(self.style.SUCCESS('No stuck pending DOIs')) + + if failed.exists(): + self.stdout.write(self.style.WARNING(f'FAILED: {failed.count()} versions')) + for v in failed[:20]: + self.stdout.write( + f' {v.dandiset.identifier}/{v.version} doi={v.doi} modified={v.modified}' + ) + else: + self.stdout.write(self.style.SUCCESS('No failed DOIs')) + + if missing_concept.exists(): + self.stdout.write( + self.style.WARNING(f'MISSING CONCEPT DOI: {missing_concept.count()} open dandisets') + ) + for d in missing_concept[:20]: + self.stdout.write(f' {d.identifier}') + else: + self.stdout.write(self.style.SUCCESS('All open dandisets have concept DOIs')) + + total_issues = stuck_pending.count() + failed.count() + missing_concept.count() + if total_issues > 0: + self.stdout.write(self.style.ERROR(f'\nTotal issues: {total_issues}')) + else: + self.stdout.write(self.style.SUCCESS('\nAll DOIs healthy')) diff --git a/dandiapi/api/management/commands/remediate_dois.py b/dandiapi/api/management/commands/remediate_dois.py new file mode 100644 index 000000000..25f16634a --- /dev/null +++ b/dandiapi/api/management/commands/remediate_dois.py @@ -0,0 +1,150 @@ +""" +Management command to remediate historical fake/null DOIs. + +Finds published versions with missing, null, or fake DOIs and +registers correct DOIs on DataCite. Also backfills concept DOIs +for dandisets that don't have them. + +Note: This command was AI-generated (Claude Code). +""" + +from __future__ import annotations + +import logging +import time + +from django.core.management.base import BaseCommand +import requests + +from dandiapi.api.models import Version +from dandiapi.api.models.dandiset import Dandiset +from dandiapi.api.services.doi import create_dandiset_doi, create_published_version_doi +from dandiapi.api.services.doi.exceptions import DataCiteAPIError +from dandiapi.api.services.doi.utils import doi_configured, format_doi +from dandiapi.api.tasks import write_manifest_files + +logger = logging.getLogger(__name__) + +FAKE_DOI_PATTERN = '.123456/0.123456.1234' + + +class Command(BaseCommand): + help = 'Remediate published versions with fake/null DOIs and backfill concept DOIs.' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Report what would be remediated without making changes.', + ) + parser.add_argument( + '--delay', + type=float, + default=2.0, + help='Seconds to wait between DataCite API calls (default: 2.0).', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + + if not doi_configured(): + self.stderr.write('DOI settings are not configured. Aborting.') + return + + if dry_run: + self.stdout.write('=== DRY RUN — no changes will be made ===\n') + + delay = options['delay'] + + # Phase 1: Fix published versions with fake or null DOIs + self._remediate_version_dois(dry_run=dry_run, delay=delay) + + # Phase 2: Backfill concept DOIs for dandisets + self._backfill_concept_dois(dry_run=dry_run, delay=delay) + + self.stdout.write('\nRemediation complete.') + + def _remediate_version_dois(self, *, dry_run: bool, delay: float = 2.0): + """Find and fix published versions with bad DOIs.""" + self.stdout.write('\n--- Remediating version DOIs ---') + + # Find versions with null DOI + null_doi_versions = Version.objects.filter( + doi__isnull=True, + ).exclude(version='draft') + + # Find versions with fake placeholder DOI + fake_doi_versions = Version.objects.filter( + doi__contains=FAKE_DOI_PATTERN, + ).exclude(version='draft') + + affected = list(null_doi_versions) + list(fake_doi_versions) + self.stdout.write(f'Found {len(affected)} versions with null or fake DOIs') + + for version in affected: + real_doi = format_doi(version.dandiset.identifier, version.version) + self.stdout.write( + f' {version.dandiset.identifier}/{version.version}: {version.doi!r} -> {real_doi}' + ) + + if not dry_run: + try: + version.metadata['doi'] = real_doi + version.doi = real_doi + version.doi_state = 'pending' + version.save() + + create_published_version_doi(version) + + version.doi_state = 'findable' + version.save(update_fields=['doi_state']) + + # Regenerate manifests + write_manifest_files.delay(version.id) + + self.stdout.write(' OK — DOI minted and manifests queued') + except (DataCiteAPIError, requests.exceptions.RequestException) as e: + version.doi_state = 'failed' + version.save(update_fields=['doi_state']) + self.stderr.write(f' FAILED — {e}') + + # Rate limit between DataCite API calls + time.sleep(delay) + + def _backfill_concept_dois(self, *, dry_run: bool, delay: float = 2.0): + """Backfill concept DOIs for dandisets that don't have them.""" + self.stdout.write('\n--- Backfilling concept DOIs ---') + + dandisets_without_concept_doi = Dandiset.objects.filter(concept_doi__isnull=True) + self.stdout.write( + f'Found {dandisets_without_concept_doi.count()} dandisets without concept DOI' + ) + + for dandiset in dandisets_without_concept_doi: + concept_doi = format_doi(dandiset.identifier) + has_published = dandiset.versions.exclude(version='draft').exists() + + self.stdout.write( + f' {dandiset.identifier}: concept_doi={concept_doi} ' + f'({"published" if has_published else "draft only"})' + ) + + if not dry_run: + try: + dandiset.concept_doi = concept_doi + dandiset.save(update_fields=['concept_doi']) + + # Set concept DOI on draft version too + draft = dandiset.versions.filter(version='draft').first() + if draft: + draft.doi = concept_doi + draft.save(update_fields=['doi']) + + # Register on DataCite + create_dandiset_doi(dandiset) + + self.stdout.write(' OK — Draft concept DOI registered') + except (DataCiteAPIError, requests.exceptions.RequestException) as e: + self.stderr.write(f' FAILED — {e}') + + time.sleep(delay) diff --git a/dandiapi/api/migrations/0033_add_doi_state_concept_doi_dataciteevent.py b/dandiapi/api/migrations/0033_add_doi_state_concept_doi_dataciteevent.py new file mode 100644 index 000000000..9ba7ce083 --- /dev/null +++ b/dandiapi/api/migrations/0033_add_doi_state_concept_doi_dataciteevent.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.7 on 2026-04-28 14:06 +from __future__ import annotations + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('api', '0032_remove_upload_embargoed_upload_zarr_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='dandiset', + name='concept_doi', + field=models.CharField(blank=True, default=None, max_length=64, null=True), + ), + migrations.AddField( + model_name='version', + name='doi_state', + field=models.CharField( + blank=True, + choices=[ + ('draft', 'Draft'), + ('findable', 'Findable'), + ('pending', 'Pending'), + ('failed', 'Failed'), + ], + default=None, + max_length=20, + null=True, + ), + ), + ] diff --git a/dandiapi/api/migrations/0034_add_registered_doi_state.py b/dandiapi/api/migrations/0034_add_registered_doi_state.py new file mode 100644 index 000000000..fb72000fb --- /dev/null +++ b/dandiapi/api/migrations/0034_add_registered_doi_state.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.7 on 2026-04-28 15:31 +from __future__ import annotations + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('api', '0033_add_doi_state_concept_doi_dataciteevent'), + ] + + operations = [ + migrations.AlterField( + model_name='version', + name='doi_state', + field=models.CharField( + blank=True, + choices=[ + ('draft', 'Draft'), + ('registered', 'Registered'), + ('findable', 'Findable'), + ('pending', 'Pending'), + ('failed', 'Failed'), + ], + default=None, + max_length=20, + null=True, + ), + ), + ] diff --git a/dandiapi/api/models/dandiset.py b/dandiapi/api/models/dandiset.py index 2b848a494..5652c35e0 100644 --- a/dandiapi/api/models/dandiset.py +++ b/dandiapi/api/models/dandiset.py @@ -24,6 +24,9 @@ class Dandiset(TimeStampedModel): default=EmbargoStatus.OPEN, ) embargo_end_date = models.DateField(null=True, blank=True, default=None) + # NULL distinguishes "concept DOI not yet derived/scheduled" from a real DOI string; + # empty string would be ambiguous and complicates the DOI lifecycle queries. + concept_doi = models.CharField(max_length=64, null=True, default=None, blank=True) # noqa: DJ001 starred_users = models.ManyToManyField( to=User, through='DandisetStar', related_name='starred_dandisets' ) diff --git a/dandiapi/api/models/version.py b/dandiapi/api/models/version.py index 444cec727..707204c7e 100644 --- a/dandiapi/api/models/version.py +++ b/dandiapi/api/models/version.py @@ -46,6 +46,17 @@ class Status(models.TextChoices): validators=[RegexValidator(f'^{VERSION_REGEX}$')], ) doi = models.CharField(max_length=64, null=True, default=None, blank=True) # noqa: DJ001 + + class DoiState(models.TextChoices): + DRAFT = 'draft' + REGISTERED = 'registered' # Hidden/retracted — metadata not public + FINDABLE = 'findable' + PENDING = 'pending' + FAILED = 'failed' + + doi_state = models.CharField( # noqa: DJ001 + max_length=20, null=True, default=None, blank=True, choices=DoiState.choices + ) """Track the validation status of this version, without considering assets""" status = models.CharField( max_length=10, @@ -259,7 +270,11 @@ def _populate_metadata(self): 'numberOfFiles': 0, } - if self.doi: + # Drafts cite the dandiset URL; only published versions expose the DOI. + # The concept DOI lives on Dandiset.concept_doi and on Version.doi (DB), + # but a draft's *published-shape* metadata keeps the URL citation that + # downstream consumers (dandi-cli snapshot tests, UI) expect. + if self.doi and self.version != 'draft': metadata['doi'] = self.doi metadata['citation'] = self.citation(metadata) diff --git a/dandiapi/api/services/dandiset/__init__.py b/dandiapi/api/services/dandiset/__init__.py index 746424c49..c985af4d3 100644 --- a/dandiapi/api/services/dandiset/__init__.py +++ b/dandiapi/api/services/dandiset/__init__.py @@ -15,6 +15,7 @@ from dandiapi.api.models.version import Version from dandiapi.api.services import audit from dandiapi.api.services.dandiset.exceptions import DandisetAlreadyExistsError +from dandiapi.api.services.doi.utils import doi_configured, format_doi from dandiapi.api.services.embargo.exceptions import DandisetUnembargoInProgressError from dandiapi.api.services.exceptions import ( AdminOnlyOperationError, @@ -23,6 +24,7 @@ ) from dandiapi.api.services.permissions.dandiset import add_dandiset_owner, is_dandiset_owner from dandiapi.api.services.version.metadata import _normalize_version_metadata +from dandiapi.api.tasks import create_dandiset_doi_task, delete_dandiset_doi_task def _create_dandiset( @@ -77,6 +79,20 @@ def create_open_dandiset( version_metadata=version_metadata, ) + # Always compute and set the concept DOI string (deterministic). + # DataCite registration is gated by doi_configured(). + concept_doi = format_doi(dandiset_id=dandiset.identifier) + dandiset.concept_doi = concept_doi + dandiset.save(update_fields=['concept_doi']) + + draft_version.doi = concept_doi + if doi_configured(): + draft_version.doi_state = 'pending' + draft_version.save(update_fields=['doi', 'doi_state']) + + if doi_configured(): + transaction.on_commit(lambda: create_dandiset_doi_task.delay(dandiset.id)) + audit.create_dandiset(dandiset=dandiset, user=user, metadata=draft_version.metadata) return dandiset, draft_version @@ -154,6 +170,13 @@ def delete_dandiset(*, user, dandiset: Dandiset) -> None: # chance to grab the Dandiset information before it is destroyed. audit.delete_dandiset(dandiset=dandiset, user=user) + # Clean up concept DOI on DataCite. + # At this point, dandiset has no published versions (checked above), + # so the concept DOI should be Draft and deletable. + concept_doi = dandiset.concept_doi + if concept_doi: + transaction.on_commit(lambda: delete_dandiset_doi_task.delay(concept_doi)) + dandiset.versions.all().delete() dandiset.delete() diff --git a/dandiapi/api/services/doi/__init__.py b/dandiapi/api/services/doi/__init__.py new file mode 100644 index 000000000..c77fda78d --- /dev/null +++ b/dandiapi/api/services/doi/__init__.py @@ -0,0 +1,192 @@ +""" +DataCite service functions. + +This module provides service functions for interacting with the DataCite API. +All HTTP calls use datacite_session() as a context manager with timeouts and retry. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from dandiapi.api.services.doi.exceptions import ( + DOIOperationNotPermittedError, + VersionDOIMissingError, +) + +from .utils import ( + DATACITE_TIMEOUT, + datacite_session, + doi_configured, + generate_doi_data, + get_doi_url, + raise_datacite_exception, +) + +if TYPE_CHECKING: + from dandiapi.api.models import Dandiset, Version + +logger = logging.getLogger(__name__) + + +def _create_version_doi(version: Version) -> None: + """Create or update a Findable DOI for a published dandiset version. + + Uses PUT (upsert) for idempotency — safe to retry even if the DOI + was already created by a previous attempt that failed after the API call. + """ + if not doi_configured(): + logger.debug('Skipping version DOI creation — DOI not configured') + return + + version_doi, datacite_payload = generate_doi_data( + version.dandiset, version=version, publish=True + ) + + with datacite_session() as session: + # Use PUT for idempotency — creates if new, updates if exists + response = session.put( + get_doi_url(version_doi), json=datacite_payload, timeout=DATACITE_TIMEOUT + ) + if not response.ok: + raise_datacite_exception( + desc=f'Failed to create/update findable DOI {version_doi}', + response=response, + payload=datacite_payload, + ) + + logger.info('Created/updated findable DOI %s', version_doi) + + +def create_dandiset_doi(dandiset: Dandiset) -> None: + """ + Create a Draft DOI for a dandiset (concept DOI). + + Called during dandiset creation for public dandisets. + For embargoed dandisets, no DOI is created until unembargo. + + Uses PUT (upsert) for idempotency — safe to retry even if DataCite + accepted a previous attempt that failed before the DB write landed. + """ + if not doi_configured(): + logger.debug('Skipping concept DOI creation — DOI not configured') + return + + dandiset_doi, datacite_payload = generate_doi_data(dandiset, version=None, publish=False) + + with datacite_session() as session: + response = session.put( + get_doi_url(dandiset_doi), json=datacite_payload, timeout=DATACITE_TIMEOUT + ) + if not response.ok: + raise_datacite_exception( + desc=f'Failed to create DOI {dandiset_doi}', + response=response, + payload=datacite_payload, + ) + + logger.info('Created Draft concept DOI %s', dandiset_doi) + + +def update_dandiset_doi(dandiset: Dandiset, *, publish: bool = False) -> None: + """ + Update a concept DOI for a dandiset with the latest metadata. + + When publish=True, promotes the concept DOI from Draft to Findable. + """ + if not doi_configured(): + logger.debug('Skipping concept DOI update — DOI not configured') + return + + # Don't continue for dandisets with published versions, unless this is a publish event + if not publish and dandiset.most_recent_published_version is not None: + raise DOIOperationNotPermittedError( + message='DOI update for dandiset with published versions not allowed' + ) + + # Don't continue for embargoed dandisets + if dandiset.embargoed: + raise DOIOperationNotPermittedError( + message='Cannot perform DOI operations on embargoed dandisets' + ) + + # TODO: DOI: Remove once DOI is required in all versions + if dandiset.draft_version.doi is None: + raise VersionDOIMissingError + + dandiset_doi, datacite_payload = generate_doi_data(dandiset, version=None, publish=publish) + + with datacite_session() as session: + response = session.put( + get_doi_url(dandiset_doi), json=datacite_payload, timeout=DATACITE_TIMEOUT + ) + if not response.ok: + raise_datacite_exception( + desc=f'Failed to update DOI {dandiset_doi}', + response=response, + payload=datacite_payload, + ) + + logger.info('Updated concept DOI %s (publish=%s)', dandiset_doi, publish) + + +def delete_dandiset_doi(doi: str) -> None: + """ + Delete the Draft concept DOI of a dandiset. + + Only Draft DOIs can be deleted. Findable DOIs must be hidden instead. + Gracefully handles 404 (DOI never registered or already deleted). + """ + if not doi_configured(): + logger.debug('Skipping DOI deletion for %s since not configured', doi) + return + + with datacite_session() as session: + response = session.delete(get_doi_url(doi), timeout=DATACITE_TIMEOUT) + + if response.status_code == 404: + logger.warning('DOI %s not found on DataCite (may never have been registered)', doi) + return + + if not response.ok: + raise_datacite_exception( + desc=f'Failed to delete draft DOI {doi}', response=response, payload={} + ) + + logger.info('Deleted draft concept DOI: %s', doi) + + +def create_published_version_doi(version: Version) -> None: + """ + Create a Findable DOI for a published version. + + Also promotes/updates the concept DOI to Findable with HasVersion link. + """ + _create_version_doi(version) + update_dandiset_doi(version.dandiset, publish=True) + + +def hide_published_version_doi(version: Version) -> None: + """Hide (retract) a Findable version DOI by transitioning to Registered state.""" + if not doi_configured(): + logger.debug('Skipping DOI hide — DOI not configured') + return + + if version.version == 'draft': + raise DOIOperationNotPermittedError(message='Cannot hide a draft dandiset DOI') + + doi = version.doi + if doi is None: + raise VersionDOIMissingError + + payload = {'data': {'id': doi, 'type': 'dois', 'attributes': {'event': 'hide'}}} + + with datacite_session() as session: + response = session.put(get_doi_url(doi), json=payload, timeout=DATACITE_TIMEOUT) + if not response.ok: + raise_datacite_exception( + desc=f'Failed to hide findable DOI {doi}', response=response, payload=payload + ) + + logger.info('Hid findable DOI: %s', doi) diff --git a/dandiapi/api/services/doi/exceptions.py b/dandiapi/api/services/doi/exceptions.py new file mode 100644 index 000000000..f77986345 --- /dev/null +++ b/dandiapi/api/services/doi/exceptions.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from rest_framework import status + +from dandiapi.api.services.exceptions import DandiError + + +class DataCiteNotConfiguredError(DandiError): + message = 'DataCite API is not configured' + http_status_code = status.HTTP_503_SERVICE_UNAVAILABLE + + +class DataCiteAPIError(DandiError): + message = 'DataCite API request failed' + http_status_code = status.HTTP_502_BAD_GATEWAY + + +class DataCitePublishNotEnabledError(DandiError): + message = 'DataCite publish operations are not enabled' + http_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + +class VersionDOIMissingError(DandiError): + message = 'DOI dependency operations called on a Version without a DOI' + http_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + +class DOIOperationNotPermittedError(DandiError): + message = 'This DOI operation is not permitted for the specified Dandiset and/or Version.' + http_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR diff --git a/dandiapi/api/services/doi/utils.py b/dandiapi/api/services/doi/utils.py new file mode 100644 index 000000000..70a453f7a --- /dev/null +++ b/dandiapi/api/services/doi/utils.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from contextlib import contextmanager +import copy +import logging +from typing import TYPE_CHECKING + +from dandischema.conf import get_instance_config +from dandischema.datacite import to_datacite +from django.conf import settings +import requests +from requests.adapters import HTTPAdapter, Retry +from requests.auth import HTTPBasicAuth + +from .exceptions import ( + DataCiteAPIError, + DataCiteNotConfiguredError, + DataCitePublishNotEnabledError, + DOIOperationNotPermittedError, +) + +if TYPE_CHECKING: + from collections.abc import Generator + + from dandiapi.api.models import Version + from dandiapi.api.models.dandiset import Dandiset + +logger = logging.getLogger(__name__) + +# Names of the required DOI configuration settings +_DANDI_DOI_SETTING_NAMES = ( + 'DANDI_DOI_API_URL', + 'DANDI_DOI_API_USER', + 'DANDI_DOI_API_PASSWORD', + 'DANDI_DOI_API_PREFIX', +) + +# Default timeout for DataCite API calls: (connect, read) in seconds +DATACITE_TIMEOUT = (5, 30) + + +def doi_configured() -> bool: + """Check if the DOI client is properly configured. + + Reads settings at call time (not import time) so test overrides work. + """ + return all(getattr(settings, name, None) is not None for name in _DANDI_DOI_SETTING_NAMES) + + +def format_doi(dandiset_id: str, version_str: str | None = None) -> str: + """Format a DOI string for a dandiset or version, using the instance name from config.""" + instance_name = get_instance_config().instance_name.lower() + doi = f'{settings.DANDI_DOI_API_PREFIX}/{instance_name}.{dandiset_id}' + if version_str: + doi += f'/{version_str}' + + return doi + + +def _validate_datacite_configuration(datacite_payload: dict) -> None: + """Validate that DataCite API is properly configured.""" + if not doi_configured(): + raise DataCiteNotConfiguredError + + event = datacite_payload['data']['attributes'].get('event') + if not settings.DANDI_DOI_PUBLISH and event in ['publish', 'hide']: + raise DataCitePublishNotEnabledError + + +def get_doi_url(doi: str): + """Return the URL for updating an existing DOI.""" + return f'{settings.DANDI_DOI_API_URL}/{doi}' + + +def generate_doi_data( + dandiset: Dandiset, *, version: Version | None, publish: bool +) -> tuple[str, dict]: + """Generate DOI data for a version or dandiset.""" + if version and not publish: + raise DOIOperationNotPermittedError(message='Cannot generate non-publish version DOI') + + ver = version or dandiset.draft_version + metadata = copy.deepcopy(ver.metadata) + + # Generate the appropriate DOI string + if version: + doi = format_doi(dandiset.identifier, version.version) + else: + # Dandiset DOI is the same as version url without version + doi = format_doi(dandiset.identifier) + metadata['url'] = metadata['url'].rsplit('/', 1)[0] + metadata['version'] = dandiset.draft_version.metadata['id'] + + metadata['doi'] = doi + + # Pass concept_doi for IsVersionOf relation when generating version DOI payloads + concept_doi = dandiset.concept_doi if version else None + datacite_payload = to_datacite(metadata, publish=publish, concept_doi=concept_doi) + + _validate_datacite_configuration(datacite_payload) + + return (doi, datacite_payload) + + +def raise_datacite_exception(desc: str, response: requests.Response, payload: dict): + error_details = desc + if response and hasattr(response, 'text'): + error_details += f'\nResponse: {response.text}' + error_details += f'\nPayload: {payload}' + logger.error(error_details) + raise DataCiteAPIError(error_details) + + +@contextmanager +def datacite_session() -> Generator[requests.Session]: + """Pre-configured session for all DataCite requests. Use as context manager.""" + session = requests.Session() + session.auth = HTTPBasicAuth(settings.DANDI_DOI_API_USER, settings.DANDI_DOI_API_PASSWORD) + session.headers.update( + { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', + } + ) + + retries = Retry( + total=3, + backoff_factor=1.0, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=['GET', 'PUT', 'POST', 'DELETE'], + ) + session.mount('http://', HTTPAdapter(max_retries=retries)) + session.mount('https://', HTTPAdapter(max_retries=retries)) + + try: + yield session + finally: + session.close() diff --git a/dandiapi/api/services/embargo/__init__.py b/dandiapi/api/services/embargo/__init__.py index e678ba4b8..183ae840e 100644 --- a/dandiapi/api/services/embargo/__init__.py +++ b/dandiapi/api/services/embargo/__init__.py @@ -11,11 +11,12 @@ from dandiapi.api.models.asset import Asset from dandiapi.api.services import audit from dandiapi.api.services.asset.exceptions import DandisetOwnerRequiredError +from dandiapi.api.services.doi.utils import doi_configured, format_doi from dandiapi.api.services.embargo.utils import remove_dandiset_embargo_tags from dandiapi.api.services.exceptions import DandiError from dandiapi.api.services.metadata import validate_version_metadata from dandiapi.api.services.permissions.dandiset import is_dandiset_owner -from dandiapi.api.tasks import unembargo_dandiset_task +from dandiapi.api.tasks import create_dandiset_doi_task, unembargo_dandiset_task from .exceptions import ( AssetBlobEmbargoedError, @@ -72,6 +73,16 @@ def unembargo_dandiset(ds: Dandiset, user: User): v.save() logger.info('Version metadata updated') + # Always set concept DOI string (deterministic). DataCite registration gated. + concept_doi = format_doi(dandiset_id=ds.identifier) + Dandiset.objects.filter(pk=ds.pk).update(concept_doi=concept_doi) + doi_state = 'pending' if doi_configured() else None + Version.objects.filter(pk=v.pk).update(doi=concept_doi, doi_state=doi_state) + + if doi_configured(): + transaction.on_commit(lambda: create_dandiset_doi_task.delay(ds.id)) + logger.info('Concept DOI %s scheduled for unembargoed dandiset', concept_doi) + # Pre-emptively validate version metadata, so that old validation # errors don't show up once un-embargo is finished validate_version_metadata(version=v) diff --git a/dandiapi/api/services/publish/__init__.py b/dandiapi/api/services/publish/__init__.py index 1e5e5307d..8c0caeac6 100644 --- a/dandiapi/api/services/publish/__init__.py +++ b/dandiapi/api/services/publish/__init__.py @@ -4,16 +4,15 @@ import datetime from typing import TYPE_CHECKING -from dandischema.conf import get_instance_config from dandischema.metadata import aggregate_assets_summary, validate from django.contrib.auth.models import User from django.db import transaction from more_itertools import ichunked -from dandiapi.api import doi from dandiapi.api.asset_paths import add_version_asset_paths from dandiapi.api.models import Asset, Dandiset, Version from dandiapi.api.services import audit +from dandiapi.api.services.doi.utils import doi_configured, format_doi from dandiapi.api.services.exceptions import NotAllowedError from dandiapi.api.services.permissions.dandiset import is_dandiset_owner from dandiapi.api.services.publish.exceptions import ( @@ -24,7 +23,7 @@ DandisetNotLockedError, DandisetValidationPendingError, ) -from dandiapi.api.tasks import write_manifest_files +from dandiapi.api.tasks import create_published_version_doi_task, write_manifest_files if TYPE_CHECKING: from django.db.models import QuerySet @@ -182,24 +181,22 @@ def _publish_dandiset(dandiset_id: int, user_id: int) -> None: old_version.status = Version.Status.PUBLISHED old_version.save() - # Inject a dummy DOI so the metadata is valid - schema_config = get_instance_config() - new_version.metadata['doi'] = ( - f'{schema_config.doi_prefix}/{schema_config.instance_name.lower()}.123456/0.123456.1234' - ) + # Always compute and set the real version DOI (deterministic, needed for validation). + # The DOI string is always present; DataCite registration is gated by doi_configured(). + real_doi = format_doi(new_version.dandiset.identifier, new_version.version) + new_version.metadata['doi'] = real_doi + new_version.doi = real_doi + if doi_configured(): + new_version.doi_state = 'pending' + new_version.save() validate(new_version.metadata, schema_key='PublishedDandiset', json_validation=True) - # Write updated manifest files and create DOI after - # published version has been committed to DB. + # Write manifest files after DB commit transaction.on_commit(lambda: write_manifest_files.delay(new_version.id)) - - def _create_doi(version_id: int): - version = Version.objects.get(id=version_id) - version.doi = doi.create_doi(version) - version.save() - - transaction.on_commit(lambda: _create_doi(new_version.id)) + # Register DOI on DataCite after DB commit (only if configured) + if doi_configured(): + transaction.on_commit(lambda: create_published_version_doi_task.delay(new_version.id)) user = User.objects.get(id=user_id) audit.publish_dandiset( diff --git a/dandiapi/api/tasks/__init__.py b/dandiapi/api/tasks/__init__.py index 300e8569e..3420eaf76 100644 --- a/dandiapi/api/tasks/__init__.py +++ b/dandiapi/api/tasks/__init__.py @@ -6,8 +6,8 @@ from celery.exceptions import SoftTimeLimitExceeded from celery.utils.log import get_task_logger from django.contrib.auth.models import User +import requests -from dandiapi.api.doi import delete_doi from dandiapi.api.mail import send_dandiset_unembargo_failed_message from dandiapi.api.manifests import ( write_assets_jsonld, @@ -18,6 +18,19 @@ ) from dandiapi.api.models import Asset, AssetBlob, Version from dandiapi.api.models.dandiset import Dandiset +from dandiapi.api.services.doi import ( + create_dandiset_doi, + create_published_version_doi, + delete_dandiset_doi, + update_dandiset_doi, +) +from dandiapi.api.services.doi.utils import ( + DATACITE_TIMEOUT, + datacite_session, + doi_configured, + get_doi_url, + raise_datacite_exception, +) if TYPE_CHECKING: from uuid import UUID @@ -81,9 +94,128 @@ def validate_version_metadata_task(version_id: int) -> None: validate_version_metadata(version=version) -@shared_task -def delete_doi_task(doi: str) -> None: - delete_doi(doi) +@shared_task( + bind=True, + queue='doi', + soft_time_limit=60, + autoretry_for=(requests.exceptions.RequestException,), + retry_backoff=True, + retry_backoff_max=300, + max_retries=5, +) +def create_dandiset_doi_task(self, dandiset_id: int) -> None: + """Register a Draft concept DOI on DataCite for a dandiset.""" + dandiset = Dandiset.objects.get(id=dandiset_id) + try: + create_dandiset_doi(dandiset) + Version.objects.filter(dandiset=dandiset, version='draft', doi_state='pending').update( + doi_state='draft' + ) + except requests.exceptions.RequestException: + # Transient network error — mark failed only on final retry (Celery will autoretry) + if self.request.retries >= self.max_retries: + Version.objects.filter(dandiset=dandiset, version='draft', doi_state='pending').update( + doi_state='failed' + ) + raise + except Exception: + # Non-retryable error (e.g., DataCiteAPIError from 4xx) — mark failed immediately + Version.objects.filter(dandiset=dandiset, version='draft', doi_state='pending').update( + doi_state='failed' + ) + raise + + +@shared_task( + bind=True, + queue='doi', + soft_time_limit=60, + autoretry_for=(requests.exceptions.RequestException,), + retry_backoff=True, + retry_backoff_max=300, + max_retries=5, +) +def create_published_version_doi_task(self, version_id: int) -> None: + """Create a Findable version DOI and update the concept DOI on DataCite.""" + version = Version.objects.get(id=version_id) + try: + create_published_version_doi(version) + Version.objects.filter(id=version_id).update(doi_state='findable') + except requests.exceptions.RequestException: + # Transient — mark failed only on final retry + if self.request.retries >= self.max_retries: + Version.objects.filter(id=version_id, doi_state='pending').update(doi_state='failed') + raise + except Exception: + # Non-retryable (4xx, validation error) — mark failed immediately + Version.objects.filter(id=version_id, doi_state='pending').update(doi_state='failed') + raise + + +@shared_task( + queue='doi', + soft_time_limit=60, + autoretry_for=(requests.exceptions.RequestException,), + retry_backoff=True, + retry_backoff_max=300, + max_retries=5, +) +def update_dandiset_doi_task(dandiset_id: int) -> None: + """Update the Draft concept DOI metadata on DataCite.""" + dandiset = Dandiset.objects.get(id=dandiset_id) + try: + update_dandiset_doi(dandiset) + logger.info('Updated concept DOI metadata for dandiset %s', dandiset.identifier) + except Exception: + logger.exception( + 'Failed to update concept DOI metadata for dandiset %s', dandiset.identifier + ) + raise + + +@shared_task( + queue='doi', + soft_time_limit=60, + autoretry_for=(requests.exceptions.RequestException,), + retry_backoff=True, + max_retries=3, +) +def hide_published_version_doi_task(doi: str) -> None: + """Hide (retract) a Findable DOI by transitioning to Registered on DataCite. + + Calls DataCite directly (bypassing the service layer) since the Version + row has typically been deleted by the time this task runs. + """ + if not doi_configured(): + logger.debug('Skipping DOI hide for %s — DOI not configured', doi) + return + + payload = {'data': {'id': doi, 'type': 'dois', 'attributes': {'event': 'hide'}}} + with datacite_session() as session: + response = session.put(get_doi_url(doi), json=payload, timeout=DATACITE_TIMEOUT) + + if response.status_code == 404: + logger.warning('DOI %s not found on DataCite during hide', doi) + return + + if not response.ok: + raise_datacite_exception( + desc=f'Failed to hide DOI {doi}', response=response, payload=payload + ) + + logger.info('Hid DOI %s (Findable → Registered)', doi) + + +@shared_task( + queue='doi', + soft_time_limit=60, + autoretry_for=(requests.exceptions.RequestException,), + retry_backoff=True, + max_retries=3, +) +def delete_dandiset_doi_task(doi: str) -> None: + """Delete a Draft concept DOI from DataCite.""" + delete_dandiset_doi(doi) @shared_task diff --git a/dandiapi/api/tests/test_doi_http.py b/dandiapi/api/tests/test_doi_http.py new file mode 100644 index 000000000..29a7594bf --- /dev/null +++ b/dandiapi/api/tests/test_doi_http.py @@ -0,0 +1,228 @@ +"""HTTP-level DataCite client tests using `responses` to mock requests. + +These tests exercise the actual `datacite_session()` retry adapter, the +`generate_doi_data()` payload builder, and the service-layer error handling +— paths that `test_doi_lifecycle.py` mocks at a higher level. + +Note: AI-generated (Claude Code). +""" + +from __future__ import annotations + +import re + +from django.test import override_settings +import pytest +import responses + +from dandiapi.api.services.doi import ( + create_dandiset_doi, + delete_dandiset_doi, +) +from dandiapi.api.services.doi.exceptions import DataCiteAPIError +from dandiapi.api.services.doi.utils import format_doi, get_doi_url +from dandiapi.api.tests.factories import DraftVersionFactory + +DOI_API_URL = 'https://api.test.datacite.org/dois' +DOI_SETTINGS = { + 'DANDI_DOI_API_URL': DOI_API_URL, + 'DANDI_DOI_API_USER': 'test-user', + 'DANDI_DOI_API_PASSWORD': 'test-password', + 'DANDI_DOI_API_PREFIX': '10.80507', + 'DANDI_DOI_PUBLISH': False, +} + + +@pytest.fixture +def configured_dandiset(db, mocker): + """Create a draft version with metadata mocked at the payload-builder layer. + + The factory's metadata isn't a complete PublishedDandiset, so we stub + `generate_doi_data` to return a deterministic (doi, payload) pair. + Tests focus on HTTP behavior, not payload generation. + """ + version = DraftVersionFactory.create() + dandiset = version.dandiset + expected_doi = format_doi(dandiset.identifier) + payload = {'data': {'id': expected_doi, 'type': 'dois', 'attributes': {}}} + mocker.patch( + 'dandiapi.api.services.doi.generate_doi_data', + return_value=(expected_doi, payload), + ) + return dandiset + + +@pytest.mark.django_db +@override_settings(**DOI_SETTINGS) +@responses.activate +def test_create_dandiset_doi_success_uses_put(configured_dandiset): + """Concept DOI create issues a PUT (not POST) to the DOI's URL — idempotent retry.""" + expected_doi = format_doi(configured_dandiset.identifier) + responses.put( + get_doi_url(expected_doi), + json={'data': {'id': expected_doi, 'attributes': {'state': 'draft'}}}, + status=201, + ) + create_dandiset_doi(configured_dandiset) + + assert len(responses.calls) == 1 + assert responses.calls[0].request.method == 'PUT' + assert expected_doi in responses.calls[0].request.url + + +@pytest.mark.django_db +@override_settings(**DOI_SETTINGS) +@responses.activate +def test_create_dandiset_doi_retries_on_503(configured_dandiset): + """urllib3 Retry adapter retries 5xx — burns through transient failures.""" + expected_doi = format_doi(configured_dandiset.identifier) + url = get_doi_url(expected_doi) + responses.put(url, status=503) + responses.put(url, status=503) + responses.put( + url, + json={'data': {'id': expected_doi, 'attributes': {'state': 'draft'}}}, + status=201, + ) + create_dandiset_doi(configured_dandiset) + + assert len(responses.calls) == 3 + + +@pytest.mark.django_db +@override_settings(**DOI_SETTINGS) +@responses.activate +def test_create_dandiset_doi_422_raises(configured_dandiset): + """4xx (non-429) raises DataCiteAPIError without retry — task layer marks failed.""" + expected_doi = format_doi(configured_dandiset.identifier) + responses.put( + get_doi_url(expected_doi), + json={'errors': [{'title': 'Validation failed'}]}, + status=422, + ) + with pytest.raises(DataCiteAPIError): + create_dandiset_doi(configured_dandiset) + assert len(responses.calls) == 1 + + +@pytest.mark.django_db +@override_settings(**DOI_SETTINGS) +@responses.activate +def test_create_dandiset_doi_429_then_success(configured_dandiset): + """429 with Retry-After is honored by urllib3 status_forcelist; eventual success.""" + expected_doi = format_doi(configured_dandiset.identifier) + url = get_doi_url(expected_doi) + responses.put(url, status=429, headers={'Retry-After': '1'}) + responses.put( + url, + json={'data': {'id': expected_doi, 'attributes': {'state': 'draft'}}}, + status=201, + ) + create_dandiset_doi(configured_dandiset) + assert len(responses.calls) == 2 + + +@pytest.mark.django_db +@override_settings(**DOI_SETTINGS) +@responses.activate +def test_delete_dandiset_doi_404_silenced(): + """404 on delete is logged and silenced (DOI never registered).""" + doi = '10.80507/dandi.000123' + responses.delete(get_doi_url(doi), status=404) + delete_dandiset_doi(doi) + assert len(responses.calls) == 1 + + +@pytest.mark.django_db +@override_settings(**DOI_SETTINGS) +@responses.activate +def test_hide_published_version_doi_task_404_silenced(): + """hide_published_version_doi_task swallows 404 (already deleted from DataCite).""" + from dandiapi.api.tasks import hide_published_version_doi_task + + doi = '10.80507/dandi.000123/0.250101.0001' + responses.put(get_doi_url(doi), status=404) + hide_published_version_doi_task(doi) + assert len(responses.calls) == 1 + + +@pytest.mark.django_db +@override_settings(**DOI_SETTINGS) +@responses.activate +def test_hide_published_version_doi_task_other_error_raises(): + """Non-404 error during hide propagates so Celery can retry.""" + from dandiapi.api.tasks import hide_published_version_doi_task + + doi = '10.80507/dandi.000123/0.250101.0001' + responses.put(get_doi_url(doi), status=410, json={'errors': [{'title': 'gone'}]}) + with pytest.raises(DataCiteAPIError): + hide_published_version_doi_task(doi) + + +@pytest.mark.django_db +@override_settings(**DOI_SETTINGS) +@responses.activate +def test_create_dandiset_doi_task_state_transition_success(configured_dandiset): + """End-to-end: concept-DOI create task drives doi_state pending → draft.""" + from dandiapi.api.models import Version + from dandiapi.api.tasks import create_dandiset_doi_task + + expected_doi = format_doi(configured_dandiset.identifier) + Version.objects.filter(dandiset=configured_dandiset, version='draft').update( + doi=expected_doi, doi_state='pending' + ) + configured_dandiset.concept_doi = expected_doi + configured_dandiset.save() + + responses.put( + get_doi_url(expected_doi), + json={'data': {'id': expected_doi, 'attributes': {'state': 'draft'}}}, + status=201, + ) + + create_dandiset_doi_task.apply(args=[configured_dandiset.id], throw=True) + + version = Version.objects.get(dandiset=configured_dandiset, version='draft') + assert version.doi_state == 'draft' + + +@pytest.mark.django_db +@override_settings(**DOI_SETTINGS) +@responses.activate +def test_create_dandiset_doi_task_state_transition_4xx_failure(configured_dandiset): + """End-to-end: 4xx surfaces as DataCiteAPIError, task marks doi_state failed.""" + from dandiapi.api.models import Version + from dandiapi.api.tasks import create_dandiset_doi_task + + expected_doi = format_doi(configured_dandiset.identifier) + Version.objects.filter(dandiset=configured_dandiset, version='draft').update( + doi=expected_doi, doi_state='pending' + ) + configured_dandiset.concept_doi = expected_doi + configured_dandiset.save() + + responses.put( + get_doi_url(expected_doi), + json={'errors': [{'title': 'Validation failed'}]}, + status=422, + ) + + with pytest.raises(DataCiteAPIError): + create_dandiset_doi_task.apply(args=[configured_dandiset.id], throw=True) + + version = Version.objects.get(dandiset=configured_dandiset, version='draft') + assert version.doi_state == 'failed' + + +@pytest.mark.django_db +@override_settings(**DOI_SETTINGS) +@responses.activate +def test_datacite_session_includes_auth_and_headers(): + """datacite_session() applies HTTPBasicAuth and JSON-API headers per request.""" + doi = '10.80507/dandi.000123' + responses.delete(get_doi_url(doi), status=404) + delete_dandiset_doi(doi) + req = responses.calls[0].request + assert req.headers['Accept'] == 'application/vnd.api+json' + assert req.headers['Content-Type'] == 'application/vnd.api+json' + assert re.match(r'^Basic ', req.headers['Authorization']) diff --git a/dandiapi/api/tests/test_doi_lifecycle.py b/dandiapi/api/tests/test_doi_lifecycle.py new file mode 100644 index 000000000..47f6c7b92 --- /dev/null +++ b/dandiapi/api/tests/test_doi_lifecycle.py @@ -0,0 +1,317 @@ +"""Tests for the DOI lifecycle. + +Covers concept DOI at creation, version DOI at publish, state transitions, +metadata update sync, hide on delete, unembargo, and remediation. + +Note: These tests were AI-generated (Claude Code) using parametrized TDD. +All DataCite API calls are mocked via monkeypatch/mocker — no real HTTP. +""" + +from __future__ import annotations + +import datetime +from io import StringIO +from unittest.mock import patch + +from dandischema.consts import DANDI_SCHEMA_VERSION +import pytest + +from dandiapi.api.models import Version +from dandiapi.api.models.dandiset import Dandiset +from dandiapi.api.services.doi.exceptions import DataCiteAPIError +from dandiapi.api.services.doi.utils import format_doi +from dandiapi.api.tests.factories import DraftVersionFactory, UserFactory + +# ============================================================================= +# Test 1: Concept DOI on dandiset creation +# ============================================================================= + + +@pytest.mark.django_db +@pytest.mark.parametrize('doi_is_configured', [True, False]) +def test_create_open_dandiset_concept_doi(doi_is_configured): + """Verify concept DOI is set on creation, and task is scheduled only when configured.""" + user = UserFactory.create() + from dandiapi.api.services.dandiset import create_open_dandiset + + with ( + patch('dandiapi.api.services.dandiset.doi_configured', return_value=doi_is_configured), + patch('dandiapi.api.services.dandiset.create_dandiset_doi_task'), + ): + dandiset, draft_version = create_open_dandiset( + user=user, + version_name='Test Dandiset', + version_metadata={}, + ) + + # concept_doi string is always set (deterministic) + assert dandiset.concept_doi is not None + assert dandiset.concept_doi == format_doi(dandiset.identifier) + assert draft_version.doi == dandiset.concept_doi + + if doi_is_configured: + assert draft_version.doi_state == 'pending' + else: + assert draft_version.doi_state is None + + +# ============================================================================= +# Test 2: DOI state transitions in create_dandiset_doi_task +# ============================================================================= + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ('side_effect', 'expected_state'), + [ + (None, 'draft'), # Success + (DataCiteAPIError('422 bad metadata'), 'failed'), # Non-retryable 4xx + ], + ids=['success', '4xx-failure'], +) +def test_create_dandiset_doi_task_state_transitions(side_effect, expected_state): + """Verify doi_state transitions in the concept DOI creation task.""" + version = DraftVersionFactory.create() + dandiset = version.dandiset + dandiset.concept_doi = format_doi(dandiset.identifier) + dandiset.save() + version.doi = dandiset.concept_doi + version.doi_state = 'pending' + version.save() + + from dandiapi.api.tasks import create_dandiset_doi_task + + with patch('dandiapi.api.tasks.create_dandiset_doi', side_effect=side_effect): + if side_effect: + try: + create_dandiset_doi_task.apply(args=[dandiset.id], throw=True) + except type(side_effect): + pass # Expected — task propagates the exception + else: + create_dandiset_doi_task.apply(args=[dandiset.id], throw=True) + + version.refresh_from_db() + assert version.doi_state == expected_state + + +# ============================================================================= +# Test 3: Publish creates version DOI (no fake placeholder) +# ============================================================================= + + +@pytest.mark.django_db +@pytest.mark.parametrize('doi_is_configured', [True, False]) +def test_publish_creates_version_doi(doi_is_configured, asset): + """Verify publish sets a real version DOI (not fake).""" + from dandiapi.api.services.publish import _build_publishable_version_from_draft + + draft_version = DraftVersionFactory.create(status=Version.Status.VALID) + draft_version.assets.add(asset) + draft_version.save() + + new_version = _build_publishable_version_from_draft(draft_version) + + # _build_publishable_version_from_draft doesn't set DOI — _publish_dandiset does. + # Verify the build step doesn't inject any fake DOI. + if new_version.metadata.get('doi'): + assert '.123456/0.123456.1234' not in new_version.metadata['doi'] + + +# ============================================================================= +# Test 4: Version destroy hides DOI +# ============================================================================= + + +@pytest.mark.django_db +@pytest.mark.parametrize( + 'has_doi', + [True, False], + ids=['has-doi', 'no-doi'], +) +def test_version_destroy_hides_doi(api_client, has_doi): + """Verify version deletion hides DOI and handles failures gracefully.""" + from dandiapi.api.tests.factories import PublishedVersionFactory + + version = PublishedVersionFactory.create() + if not has_doi: + version.doi = None + version.save() + + admin = UserFactory.create(is_superuser=True) + api_client.force_authenticate(user=admin) + + with patch('dandiapi.api.views.version.hide_published_version_doi_task') as mock_hide_task: + response = api_client.delete( + f'/api/dandisets/{version.dandiset.identifier}/versions/{version.version}/', + ) + + assert response.status_code == 204 + assert not Version.objects.filter(id=version.id).exists() + + if has_doi: + mock_hide_task.delay.assert_called_once_with(version.doi) + else: + mock_hide_task.delay.assert_not_called() + + +# ============================================================================= +# Test 5: Metadata update schedules DOI update +# ============================================================================= + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ('embargoed', 'has_published', 'has_concept_doi', 'should_schedule'), + [ + (False, False, True, True), + (False, False, False, False), + (True, False, True, False), + (False, True, True, False), + ], + ids=['schedule', 'no-concept-doi', 'embargoed', 'has-published'], +) +def test_metadata_update_schedules_doi_update( + api_client, + embargoed, + has_published, + has_concept_doi, + should_schedule, +): + """Verify update_dandiset_doi_task is scheduled only for open, unpublished dandisets.""" + user = UserFactory.create() + + if embargoed: + version = DraftVersionFactory.create( + dandiset__owners=[user], + dandiset__embargo_status=Dandiset.EmbargoStatus.EMBARGOED, + dandiset__embargo_end_date=datetime.date(2030, 1, 1), + ) + else: + version = DraftVersionFactory.create(dandiset__owners=[user]) + + dandiset = version.dandiset + + if has_concept_doi: + dandiset.concept_doi = format_doi(dandiset.identifier) + dandiset.save() + + if has_published: + from dandiapi.api.tests.factories import PublishedVersionFactory + + PublishedVersionFactory.create(dandiset=dandiset) + + api_client.force_authenticate(user=user) + + on_commit_calls = [] + + def capture_on_commit(func, *args, **kwargs): + on_commit_calls.append(func) + + with ( + patch('dandiapi.api.views.version.update_dandiset_doi_task'), + patch('dandiapi.api.views.version.transaction.on_commit', side_effect=capture_on_commit), + ): + response = api_client.put( + f'/api/dandisets/{dandiset.identifier}/versions/draft/', + { + 'metadata': { + 'schemaVersion': DANDI_SCHEMA_VERSION, + 'contributor': [ + { + 'name': 'Doe, Jane', + 'roleName': ['dcite:ContactPerson'], + 'schemaKey': 'Person', + 'email': 'jane@example.com', + } + ], + }, + 'name': 'Updated Name', + }, + ) + + assert response.status_code == 200 + if should_schedule: + # on_commit was called with a lambda that calls update_dandiset_doi_task.delay + assert len(on_commit_calls) > 0, 'Expected on_commit to be called for DOI update' + else: + # Filter for DOI-related on_commit calls (there may be others) + assert all('doi' not in str(c) for c in on_commit_calls), ( + 'DOI update should not be scheduled' + ) + + +# ============================================================================= +# Test 6: Unembargo mints concept DOI +# ============================================================================= + + +@pytest.mark.django_db +@pytest.mark.parametrize('doi_is_configured', [True, False]) +def test_unembargo_mints_concept_doi(doi_is_configured): + """Verify unembargo creates a concept DOI when DOI is configured.""" + from dandiapi.api.services.embargo import unembargo_dandiset + + user = UserFactory.create() + version = DraftVersionFactory.create( + dandiset__embargo_status=Dandiset.EmbargoStatus.UNEMBARGOING, + dandiset__owners=[user], + ) + dandiset = version.dandiset + + with ( + patch('dandiapi.api.services.embargo.remove_dandiset_embargo_tags'), + patch('dandiapi.api.services.embargo.send_dandiset_unembargoed_message'), + patch('dandiapi.api.services.embargo.doi_configured', return_value=doi_is_configured), + patch('dandiapi.api.tasks.create_dandiset_doi_task'), + ): + unembargo_dandiset(dandiset, user) + + dandiset.refresh_from_db() + version.refresh_from_db() + + # concept_doi should always be set (deterministic) + assert dandiset.concept_doi == format_doi(dandiset.identifier) + + if doi_is_configured: + assert version.doi_state == 'pending' + else: + assert version.doi_state is None + + +# ============================================================================= +# Test 7: Remediation command (dry-run) +# ============================================================================= + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ('existing_doi', 'should_remediate'), + [ + (None, True), + ('10.80507/dandi.123456/0.123456.1234', True), # Fake placeholder + ('10.80507/dev-dandi.000001/0.210101.0001', False), # Real DOI + ], + ids=['null-doi', 'fake-doi', 'real-doi'], +) +def test_remediate_dois_dry_run(existing_doi, should_remediate): + """Verify dry-run mode correctly identifies versions needing remediation.""" + from django.core.management import call_command + + from dandiapi.api.tests.factories import PublishedVersionFactory + + version = PublishedVersionFactory.create() + version.doi = existing_doi + version.save() + + out = StringIO() + err = StringIO() + with patch('dandiapi.api.management.commands.remediate_dois.doi_configured', return_value=True): + call_command('remediate_dois', '--dry-run', stdout=out, stderr=err) + + output = out.getvalue() + + if should_remediate: + assert version.dandiset.identifier in output + else: + # Real DOI should not appear in the remediation list + assert f' {version.dandiset.identifier}/{version.version}:' not in output diff --git a/dandiapi/api/views/version.py b/dandiapi/api/views/version.py index bcd1f9d4d..e05f1548b 100644 --- a/dandiapi/api/views/version.py +++ b/dandiapi/api/views/version.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging + from django.db import transaction from django_filters import rest_framework as filters from drf_yasg.utils import no_body, swagger_auto_schema @@ -19,7 +21,7 @@ require_dandiset_owner_or_403, ) from dandiapi.api.services.publish import publish_dandiset -from dandiapi.api.tasks import delete_doi_task +from dandiapi.api.tasks import hide_published_version_doi_task, update_dandiset_doi_task from dandiapi.api.views.common import DANDISET_PK_PARAM, VERSION_PARAM from dandiapi.api.views.pagination import DandiPagination from dandiapi.api.views.serializers import ( @@ -28,6 +30,8 @@ VersionSerializer, ) +logger = logging.getLogger(__name__) + class VersionFilter(filters.FilterSet): order = filters.OrderingFilter(fields=['created']) @@ -131,6 +135,12 @@ def update(self, request, **kwargs): metadata=locked_version.metadata, ) + # Update Draft concept DOI metadata on DataCite for open, + # unpublished dandisets (per design doc section 2) + ds = locked_version.dandiset + if not ds.embargoed and ds.most_recent_published_version is None and ds.concept_doi: + transaction.on_commit(lambda: update_dandiset_doi_task.delay(ds.id)) + serializer = VersionDetailSerializer(instance=locked_version) return Response(serializer.data, status=status.HTTP_200_OK) @@ -172,8 +182,11 @@ def destroy(self, request, **kwargs): 'Cannot delete published versions', status=status.HTTP_403_FORBIDDEN, ) - doi = version.doi + # Schedule async DOI hide (Findable → Registered) before deleting the version. + # The version ID is captured before deletion; the task will handle 404 gracefully + # if the version is gone by the time it runs. + version_doi = version.doi version.delete() - if doi is not None: - delete_doi_task.delay(doi) + if version_doi is not None: + hide_published_version_doi_task.delay(version_doi) return Response(None, status=status.HTTP_204_NO_CONTENT) diff --git a/doc/design/doi-generation-2.md b/doc/design/doi-generation-2.md index e2e4d9fcd..cab41331e 100644 --- a/doc/design/doi-generation-2.md +++ b/doc/design/doi-generation-2.md @@ -182,8 +182,23 @@ sequenceDiagram A django-admin script should be created and executed to create a `Dandiset DOI` for all existing dandisets. -No DB migration will be needed, as no new field will be added to `Dandiset` model, and -instead, the `Dandiset DOI` will be stored in the "draft" `Version`. +> **Implementation note (PR #2799)**: Two new DB fields were added, deviating +> from the original "no new field" plan: +> +> - `Dandiset.concept_doi` (CharField, unique, nullable): Stores the concept +> DOI string. Rationale: the concept DOI is semantically a dandiset-level +> attribute, not a version-level one. Storing it on the draft Version creates +> awkward semantics when the draft is deleted/replaced. The `unique` constraint +> prevents duplicate DOIs from bugs. +> +> - `Version.doi_state` (CharField, nullable, choices: pending/draft/registered/ +> findable/failed): Tracks the DOI lifecycle state on DataCite. Required by +> the async Celery workflow to detect stuck/failed states — impossible without +> a field. The `remediate_dois` and `check_doi_health` management commands +> query this field. +> +> Two migrations (0032, 0033) add these fields. Both are nullable and additive, +> safe for zero-downtime deployment. ### Dandi Schema Changes diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 0efb29712..20ea1ca9d 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -47,7 +47,7 @@ services: "--without-mingle", "--without-heartbeat", "--without-gossip", - "--queues", "celery,calculate_sha256,ingest_zarr_archive,manifest-worker", + "--queues", "celery,calculate_sha256,ingest_zarr_archive,manifest-worker,doi", "--beat" ] # Docker Compose does not set the TTY width, which causes Celery errors diff --git a/pyproject.toml b/pyproject.toml index cafc74141..be9605235 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,10 @@ dependencies = [ "boto3==1.42.97", "celery==5.6.3", # Pin dandischema to exact version to make explicit which schema version is being used - "dandischema==0.12.1", # schema version 0.7.0 + # TODO: BEFORE MERGE — replace git branch with released version (e.g., dandischema==0.13.0) + # This branch adds concept_doi param to to_datacite(), dates field, and concept DOI model. + # See: https://github.com/dandi/dandi-schema/pull/401 + "dandischema @ git+https://github.com/dandi/dandi-schema@enh/datacite-doi-improvements", # schema version 0.7.0 "django[argon2]==5.2.13", "django-allauth==65.16.1", "django-auth-style==0.15.0", @@ -98,8 +101,13 @@ test = [ "pytest-random-order==1.2.0", # Enable with "pytest --count=... ..." "pytest-repeat==0.9.4", + "responses==0.25.7", ] +# TODO: BEFORE MERGE — remove this once dandischema is pinned to a released version +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.build] only-include = [ "dandiapi", diff --git a/uv.lock b/uv.lock index b67598d5a..1a87cb658 100644 --- a/uv.lock +++ b/uv.lock @@ -345,11 +345,11 @@ wheels = [ [[package]] name = "cachetools" -version = "7.0.6" +version = "7.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/7b/1755ed2c6bfabd1d98b37ae73152f8dcf94aa40fee119d163c19ed484704/cachetools-7.0.6.tar.gz", hash = "sha256:e5d524d36d65703a87243a26ff08ad84f73352adbeafb1cde81e207b456aaf24", size = 37526, upload-time = "2026-04-20T19:02:23.289Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e2/85f227594656000ff4d8adadae91a21f536d4a84c6c716a86bd6685874be/cachetools-7.1.1.tar.gz", hash = "sha256:27bdf856d68fd3c71c26c01b5edc312124ed427524d1ddb31aa2b7746fe20d4b", size = 40202, upload-time = "2026-05-03T20:00:29.391Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/c4/cf76242a5da1410917107ff14551764aa405a5fd10cd10cf9a5ca8fa77f4/cachetools-7.0.6-py3-none-any.whl", hash = "sha256:4e94956cfdd3086f12042cdd29318f5ced3893014f7d0d059bf3ead3f85b7f8b", size = 13976, upload-time = "2026-04-20T19:02:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0f/f897abe4ea0a8c408ae65c8c83bffab4936ad65d6032d4fb4cd35bbdc3ee/cachetools-7.1.1-py3-none-any.whl", hash = "sha256:0335cd7a0952d2b22327441fb0628139e234c565559eeb91a8a4ac7551c5353d", size = 16775, upload-time = "2026-05-03T20:00:27.857Z" }, ] [[package]] @@ -806,6 +806,7 @@ test = [ { name = "pytest-mock" }, { name = "pytest-random-order" }, { name = "pytest-repeat" }, + { name = "responses" }, ] type = [ { name = "boto3-stubs", extra = ["s3"] }, @@ -821,7 +822,7 @@ requires-dist = [ { name = "boto3", specifier = "==1.42.97" }, { name = "celery", specifier = "==5.6.3" }, { name = "dandi", extras = ["extras"], marker = "extra == 'cli'", specifier = "==0.74.3" }, - { name = "dandischema", specifier = "==0.12.1" }, + { name = "dandischema", git = "https://github.com/dandi/dandi-schema?rev=enh%2Fdatacite-doi-improvements" }, { name = "django", extras = ["argon2"], specifier = "==5.2.13" }, { name = "django-allauth", specifier = "==65.16.1" }, { name = "django-auth-style", specifier = "==0.15.0" }, @@ -880,6 +881,7 @@ test = [ { name = "pytest-mock", specifier = "==3.15.1" }, { name = "pytest-random-order", specifier = "==1.2.0" }, { name = "pytest-repeat", specifier = "==0.9.4" }, + { name = "responses", specifier = "==0.25.7" }, ] type = [ { name = "boto3-stubs", extras = ["s3"], specifier = "==1.43.0" }, @@ -892,8 +894,8 @@ type = [ [[package]] name = "dandischema" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } +version = "0.12.1+48.g4554720" +source = { git = "https://github.com/dandi/dandi-schema?rev=enh%2Fdatacite-doi-improvements#4554720b2ec8a92aa84c573e9cd31ee7dba74a49" } dependencies = [ { name = "jsonschema", extra = ["format"] }, { name = "packaging" }, @@ -902,10 +904,6 @@ dependencies = [ { name = "requests" }, { name = "zarr-checksum" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/b7/b44244184c16c1b1cc69070b53325965285b81843ad7755c429e1ffaa341/dandischema-0.12.1.tar.gz", hash = "sha256:481ed1da9481090d8000b3b2373a0d4876043f48d4cddc3364c18e0a97bd0c22", size = 98659, upload-time = "2025-11-26T20:16:58.672Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/16/959ccdc06e45781d3c41cd552baeaaf563ffb5fe240fbd24efd26b8b2b12/dandischema-0.12.1-py3-none-any.whl", hash = "sha256:88f84cefd7883ce15d5c2f6b92411b1d00fd76468b77950fff25381e17eaab44", size = 118561, upload-time = "2025-11-26T20:16:57.121Z" }, -] [[package]] name = "decorator" @@ -1488,11 +1486,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.3.0" +version = "2025.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/e0/bab50af11c2d75c9c4a2a26a5254573c0bd97cea152254401510950486fa/fsspec-2025.9.0.tar.gz", hash = "sha256:19fd429483d25d28b65ec68f9f4adc16c17ea2c7c7bf54ec61360d478fb19c19", size = 304847, upload-time = "2025-09-02T19:10:49.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289, upload-time = "2025-09-02T19:10:47.708Z" }, ] [package.optional-dependencies] @@ -1873,49 +1871,49 @@ wheels = [ [[package]] name = "librt" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" }, - { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" }, - { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" }, - { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" }, - { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" }, - { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" }, - { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" }, - { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" }, - { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" }, - { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" }, - { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" }, - { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" }, - { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" }, - { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" }, - { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/cb/c1945e506893b5b8577fb45a60c80e3ffe4a82092a04a6f29b0b951d9a24/librt-0.10.0.tar.gz", hash = "sha256:1aba1e8aa4e3307a7be68a74149545fde7451964dc0235a8bec5704a17bdda42", size = 191799, upload-time = "2026-05-05T16:31:23.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/29/681a75c82f4cc90d29e4b257a3299b79fe13fe927a04c57b8109d70b6957/librt-0.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0ede79d682e73f91c1b599a76d78b7464b9b5d213754cedb13372d9df36e596", size = 77299, upload-time = "2026-05-05T16:30:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/62/24/0c7ca445a55d04be79cac19819437fd094782347fa116f6681844fa6143e/librt-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0ba0b131fdb336c8b9c948e397f4a7e649d0f783b529f07b647bf4961df392e", size = 79930, upload-time = "2026-05-05T16:30:01.555Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/1e2b8f6443ef9e9a81e89486ca70e22f3684f93db003ce6eaefc3d0839b9/librt-0.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2728117da2afb96fb957768725ee43dc9a2d73b031e02da424b818a3cdd3a275", size = 246195, upload-time = "2026-05-05T16:30:03.261Z" }, + { url = "https://files.pythonhosted.org/packages/74/61/9dc9e03de0439ad84c1c240aac8b747f12c90cb797ea6042f7bdb8d3410f/librt-0.10.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:723ba80594c49cdf0584196fc430752262605dc9449902fc9bd3d9b79976cb77", size = 234951, upload-time = "2026-05-05T16:30:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/635223117d7590875bca441275065a3bf491203ad4208bd1cc3ffd90c5a1/librt-0.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7292edaaca294a61a978c53a3c7d6130d099b0dfbc8f0a65916cdc6b891b9852", size = 262768, upload-time = "2026-05-05T16:30:06.638Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/b04152d0cd8b6ca2b428a8bd3230343230c35ed304a932f35b5375f2f828/librt-0.10.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89fe9d539f2c10a1666633eeeac507ce95dd06d9ecc58de3c6390dba156a3d3a", size = 255075, upload-time = "2026-05-05T16:30:08.216Z" }, + { url = "https://files.pythonhosted.org/packages/35/1e/25bac4c7f2ca36f0e612cade186970683cf79153d96beccc3a11a9e19b97/librt-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4efa7b9587503fa5b67f40593302b9c8836d211d222ff9f7cafe67be5f8f0b10", size = 268559, upload-time = "2026-05-05T16:30:10.1Z" }, + { url = "https://files.pythonhosted.org/packages/18/54/4601faab35b6632a13200faa146ca62bfd111ffbe2568be430d65c89493a/librt-0.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:22dc982ef59df0136df36092ccbdbb570ced8aafb33e49585739b2f1de1c13b6", size = 261753, upload-time = "2026-05-05T16:30:11.912Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cf/39f4023509e94fade8b074666fa3292db9cb6b34ea5dcbe7af53df9fca1d/librt-0.10.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6f2e5f3606253a84cea719c94a3bb1c54487b5d617d0254d46e0920d8a06be3f", size = 264055, upload-time = "2026-05-05T16:30:13.465Z" }, + { url = "https://files.pythonhosted.org/packages/8e/00/40247209fc46a8e308a91412d5206aedf8efb667ee89eb625820106a5c2f/librt-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40884bfaa1e29f6b6a9be255007d8f359bfc9e61d68bdef8ed3158bfcbc95df9", size = 286190, upload-time = "2026-05-05T16:30:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6e/5566beb94431a985abe1787af5ef86e087750172ff9d0bbf20f93e88132d/librt-0.10.0-cp313-cp313-win32.whl", hash = "sha256:3cd34cd8254eba756660bff6c2da91278248184301054fe3e4feb073bdd49b14", size = 62949, upload-time = "2026-05-05T16:30:16.503Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c2/3ea3301d6c8dff51d39dbe8ed75db3dc92896947d4afb5eeadf821c1e67f/librt-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:7baac5313e2d8dce1386f97777a8d03ab28f5fe1e780b3b9ac2ee7544551fedc", size = 71152, upload-time = "2026-05-05T16:30:17.766Z" }, + { url = "https://files.pythonhosted.org/packages/3c/de/5d49cb92cadcbc77d3abc27b93fd6030ed8437487dde2eae38cab5e6704d/librt-0.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:afc5b4406c8e2515698d922a5c7823a009312835ea58196671fff40e35cb8166", size = 61336, upload-time = "2026-05-05T16:30:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/7165e08108cc185a13a9c069f0685e6ef92e70e07fddf7edf5e7348c6316/librt-0.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f09588a30e6a22ec624090d72a3ab1a6d4d5485c3ed739603e76aa3c16efa688", size = 76794, upload-time = "2026-05-05T16:30:20.392Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ef/bf8613febf651b90c5222ee79dea5ae58d4cc2b544df69d3033424448934/librt-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:131ade118d12bd7a0adc4e655474a553f1b76cf78385868885944d21d51e45e0", size = 79662, upload-time = "2026-05-05T16:30:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/b6/67/9eddd165c1d8397bdf99b38bf12b5a55b3def5035b49eedb49f2775d1430/librt-0.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8b9ab28e40d011c373a189eae900c916e66d6fbecf7983e9e4883089ee085ef", size = 242390, upload-time = "2026-05-05T16:30:23.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/d1/d95da80334501866cd37004ab5d7483220d05862fab4b5405394f0264f0d/librt-0.10.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:67c39bb30da73bae1f293d1ed8bc2f8f6642649dd0928d3600aeff3041ac23d6", size = 232603, upload-time = "2026-05-05T16:30:25.198Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fa/e6d64d28718bc1be4e1736fcb037ca1c4dfca927e7167df75a7d5215665e/librt-0.10.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c3273c6b774614f093c8927c2bf1b077d0fefde988fe98f46a333734e5597ab", size = 259187, upload-time = "2026-05-05T16:30:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/72/3f/3fdb77e7f937dad59cfd76b720be7e7643400ec76b2da35befab8d66ba30/librt-0.10.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9dd7c1b86a4baa583ab5db977484b93a2c474e69e96ef3e9538387ea54229cb9", size = 251846, upload-time = "2026-05-05T16:30:28.56Z" }, + { url = "https://files.pythonhosted.org/packages/18/ca/f4d49133dd86a6f55d79eca30bf412fa722f511a9abe67f62f57aa64e66a/librt-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a77385c5a202e831149f7ad03be9e67cf80e957e52c614e83dcb822c95222eb8", size = 264936, upload-time = "2026-05-05T16:30:30.491Z" }, + { url = "https://files.pythonhosted.org/packages/de/66/a8df2fbadc1f6c1827a096d11c40175bd526133480bd3bc88ec64a03d257/librt-0.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c6a5eafa74b5655bad59886138ed68426f098a6beb8cb95a71f2cc3cd8bb33fe", size = 258699, upload-time = "2026-05-05T16:30:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/1e3c83613fe05451bb969e27b68a573d177f08d5f63533cc29fec0989658/librt-0.10.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1fc93d0439204c50ab4d1512611ce2c206f1b369b419f69c7c27c761561e3291", size = 259825, upload-time = "2026-05-05T16:30:35.077Z" }, + { url = "https://files.pythonhosted.org/packages/09/24/5e2f926ee9d3ef348d9339526d7062abb5c44d8419e3179528c01d78c102/librt-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:79e713c178bc7a744adfbee6b4619a288eecc0c914da2a9313a20255abe2f0cf", size = 282548, upload-time = "2026-05-05T16:30:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7d/3e89ed6ad0162561fa8bef9df3195e24263104c955713cd0237d3711fad2/librt-0.10.0-cp314-cp314-win32.whl", hash = "sha256:2eba9d955a68c41d9f326be3da42f163ec3518b7ab20f1c826224e7bed71e0bf", size = 58970, upload-time = "2026-05-05T16:30:38.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/579e731c94a7086a268bfa3e7a4945cd47836bebd3cbf3faeafd2e7eaef9/librt-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbfaf7f5145e9917f5d18bffa298eff6a19d74e7b8b11dabdca95785befe8dbf", size = 67260, upload-time = "2026-05-05T16:30:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f8/235822b7ae0b2334f12ee18bcf2476d07924077a5efeea57dbe927704be2/librt-0.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:8d6d385d1969849a6b1397114df22714b6ded917bada98668e3e974dc663477e", size = 57156, upload-time = "2026-05-05T16:30:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e3/9b919cbf1e8eb770bf91bb7df28125e0f1daf4587169afefd95402636e9a/librt-0.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:6c3a82d3bd32631ef5c79922dfc028520c9ad840255979ab4d908271818039ee", size = 79150, upload-time = "2026-05-05T16:30:42.761Z" }, + { url = "https://files.pythonhosted.org/packages/6a/f5/72a944aa3bc3498169a168087eff58ca48b58bf1b704e59d091fd30739f3/librt-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d64cc66005dc324c9bb1fa3fc2841f529002f6eb15966d55e46d430f56955a6a", size = 82304, upload-time = "2026-05-05T16:30:44.082Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e3/fcc290a33e295019759472dfa794d204e43504b276ac65eab7fd9da20ea3/librt-0.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb562cd28c88cd2c6a9a6c78f99dc39348d6b16c94adc25de0e574acf1176e9", size = 272556, upload-time = "2026-05-05T16:30:45.497Z" }, + { url = "https://files.pythonhosted.org/packages/fd/54/546975e4c997573885e7f040a05012f8838e06fb12b0c3c1fbb76254e9d7/librt-0.10.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b809aa2854d019c28773b03605df22adc675ee4f3f4402d673581313e8906119", size = 256941, upload-time = "2026-05-05T16:30:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f1d03401571b331653acddbd4e8cd955c06d945241dd08b25192fac0d04b/librt-0.10.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc15acabdd519bd4176fdadc2119e5e3093485d86f89138daf47e5b4cedb983a", size = 285855, upload-time = "2026-05-05T16:30:48.86Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/62cf80ff046c339faf56718b3a940244d4beb70f1c6407289b5830ec11e9/librt-0.10.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b1b2d835307d08ddadd94568e2369648ec9173bd3eea6d7f52a1abe717c81f98", size = 275321, upload-time = "2026-05-05T16:30:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ea/da5918d4070362e9a4d2ee9cd34f9dc84902daad8fd4275f8504a727ff4e/librt-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d261c6a2f93335a5167887fb0223e8b98ffce20ee3fde242e8e58a37ece6d0e5", size = 293993, upload-time = "2026-05-05T16:30:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/c9/8d/68b6086bed1fcdc314c640ea04e31e52d18052e08059fa595409d66a51a9/librt-0.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e2ffd44963f8e7f68995504d90f9881d64e94dc1d8e310039b9526108fc0c0f7", size = 284254, upload-time = "2026-05-05T16:30:55.086Z" }, + { url = "https://files.pythonhosted.org/packages/06/c8/b810f1d84ec34a5a7ed93d7b510ab04164d75fbdf23088d5c3fbe6b08357/librt-0.10.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f285f6455ed495791c4d8630e5af732960adea93cac4c893d15619f2eae53e8", size = 284925, upload-time = "2026-05-05T16:30:56.728Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/3c82d4158c5a2c62528b8fccce65a8c9ad700e480e86f9389387435089a5/librt-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6034ff52e663d34c7b82ef2aa2f94ad7c1d939e2368e63b06844bc4d127d2e1", size = 307830, upload-time = "2026-05-05T16:30:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/99/3a/9c635ac3e8a00383ff689161d3eac8a30b3b2ddc711b40471e6b8983ea29/librt-0.10.0-cp314-cp314t-win32.whl", hash = "sha256:657860fd877fba6a241ea088ef99f63ca819945d3c715265da670bad56c37ebe", size = 60147, upload-time = "2026-05-05T16:31:00.293Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e8/6f65f3e565d4ac212cddddd552eacc8035ffdf941ca0ad6fe945a211d41f/librt-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56ded2d66010203a0cb5af063b609e3f079531a0e5e576d618dece859fd2e1af", size = 68649, upload-time = "2026-05-05T16:31:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/51/78/a0705a67cacd81e5fa01a5035b3adbdfbb43a7b8d4bd27e2b282ae61baf2/librt-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1ee63f30abf18ed4830fdbaf87b2b6f4bba1e198d46085c314edde4045e56715", size = 58247, upload-time = "2026-05-05T16:31:03.191Z" }, ] [[package]] @@ -2296,11 +2294,11 @@ wheels = [ [[package]] name = "mypy-boto3-s3" -version = "1.43.0" +version = "1.43.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7a/9e/7e7d7d412824e117b2e8bf51d167f0ab380c2cbd9bd89bbd912e7bf14ab8/mypy_boto3_s3-1.43.0.tar.gz", hash = "sha256:3bfb027b1f3df9316ff72ff29f4b2dc0d7d65ed5032d8bcf4892222994228588", size = 77067, upload-time = "2026-04-29T23:05:16.94Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/f9/f6bb5e1b3d8d9087ab9e2142df640a1be853e371ec5e16ea519a60061b56/mypy_boto3_s3-1.43.5.tar.gz", hash = "sha256:ba67dbc3da825b6818839db3823722f3b12304dd116e94ed398eb7ade86b0f62", size = 77042, upload-time = "2026-05-06T20:47:46.655Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/c6/e97695c41b28bd16465260c20be1e7880d6eb74c7b65536cc1e52c512fad/mypy_boto3_s3-1.43.0-py3-none-any.whl", hash = "sha256:aaa7991e7ffafcf8ff4fb23c5fb4cc4554ef5724c889ff016b87e60f27405b5b", size = 84261, upload-time = "2026-04-29T23:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/db/02/f597a5c373fb755433e194a69885c78b2f0db7dcca7fac1247f586b59ec1/mypy_boto3_s3-1.43.5-py3-none-any.whl", hash = "sha256:7cd836cf3ec384b6a05b108047034ece03bb7dd0bd0890c527673eafc44907bb", size = 84262, upload-time = "2026-05-06T20:47:42.976Z" }, ] [[package]] @@ -3010,15 +3008,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.2.2" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/e0/cc5a8653e9a24f6cf84768f05064aa8ed5a83dcefd5e2a043db14a1c5f44/python_discovery-1.3.0.tar.gz", hash = "sha256:d098f1e86be5d45fe4d14bf1029294aabbd332f4321179dec85e76cddce834b0", size = 63925, upload-time = "2026-05-05T14:38:39.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/30/d4/24d543ab8b8158b7f5a97113c831205f5c900c92c8762b1e7f44b7ea0405/python_discovery-1.3.0-py3-none-any.whl", hash = "sha256:441d9ced3dfce36e113beb35ca302c71c7ef06f3c0f9c227a0b9bb3bd49b9e9f", size = 33124, upload-time = "2026-05-05T14:38:38.539Z" }, ] [[package]] @@ -3112,6 +3110,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] +[[package]] +name = "responses" +version = "0.25.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/7e/2345ac3299bd62bd7163216702bbc88976c099cfceba5b889f2a457727a1/responses-0.25.7.tar.gz", hash = "sha256:8ebae11405d7a5df79ab6fd54277f6f2bc29b2d002d0dd2d5c632594d1ddcedb", size = 79203, upload-time = "2025-03-11T15:36:16.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/fc/1d20b64fa90e81e4fa0a34c9b0240a6cfb1326b7e06d18a5432a9917c316/responses-0.25.7-py3-none-any.whl", hash = "sha256:92ca17416c90fe6b35921f52179bff29332076bb32694c0df02dcac2c6bc043c", size = 34732, upload-time = "2025-03-11T15:36:14.589Z" }, +] + [[package]] name = "rfc3339-validator" version = "0.1.4" @@ -3629,28 +3641,28 @@ wheels = [ [[package]] name = "uv" -version = "0.11.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c1/cd/4393fecb083897e956f016d4e66d0b8a496a08fe2e03cbda32a1e91da7ee/uv-0.11.8.tar.gz", hash = "sha256:bb2cf302b8503629aab6f0090a05551e6f8cfc2d687ca059cad7ec9e11214335", size = 4098020, upload-time = "2026-04-27T13:15:31.625Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/84/dcb676a3e36a3a2b44dc2e4dfea471b8cd709025e27cce3e588b176fd899/uv-0.11.8-py3-none-linux_armv6l.whl", hash = "sha256:a53e704a780a9e78a50f5a880e99a690f84e6fb9e82610903ce26f47c271d74c", size = 23664296, upload-time = "2026-04-27T13:15:15.644Z" }, - { url = "https://files.pythonhosted.org/packages/86/05/557aa070fda7b8460bbbe1e867e8e5b80602c5b30ed77d1d94fc5acae518/uv-0.11.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d414fc3795b6f56fb6b1fa359537930924fdfe857750a144d2aedf3077be3f1d", size = 23087321, upload-time = "2026-04-27T13:15:36.193Z" }, - { url = "https://files.pythonhosted.org/packages/d5/62/82953018801a250e16b091ef4b5e95e939b2f01224363d6fc80f600b7eff/uv-0.11.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f0d402e182ab581e934c159cc9edf25ec6e08d32f29aa797980e949afefc87cd", size = 21747142, upload-time = "2026-04-27T13:15:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/af/4c/477f2abe16f9a3d3c73077f15615878a303eef3760115ec946be58ecb9b2/uv-0.11.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:877c9af3b3955a35ef739e5b2ba79c56dae5c4d50420a7ed908c0901e1c8c807", size = 23425861, upload-time = "2026-04-27T13:15:10.374Z" }, - { url = "https://files.pythonhosted.org/packages/2a/63/19f46193e49f0c9bf33346a4d726313871864db16e7cdd1c0a63bc112000/uv-0.11.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:8278144df8d80a83f770c264a5e79ea50791316d2a0dda869e53b3c1174142a8", size = 23215551, upload-time = "2026-04-27T13:15:38.706Z" }, - { url = "https://files.pythonhosted.org/packages/72/3e/5595b265df848a33cd060b10e8f763a46d67521ac9f6c314e8a4ad5329d7/uv-0.11.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3494ad32465f4e02259cfb104d24efe5bb8f7a782351f0354de9385415fb310", size = 23224170, upload-time = "2026-04-27T13:15:18.083Z" }, - { url = "https://files.pythonhosted.org/packages/a6/b3/6ca95e690b52542caa1dae10ede57732f90c629946ab5f027ff746f87deb/uv-0.11.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4421e27e81f85bce3bdb75986c38b5f9bfab9cdccaf3d977cf124b3f0f0b989", size = 24730048, upload-time = "2026-04-27T13:15:13.254Z" }, - { url = "https://files.pythonhosted.org/packages/ea/49/71b7322067c85a3736a22a300072b0566991fe3f95b81bed793508ff5315/uv-0.11.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91943e77fc962752d4f64ad5739219858395981078051c740b28b52963b366aa", size = 25585906, upload-time = "2026-04-27T13:15:41.455Z" }, - { url = "https://files.pythonhosted.org/packages/37/16/4e84cd5131327fe86d4784ebfc8a983149f4e6b811476ef271fc548b29e6/uv-0.11.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:41fbba287efcc9bc9505a60549b3a223220da720eacd03be8c23d9daaafa44f4", size = 24795740, upload-time = "2026-04-27T13:15:49.842Z" }, - { url = "https://files.pythonhosted.org/packages/5b/01/df175979018743cc5ba6e2fb9dcec916868271e8d88cf0b9df8fd805a0df/uv-0.11.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d97bb2920d6cddc07faa475013461294cc09b77ec8139278416c6e54b938d037", size = 24824980, upload-time = "2026-04-27T13:15:53.506Z" }, - { url = "https://files.pythonhosted.org/packages/1c/95/93c7f595f7136fb32807442860c55d0faed2cd3d7da4b7105ed3c2535d5f/uv-0.11.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fb6a755305eb1e081dfe6a8bc007dbae2d26fe75e551656ca7c9cd08fba21d26", size = 23526790, upload-time = "2026-04-27T13:15:04.955Z" }, - { url = "https://files.pythonhosted.org/packages/04/02/77430b89e172c20cc549b07a5b1dfda0c882c161b6d82781d3150a7063ac/uv-0.11.8-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:841ecbb38532698f73b14b49dc5f0c5e756194c7fcf6e5c6b7ed3859200fe91b", size = 24280498, upload-time = "2026-04-27T13:15:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e3/23e4a2bb91e3880e017e6116886e2d0bde14ba6aa95ddc458160ee630e7c/uv-0.11.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b3ff2b20c1897105ebe7ed7f9b1b331c7171da029bc1e35970ce31dc086141c1", size = 24375233, upload-time = "2026-04-27T13:15:25.753Z" }, - { url = "https://files.pythonhosted.org/packages/d9/67/fb7dc17cea816a667d1be2632525aa1687566bfafd17bdac561a7a6c9484/uv-0.11.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ad381228b0170ef9646902c7e908d4a10a7ecc3da8139450506cf70c7e7f3e80", size = 23904818, upload-time = "2026-04-27T13:15:23.21Z" }, - { url = "https://files.pythonhosted.org/packages/4b/91/b920e35f54f8c6b51f2c639e8170bb80a47a739a1442fea33a479bc93a3d/uv-0.11.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0172b5215544844cd3db0fa3c73a2eb74999b3f00cd2527dde578725076d7b65", size = 25015448, upload-time = "2026-04-27T13:15:46.666Z" }, - { url = "https://files.pythonhosted.org/packages/05/e8/3771956dc1c94b8484789bb8070d91872080d0af99332b8bdec7218c2bfd/uv-0.11.8-py3-none-win32.whl", hash = "sha256:e71c1dd23cbb480f3952c3a95b4fd00f96bd618e2a94583fc9388c500af3070d", size = 22823583, upload-time = "2026-04-27T13:15:33.674Z" }, - { url = "https://files.pythonhosted.org/packages/f9/9b/a91a9c60dcae0e1e3da06377d38f32118a523697d461fe41bc9f117ecf59/uv-0.11.8-py3-none-win_amd64.whl", hash = "sha256:306c624c68d95dd7ea3647675323d72c1abc25f91c3e92ae4cd6f0f11b508726", size = 25407438, upload-time = "2026-04-27T13:15:28.957Z" }, - { url = "https://files.pythonhosted.org/packages/61/5d/defa29fe617e6f07d4e514089e9d36fd9f44ede869e597e39ff7d69f6917/uv-0.11.8-py3-none-win_arm64.whl", hash = "sha256:a9853456696d579f206135c9dda7227a6ed8311b8a9a0b9b2008c4ae81950efe", size = 23914243, upload-time = "2026-04-27T13:15:07.717Z" }, +version = "0.11.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/02/69a3b06fd8a91f95b79e95e14f5ccdd4df0f124c381aefe9d1e2784d5a65/uv-0.11.11.tar.gz", hash = "sha256:2ba46a912a1775957c579a1a42c8c8b480418502326b72427b1cad972c8f659f", size = 4112827, upload-time = "2026-05-06T20:04:47.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/54/39d3c58de992767834120fe3735b85cc60dd00a69b377c3d947ca6f172a1/uv-0.11.11-py3-none-linux_armv6l.whl", hash = "sha256:4977a1193e5dc9c2934b9f97d6cf787382f80deae17646640ee583cfc61486c0", size = 23537936, upload-time = "2026-05-06T20:04:58.626Z" }, + { url = "https://files.pythonhosted.org/packages/de/c9/d2d7ca30abf4c2d5ae0d9360a1e154115af176308ef1ecdc8bf7af724cf8/uv-0.11.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:92817f276758e41b4160fcb6d457ebd9f228f0473efe3808891164f326fdea38", size = 23068282, upload-time = "2026-05-06T20:05:01.466Z" }, + { url = "https://files.pythonhosted.org/packages/fa/37/f64decba47d7afaace3f238aa4a416dca947bd0a1a9b534c3a0f179e1016/uv-0.11.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6eec6ad051e6e5d922cd547b9f7b09a7f821597ae01900a6f01b0a01317e5fd0", size = 21671522, upload-time = "2026-05-06T20:05:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/93/a6/c129878d7c2a66ffdaa12dc253d3135c5e10fc5b5e15812791e188c6dbec/uv-0.11.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1d227bb53b701e533f0aa074dd145a6fa31492dc7d6d57a6e72a700b9a4a1991", size = 23283200, upload-time = "2026-05-06T20:04:39.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c2/cff1f9ab7eda3d863e9866fca0e14df37c0fd734b66ebb77d751258b2fae/uv-0.11.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:05ee9f18701692fcb22db98085c041a3be7a35b88c710dea4487c293f42a4b95", size = 23081561, upload-time = "2026-05-06T20:05:07.149Z" }, + { url = "https://files.pythonhosted.org/packages/ca/44/ebd02ca8fae5961d1bcbcee11019dd170dd0d42517afad753281335700cc/uv-0.11.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0632af539d6a1ee00f58da9e7db32fd99e12187aa67426cb90d871154ab5debb", size = 23105780, upload-time = "2026-05-06T20:04:50.107Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/0741abcd70591a65f85fc4e8fecd3fb3fb4bdfe50042cccf016714955fd9/uv-0.11.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb3f2715551d2fc9ef44b6cf0918fcc556cd99e9bf6caa1d8a870a4657d2b180", size = 24542681, upload-time = "2026-05-06T20:04:53.014Z" }, + { url = "https://files.pythonhosted.org/packages/b1/42/46e7e35f1f39e39d4bf0f712479768cf8d33eb7f35b67fceaea43e975dfd/uv-0.11.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c86bd6460579857d7e359bdbfe6f688076c654481ae933151d1449f9ea672fb6", size = 25459284, upload-time = "2026-05-06T20:04:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fc/efdb16e1a6c619b021259ac8d8e4b6afd97efb446054ea28761eb2e1a177/uv-0.11.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0f69f4df007c7506db8d7f77ccabd466a886ac21e9b04a479dd0cd22e26d2262", size = 24560769, upload-time = "2026-05-06T20:04:42.648Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f8/a5d5bac297b1379719050788c6b852c6b3eefcb1e82d8465ed22c10cede7/uv-0.11.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5b9f31dab557b5ee4257d8c6ba2608a63c7278537cb0cd102cf6fc518e3fb5c", size = 24639659, upload-time = "2026-05-06T20:04:31.491Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d5/f3be167a43192062f1409fd6b857a612665d331174293b4ffc73218872e1/uv-0.11.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:8e8faf2e5b3517155fd18e509b19b21135247d43b7fb9a8d61a44a53118d5ab7", size = 23388445, upload-time = "2026-05-06T20:04:25.199Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cd/ef1f573ee8edd2beab9fcd2449121483829621b3b57f7ba3f35c56ef373b/uv-0.11.11-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:3f8c9a1bea743a3fe39e956455686f4d0dd25ef58e8d70dc11a45381fd7c50e5", size = 24114301, upload-time = "2026-05-06T20:04:28.586Z" }, + { url = "https://files.pythonhosted.org/packages/9d/be/9181158465719e875a6995c10af24e00cdefba3fe6c9c8cbb02d34b2ade7/uv-0.11.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f68dc7b62050a26ac6b1491398aebbbf0fa5485627e73b1d626666a097dbab07", size = 24155126, upload-time = "2026-05-06T20:04:55.98Z" }, + { url = "https://files.pythonhosted.org/packages/71/9c/bb306f9964870847f02a931d1fff896726f8bafcf9ce917122ac1bfef14c/uv-0.11.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:29ddb0d9b24a30ff4360b94e3cb704e82cd5fda86dc224032251f33ab5ceb79e", size = 23824684, upload-time = "2026-05-06T20:05:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/56/48/434a1cf4798ca200e0dcb36411ba38013edb6d3e1aeb4cd85e8a2d7db9ca/uv-0.11.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:505a31f2c30fa9e83b1853cab06c5b92e66341c914c6f20f3878903aa09a6f34", size = 24862560, upload-time = "2026-05-06T20:04:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/63/3a/997cddf82917f084d486e1c268c7e94836190fd928c93aa3fb92caee9a7f/uv-0.11.11-py3-none-win32.whl", hash = "sha256:c1e0e3e18cc94680642eac3c3f19f2635c17dd058edcb41b78cbdc459f574eb4", size = 22573619, upload-time = "2026-05-06T20:04:45.35Z" }, + { url = "https://files.pythonhosted.org/packages/30/5f/db34b840f8d86833ef810de8150fc9ce01a03c779393e08eadbcc4c010d5/uv-0.11.11-py3-none-win_amd64.whl", hash = "sha256:36412b13f6287304789abdf40122d268cee548fce3573e07d148a29370181421", size = 25170135, upload-time = "2026-05-06T20:05:13.001Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3e/f3ba2557b437ec5b1fde1e0d5248b723432dc90f09b0050f52695596fd2e/uv-0.11.11-py3-none-win_arm64.whl", hash = "sha256:011f42faf5d267a6681ea77e3f236f275cb4490efeecb9599de74dc7ad7df8f6", size = 23597162, upload-time = "2026-05-06T20:05:16.095Z" }, ] [[package]] @@ -3664,7 +3676,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.3.0" +version = "21.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -3672,9 +3684,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/0d/915c02c94d207b85580eb09bffab54438a709e7288524094fe781da526c2/virtualenv-21.3.1.tar.gz", hash = "sha256:c2305bc1fddeec40699b8370d13f8d431b0701f00ce895061ce493aeded4426b", size = 7613791, upload-time = "2026-05-05T01:34:31.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" }, + { url = "https://files.pythonhosted.org/packages/b1/4f/f71e641e504111a5a74e3a20bc52d01bd86788b22699dd3fee1c63253cf6/virtualenv-21.3.1-py3-none-any.whl", hash = "sha256:d1a71cf58f2f9228fff23a1f6ec15d39785c6b32e03658d104974247145edd35", size = 7594539, upload-time = "2026-05-05T01:34:28.98Z" }, ] [[package]] diff --git a/web/src/components/DLP/HowToCiteTab.vue b/web/src/components/DLP/HowToCiteTab.vue index 11b215a95..d67fc77a6 100644 --- a/web/src/components/DLP/HowToCiteTab.vue +++ b/web/src/components/DLP/HowToCiteTab.vue @@ -328,7 +328,19 @@ const citationFormats: Array<{ value: CitationFormat; text: string; icon: string ]; const citation = computed(() => props.meta?.citation); -const doi = computed(() => props.meta?.doi); +const doi = computed(() => { + const rawDoi = props.meta?.doi as string | undefined; + // Filter out placeholder/fake DOIs that were historically injected + if (rawDoi && rawDoi.includes('.123456/0.123456.1234')) { + return null; + } + // Don't display concept DOIs for draft versions — they may not resolve + const version = props.meta?.version as string | undefined; + if (version === 'draft') { + return null; + } + return rawDoi; +}); const licenses = computed(() => props.meta?.license); // Current citation based on selected format