diff --git a/docs/content/api.md b/docs/content/api.md index 87d46783..e27b9851 100644 --- a/docs/content/api.md +++ b/docs/content/api.md @@ -607,13 +607,13 @@ argument for branching on `code`. | Status | Codes | | --- | --- | | **401** | `UNAUTHORIZED` — with a `WWW-Authenticate: Bearer` challenge | -| **404** | `PROJECT_NOT_FOUND` · `SCHEMA_NOT_FOUND` · `SCHEMA_DRAFT_NOT_FOUND` · `BATCH_NOT_FOUND` · `JOB_NOT_FOUND` · `INGEST_JOB_NOT_FOUND` · `BACKGROUND_JOB_NOT_FOUND` · `ASSET_NOT_FOUND` · `SOURCE_NOT_FOUND` · `DATASET_NOT_FOUND` · `ANNOTATION_NOT_FOUND` · `RELEASE_NOT_FOUND` · `TOKEN_NOT_FOUND` · `INFERENCE_CONNECTION_NOT_FOUND` · `ASSET_NOT_IN_JOB` · `ASSET_NOT_IN_DATASET` · `NO_SPLIT_RECIPE` · `EXPORT_FORMAT_NOT_FOUND` · `THUMBNAIL_NOT_CACHED` · `NOT_FOUND` (no such route) | +| **404** | `PROJECT_NOT_FOUND` · `SCHEMA_NOT_FOUND` · `SCHEMA_DRAFT_NOT_FOUND` · `BATCH_NOT_FOUND` · `JOB_NOT_FOUND` · `INGEST_JOB_NOT_FOUND` · `BACKGROUND_JOB_NOT_FOUND` · `ASSET_NOT_FOUND` · `SOURCE_NOT_FOUND` · `DATASET_NOT_FOUND` · `ANNOTATION_NOT_FOUND` · `RELEASE_NOT_FOUND` · `TOKEN_NOT_FOUND` · `INFERENCE_CONNECTION_NOT_FOUND` · `ASSET_NOT_IN_JOB` · `ASSET_NOT_IN_DATASET` · `NO_SPLIT_RECIPE` · `EXPORT_FORMAT_NOT_FOUND` · `EXPORT_TARGET_NOT_FOUND` · `THUMBNAIL_NOT_CACHED` · `NOT_FOUND` (no such route) | | **405** | `METHOD_NOT_ALLOWED` | | **409** | `PROJECT_NAME_TAKEN` · `RELEASE_TAG_TAKEN` · `TOKEN_NAME_TAKEN` · `INFERENCE_CONNECTION_NAME_TAKEN` · `WORKSPACE_ALREADY_EXISTS` · `WORKSPACE_NOT_EMPTY` · `SCHEMA_VERSION_CONFLICT` · `INVALID_TRANSITION` · `STALE_WRITE` · `BATCH_NOT_EDITABLE` · `BATCH_IMMUTABLE` · `BATCH_NOT_IN_ANNOTATION` · `ASSET_NOT_WRITABLE` · `JOB_FINISHED` · `BATCH_NOT_COMPLETE` · `JOB_NOT_COMPLETE` · `EMPTY_BATCH` · `EMPTY_RELEASE` · `RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA` · `CONFIRMATION_REQUIRED` · `DESTRUCTIVE_SCHEMA_CHANGE` · `SCHEMA_CHANGE_WOULD_ORPHAN` · `SCHEMA_HAS_NO_DETECTABLE_CLASS` · `UNSERIALIZABLE_MANIFEST` · `LOSSY_EXPORT_NOT_CONSENTED` · `EXPORT_SOURCE_UNREADABLE` · `INFERENCE_CONNECTION_NOT_DOWNLOADABLE` · `INFERENCE_CONNECTION_NOT_CHECKABLE` · `INFERENCE_CONNECTION_NOT_TESTABLE` · `INFERENCE_CONNECTION_MODEL_FIXED` · `WEIGHTS_DAMAGED` · `INFERENCE_CONNECTION_NOT_SET_UP` | | **422** | `VALIDATION_ERROR` · `ASSET_NOT_IN_BATCH` · `ANNOTATION_NOT_FROM_MODEL` · `INVALID_NAME` · `INFERENCE_CONNECTION_INVALID` · `INVALID_SCHEMA` · `UNSUPPORTED_GEOMETRY` · `INVALID_ANNOTATION` · `LABEL_CLASS_NOT_IN_SCHEMA` · `DISALLOWED_GEOMETRY` · `ANNOTATION_GEOMETRY_OUT_OF_BOUNDS` · `DUPLICATE_CLASSIFICATION_TAG` · `MISSING_REQUIRED_ATTRIBUTE` · `UNKNOWN_ATTRIBUTE` · `INVALID_ATTRIBUTE_VALUE` · `INVALID_PARTITION` · `UNKNOWN_JOB_TYPE` · `MEDIA_ERROR` · `UNSUPPORTED_MEDIA` · `CORRUPT_MEDIA` · `UNSUPPORTED_PROMPT` · `PROMPT_POINT_OUT_OF_BOUNDS` · `GEOMETRY_NOT_PRODUCED` | | **502** | `INFERENCE_ENDPOINT_UNAVAILABLE` | | **503** | `WORKSPACE_BUSY` | -| **500** | `WORKSPACE_CORRUPT` · `NOT_A_WORKSPACE` · `WORKSPACE_FORMAT_TOO_NEW` · `WORKSPACE_SCHEMA_MISMATCH` · `ENTITY_NOT_FOUND` · `ENTITY_ALREADY_EXISTS` · `CONSTRAINT_VIOLATED` · `MEDIA_TOOL_UNAVAILABLE` · `LOCAL_INFERENCE_UNAVAILABLE` · `INFERENCE_CONNECTION_NOT_RUNNABLE` · `INFERENCE_OUT_OF_MEMORY` · `INTERNAL_ERROR` | +| **500** | `WORKSPACE_CORRUPT` · `NOT_A_WORKSPACE` · `WORKSPACE_FORMAT_TOO_NEW` · `WORKSPACE_SCHEMA_MISMATCH` · `ENTITY_NOT_FOUND` · `ENTITY_ALREADY_EXISTS` · `CONSTRAINT_VIOLATED` · `MEDIA_TOOL_UNAVAILABLE` · `LOCAL_INFERENCE_UNAVAILABLE` · `INFERENCE_CONNECTION_NOT_RUNNABLE` · `INFERENCE_OUT_OF_MEMORY` · `EXPORT_TARGET_CONFLICT` · `INVALID_EXPORT_TARGET` · `INTERNAL_ERROR` | Every row but `VALIDATION_ERROR`, `NOT_FOUND`, `METHOD_NOT_ALLOWED`, `UNAUTHORIZED` and `INTERNAL_ERROR` — the five the framework and the auth guard raise — comes from `ERROR_RULES` diff --git a/src/visionset/formats/_dummy.py b/src/visionset/formats/_dummy.py index 3a36a1fc..63613ad8 100644 --- a/src/visionset/formats/_dummy.py +++ b/src/visionset/formats/_dummy.py @@ -7,6 +7,7 @@ from pathlib import Path +from visionset.formats._targets import self_target from visionset.kernel.domain import GeometryType, Manifest, Release from visionset.kernel.ports import ContentReader @@ -40,6 +41,8 @@ class DummyExporter: #: that named it would have to grow when the domain does. supported_modalities = frozenset({"image", "video", "point_cloud"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, diff --git a/src/visionset/formats/_targets.py b/src/visionset/formats/_targets.py new file mode 100644 index 00000000..c1c6fa2f --- /dev/null +++ b/src/visionset/formats/_targets.py @@ -0,0 +1,43 @@ +# usage: from visionset.formats._targets import self_target +"""The one-target declaration every non-YOLO exporter shares. + +An exporter that is not a trainer's format still declares exactly one target, +named after itself, family ``other``, with no trainer tasks — so a surface +renders one control for every export rather than a target select beside a +format select. Spelled once here for the same reason ``_layout`` exists: +the day two spellings of the rule disagree, the catalog and the format list +stop describing the same thing. + +Private to :mod:`visionset.formats`, like ``_layout``: importable, but not part +of the ``Exporter`` contract. +""" + +from __future__ import annotations + +from visionset.kernel.domain import ( + ExportTarget, + GeometryType, + PreprocessingHints, + TargetFamily, +) + + +def self_target(format_name: str, geometries: frozenset[GeometryType]) -> frozenset[ExportTarget]: + """The whole ``targets`` declaration for a format that is its own target.""" + return frozenset( + { + ExportTarget( + name=format_name, + label=format_name, + family=TargetFamily.OTHER, + tasks=frozenset(), + supported_geometries=geometries, + hints=PreprocessingHints( + recommended_size=None, + recommended_strategy=None, + trainer_resizes=True, + augmentation_common=False, + ), + ) + } + ) diff --git a/src/visionset/formats/classification/__init__.py b/src/visionset/formats/classification/__init__.py index 2ffbd427..70442a10 100644 --- a/src/visionset/formats/classification/__init__.py +++ b/src/visionset/formats/classification/__init__.py @@ -58,6 +58,7 @@ class index on the *from-the-schema-not-the-data* half of its rule — deriving from typing import Final from visionset.formats._layout import IMAGES_DIRNAME, folds_of, write_image +from visionset.formats._targets import self_target from visionset.kernel.domain import ( ClassificationGeometry, GeometryType, @@ -107,6 +108,8 @@ class ClassificationExporter: #: A classification dataset is a directory of pictures. supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, diff --git a/src/visionset/formats/coco/__init__.py b/src/visionset/formats/coco/__init__.py index 031d9c5c..14f4270d 100644 --- a/src/visionset/formats/coco/__init__.py +++ b/src/visionset/formats/coco/__init__.py @@ -54,6 +54,7 @@ folds_of, write_image, ) +from visionset.formats._targets import self_target from visionset.kernel.domain import ( BboxGeometry, GeometryType, @@ -115,6 +116,8 @@ class CocoExporter: supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, diff --git a/src/visionset/formats/lanes/__init__.py b/src/visionset/formats/lanes/__init__.py index e264ed35..2210cd7a 100644 --- a/src/visionset/formats/lanes/__init__.py +++ b/src/visionset/formats/lanes/__init__.py @@ -52,6 +52,7 @@ folds_of, write_image, ) +from visionset.formats._targets import self_target from visionset.formats.lanes._core import ( BDD100K_CATEGORIES, CULANE_SLOTS, @@ -106,6 +107,11 @@ class TuSimpleExporter: supported_modalities = frozenset({"image"}) + #: The one self-named target whose geometries are not the supported set: + #: that set is empty here, everything arriving degraded, and a target must + #: carry at least one geometry — so it names the polyline this format writes. + targets = self_target(format_name, frozenset({GeometryType.POLYLINE})) + def export( self, release: Release, @@ -138,6 +144,8 @@ class CurveLanesExporter: degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, @@ -164,6 +172,8 @@ class Bdd100kLaneExporter: degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, @@ -196,6 +206,8 @@ class CuLaneExporter: degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, @@ -229,6 +241,8 @@ class OpenLane2dExporter: degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, diff --git a/src/visionset/formats/voc/__init__.py b/src/visionset/formats/voc/__init__.py index f088bcbf..17f59672 100644 --- a/src/visionset/formats/voc/__init__.py +++ b/src/visionset/formats/voc/__init__.py @@ -45,6 +45,7 @@ folds_of, write_image, ) +from visionset.formats._targets import self_target from visionset.kernel.domain import ( BboxGeometry, GeometryType, @@ -101,6 +102,8 @@ class VocExporter: supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, diff --git a/src/visionset/formats/yolo/__init__.py b/src/visionset/formats/yolo/__init__.py index 18f52f89..5f01f0a3 100644 --- a/src/visionset/formats/yolo/__init__.py +++ b/src/visionset/formats/yolo/__init__.py @@ -62,6 +62,7 @@ folds_of, write_image, ) +from visionset.formats._targets import self_target from visionset.kernel.domain import ( BboxGeometry, Geometry, @@ -118,6 +119,8 @@ class YoloDetectionExporter: #: A YOLO dataset is a directory of pictures. supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) + def export( self, release: Release, diff --git a/src/visionset/kernel/__init__.py b/src/visionset/kernel/__init__.py index 63d9262b..b0690caf 100644 --- a/src/visionset/kernel/__init__.py +++ b/src/visionset/kernel/__init__.py @@ -34,6 +34,8 @@ EntityNotFound, ExportFormatNotFound, ExportSourceUnreadable, + ExportTargetConflict, + ExportTargetNotFound, GeometryNotProduced, InferenceConnectionInvalid, InferenceConnectionModelFixed, @@ -49,6 +51,7 @@ IngestJobNotFound, InvalidAnnotation, InvalidAttributeValue, + InvalidExportTarget, InvalidName, InvalidPartition, InvalidSchema, @@ -124,10 +127,13 @@ "EntityNotFound", "ExportFormatNotFound", "ExportSourceUnreadable", + "ExportTargetConflict", + "ExportTargetNotFound", "GeometryNotProduced", "IngestJobNotFound", "InvalidAnnotation", "InvalidAttributeValue", + "InvalidExportTarget", "InvalidName", "InvalidPartition", "InvalidSchema", diff --git a/src/visionset/kernel/domain/__init__.py b/src/visionset/kernel/domain/__init__.py index bf43fccf..a6d89829 100644 --- a/src/visionset/kernel/domain/__init__.py +++ b/src/visionset/kernel/domain/__init__.py @@ -66,6 +66,14 @@ IngestCompleted, ReleasePublished, ) +from visionset.kernel.domain.export_target import ( + TARGET_NAME_PATTERN, + ExportTarget, + PreprocessingHints, + ResizeStrategy, + TargetFamily, + Task, +) from visionset.kernel.domain.geometry import ( IMPLEMENTED_GEOMETRIES, BboxGeometry, @@ -379,6 +387,12 @@ "ClassExportStatus", "ExportCompatibility", "ExportResult", + "ExportTarget", + "PreprocessingHints", + "ResizeStrategy", + "TargetFamily", + "Task", + "TARGET_NAME_PATTERN", "Geometry", "GeometryType", "ImageFormat", diff --git a/src/visionset/kernel/domain/export_target.py b/src/visionset/kernel/domain/export_target.py new file mode 100644 index 00000000..17c899c2 --- /dev/null +++ b/src/visionset/kernel/domain/export_target.py @@ -0,0 +1,121 @@ +# usage: from visionset.kernel.domain import ExportTarget, Task, TargetFamily +"""Export targets: the model a person will train, declared by an exporter. + +The user-facing unit of export is a target, and the format that writes for it +is an implementation detail of the declaration: a target resolves to exactly +one exporter, never to a runtime switch. Exporters declare their targets on the +``Exporter`` port, so the catalog every surface renders is derived from what is +installed rather than kept anywhere by hand. + +``ResizeStrategy`` lives here rather than with the pre-processing steps because +:class:`PreprocessingHints` references it: a target recommends a strategy, and +a recipe later applies one. +""" + +from __future__ import annotations + +import re +from enum import StrEnum +from typing import Final + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from visionset.kernel.domain.schema import GeometryType + +TARGET_NAME_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9-]*$") +"""What a target may be called: a lowercase slug, as typed in a URL or a flag. + +A target name is an identifier a person types and a script repeats — ``yolo11``, +never a display string. The label field is where capitals and spaces belong. +""" + + +class Task(StrEnum): + """A trainer-side task an export target accepts.""" + + DETECT = "detect" + SEGMENT = "segment" + CLASSIFY = "classify" + POSE = "pose" + OBB = "obb" + SEMANTIC = "semantic" + DEPTH = "depth" + + +class ResizeStrategy(StrEnum): + """How an image reaches a requested size. + + ``stretch`` scales each axis independently onto the size; ``letterbox`` + scales by the limiting axis and pads the rest, preserving aspect ratio. + """ + + STRETCH = "stretch" + LETTERBOX = "letterbox" + + +class PreprocessingHints(BaseModel): + """What a target's trainer expects of its input images. + + Hints, never requirements: an export is valid without honouring any of + them. ``recommended_size`` is ``(width, height)``. ``trainer_resizes`` says + the trainer resizes on its own, so pre-resizing is an optimization rather + than a need; ``augmentation_common`` says augmentation is the ordinary + practice when training this target. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + recommended_size: tuple[int, int] | None + recommended_strategy: ResizeStrategy | None + trainer_resizes: bool + augmentation_common: bool + + @model_validator(mode="after") + def _strategy_requires_a_size(self) -> PreprocessingHints: + if self.recommended_strategy is not None and self.recommended_size is None: + raise ValueError("a recommended strategy without a recommended size resizes to nothing") + return self + + +class TargetFamily(StrEnum): + """Which group of trainers a target belongs to. + + ``other`` is the family of every exporter that is not a YOLO trainer: such + an exporter declares one target named after itself, so every export is + addressed the same way. + """ + + ULTRALYTICS_YOLO = "ultralytics-yolo" + COMMUNITY_YOLO = "community-yolo" + OTHER = "other" + + +class ExportTarget(BaseModel): + """One model a person can train on an exporter's output. + + Declared on the ``Exporter`` port; names are unique across every installed + plugin, which is what lets a caller name a target and nothing else. + ``tasks`` is what the trainer accepts — empty for family ``other``, where + there is no trainer to accept anything. ``supported_geometries`` is what an + export addressed to this target carries, never wider than what the + declaring exporter can write. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + label: str + family: TargetFamily + tasks: frozenset[Task] + supported_geometries: frozenset[GeometryType] = Field(min_length=1) + hints: PreprocessingHints + + @field_validator("name") + @classmethod + def _name_is_a_slug(cls, value: str) -> str: + if not TARGET_NAME_PATTERN.match(value): + raise ValueError( + f"target name {value!r} is not a slug: lowercase letters, digits and " + "hyphens, starting with a letter or digit" + ) + return value diff --git a/src/visionset/kernel/errors.py b/src/visionset/kernel/errors.py index 4e9167a6..44c25887 100644 --- a/src/visionset/kernel/errors.py +++ b/src/visionset/kernel/errors.py @@ -774,6 +774,54 @@ class ExportFormatNotFound(VisionSetError): """ +class ExportTargetNotFound(VisionSetError): + """No installed exporter declares a target with that name. + + ``ExportFormatNotFound``'s sibling one vocabulary over: a format is what an + exporter calls itself, a target is a model the exporter declares it can + write for, and a caller may name either. Like that one, this is the caller + naming something that is not there — installing a distribution whose + exporter declares the target is what fixes it. + """ + + installed: tuple[str, ...] | None = None + """Every target name the installed exporters declare, sorted. + + A class attribute with a ``None`` default and **not** a constructor + parameter, exactly as ``LossyExportNotConsented.compatibility`` is — so this + error stays constructible from one message. ``resolve_target`` sets it as a + keyword. + """ + + def __init__(self, message: str, *, installed: tuple[str, ...] | None = None) -> None: + super().__init__(message) + if installed is not None: + self.installed = installed + + +class ExportTargetConflict(VisionSetError): + """Two installed exporters declare a target under one name. + + Names resolve to exactly one exporter, so a duplicated one no longer + identifies anything and picking either plugin would be a guess. Not a + ``ExportTargetNotFound``: the target is very much there, twice, and the + remedy is to remove one of the distributions rather than to install one. + The message names the formats making the claim. + """ + + +class InvalidExportTarget(VisionSetError): + """An exporter declares a target it cannot deliver. + + A target's ``supported_geometries`` must stay within the declaring + exporter's own, because the target is a promise about that exporter's + output — one claiming a geometry the exporter never writes would make the + catalog describe files that do not appear. Raised by ``validate_targets``, + which is a check on the *declaration*: nothing about the caller's request + is wrong, the installed plugin is. + """ + + class ExportSourceUnreadable(VisionSetError): """A release names bytes an export cannot use: gone, or not decodable. diff --git a/src/visionset/kernel/ports/__init__.py b/src/visionset/kernel/ports/__init__.py index 27702589..843af19e 100644 --- a/src/visionset/kernel/ports/__init__.py +++ b/src/visionset/kernel/ports/__init__.py @@ -7,7 +7,12 @@ from visionset.kernel.ports.auth_provider import AuthProvider from visionset.kernel.ports.blob_store import BlobStore from visionset.kernel.ports.event_bus import EventBus -from visionset.kernel.ports.exporter import ContentReader, Exporter +from visionset.kernel.ports.exporter import ( + ContentReader, + Exporter, + resolve_target, + validate_targets, +) from visionset.kernel.ports.image_processor import ( DEFAULT_THUMBNAIL_MAX_EDGE, THUMBNAIL_FORMAT, @@ -55,4 +60,6 @@ "UnitOfWork", "VideoProcessor", "WeightsSource", + "resolve_target", + "validate_targets", ] diff --git a/src/visionset/kernel/ports/exporter.py b/src/visionset/kernel/ports/exporter.py index b74face4..b3d3fb98 100644 --- a/src/visionset/kernel/ports/exporter.py +++ b/src/visionset/kernel/ports/exporter.py @@ -1,8 +1,13 @@ -from collections.abc import Callable +from collections.abc import Callable, Mapping from pathlib import Path from typing import BinaryIO, Protocol, runtime_checkable -from visionset.kernel.domain import GeometryType, Manifest, Release +from visionset.kernel.domain import ExportTarget, GeometryType, Manifest, Release +from visionset.kernel.errors import ( + ExportTargetConflict, + ExportTargetNotFound, + InvalidExportTarget, +) ContentReader = Callable[[str], BinaryIO] """Resolve one content hash to the bytes behind it, for the duration of a call. @@ -136,6 +141,22 @@ class Exporter(Protocol): #: ``image``. supported_modalities: frozenset[str] + #: Which models a person can train on this format's output. + #: + #: At least one, so every installed format is reachable through the one + #: target control a surface renders — an exporter with no target would be a + #: format nothing can address. A non-YOLO format declares a single target + #: named after its own ``format_name``, family ``other``, so exporting to it + #: is the same gesture as exporting to a trainer. + #: + #: Names are unique across every installed plugin — :func:`resolve_target` + #: is what turns one into an exporter — and each target's + #: ``supported_geometries`` stays within the exporter's own, which + #: :func:`validate_targets` checks: a target is a promise about this + #: exporter's output, and one promising a geometry the format never writes + #: would make the catalog describe files that do not appear. + targets: frozenset[ExportTarget] + def export( self, release: Release, @@ -144,3 +165,60 @@ def export( *, content: ContentReader, ) -> None: ... + + +def validate_targets(exporter: Exporter) -> None: + """Check an exporter's target declarations against the exporter itself. + + Every target's ``supported_geometries`` must be a subset of the exporter's + own, so a defective declaration is refused where it can be named rather + than surfacing as a catalog entry whose exports are missing what it + promised. + + Raises: + InvalidExportTarget: a target claims a geometry the exporter does not + write. + """ + for target in exporter.targets: + undeliverable = target.supported_geometries - exporter.supported_geometries + if undeliverable: + claimed = ", ".join(sorted(one.value for one in undeliverable)) + raise InvalidExportTarget( + f"format {exporter.format_name!r} declares target {target.name!r} " + f"supporting geometries it does not write: {claimed}" + ) + + +def resolve_target(installed: Mapping[str, Exporter], name: str) -> tuple[Exporter, ExportTarget]: + """The exporter declaring that target, and the declaration itself. + + Pure resolution over exporters already in hand, the way ``pick`` resolves a + format name: the kernel may not scan entry points, so whoever composed the + call passes what is installed. + + Raises: + ExportTargetNotFound: no installed exporter declares the name. + ExportTargetConflict: more than one installed exporter declares it. + """ + matches = [ + (exporter, target) + for exporter in installed.values() + for target in exporter.targets + if target.name == name + ] + if not matches: + every = tuple( + sorted(target.name for exporter in installed.values() for target in exporter.targets) + ) + known = ", ".join(every) or "none" + raise ExportTargetNotFound( + f"no installed exporter declares target {name!r}; installed targets: {known}", + installed=every, + ) + if len(matches) > 1: + formats = ", ".join(sorted(exporter.format_name for exporter, _ in matches)) + raise ExportTargetConflict( + f"target {name!r} is declared by more than one installed format ({formats}); " + "remove one of the distributions, or export by format name" + ) + return matches[0] diff --git a/src/visionset/server/errors.py b/src/visionset/server/errors.py index 32de741d..85a3a3cd 100644 --- a/src/visionset/server/errors.py +++ b/src/visionset/server/errors.py @@ -71,6 +71,8 @@ EntityNotFound, ExportFormatNotFound, ExportSourceUnreadable, + ExportTargetConflict, + ExportTargetNotFound, GeometryNotProduced, InferenceConnectionInvalid, InferenceConnectionModelFixed, @@ -86,6 +88,7 @@ IngestJobNotFound, InvalidAnnotation, InvalidAttributeValue, + InvalidExportTarget, InvalidName, InvalidPartition, InvalidSchema, @@ -240,6 +243,12 @@ class ErrorRule: # missing a tool it should have"; it is "there is no such thing here", and # ``GET /formats`` is what says which things there are. ExportFormatNotFound: ErrorRule(404, "EXPORT_FORMAT_NOT_FOUND"), + # The same reading one vocabulary over: the caller named a target no + # installed exporter declares. No route raises it yet — the target routes are + # not built — mapped anyway for BATCH_IMMUTABLE's reason: the + # exact-correspondence test keeps this table total, and an unmapped kernel + # error would answer 500 the day a route appears. + ExportTargetNotFound: ErrorRule(404, "EXPORT_TARGET_NOT_FOUND"), # A preview that was never rendered, which is not damage: a thumbnail hash is # a cache key, so NULL is an ordinary state with three causes and one remedy. # A 404 rather than an empty 200 because the caller asked for a specific @@ -454,6 +463,15 @@ class ErrorRule: InferenceConnectionNotRunnable: ErrorRule( 500, "INFERENCE_CONNECTION_NOT_RUNNABLE", expose_message=True ), + # A deployment condition on NOT_RUNNABLE's reading: two installed + # distributions claim one target name, and no edit to the request changes + # what is installed. No route raises it yet; mapped for BATCH_IMMUTABLE's + # reason. + ExportTargetConflict: ErrorRule(500, "EXPORT_TARGET_CONFLICT"), + # A defective installed plugin — a target promising geometries its own + # exporter never writes — which is nothing a caller can fix. No route + # raises it yet; mapped for BATCH_IMMUTABLE's reason. + InvalidExportTarget: ErrorRule(500, "INVALID_EXPORT_TARGET"), } ERROR_RESPONSES: Final[dict[int | str, dict[str, Any]]] = { diff --git a/tests/formats/test_entry_points.py b/tests/formats/test_entry_points.py index 32061edc..ab756d7c 100644 --- a/tests/formats/test_entry_points.py +++ b/tests/formats/test_entry_points.py @@ -18,3 +18,4 @@ def test_discovered_exporter_satisfies_the_port() -> None: exporter = exporter_cls() assert isinstance(exporter, Exporter) assert exporter.format_name == "dummy" + assert {target.name for target in exporter.targets} == {"dummy"} diff --git a/tests/formats/test_registry.py b/tests/formats/test_registry.py index 4ea985bc..a4a0b9c7 100644 --- a/tests/formats/test_registry.py +++ b/tests/formats/test_registry.py @@ -13,6 +13,7 @@ import pytest +from visionset.formats._targets import self_target from visionset.formats.registry import exporter, exporters, pick from visionset.kernel.domain import Annotation, GeometryType, Manifest, Release from visionset.kernel.errors import ExportFormatNotFound @@ -38,6 +39,7 @@ class _AnExporter: supported_geometries = frozenset(GeometryType) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) def export( self, @@ -59,6 +61,30 @@ def test_a_discovered_exporter_declares_whether_it_is_lossy() -> None: assert exporters()["dummy"].lossy is False +def test_a_discovered_exporter_declares_its_targets() -> None: + (target,) = exporters()["dummy"].targets + + assert target.name == "dummy" + assert target.tasks == frozenset() + + +def test_every_installed_exporter_stays_discovered() -> None: + """Discovery filters on the port, so one missing member silently drops a + plugin from every surface — this is what would say which one.""" + assert set(exporters()) >= { + "dummy", + "yolo", + "coco", + "voc", + "classification", + "tusimple", + "curvelanes", + "bdd100k-lane", + "culane", + "openlane-2d", + } + + def test_exporters_are_keyed_by_what_they_call_themselves() -> None: """Not by their entry-point name: only one of the two is the caller's contract.""" assert all(name == plugin.format_name for name, plugin in exporters().items()) @@ -130,3 +156,31 @@ def export( return None assert not isinstance(_Outdated(), Exporter) + + +def test_a_plugin_missing_the_targets_member_is_not_an_exporter() -> None: + """An exporter with no target would be a format no target control can reach. + + Unlike ``_Outdated`` above, this plugin carries every *other* member of the + port, so what fails the check is ``targets`` alone. + """ + from visionset.kernel.ports import Exporter + + class _Targetless: + format_name = "targetless" + lossy = False + supported_geometries = frozenset(GeometryType) + degraded_geometries: frozenset[GeometryType] = frozenset() + supported_modalities = frozenset({"image"}) + + def export( + self, + release: Release, + manifest: Manifest, + dest: Path, + *, + content: ContentReader, + ) -> None: + return None + + assert not isinstance(_Targetless(), Exporter) diff --git a/tests/kernel/test_export_target.py b/tests/kernel/test_export_target.py new file mode 100644 index 00000000..d61dd3a9 --- /dev/null +++ b/tests/kernel/test_export_target.py @@ -0,0 +1,206 @@ +"""The export-target domain: what a target may declare, and how a name resolves. + +The port-side contract — an exporter without ``targets`` is not an ``Exporter`` +at all — is asserted in ``tests/formats/test_registry.py`` beside its siblings; +this file owns the model's invariants and the pure kernel resolution. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from visionset.kernel.domain import ( + ExportTarget, + GeometryType, + Manifest, + PreprocessingHints, + Release, + ResizeStrategy, + TargetFamily, + Task, +) +from visionset.kernel.errors import ( + ExportTargetConflict, + ExportTargetNotFound, + InvalidExportTarget, +) +from visionset.kernel.ports import ContentReader, resolve_target, validate_targets + +NO_HINTS = PreprocessingHints( + recommended_size=None, + recommended_strategy=None, + trainer_resizes=True, + augmentation_common=False, +) + + +def _target( + name: str = "a-target", + geometries: frozenset[GeometryType] = frozenset({GeometryType.BBOX}), +) -> ExportTarget: + return ExportTarget( + name=name, + label=name, + family=TargetFamily.OTHER, + tasks=frozenset(), + supported_geometries=geometries, + hints=NO_HINTS, + ) + + +class _Format: + lossy = False + degraded_geometries: frozenset[GeometryType] = frozenset() + supported_modalities = frozenset({"image"}) + + def __init__( + self, + format_name: str, + supported: frozenset[GeometryType], + targets: frozenset[ExportTarget], + ) -> None: + self.format_name = format_name + self.supported_geometries = supported + self.targets = targets + + def export( + self, + release: Release, + manifest: Manifest, + dest: Path, + *, + content: ContentReader, + ) -> None: + return None + + +def test_a_target_carries_its_whole_declaration() -> None: + target = ExportTarget( + name="yolo11", + label="YOLO11", + family=TargetFamily.ULTRALYTICS_YOLO, + tasks=frozenset({Task.DETECT, Task.SEGMENT}), + supported_geometries=frozenset({GeometryType.BBOX, GeometryType.POLYGON}), + hints=PreprocessingHints( + recommended_size=(640, 640), + recommended_strategy=ResizeStrategy.LETTERBOX, + trainer_resizes=True, + augmentation_common=True, + ), + ) + + assert target.name == "yolo11" + assert target.hints.recommended_size == (640, 640) + + +@pytest.mark.parametrize("name", ["YOLO11", "-lead", "a_b", "", "yolo 11", "über"]) +def test_a_target_name_must_be_a_slug(name: str) -> None: + with pytest.raises(ValidationError): + _target(name=name) + + +@pytest.mark.parametrize("name", ["yolo11", "bdd100k-lane", "26", "a"]) +def test_slug_names_are_accepted(name: str) -> None: + assert _target(name=name).name == name + + +def test_a_target_must_carry_at_least_one_geometry() -> None: + with pytest.raises(ValidationError): + _target(geometries=frozenset()) + + +def test_a_recommended_strategy_requires_a_recommended_size() -> None: + with pytest.raises(ValidationError): + PreprocessingHints( + recommended_size=None, + recommended_strategy=ResizeStrategy.LETTERBOX, + trainer_resizes=True, + augmentation_common=True, + ) + + +def test_a_recommended_size_needs_no_strategy() -> None: + hints = PreprocessingHints( + recommended_size=(640, 640), + recommended_strategy=None, + trainer_resizes=True, + augmentation_common=False, + ) + + assert hints.recommended_strategy is None + + +def test_targets_live_in_a_frozenset() -> None: + """The port field's type: a declaration that is not hashable cannot be one.""" + declared = frozenset({_target("one"), _target("two")}) + + assert len(declared) == 2 + + +def test_resolving_returns_the_declaring_exporter_and_the_declaration() -> None: + target = _target("wanted") + plugin = _Format("a-format", frozenset({GeometryType.BBOX}), frozenset({target})) + other = _Format("b-format", frozenset({GeometryType.BBOX}), frozenset({_target("unwanted")})) + + found_exporter, found_target = resolve_target( + {plugin.format_name: plugin, other.format_name: other}, "wanted" + ) + + assert found_exporter is plugin + assert found_target is target + + +def test_an_unknown_target_is_refused_listing_what_is_installed() -> None: + plugin = _Format("a-format", frozenset({GeometryType.BBOX}), frozenset({_target("real")})) + + with pytest.raises(ExportTargetNotFound) as refusal: + resolve_target({plugin.format_name: plugin}, "reall") + + assert "reall" in str(refusal.value) + assert "real" in str(refusal.value) + assert refusal.value.installed == ("real",) + + +def test_the_refusal_says_none_when_nothing_declares_a_target() -> None: + with pytest.raises(ExportTargetNotFound) as refusal: + resolve_target({}, "anything") + + assert "none" in str(refusal.value) + + +def test_a_target_declared_twice_is_a_conflict_naming_both_formats() -> None: + first = _Format("a-format", frozenset({GeometryType.BBOX}), frozenset({_target("taken")})) + second = _Format("b-format", frozenset({GeometryType.BBOX}), frozenset({_target("taken")})) + + with pytest.raises(ExportTargetConflict) as refusal: + resolve_target({first.format_name: first, second.format_name: second}, "taken") + + assert "a-format" in str(refusal.value) + assert "b-format" in str(refusal.value) + + +def test_a_target_within_its_exporter_validates() -> None: + plugin = _Format( + "a-format", + frozenset({GeometryType.BBOX, GeometryType.POLYGON}), + frozenset({_target(geometries=frozenset({GeometryType.BBOX}))}), + ) + + validate_targets(plugin) + + +def test_a_target_wider_than_its_exporter_is_refused_by_name() -> None: + plugin = _Format( + "a-format", + frozenset({GeometryType.BBOX}), + frozenset({_target("wide", frozenset({GeometryType.BBOX, GeometryType.POLYGON}))}), + ) + + with pytest.raises(InvalidExportTarget) as refusal: + validate_targets(plugin) + + assert "wide" in str(refusal.value) + assert str(refusal.value).endswith("polygon") diff --git a/tests/mcp/test_release_tools.py b/tests/mcp/test_release_tools.py index ba607288..528d366e 100644 --- a/tests/mcp/test_release_tools.py +++ b/tests/mcp/test_release_tools.py @@ -9,6 +9,7 @@ import pytest from tests.mcp._flow import BBOX, SCHEMA_CLASSES, call, error, open_batch, payload +from visionset.formats._targets import self_target from visionset.kernel.domain import GeometryType from visionset.kernel.ports import ContentReader, Exporter from visionset.kernel.services import EXPORT_REPORT_FILENAME @@ -352,6 +353,7 @@ class LossyExporter: supported_geometries = frozenset(GeometryType) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) def export( self, @@ -413,6 +415,7 @@ class PolygonsOnlyExporter: supported_geometries = frozenset({GeometryType.POLYGON}) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) def export( self, diff --git a/tests/server/test_errors.py b/tests/server/test_errors.py index 838d640b..491298f2 100644 --- a/tests/server/test_errors.py +++ b/tests/server/test_errors.py @@ -69,6 +69,7 @@ "AssetNotInDataset": (404, "ASSET_NOT_IN_DATASET"), "NoSplitRecipe": (404, "NO_SPLIT_RECIPE"), "ExportFormatNotFound": (404, "EXPORT_FORMAT_NOT_FOUND"), + "ExportTargetNotFound": (404, "EXPORT_TARGET_NOT_FOUND"), # A release naming bytes an export cannot use. 409 rather than 500 for # `UnserializableManifest`'s reason — the request is fine, the stored state is # not — so the message naming the asset reaches the caller. @@ -143,6 +144,8 @@ "LocalInferenceUnavailable": (500, "LOCAL_INFERENCE_UNAVAILABLE"), "InferenceOutOfMemory": (500, "INFERENCE_OUT_OF_MEMORY"), "InferenceConnectionNotRunnable": (500, "INFERENCE_CONNECTION_NOT_RUNNABLE"), + "ExportTargetConflict": (500, "EXPORT_TARGET_CONFLICT"), + "InvalidExportTarget": (500, "INVALID_EXPORT_TARGET"), } # A code outlives the class name it was derived from. Rename a class and its