Smarter Annotations Library
Kubernetes-style annotations on every Smarter resource: their validation, their storage as rows of
Annotation, queries and selectors, and the handlers and signal
that integrations use to react when they change. See
Annotations for how manifests use them.
Smarter annotations: Kubernetes-style key-value metadata on every Smarter resource.
Every resource built on smarter.lib.django.models.MetaDataModel has annotations, set
from its manifest’s metadata.annotations. Integrations use them to associate Smarter
resources with resources in their own systems, query them (selectors,
models.Annotation), and react when they change (handlers, signals).
Validation
Validation and normalization of Smarter annotations.
Smarter annotations follow Kubernetes annotations: a map of string keys to string values that
any client or extension may attach to a resource, for its own purposes. A key is an optional
DNS-subdomain prefix that names its owner, e.g. acme.com, then a slash and a name, e.g.
acme.com/crm-account-id. Smarter’s keys are a superset of Kubernetes keys: the name may have
several slash-separated segments, e.g. smarter.sh/llmclient/use-case. Every valid Kubernetes
key is a valid Smarter key.
The functions here are the one place that decides what a valid annotation is. The SAM manifest
models, the MetaDataModel.annotations setter, and the data migrations all call them.
- smarter.lib.annotation.validation.AnnotationsInput = None | str | dict[str, typing.Any] | list[dict[str, typing.Any]]
What callers may pass as annotations.
A map, the legacy list of one-key maps, either of them as a JSON string, or
None.
- smarter.lib.annotation.validation.AnnotationsMap
string keys mapped to string values, as in Kubernetes.
- Type:
A resource’s annotations
- exception smarter.lib.annotation.validation.SmarterAnnotationError(message='')[source]
Bases:
SmarterValueErrorRaised for an invalid annotation key, value or set of annotations.
- smarter.lib.annotation.validation.annotations_size(annotations)[source]
Return the size of a resource’s annotations in bytes: every key and value, UTF-8 encoded.
- smarter.lib.annotation.validation.coerce_annotation_value(key, value, lenient=False)[source]
Return an annotation’s value as a string.
Annotation values are strings, as in Kubernetes. YAML parses unquoted scalars such as
true,42or2026-10-28as other types, so they are converted to the string that was written: booleans to"true"or"false", dates to ISO 8601,Noneto"".- Parameters:
- Raises:
SmarterAnnotationError – If the value is a list, a dict, or bytes that aren’t UTF-8.
- Returns:
The value as a string.
- Return type:
- smarter.lib.annotation.validation.is_reserved_annotation_key(key)[source]
Return whether a key belongs to the Smarter platform: its prefix is
smarter.shor a subdomain.
- smarter.lib.annotation.validation.normalize_annotations(value, lenient=False)[source]
Return annotations in any accepted shape as a validated map of strings to strings.
Accepts the Kubernetes map,
{"acme.com/crm-id": "42"}, and the list of one-key maps that older SAM manifests use,[{"acme.com/crm-id": "42"}], or either one as a JSON string.Example:
normalize_annotations([{"acme.com/active": True}]) # {"acme.com/active": "true"}
- Parameters:
- Raises:
SmarterAnnotationError – If a key or value is invalid, a key appears twice, or the annotations are larger than 256 KiB.
- Returns:
The annotations, in their original order.
- Return type:
- smarter.lib.annotation.validation.prefix_matches(prefix, owner)[source]
Return whether a key’s prefix is
owneror one of its subdomains.Example:
prefix_matches("crm.acme.com", "acme.com") # True prefix_matches("notacme.com", "acme.com") # False
- Parameters:
prefix (
str|None) – A key’s prefix, fromsplit_annotation_key().owner (
str) – A DNS domain, e.g.acme.com.
- Returns:
True if the prefix belongs to the owner.
- Return type:
- smarter.lib.annotation.validation.split_annotation_key(key)[source]
Split an annotation key into its prefix and its name.
The prefix is everything before the first slash. A key without a slash has no prefix.
Example:
split_annotation_key("acme.com/crm/account-id") # ("acme.com", "crm/account-id") split_annotation_key("owner") # (None, "owner")
- smarter.lib.annotation.validation.validate_annotation_key(key)[source]
Validate an annotation key, and return it.
A key is
[prefix/]name. The prefix, when present, is a DNS subdomain of at most 253 characters. The name is one or more segments separated by slashes. Each segment is at most 63 characters, alphanumeric at both ends, with-,_and.between. The whole key is at most 317 characters.- Parameters:
key (
str) – The annotation key.- Raises:
SmarterAnnotationError – If the key is not valid.
- Returns:
The key.
- Return type:
Constants of Smarter annotations, which follow Kubernetes annotations.
- smarter.lib.annotation.const.ANNOTATIONS_TOTAL_MAX_BYTES = 262144
256 KiB, as in Kubernetes.
- Type:
The most that one resource’s annotations, keys and values together, may hold
- smarter.lib.annotation.const.ANNOTATION_KEY_MAX_LENGTH = 317
the longest key that Kubernetes accepts.
- Type:
The longest key, 317 characters
- smarter.lib.annotation.const.ANNOTATION_KEY_PREFIX_MAX_LENGTH = 253
The longest key prefix, a DNS subdomain such as
acme.com, as in Kubernetes.
- smarter.lib.annotation.const.ANNOTATION_KEY_SEGMENT_MAX_LENGTH = 63
The longest segment of a key’s name, the parts between slashes, as in Kubernetes.
- smarter.lib.annotation.const.ANNOTATION_SELECTOR_PARAM = 'annotation'
The query parameter of the cli
getapi that holds an annotation selector, e.g.?annotation=acme.com/crm-id=42.
- smarter.lib.annotation.const.RESERVED_ANNOTATION_PREFIXES = ('smarter.sh',)
Key prefixes that belong to the Smarter platform, as
kubernetes.iobelongs to Kubernetes.A prefix’s subdomains are reserved too, e.g.
llmclient.smarter.sh. Manifests may carry these keys, but only Smarter’s own code registers handlers for them.('smarter.sh', )
- smarter.lib.annotation.const.SOURCE_COMMIT_ANNOTATION = 'smarter.sh/source-commit'
The commit of
SOURCE_REPOSITORY_ANNOTATIONthat the manifest was applied from.
- smarter.lib.annotation.const.SOURCE_PATH_ANNOTATION = 'smarter.sh/source-path'
The manifest’s file, relative to the root of
SOURCE_REPOSITORY_ANNOTATION, e.g.llm-clients/stackademy.yaml.
- smarter.lib.annotation.const.SOURCE_REPOSITORY_ANNOTATION = 'smarter.sh/source-repository'
The repository that a resource was applied from, e.g.
https://github.com/smarter-sh/stackademy.manage.py load_from_githubsets it, as Flux and Argo CD annotate the Kubernetes objects that they apply from git.
Model and Queries
The Annotation model: one row per annotation of a Smarter resource.
Annotations belong to smarter.lib.django.models.MetaDataModel, which is abstract, so a
row points at its resource through Django’s content types framework, as django-taggit’s tags do:
a content_type and an object_id. A resource that uses multi-table inheritance, e.g. a
PluginMetaStatic, keeps its annotations under its root model, PluginMeta, the table that
holds its metadata, so that both classes see the same annotations.
- class smarter.lib.annotation.models.Annotation(*args, **kwargs)[source]
Bases:
TimestampedModelOne annotation of a Smarter resource: a key and a string value, as in Kubernetes.
Read and write a resource’s annotations through
smarter.lib.django.models.MetaDataModel.annotations. Use this model directly to query annotations across resources.Example:
from smarter.lib.annotation.models import Annotation # every Smarter resource of any kind that maps to CRM account A-10042 Annotation.objects.with_key("acme.com/crm-account-id", "A-10042").resources() # every annotation that acme.com's integration owns Annotation.objects.with_prefix("acme.com")
- Parameters:
id (Unknown) – Primary key: ID
created_at (Unknown) – Created at
updated_at (Unknown) – Updated at
object_id (Unknown) – Object id. The primary key of the annotated resource, as a string: some resources’ keys are strings.
key (Unknown) – Key. The annotation key, e.g. ‘acme.com/crm-account-id’: an optional DNS prefix, then a name.
prefix (Unknown) – Prefix. The key’s prefix, e.g. ‘acme.com’, or empty when the key has none. Set from the key.
value (Unknown) – Value. The annotation value, a string.
content_object (Unknown) – Generic foreign key to the
ContentTypespecified incontent_type
Relationship fields:
- Parameters:
content_type (Unknown) – Content type. The kind of the annotated resource: its root model. (related name:
+)
- exception DoesNotExist
Bases:
ObjectDoesNotExist
- exception MultipleObjectsReturned
Bases:
MultipleObjectsReturned
- exception NotUpdated
Bases:
ObjectNotUpdated,DatabaseError
- content_object
Provide a generic many-to-one relation through the
content_typeandobject_idfields.This class also doubles as an accessor to the related object (similar to ForwardManyToOneDescriptor) by adding itself as a model attribute.
- content_type
-
Content type. The kind of the annotated resource: its root model. (related name:
+)- Type:
Type
- content_type_id
Internal field, use
content_typeinstead.
- created_at
-
Created at
Timestamp indicating when the model instance was created.
This field is automatically set to the current date and time when the instance is first created. It is indexed in the database for efficient querying.
- Type:
Type
- id: int
-
Primary key: ID
The row’s primary key, which Django adds to every concrete model. Declared, without a value, only so that type checkers know it: Django ignores a bare annotation.
- Type:
Type
- key
-
Key. The annotation key, e.g. ‘acme.com/crm-account-id’: an optional DNS prefix, then a name.
- Type:
Type
- object_id
-
Object id. The primary key of the annotated resource, as a string: some resources’ keys are strings.
- Type:
Type
- objects: AnnotationManager = <smarter.lib.annotation.models.AnnotationManager object>
- prefix
-
Prefix. The key’s prefix, e.g. ‘acme.com’, or empty when the key has none. Set from the key.
- Type:
Type
- updated_at
-
Updated at
Timestamp indicating when the model instance was last updated.
This field is automatically updated to the current date and time whenever the instance is saved. It is indexed in the database for efficient querying.
- Type:
Type
- class smarter.lib.annotation.models.AnnotationManager(*args, **kwargs)[source]
Bases:
ManagerThe manager of
Annotation, whose queries areAnnotationQuerySet.It declares the queryset’s methods, so that type checkers know them on
Annotation.objects.- delete_for_object(instance)[source]
See
AnnotationQuerySet.delete_for_object().- Return type:
- for_model(model)[source]
See
AnnotationQuerySet.for_model().- Return type:
- for_object(instance)[source]
See
AnnotationQuerySet.for_object().- Return type:
- sync(instance, annotations)[source]
Make a resource’s annotations exactly
annotations; seeAnnotationQuerySet.sync().
- with_key(key, value=None)[source]
Return the annotations with a key, and optionally a value; see
AnnotationQuerySet.with_key().- Parameters:
- Return type:
- Returns:
The annotations.
- with_prefix(prefix)[source]
See
AnnotationQuerySet.with_prefix().- Return type:
- class smarter.lib.annotation.models.AnnotationQuerySet(model=None, query=None, using=None, hints=None)[source]
Bases:
QuerySetQueries of annotations, across one resource, one kind, or every kind.
- delete_for_object(instance)[source]
Delete a resource’s annotations, when the resource itself is deleted.
Handlers and the
annotations_changedsignal are sent the removals, withresource_deleted=True.- Parameters:
instance (
Model) – The resource being deleted.- Returns:
The removals.
- Return type:
- for_model(model)[source]
Return the annotations of every resource of a model, e.g. every LLMClient.
- Parameters:
- Returns:
The annotations.
- Return type:
- for_object(instance)[source]
Return the annotations of one resource.
- Parameters:
instance (
Model) – The resource.- Returns:
The annotations.
- Return type:
- object_ids_for(model)[source]
Return the
object_idof each annotation, as the primary key type ofmodel, for a subquery.object_idis a string, because some resources’ primary keys are. Casting it back to an integer key lets the database compare it with the resource table’s index.Example:
LLMClient.objects.filter(pk__in=Annotation.objects.with_key("acme.com/id").object_ids_for(LLMClient))
- resources()[source]
Return the resources, of any kind, that these annotations belong to.
This is the lookup from a key in another system to the Smarter resources that are associated with it, e.g. every resource annotated
acme.com/crm-account-id=A-10042. Resources are returned as their root model (seeannotation_root_model()), and are not filtered by any user’s permissions.Example:
Annotation.objects.with_key("acme.com/crm-account-id", "A-10042").resources()
- sync(instance, annotations)[source]
Make a resource’s annotations exactly
annotations, and return what changed.Keys that aren’t in
annotationsare removed, askubectl applydoes with a manifest’s annotations. Registered annotation handlers, and theannotations_changedsignal, are sent the changes once the transaction commits.
- with_key(key, value=None)[source]
Return the annotations with a key, and optionally a value.
- Parameters:
- Returns:
The annotations.
- Return type:
- smarter.lib.annotation.models.annotation_content_type(model)[source]
Return the content type under which a resource’s annotations are stored.
- Parameters:
- Returns:
The content type of
annotation_root_model().- Return type:
- smarter.lib.annotation.models.annotation_root_model(model)[source]
Return the model under which a resource’s annotations are stored.
That is the resource’s farthest concrete ancestor: the model itself, unless it uses multi-table inheritance. Proxy models resolve to their concrete model.
- smarter.lib.annotation.models.get_cached_annotations(content_type_id, pk)[source]
Return the annotations of one resource, from the cache when possible.
Invalidated, once the transaction commits, whenever one of the resource’s rows is saved or deleted, however that happens: by
AnnotationQuerySet.sync(), by the Django admin, or directly (seesmarter.lib.annotation.receivers).
- smarter.lib.annotation.models.prefetch_annotations(instances)[source]
Read the annotations of many resources at once, in one query per kind.
This is
prefetch_related()for annotations, which aren’t a relation that Django can prefetch. A list that shows every resource’s annotations calls it with a page of resources, so that the page doesn’t cost one query per resource. Each instance then answerssmarter.lib.django.models.MetaDataModel.annotationsfrom what was read, until its annotations are next assigned or saved. Instances that have no annotations are skipped.Example:
page = list(LLMClient.objects.filter(user_profile=user_profile)[:50]) prefetch_annotations(page) [llmclient.annotations for llmclient in page] # no more queries
Selectors
Annotation selectors: filter resources by their annotations.
The syntax is that of Kubernetes label selectors, one requirement at a time. Several
requirements must all match. Values are free text and may contain commas, so a selector is a
list of requirements, not one comma-separated string. See
smarter.lib.annotation.contract.AnnotationRequirement.
Example:
from smarter.apps.llmclient.models import LLMClient
from smarter.lib.annotation.selectors import filter_by_annotations
filter_by_annotations(LLMClient.objects.all(), ["acme.com/crm-account-id=A-10042", "!acme.com/archived"])
- smarter.lib.annotation.selectors.AnnotationSelector = None | str | collections.abc.Iterable[str | smarter.lib.annotation.contract.AnnotationRequirement]
One requirement, a list of them, or
Nonefor no requirement.
- smarter.lib.annotation.selectors.filter_by_annotations(queryset, selector)[source]
Return the resources of
querysetthat match every requirement ofselector.As in Kubernetes,
key!=valuealso matches resources that don’t have the key.- Parameters:
- Raises:
SmarterAnnotationError – If a requirement is invalid.
- Returns:
The filtered queryset.
- Return type:
- smarter.lib.annotation.selectors.model_with_annotation(model, key, value=None)[source]
Return every resource of
modelthat has the annotationkey, withvalueif given.
- smarter.lib.annotation.selectors.parse_annotation_requirement(requirement)[source]
Parse one requirement, e.g.
acme.com/crm-id=A-10042.- Parameters:
requirement (
str) –key,!key,key=value,key==valueorkey!=value.- Raises:
SmarterAnnotationError – If the requirement or its key is invalid.
- Returns:
The requirement.
- Return type:
- smarter.lib.annotation.selectors.parse_annotation_selector(selector)[source]
Parse a selector into its requirements.
- Parameters:
selector (
None|str|Iterable[str|AnnotationRequirement]) – One requirement, a list of them, orNone.- Raises:
SmarterAnnotationError – If a requirement is invalid.
- Returns:
The requirements, empty for
None.- Return type:
Handlers and Signals
Annotation handlers: code that reacts when annotations with a given prefix change.
A handler is the Smarter counterpart of a Kubernetes controller that watches an annotation. An
integration owns a key prefix, e.g. acme.com, and registers a handler for it. Whenever a
manifest is applied, or a resource deleted, in a way that adds, changes or removes an annotation
whose prefix is acme.com or one of its subdomains, the handler is called once per changed
annotation, after the database transaction commits.
Example:
# acme_integration/apps.py
from django.apps import AppConfig
class AcmeIntegrationConfig(AppConfig):
name = "acme_integration"
def ready(self):
from . import handlers # noqa: F401 registers the handlers below
# acme_integration/handlers.py
from smarter.lib.annotation.handlers import AnnotationEvent, annotation_handler
from .tasks import link_crm_account
@annotation_handler("acme.com")
def crm_account(event: AnnotationEvent) -> None:
if event.change.key == "acme.com/crm-account-id" and event.change.action != "removed":
link_crm_account.delay(event.model_label, event.object_id, event.change.new_value)
Handlers run in the request that applied the manifest. Hand slow work, such as calls to a legacy system, to a Celery task. An exception in a handler is logged, and doesn’t affect the resource or the other handlers.
- class smarter.lib.annotation.handlers.AnnotationEvent(instance, model_label, object_id, change, resource_deleted=False)[source]
What an annotation handler is called with: one changed annotation of one resource.
- __init__(instance, model_label, object_id, change, resource_deleted=False)
- change: AnnotationChange
The annotation’s key, and its value before and after.
- instance: Model
The resource.
After a deletion, it is the deleted instance, and its
pkmay beNone.
- smarter.lib.annotation.handlers.AnnotationHandler
A function that is called with an
AnnotationEvent.alias of
Callable[[AnnotationEvent],None]
- smarter.lib.annotation.handlers.annotation_handler(prefix)[source]
Register the decorated function as an annotation handler for
prefix.See
register_annotation_handler().- Parameters:
prefix (
str) – A DNS domain that the integration owns, e.g.acme.com.- Returns:
The decorator.
- Return type:
Callable[[Callable[[AnnotationEvent],None]],Callable[[AnnotationEvent],None]]
- smarter.lib.annotation.handlers.dispatch_annotation_changes(instance, changes, resource_deleted=False)[source]
Send
changesto theannotations_changedsignal and the matching handlers, once the transaction commits.- Parameters:
instance (
Model) – The resource.changes (
list[AnnotationChange]) – What changed.resource_deleted (
bool) – True when the resource was deleted.
- Return type:
- smarter.lib.annotation.handlers.handlers_for_key(key)[source]
Return the handlers to call when the annotation
keychanges.- Parameters:
key (
str) – An annotation key.- Returns:
The handlers of every registered prefix that the key’s prefix matches.
- Return type:
list[Callable[[AnnotationEvent],None]]
- smarter.lib.annotation.handlers.register_annotation_handler(prefix, handler)[source]
Call
handlerwhenever an annotation whose key’s prefix isprefix, or a subdomain of it, changes.The prefixes that belong to the Smarter Project,
smarter.shand its subdomains, may only be handled by Smarter’s own code.- Parameters:
prefix (
str) – A DNS domain that the integration owns, e.g.acme.com.handler (
Callable[[AnnotationEvent],None]) – The function to call.
- Raises:
SmarterAnnotationError – If the prefix isn’t a DNS subdomain, or is reserved.
- Returns:
The handler, so that this can be used as a decorator.
- Return type:
- smarter.lib.annotation.handlers.unregister_annotation_handler(prefix, handler)[source]
Stop calling
handlerforprefix.Does nothing if it isn’t registered.
- Parameters:
prefix (
str) – The prefix it was registered for.handler (
Callable[[AnnotationEvent],None]) – The handler.
- Return type:
Signals of Smarter annotations.
- smarter.lib.annotation.signals.annotations_changed = <django.dispatch.dispatcher.Signal object>
Sent after a transaction that added, changed or removed a resource’s annotations commits.
Keyword arguments:
sender: the resource’s model class.instance: the resource.object_id: the resource’s primary key, which survives the resource’s deletion.changes: a list ofsmarter.lib.annotation.contract.AnnotationChange.resource_deleted: True when the annotations were removed because the resource was deleted.
Receivers that care about one integration’s keys should register an annotation handler instead (
smarter.lib.annotation.handlers.annotation_handler()), which is called with that prefix’s changes only.
Django signal receivers of Smarter annotations.
- smarter.lib.annotation.receivers.delete_annotations(sender, instance, **kwargs)[source]
Delete a resource’s annotations when the resource is deleted.
Annotations point at their resource through a content type, not a foreign key, so the database doesn’t cascade the deletion.
- smarter.lib.annotation.receivers.invalidate_cached_annotations(sender, instance, **kwargs)[source]
Make a resource’s cached annotations stale when one of its rows is saved or deleted.
Rows change through
sync(), and also through the Django admin and direct writes, which don’t go through it. Waiting for the commit keeps a request that reads the rows before the change commits from caching the old ones.
Pydantic models of annotation changes and selectors.
- smarter.lib.annotation.contract.AnnotationAction
What happened to an annotation.
alias of
Literal[‘added’, ‘changed’, ‘removed’]
- class smarter.lib.annotation.contract.AnnotationChange(**data)[source]
Bases:
BaseModelOne annotation of a resource that was added, changed or removed.
- model_config = {'extra': 'forbid', 'frozen': True}
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- smarter.lib.annotation.contract.AnnotationOperator
How an
AnnotationRequirementtests a resource’s annotation.alias of
Literal[‘exists’, ‘not_exists’, ‘equals’, ‘not_equals’]
- class smarter.lib.annotation.contract.AnnotationRequirement(**data)[source]
Bases:
BaseModelOne requirement of an annotation selector, in the syntax of Kubernetes label selectors.
Selector
Matches resources whose annotation
acme.com/crm-idacme.com/crm-idexists, with any value!acme.com/crm-idacme.com/crm-iddoesn’t existacme.com/crm-id=A-10042acme.com/crm-idisA-10042acme.com/crm-id!=A-10042acme.com/crm-idis missing, or isn’tA-10042- model_config = {'extra': 'forbid', 'frozen': True}
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
Data Migration
The data migration that moves annotations out of the JSON column of each model into Annotation rows.
MetaDataModel.annotations used to be a JSON column on every resource’s table, holding
a list of one-key maps. Each app that owns such a table has an annotation_rows migration that copies
the column into smarter.lib.annotation.models.Annotation rows, then removes it. This module
is the copy, shared by those migrations.
Note
This is a one-time migration, for upgrading a platform from Smarter 0.37 to the latest stable
release. Once every platform has upgraded, it has nothing left to do. Nothing outside those
annotation_rows migrations should call it.
- smarter.lib.annotation.data_migration.copy_annotations_to_rows(app_label, *model_names)[source]
Return the
RunPythonoperation that copies theannotationscolumn of models into Annotation rows.Runs before the
RemoveFieldoperations of the same migration. Legacy values are normalized leniently: invalid keys are skipped, a repeated key keeps its last value, and list or dict values are kept as JSON strings. The reverse copies the rows back into the column, as a list of one-key maps.