From 03d39af711aaf3895de699306e79ae64d87d59c0 Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Fri, 25 Sep 2026 09:40:59 +0200 Subject: [PATCH 1/4] docs(python): build the Python reference from the type stub, for Read the Docs The Python client's reference is built by Sphinx from datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi, which is now the one place its signatures and prose live. The same file is what IDEs show on hover, so the site and the tooltip cannot disagree. The build reads the stub statically and needs neither cargo nor the compiled module. sphinx-autoapi documents the data types, one page per class, grouped by hand in docs-python/*.rst; a class missing from those groups fails the build. The service classes are not something a caller names, so a small local extension (docs-python/_ext/service_pages.py) skips them and writes one page per client attribute instead: `timeseries.by_ids(...)`, methods grouped by docs-python/structure.toml, the sync and async clients side by side. A method missing from that file, or a name it lists that no service has, fails the build. The stub changes are what the build and stubtest needed: - Type aliases are gone; every annotation spells out what that parameter accepts, taken from the Rust type it binds to. The shared unions claimed more than most methods take -- timeseries.by_ids never accepted a Resource. - `list[...]` inside the services is `builtins.list[...]`: their `list` method shadowed the builtin, so mypy and Pyright read every later annotation in the class as invalid. DataSort's `property` shadowed `@property` the same way. - Constructors are `__new__`, as pyo3 builds them, and the classes that cannot be subclassed are `@final`. - TimeSeries' constructor matches the runtime order; DatapointsCollectionString declares its real constructor instead of five members it does not have; Page declares the sequence methods it implements. - The timeseries service and TimeSeries carry full numpydoc prose: parameters, returns, raises and runnable examples. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- .gitignore | 2 + .readthedocs.yaml | 15 + .../intellistream_datahub_sdk/__init__.pyi | 1930 ++++++++++++----- docs-python/_ext/service_pages.py | 220 ++ docs-python/_static/custom.css | 4 + .../_templates/autoapi/python/class.rst | 104 + .../_templates/autoapi/python/module.rst | 10 + docs-python/clients.rst | 12 + docs-python/conf.py | 46 + docs-python/datapoints.rst | 14 + docs-python/entities.rst | 21 + docs-python/files.rst | 10 + docs-python/filters-and-identifiers.rst | 19 + docs-python/graph.rst | 13 + docs-python/index.rst | 58 + docs-python/requirements.txt | 5 + docs-python/structure.toml | 113 + docs-python/subscriptions.rst | 16 + docs-python/updates.rst | 20 + 19 files changed, 2127 insertions(+), 505 deletions(-) create mode 100644 .readthedocs.yaml create mode 100644 docs-python/_ext/service_pages.py create mode 100644 docs-python/_static/custom.css create mode 100644 docs-python/_templates/autoapi/python/class.rst create mode 100644 docs-python/_templates/autoapi/python/module.rst create mode 100644 docs-python/clients.rst create mode 100644 docs-python/conf.py create mode 100644 docs-python/datapoints.rst create mode 100644 docs-python/entities.rst create mode 100644 docs-python/files.rst create mode 100644 docs-python/filters-and-identifiers.rst create mode 100644 docs-python/graph.rst create mode 100644 docs-python/index.rst create mode 100644 docs-python/requirements.txt create mode 100644 docs-python/structure.toml create mode 100644 docs-python/subscriptions.rst create mode 100644 docs-python/updates.rst diff --git a/.gitignore b/.gitignore index 828f237..c487861 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ __pycache__/ # Wheel and sdist build output /dist/ /datahub_python_bindings/dist/ +docs-python/_build/ +docs-python/services/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..6156fdb --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,15 @@ +# Read the Docs builds the Python client reference from the type stub; see docs-python/conf.py. +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.12" + +sphinx: + configuration: docs-python/conf.py + fail_on_warning: true + +python: + install: + - requirements: docs-python/requirements.txt diff --git a/datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi b/datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi index ccde6d0..d1a0f77 100644 --- a/datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi +++ b/datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi @@ -1,43 +1,28 @@ -"""Type stubs for the intellistream_datahub_sdk pyo3 extension module. - -The runtime is a single flat module: every class is exported at the top level. -This stub matches that structure; do not introduce submodules unless the Rust -registration in src/lib.rs::intellistream_datahub_sdk() also adds them. +# Type stubs for the intellistream_datahub_sdk pyo3 extension module. +# +# The runtime is a single flat module: every class is exported at the top level. +# This stub matches that structure; do not introduce submodules unless the Rust +# registration in src/lib.rs::intellistream_datahub_sdk() also adds them. +"""Python client for the IntelliStream DataHub. + +Services are reached through a configured client, never imported: ``client.timeseries``, +``client.events``, ``client.datasets`` and so on. ``AsyncDataHubClient`` exposes the same +services with every method a coroutine. """ from __future__ import annotations +import builtins + import datetime -from typing import Any, Iterable, Iterator, Mapping, Optional, Sequence, Union +from typing import Any, Iterable, Iterator, Mapping, Optional, Sequence, Union, final, overload from uuid import UUID -# One node of any type, as the /resources endpoints return them. Which class you get is decided -# by the node's own intrinsic type-label, so `isinstance(n, TimeSeries)` works and every object -# is the same class its own endpoint would hand back. `n.node_type` gives the name as a string -# when dispatching from data rather than by branching. -Node = Union["Asset", "TimeSeries", "Function", "Resource", "Dataset", "Policy"] - -# Convenience alias: every entity-like input accepts either the entity itself, -# an IdCollection wrapper, a numeric id, or an external_id string. -Identifiable = Union["TimeSeries", "Resource", "Unit", "Event", "IdCollection", int, str] - -# A filter's pattern list. `*` and `%` are wildcards, `_` is literal, matching is -# case-insensitive, and an entry with no wildcard matches exactly. Entries OR together. -# A bare string means the same as a one-element list. -PatternList = Union[str, Sequence[str]] - -# Metadata criteria: every entry must be present on the matched row. A `None` value matches -# the key alone, whatever it carries. -MetadataFilter = Mapping[str, Optional[str]] - -# How a filter names a data set: by numeric id, by external id, or by an explicit IdCollection. -DataSetRef = Union[int, str, "IdCollection"] -# A sort property, as one name or a one-element list. Only the first recognised entry is used. -SortBy = Union[str, Sequence[str]] +@final class Page(Sequence[Any]): """One page of a filter result: the rows, plus where to continue from. @@ -63,8 +48,18 @@ class Page(Sequence[Any]): Not a ``list`` subclass, so ``isinstance(page, list)`` is ``False``; use ``page.items`` when something demands a real list. """ - @property - def items(self) -> list[Any]: ... + def __len__(self) -> int: ... + @overload + def __getitem__(self, index: int) -> Any: ... + @overload + def __getitem__(self, index: slice) -> list[Any]: ... + def __iter__(self) -> Iterator[Any]: ... + def __contains__(self, item: object) -> bool: ... + @property + def items(self) -> list[Any]: + """ + The rows, as a plain list. + """ @property def next_cursor(self) -> str | None: """Send back as the next request's ``cursor``, with the same sort that produced it.""" @@ -110,9 +105,10 @@ class DataHubException(Exception): # ====================== Clients ====================== +@final class DataHubClient: - def __init__( - self, + def __new__( + cls, base_url: str, token: str | None = None, token_url: str | None = None, @@ -132,7 +128,7 @@ class DataHubClient: assertion_scope: str | None = None, assertion_audience: str | None = None, assertion_grant: str | None = None, - ) -> None: + ) -> DataHubClient: """Durable ingest buffering (off by default): when the API is unreachable, datapoint and event ingestion spools to disk and is flushed on a later call. Enable it with `enable_buffering=True` or by setting `buffer_retention_secs` / `buffer_max_bytes` @@ -188,9 +184,10 @@ class DataHubClient: def edges(self) -> EdgesServiceSync: ... +@final class AsyncDataHubClient: - def __init__( - self, + def __new__( + cls, base_url: str, token: str | None = None, token_url: str | None = None, @@ -210,7 +207,7 @@ class AsyncDataHubClient: assertion_scope: str | None = None, assertion_audience: str | None = None, assertion_grant: str | None = None, - ) -> None: + ) -> AsyncDataHubClient: """See `DataHubClient.__init__` for the durable-buffering parameters.""" ... @classmethod @@ -243,15 +240,17 @@ class AsyncDataHubClient: # ====================== Identifiers & search ====================== +@final class IdCollection: """Names an entity by `id`, `external_id`, or both. Building one with neither raises.""" - def __init__(self, id: int | None = None, external_id: str | None = None) -> None: ... + def __new__(cls, id: int | None = None, external_id: str | None = None) -> IdCollection: ... @property def id(self) -> int | None: ... @property def external_id(self) -> str | None: ... +@final class TimeSeriesFilter: """AND-combined criteria for ``timeseries.filter`` (``POST /timeseries/filter``) and the ``filter`` of ``timeseries.search``. @@ -261,8 +260,8 @@ class TimeSeriesFilter: paged differently each time. ``external_id``, ``name``, ``source``, ``unit`` and ``unit_external_id`` are pattern - lists — see ``PatternList``. Each is singular because each also takes a bare string, though a - list is always accepted. ``labels`` keeps its plural: its entries must **all** be present, and + lists: one string or a list of them, where ``*`` and ``%`` are wildcards, ``_`` is literal, + and matching ignores case. An entry without a wildcard matches exactly. ``labels`` keeps its plural: its entries must **all** be present, and so must every ``metadata`` entry, where a ``None`` value matches the key alone. ``value_type`` is matched exactly (case-insensitively) against ``BIGINT``, ``FLOAT``, ``FLOAT32``, ``NUMERIC``, ``DECIMAL32``, ``TEXT``, ``MIXED``. @@ -273,53 +272,57 @@ class TimeSeriesFilter: restriction when empty. """ - def __init__( - self, + def __new__( + cls, id: Sequence[int] | None = None, - external_id: PatternList | None = None, - name: PatternList | None = None, - source: PatternList | None = None, - labels: PatternList | None = None, - metadata: MetadataFilter | None = None, + external_id: str | Sequence[str] | None = None, + name: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + labels: str | Sequence[str] | None = None, + metadata: Mapping[str, str | None] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, - data_set_id: Sequence[DataSetRef] | None = None, - unit: PatternList | None = None, - unit_external_id: PatternList | None = None, - value_type: PatternList | None = None, - ) -> None: ... + data_set_id: Sequence[int | str | IdCollection] | None = None, + unit: str | Sequence[str] | None = None, + unit_external_id: str | Sequence[str] | None = None, + value_type: str | Sequence[str] | None = None, + ) -> TimeSeriesFilter: ... # ====================== Field update wrappers ====================== +@final class FieldStr: - def __init__(self, value: str | None = None, set_null: bool = False) -> None: ... + def __new__(cls, value: str | None = None, set_null: bool = False) -> FieldStr: ... @property def value(self) -> str | None: ... @property def set_null(self) -> bool: ... +@final class FieldU64: - def __init__(self, value: int | None = None, set_null: bool = False) -> None: ... + def __new__(cls, value: int | None = None, set_null: bool = False) -> FieldU64: ... @property def value(self) -> int | None: ... @property def set_null(self) -> bool: ... +@final class FieldBool: - def __init__(self, value: bool | None = None, set_null: bool = False) -> None: ... + def __new__(cls, value: bool | None = None, set_null: bool = False) -> FieldBool: ... @property def value(self) -> bool | None: ... @property def set_null(self) -> bool: ... +@final class FieldGeoJson: """The `set`/`set_null` pair for a geolocation: the value is a GeoJSON geometry dict, e.g. `{"type": "Point", "coordinates": [10.75, 59.91]}`.""" - def __init__(self, value: dict[str, Any] | None = None, set_null: bool = False) -> None: ... + def __new__(cls, value: dict[str, Any] | None = None, set_null: bool = False) -> FieldGeoJson: ... @property def value(self) -> dict[str, Any] | None: ... @property @@ -328,49 +331,137 @@ class FieldGeoJson: # An update is either a replace (`set`) or a delta (`add`/`remove`), never both. The two # constructors make the illegal mix unrepresentable; there is no bare initializer. +@final class ListFieldStr: @classmethod - def set(cls, values: list[str]) -> ListFieldStr: ... + def set(cls, values: list[str]) -> ListFieldStr: + """ + Replace the whole list. + """ @classmethod - def delta(cls, add: list[str] | None = None, remove: list[str] | None = None) -> ListFieldStr: ... + def delta(cls, add: list[str] | None = None, remove: list[str] | None = None) -> ListFieldStr: + """ + Add and/or remove entries, keeping the rest. Pass ``add``, ``remove``, or both. + """ # Entries name a resource by id, external_id, or both; `remove` matches on whichever side is given. +@final class ListFieldIdCollection: + """ + The related-resource list of an ``EventUpdate``. Entries are ``IdCollection`` objects, so a resource can + be named by id, external_id, or both; ``remove`` matches on whichever side is given. + """ @classmethod - def set(cls, values: list[IdCollection]) -> ListFieldIdCollection: ... + def set(cls, values: list[IdCollection]) -> ListFieldIdCollection: + """ + Replace the whole list. + """ @classmethod def delta( cls, add: list[IdCollection] | None = None, remove: list[IdCollection] | None = None, - ) -> ListFieldIdCollection: ... + ) -> ListFieldIdCollection: + """ + Add and/or remove entries, keeping the rest. Pass ``add``, ``remove``, or both. + """ +@final class MapField: @classmethod - def set(cls, values: dict[str, str]) -> MapField: ... + def set(cls, values: dict[str, str]) -> MapField: + """ + Replace all entries. + """ @classmethod - def delta(cls, add: dict[str, str] | None = None, remove: list[str] | None = None) -> MapField: ... + def delta(cls, add: dict[str, str] | None = None, remove: list[str] | None = None) -> MapField: + """ + Add and/or remove entries, keeping the rest. Pass ``add``, ``remove``, or both. + """ # ====================== Time series ====================== +@final class TimeSeries: - def __init__( - self, - external_id: str, + """A time series: a named, typed sequence of ``(timestamp, value)`` datapoints. + + A ``TimeSeries`` describes the series -- its identity, value type, unit and metadata. + The datapoints themselves are written and read through ``client.timeseries`` + (:meth:`timeseries.insert_datapoints`, + :meth:`timeseries.retrieve_datapoints`). Build one locally and pass it to + ``client.timeseries.create``; objects returned by the client also carry the + server-assigned ``id`` and timestamps. + + Parameters + ---------- + name : str, optional + Display name. If omitted, ``external_id`` is used. + external_id : str, optional + Your identifier for the series, unique among time series in the tenant and 3--512 + characters long. If omitted, it is derived from ``name`` in lower snake case. + At least one of ``name`` and ``external_id`` is required. + value_type : {"bigint", "float", "text"}, default "bigint" + What the datapoints hold, case-insensitive; ``"decimal"`` is accepted as an alias + for ``"float"``. Fixed at creation: the server refuses to change it, and refuses + datapoints that do not parse as this type. + metadata : dict of str to str, optional + Free-form key/value pairs, filterable with ``metadata=`` in + :meth:`timeseries.filter`. + description : str, optional + Free text, matched by :meth:`timeseries.search`. + unit : str, optional + The unit as free text, for example ``"bar"`` or ``"m3/h"``. + unit_external_id : str, optional + A unit from the tenant's unit catalogue, for example ``"pressure_bar"``; see + ``client.units``. + data_set_id : int, optional + The data set the series belongs to, which also decides who may read it. + related_resources : list of RelatedNode, optional + Nodes to connect to on create. Each becomes a relationship edge server-side. + source : str, optional + Where the series comes from, for example the name of the system that feeds it. + + Raises + ------ + ValueError + If neither ``name`` nor ``external_id`` is given, or ``value_type`` is not one of + the accepted spellings. + + See Also + -------- + timeseries.create : Store new series. + TimeSeriesUpdate : Change a stored series. + + Examples + -------- + >>> from intellistream_datahub_sdk import TimeSeries + >>> ts = TimeSeries( + ... external_id="pump_a_pressure", + ... name="Pump A discharge pressure", + ... value_type="float", + ... unit_external_id="pressure_bar", + ... metadata={"site": "north"}, + ... ) + >>> [created] = client.timeseries.create([ts]) + >>> created.id is not None + True + """ + def __new__( + cls, name: str | None = None, - value_type: str | None = None, + external_id: str | None = None, + value_type: str = "bigint", + metadata: dict[str, str] | None = None, + description: str | None = None, unit: str | None = None, unit_external_id: str | None = None, - description: str | None = None, - metadata: dict[str, str] | None = None, data_set_id: int | None = None, - id: int | None = None, related_resources: list[RelatedNode] | None = None, source: str | None = None, - ) -> None: ... + ) -> TimeSeries: ... @property def node_type(self) -> str: """This node's type as a string ("asset", "timeseries", "function", "resource", @@ -408,7 +499,11 @@ class TimeSeries: @unit_external_id.setter def unit_external_id(self, value: str | None) -> None: ... @property - def value_type(self) -> str | None: ... + def value_type(self) -> str | None: + """ + ``None`` on a series reached through ``neighbors()`` — the graph does not carry the column. + Re-read the series by id when the value type matters. + """ @value_type.setter def value_type(self, value: str) -> None: ... @property @@ -429,28 +524,47 @@ class TimeSeries: depth: int = -1, relationship_types: list[str] | None = None, limit: int = 5000, - ) -> ResourceNetwork: ... + ) -> ResourceNetwork: + """ + Walk the graph from this timeseries and return the connected sub-graph (its ``nodes``, the + ``edges`` between them, and their ``labels``). ``depth`` bounds the traversal in hops + (``-1``, the default, = the whole connected component); ``relationship_types`` filters which + edge types to follow (``None`` = all); ``limit`` caps the node count. Neighbour nodes are + modelled as ``Resource``. Blocking; see [``neighbors_async``] for the awaitable variant. + """ async def neighbors_async( self, depth: int = -1, relationship_types: list[str] | None = None, limit: int = 5000, - ) -> ResourceNetwork: ... - def related_events(self, limit: int = 100) -> list[Event]: ... - async def related_events_async(self, limit: int = 100) -> list[Event]: ... + ) -> ResourceNetwork: + """ + Awaitable variant of [``neighbors``]. + """ + def related_events(self, limit: int = 100) -> list[Event]: + """ + Fetch events whose ``related_resources`` include this + timeseries (matched by graph-node id when present, else external id), via ``events.filter``. + ``limit`` caps the results (default 100). Blocking; see [``related_events_async``]. + """ + async def related_events_async(self, limit: int = 100) -> list[Event]: + """ + Awaitable variant of [``related_events``]. + """ +@final class RelatedNode: """The unified node-centric relation, mirroring server-side `RelatedNode`: a node this one is connected to, with `relationship_type` and (on read) `direction` / `edge_id`. On input pass `id` or `external_id` plus a `relationship_type`.""" - def __init__( - self, + def __new__( + cls, *, relationship_type: str | None = None, id: int | None = None, external_id: str | None = None, - ) -> None: ... + ) -> RelatedNode: ... @classmethod def from_id(cls, id: int, relationship_type: str) -> RelatedNode: ... @classmethod @@ -464,15 +578,26 @@ class RelatedNode: @property def relationship_type(self) -> str | None: ... @property - def direction(self) -> str | None: ... + def direction(self) -> str | None: + """ + ``"OUTBOUND"`` / ``"INBOUND"`` on read; ``None`` on input. + """ @property def edge_id(self) -> int | None: ... +@final class TimeSeriesUpdate: - def __init__( - self, - ts: Identifiable, + """ + Python wrapper for TimeseriesUpdate, represents a request for change to a timeseries + + Parameters + ---------- + ts: Timeseries + """ + def __new__( + cls, + ts: int | str | TimeSeries | IdCollection, external_id: FieldStr | None = None, name: FieldStr | None = None, metadata: MapField | None = None, @@ -481,7 +606,7 @@ class TimeSeriesUpdate: unit_external_id: FieldStr | None = None, data_set_id: FieldU64 | None = None, source: FieldStr | None = None, - ) -> None: ... + ) -> TimeSeriesUpdate: ... @property def target_external_id(self) -> str | None: ... @property @@ -504,13 +629,41 @@ class TimeSeriesUpdate: def source(self) -> FieldStr: ... +@final class DeleteFilter: - def __init__( - self, - ts: Identifiable, + """ + One series, and the window of datapoints to remove from it, for ``delete_datapoints``. + + Both bounds are optional and the window is half-open, so: + give both to clear the window between them, ``inclusive_begin`` alone to clear everything from + that instant onward, ``exclusive_end`` alone to clear everything before it, and neither to clear + every datapoint of the series while keeping its definition, edges and subscriptions. + + The purge is asynchronous: the call returns once the request is accepted, and a read straight + afterwards can still see the datapoints. It cannot be undone. + + Parameters + ---------- + ts : int, str, TimeSeries or IdCollection + The series, as an external id, an id, or a TimeSeries. + inclusive_begin : datetime | None + Start of the window, included. Must be timezone-aware. + exclusive_end : datetime | None + End of the window, excluded. Must be timezone-aware. + + Examples + -------- + >>> # everything recorded before 2026 goes; the series itself stays + >>> f = DeleteFilter(ts="engine_temperature", + ... exclusive_end=pd.Timestamp("2026-01-01", tz="UTC")) + >>> client.timeseries.delete_datapoints([f]) + """ + def __new__( + cls, + ts: int | str | TimeSeries | IdCollection, inclusive_begin: datetime.datetime | None = None, exclusive_end: datetime.datetime | None = None, - ) -> None: ... + ) -> DeleteFilter: ... @property def target_id(self) -> int | None: ... @property @@ -546,8 +699,9 @@ class Datapoint: def __str__(self) -> str: ... +@final class DatapointString: - def __init__(self, ts: datetime.datetime, value: str) -> None: ... + def __new__(cls, ts: datetime.datetime, value: str) -> DatapointString: ... @classmethod def from_int(cls, ts: datetime.datetime, value: int) -> DatapointString: ... @classmethod @@ -562,16 +716,20 @@ class DatapointString: def value(self, value: str) -> None: ... +@final class DatapointsCollectionString: - def get_datapoints(self) -> list[Datapoint]: ... - def as_dict(self) -> dict[str, Any]: ... - def __len__(self) -> int: ... - @property - def next_cursor(self) -> str | None: ... - @property - def id(self) -> int | None: ... + """Datapoints to write to one time series, for ``insert_datapoints``. + + Parameters + ---------- + datapoints : list of DatapointString + ts : int, str, TimeSeries or IdCollection + The target series, by id, external id, ``IdCollection`` or ``TimeSeries``. + """ + def __new__(cls, datapoints: list[DatapointString], ts: int | str | TimeSeries | IdCollection) -> DatapointsCollectionString: ... +@final class DatapointsCollectionDatapoints: def get_datapoints(self) -> list[Datapoint]: ... def as_dict(self) -> dict[str, Any]: ... @@ -582,17 +740,18 @@ class DatapointsCollectionDatapoints: def id(self) -> int | None: ... +@final class RetrieveFilter: - def __init__( - self, - ts: Identifiable, + def __new__( + cls, + ts: int | str | TimeSeries | IdCollection, start: datetime.datetime | None = None, end: datetime.datetime | None = None, limit: int | None = None, aggregates: list[str] | None = None, granularity: str | None = None, cursor: str | None = None, - ) -> None: ... + ) -> RetrieveFilter: ... @property def start(self) -> datetime.datetime | None: ... @property @@ -608,106 +767,480 @@ class RetrieveFilter: class TimeSeriesServiceSync: - def list(self, limit: int | None = None) -> list[TimeSeries]: ... - def create(self, input: list[TimeSeries]) -> list[TimeSeries]: ... - def by_ids(self, input: list[Identifiable]) -> list[TimeSeries]: ... - def delete(self, input: list[Identifiable]) -> None: ... - def update(self, input: list[TimeSeriesUpdate]) -> list[TimeSeries]: ... + """Create, find, change and delete time series, and write and read their datapoints. + + Reached as ``client.timeseries`` on a configured ``DataHubClient``, never constructed. + ``AsyncDataHubClient`` has the same methods as coroutines. + + Every call that reaches the server raises ``DataHubException`` when the server refuses + it; the exception carries the HTTP ``status_code`` and, where the server explained + itself, a ``problem_slug`` to branch on. + """ + def list(self, limit: int | None = None) -> builtins.list[TimeSeries]: + """List time series, newest created first. + + This is a first page and nothing more: there is no cursor to continue from. To go + further, narrow the query with :meth:`timeseries.filter` rather than raising + ``limit``. + + Parameters + ---------- + limit : int, optional + How many to return. Defaults to the server's 1000; above 10000 is refused. + + Returns + ------- + list of TimeSeries + + See Also + -------- + timeseries.filter : Select by criteria, with paging. + + Examples + -------- + >>> recent = client.timeseries.list(limit=20) + """ + def create(self, input: builtins.list[TimeSeries]) -> builtins.list[TimeSeries]: + """Store new time series. + + The batch is all-or-nothing: if any series fails validation, none is created. + + Parameters + ---------- + input : list of TimeSeries + The series to create. Each ``external_id`` must be unused among the tenant's + time series. + + Returns + ------- + list of TimeSeries + The stored series, with ``id`` and timestamps filled in by the server. + + Raises + ------ + DataHubException + ``status_code`` 409 if an ``external_id`` is already taken; the problem's + ``duplicated`` member names it. + + Examples + -------- + >>> from intellistream_datahub_sdk import TimeSeries + >>> [ts] = client.timeseries.create( + ... [TimeSeries(external_id="pump_a_pressure", value_type="float", unit="bar")] + ... ) + """ + def by_ids(self, input: builtins.list[int | str | TimeSeries | IdCollection]) -> builtins.list[TimeSeries]: + """Fetch time series by id or external id. + + What is not found is left out of the result rather than raising, so compare the + result with what you asked for to detect missing series. + + Parameters + ---------- + input : list of int, str, TimeSeries or IdCollection + Each entry is a numeric id, an external id, a ``TimeSeries`` or an + ``IdCollection``. Mix freely. + + Returns + ------- + list of TimeSeries + + Examples + -------- + >>> found = client.timeseries.by_ids([42, "pump_a_pressure"]) + """ + def delete(self, input: builtins.list[int | str | TimeSeries | IdCollection]) -> None: + """Delete time series, and every datapoint they hold. + + This cannot be undone. Deleting a series that is already gone is a no-op. To clear a + series' datapoints but keep the series, use :meth:`timeseries.delete_datapoints`. + + Parameters + ---------- + input : list of int, str, TimeSeries or IdCollection + The series to delete, by id, external id, ``TimeSeries`` or ``IdCollection``. + + Raises + ------ + DataHubException + ``status_code`` 400 if a series is the start of a relationship, or is still + bound to a subscription. Remove those first. + + Examples + -------- + >>> client.timeseries.delete(["pump_a_pressure"]) + """ + def update(self, input: builtins.list[TimeSeriesUpdate]) -> builtins.list[TimeSeries]: + """Change fields on existing time series. + + Only the fields named in each ``TimeSeriesUpdate`` change. ``value_type`` cannot be + changed; create a new series instead. The batch is all-or-nothing. + + Parameters + ---------- + input : list of TimeSeriesUpdate + + Returns + ------- + list of TimeSeries + The series as they stand after the update. + + Examples + -------- + >>> from intellistream_datahub_sdk import FieldStr, MapField, TimeSeriesUpdate + >>> client.timeseries.update([ + ... TimeSeriesUpdate( + ... "pump_a_pressure", + ... description=FieldStr("Discharge side, after the check valve"), + ... metadata=MapField.delta(add={"calibrated": "2026-09"}), + ... ) + ... ]) + """ def search( self, query: str, filter: TimeSeriesFilter | None = None, limit: int | None = None, - ) -> list[TimeSeries]: - """Free-text search for ``query``, ranked by relevance. - - ``filter`` takes the same criteria as ``filter()`` and only ever removes hits from the - phrase's — it cannot widen them, so omitting it returns them as found. ``limit`` caps what - survives, defaulting to 100 and capping at 1000; the ``filter`` endpoints use 1000/10000, - which is easy to conflate. + ) -> builtins.list[TimeSeries]: + """Free-text search over name, external id and description. + + Matching is word-aware and fuzzy -- ``"temp"`` also finds ``"temperature"`` -- and + results are ranked, best match first. For exact lookups use + :meth:`timeseries.by_ids`; for structured queries without a phrase, use + :meth:`timeseries.filter`. + + Parameters + ---------- + query : str + The phrase, 3--140 characters. + filter : TimeSeriesFilter, optional + Narrows the phrase's hits. It only ever removes results, never adds them. + limit : int, optional + How many to return. Defaults to 100; above 1000 is refused. + + Returns + ------- + list of TimeSeries + + Examples + -------- + >>> from intellistream_datahub_sdk import TimeSeriesFilter + >>> client.timeseries.search("discharge pressure", filter=TimeSeriesFilter(unit="bar")) """ def filter( self, *, filter: TimeSeriesFilter | None = None, id: Sequence[int] | None = None, - external_id: PatternList | None = None, - name: PatternList | None = None, - source: PatternList | None = None, - labels: PatternList | None = None, - metadata: MetadataFilter | None = None, + external_id: str | Sequence[str] | None = None, + name: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + labels: str | Sequence[str] | None = None, + metadata: Mapping[str, str | None] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, - data_set_id: Sequence[DataSetRef] | None = None, - unit: PatternList | None = None, - unit_external_id: PatternList | None = None, - value_type: PatternList | None = None, + data_set_id: Sequence[int | str | IdCollection] | None = None, + unit: str | Sequence[str] | None = None, + unit_external_id: str | Sequence[str] | None = None, + value_type: str | Sequence[str] | None = None, limit: int | None = None, - sort_by: SortBy | None = None, + sort_by: str | Sequence[str] | None = None, sort_order: str | None = None, cursor: str | None = None, ) -> Page: - """Pass either ``filter=`` or the individual criteria keywords; passing both is a - ``TypeError``. Paging is always given here rather than on the filter, so one filter can be - reused across calls. + """Select time series by criteria, one page at a time. + + Give the criteria either as keywords or as a prepared ``filter=``, not both. Criteria + combine with AND; the entries of one list combine with OR. Pattern lists accept a + single string or a list, where ``*`` and ``%`` are wildcards, ``_`` is literal, and + matching ignores case. + + Parameters + ---------- + filter : TimeSeriesFilter, optional + The criteria as one object, reusable across calls. Exclusive with the + criteria keywords below. + id : sequence of int, optional + external_id, name, source, unit, unit_external_id : str or sequence of str, optional + labels : str or sequence of str, optional + Every label listed must be present. + metadata : mapping of str to str or None, optional + Every key listed must be present; a ``None`` value matches the key alone. + created_time, last_updated_time : TimeFilter, optional + Inclusive at both ends. + data_set_id : sequence of int, str or IdCollection, optional + Data sets by id or external id; includes everything beneath them in the data + set hierarchy. + value_type : str or sequence of str, optional + Matched exactly, ignoring case. + limit : int, optional + Page size. Defaults to 1000; above 10000 is refused. + sort_by : str, optional + One property; defaults to ``createdTime``. + sort_order : {"asc", "desc"}, optional + Defaults to descending. + cursor : str, optional + ``next_cursor`` from the previous page. It belongs to its sort: continuing it + under another is refused. + + Returns + ------- + Page + A list-like page of ``TimeSeries``. Its ``next_cursor`` is ``None`` on the last + page. + + Raises + ------ + TypeError + If both ``filter=`` and criteria keywords are given. + + Examples + -------- + Every pressure series in bar, a page at a time: + + >>> page = client.timeseries.filter(name="*pressure*", unit="bar", limit=500) + >>> series = list(page) + >>> while page.next_cursor: + ... page = client.timeseries.filter( + ... name="*pressure*", unit="bar", limit=500, cursor=page.next_cursor + ... ) + ... series.extend(page) """ - def insert_datapoints(self, input: list[DatapointsCollectionString]) -> list[str]: ... + def insert_datapoints(self, input: builtins.list[DatapointsCollectionString]) -> builtins.list[str]: + """Write datapoints to one or more time series. + + A datapoint whose timestamp already exists replaces the stored value, so retrying a + write is safe. If some target series do not exist, the datapoints for the others are + still written and the call raises. + + Parameters + ---------- + input : list of DatapointsCollectionString + One collection per target series. + + Returns + ------- + list of str + Empty on success. + + Raises + ------ + DataHubException + ``status_code`` 404 naming the series that do not exist; 422 for a value that + does not parse as the series' value type. + + See Also + -------- + timeseries.insert_from_lists : The same for one series from two parallel lists. + timeseries.insert_datapoints_binary : The same write, compressed. + + Examples + -------- + >>> from datetime import datetime, timezone + >>> from intellistream_datahub_sdk import DatapointString, DatapointsCollectionString + >>> now = datetime.now(timezone.utc) + >>> client.timeseries.insert_datapoints([ + ... DatapointsCollectionString( + ... [DatapointString(now, "4.2")], "pump_a_pressure" + ... ) + ... ]) + [] + """ def insert_datapoints_binary( self, - input: list[DatapointsCollectionString], + input: builtins.list[DatapointsCollectionString], zstd_level: int | None = None, - ) -> list[str]: ... + ) -> builtins.list[str]: + """Write datapoints as compressed Arrow frames. + + The same input as :meth:`timeseries.insert_datapoints`, sent to the binary endpoint. + Much faster for large writes. Each value is checked against its series' value type + before anything is sent. + + Parameters + ---------- + input : list of DatapointsCollectionString + zstd_level : {1, 3, 9}, optional + Compression level. Defaults to 9. + + Returns + ------- + list of str + Empty on success. + """ def insert_from_lists_binary( self, - timestamps: list[datetime.datetime], - values: list[float], - ts: Identifiable, + timestamps: builtins.list[datetime.datetime], + values: builtins.list[float], + ts: int | str | TimeSeries | IdCollection, zstd_level: int | None = None, - ) -> list[str]: ... + ) -> builtins.list[str]: + """Write one series' datapoints from two parallel lists, as compressed Arrow frames. + + The binary counterpart of :meth:`timeseries.insert_from_lists`. + + Parameters + ---------- + timestamps : list of datetime + values : list of float + The same length as ``timestamps``. + ts : int, str, TimeSeries or IdCollection + The target series. + zstd_level : {1, 3, 9}, optional + Compression level. Defaults to 9. + + Returns + ------- + list of str + Empty on success. + + Raises + ------ + ValueError + If ``timestamps`` and ``values`` differ in length. + """ def insert_from_lists( self, - timestamps: list[datetime.datetime], - values: list[float], - ts: Identifiable, - ) -> list[str]: ... - def retrieve_datapoints(self, input: RetrieveFilter) -> list[DatapointsCollectionDatapoints]: ... - def delete_datapoints(self, input: list[DeleteFilter]) -> None: ... + timestamps: builtins.list[datetime.datetime], + values: builtins.list[float], + ts: int | str | TimeSeries | IdCollection, + ) -> builtins.list[str]: + """Write one series' datapoints from two parallel lists. + + The shape a pair of DataFrame columns arrives in. + + Parameters + ---------- + timestamps : list of datetime + values : list of float + The same length as ``timestamps``. + ts : int, str, TimeSeries or IdCollection + The target series. + + Returns + ------- + list of str + Empty on success. + + Examples + -------- + >>> from datetime import datetime, timedelta, timezone + >>> start = datetime(2026, 9, 1, tzinfo=timezone.utc) + >>> timestamps = [start + timedelta(minutes=i) for i in range(3)] + >>> client.timeseries.insert_from_lists(timestamps, [4.1, 4.2, 4.3], "pump_a_pressure") + [] + """ + def retrieve_datapoints(self, input: RetrieveFilter) -> builtins.list[DatapointsCollectionDatapoints]: + """Read one series' datapoints over a time window, raw or aggregated. + + The window includes ``start`` and excludes ``end``. Leave both out for the most + recent ``limit`` points. When the server splits a large answer, the collection's + ``next_cursor`` is set: pass it back as ``RetrieveFilter(cursor=...)`` for the rest. + + Parameters + ---------- + input : RetrieveFilter + The series, window, limit and, optionally, ``aggregates`` with a + ``granularity``. + + Returns + ------- + list of DatapointsCollectionDatapoints + Call ``get_datapoints()`` on each for the ``Datapoint`` objects. + + Examples + -------- + Hourly averages over one day: + + >>> from datetime import datetime, timezone + >>> from intellistream_datahub_sdk import RetrieveFilter + >>> [result] = client.timeseries.retrieve_datapoints( + ... RetrieveFilter( + ... "pump_a_pressure", + ... start=datetime(2026, 9, 1, tzinfo=timezone.utc), + ... end=datetime(2026, 9, 2, tzinfo=timezone.utc), + ... aggregates=["avg", "min", "max"], + ... granularity="1h", + ... ) + ... ) + >>> [(p.timestamp, p.average) for p in result.get_datapoints()][:2] + """ + def delete_datapoints(self, input: builtins.list[DeleteFilter]) -> None: + """Delete datapoints in a time window, keeping the series. + + This cannot be undone. Each ``DeleteFilter`` names a series and a window that + includes ``inclusive_begin`` and excludes ``exclusive_end``; leave either end open + to delete from the beginning or to the end, and both to empty the series. + + Parameters + ---------- + input : list of DeleteFilter + + Examples + -------- + >>> from datetime import datetime, timezone + >>> from intellistream_datahub_sdk import DeleteFilter + >>> client.timeseries.delete_datapoints([ + ... DeleteFilter( + ... "pump_a_pressure", + ... inclusive_begin=datetime(2026, 9, 1, tzinfo=timezone.utc), + ... exclusive_end=datetime(2026, 9, 2, tzinfo=timezone.utc), + ... ) + ... ]) + """ def retrieve_latest_datapoints( - self, input: list[Identifiable] - ) -> list[DatapointsCollectionDatapoints]: ... + self, input: builtins.list[int | str | TimeSeries | IdCollection] + ) -> builtins.list[DatapointsCollectionDatapoints]: + """Read the most recent datapoint of each series. + + A series with no datapoints is left out of the result. + + Parameters + ---------- + input : list of int, str, TimeSeries or IdCollection + + Returns + ------- + list of DatapointsCollectionDatapoints + One per series that has data, each holding its single latest datapoint. + + Examples + -------- + >>> latest = client.timeseries.retrieve_latest_datapoints(["pump_a_pressure"]) + """ class TimeSeriesServiceAsync: - async def list(self, limit: int | None = None) -> list[TimeSeries]: ... - async def create(self, input: list[TimeSeries]) -> list[TimeSeries]: ... - async def by_ids(self, input: list[Identifiable]) -> list[TimeSeries]: ... - async def delete(self, input: list[Identifiable]) -> None: ... - async def update(self, input: list[TimeSeriesUpdate]) -> list[TimeSeries]: ... + async def list(self, limit: int | None = None) -> builtins.list[TimeSeries]: ... + async def create(self, input: builtins.list[TimeSeries]) -> builtins.list[TimeSeries]: ... + async def by_ids(self, input: builtins.list[int | str | TimeSeries | IdCollection]) -> builtins.list[TimeSeries]: ... + async def delete(self, input: builtins.list[int | str | TimeSeries | IdCollection]) -> None: ... + async def update(self, input: builtins.list[TimeSeriesUpdate]) -> builtins.list[TimeSeries]: ... async def search( self, query: str, filter: TimeSeriesFilter | None = None, limit: int | None = None, - ) -> list[TimeSeries]: ... + ) -> builtins.list[TimeSeries]: ... async def filter( self, *, filter: TimeSeriesFilter | None = None, id: Sequence[int] | None = None, - external_id: PatternList | None = None, - name: PatternList | None = None, - source: PatternList | None = None, - labels: PatternList | None = None, - metadata: MetadataFilter | None = None, + external_id: str | Sequence[str] | None = None, + name: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + labels: str | Sequence[str] | None = None, + metadata: Mapping[str, str | None] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, - data_set_id: Sequence[DataSetRef] | None = None, - unit: PatternList | None = None, - unit_external_id: PatternList | None = None, - value_type: PatternList | None = None, + data_set_id: Sequence[int | str | IdCollection] | None = None, + unit: str | Sequence[str] | None = None, + unit_external_id: str | Sequence[str] | None = None, + value_type: str | Sequence[str] | None = None, limit: int | None = None, - sort_by: SortBy | None = None, + sort_by: str | Sequence[str] | None = None, sort_order: str | None = None, cursor: str | None = None, ) -> Page: @@ -716,27 +1249,28 @@ class TimeSeriesServiceAsync: reused across calls. """ - async def insert_datapoints(self, input: list[DatapointsCollectionString]) -> list[str]: ... + async def insert_datapoints(self, input: builtins.list[DatapointsCollectionString]) -> builtins.list[str]: ... async def insert_from_lists( self, - timestamps: list[datetime.datetime], - values: list[float], - ts: Identifiable, - ) -> list[str]: ... + timestamps: builtins.list[datetime.datetime], + values: builtins.list[float], + ts: int | str | TimeSeries | IdCollection, + ) -> builtins.list[str]: ... async def retrieve_datapoints( self, input: RetrieveFilter - ) -> list[DatapointsCollectionDatapoints]: ... - async def delete_datapoints(self, input: list[DeleteFilter]) -> None: ... + ) -> builtins.list[DatapointsCollectionDatapoints]: ... + async def delete_datapoints(self, input: builtins.list[DeleteFilter]) -> None: ... async def retrieve_latest_datapoints( - self, input: list[Identifiable] - ) -> list[DatapointsCollectionDatapoints]: ... + self, input: builtins.list[int | str | TimeSeries | IdCollection] + ) -> builtins.list[DatapointsCollectionDatapoints]: ... # ====================== Events ====================== +@final class Event: - def __init__( - self, + def __new__( + cls, external_id: str, type: str, event_time: datetime.datetime, @@ -747,7 +1281,7 @@ class Event: data_set_id: int | None = None, related_resources: list[IdCollection] | None = None, source: str | None = None, - ) -> None: + ) -> Event: """``type`` is required: the API rejects a blank one with status 400.""" ... @property @@ -787,7 +1321,11 @@ class Event: # Resources this event is attached to, each named by id, external_id, or both. # Events returned by the API carry both sides, resolved server-side. @property - def related_resources(self) -> list[IdCollection]: ... + def related_resources(self) -> list[IdCollection]: + """ + The resources this event is attached to, each named by ``id``, ``external_id``, or both. + Events returned by the API carry both sides, resolved server-side. + """ @related_resources.setter def related_resources(self, value: list[IdCollection]) -> None: ... @property @@ -799,24 +1337,34 @@ class Event: @property def last_updated_time(self) -> datetime.datetime | None: ... # --- navigation (only on events returned by the API; raises otherwise) --- - def related_resource_nodes(self) -> list[Node]: ... - async def related_resource_nodes_async(self) -> list[Node]: ... + def related_resource_nodes(self) -> list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: + """ + Fetch the resources this event references (its ``related_resources``), resolved via the + resources service. Blocking; see [``related_resource_nodes_async``] for the awaitable variant. + """ + async def related_resource_nodes_async(self) -> list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: + """ + Awaitable variant of [``related_resource_nodes``]. + """ +@final class TimeFilter: - def __init__( - self, + def __new__( + cls, start: datetime.datetime | None = None, end: datetime.datetime | None = None, - ) -> None: ... + ) -> TimeFilter: ... +@final class EventFilter: """AND-combined criteria for ``events.filter`` (``POST /events/filter``). - ``external_id``, ``source``, ``type``, ``sub_type`` and ``status`` are pattern lists — see - ``PatternList`` — so ``type=["alarm", "warning"]`` is one call. They are named in the singular - because each also takes a bare string. Every ``metadata`` entry must be present, and a ``None`` + ``external_id``, ``source``, ``type``, ``sub_type`` and ``status`` are pattern lists: one + string or a list of them, where ``*`` and ``%`` are wildcards, ``_`` is literal, and + matching ignores case -- except that an ``external_id`` entry without a wildcard matches + exactly, case included. So ``type=["alarm", "warning"]`` is one call. Every ``metadata`` entry must be present, and a ``None`` value matches the key alone. ``related_resources`` keeps its plural: every entry of it must be attached to the event. @@ -827,37 +1375,41 @@ class EventFilter: There is no ``id``: events are keyed by UUID, and the field the api used to declare was typed as a long that nothing read. Use ``events.by_ids`` to look one up. """ - def __init__( - self, - external_id: PatternList | None = None, - source: PatternList | None = None, - type: PatternList | None = None, - sub_type: PatternList | None = None, - status: PatternList | None = None, - data_set_id: Sequence[DataSetRef] | None = None, + def __new__( + cls, + external_id: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + type: str | Sequence[str] | None = None, + sub_type: str | Sequence[str] | None = None, + status: str | Sequence[str] | None = None, + data_set_id: Sequence[int | str | IdCollection] | None = None, event_time: TimeFilter | None = None, - metadata: MetadataFilter | None = None, + metadata: Mapping[str, str | None] | None = None, related_resources: list[IdCollection] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, - ) -> None: ... + ) -> EventFilter: ... +@final class EventIdCollection: - def __init__( - self, + """ + Event id selector exposed to Python. Events are keyed by a client-generated UUID v7, so this + carries the ``id`` (UUID) and/or the ``external_id``. Construct with either or both: + ``EventIdCollection(id=my_uuid)`` or ``EventIdCollection(external_id="...")``. + """ + def __new__( + cls, id: UUID | None = None, external_id: str | None = None, - ) -> None: ... + ) -> EventIdCollection: ... @property def id(self) -> UUID | None: ... @property def external_id(self) -> str | None: ... -EventIdentifiable = Union[Event, EventIdCollection, UUID, str] - - +@final class EventUpdate: """Field-level changes for one event. @@ -872,9 +1424,9 @@ class EventUpdate: creating a new event and deleting the old one; record a corrected time the same way. """ - def __init__( - self, - event: EventIdentifiable, + def __new__( + cls, + event: Event | EventIdCollection | UUID | str, description: FieldStr | None = None, type: FieldStr | None = None, sub_type: FieldStr | None = None, @@ -883,13 +1435,14 @@ class EventUpdate: metadata: MapField | None = None, source: FieldStr | None = None, related_resources: ListFieldIdCollection | None = None, - ) -> None: ... + ) -> EventUpdate: ... @property def target_id(self) -> UUID | None: ... @property def target_external_id(self) -> str | None: ... +@final class EventDimension: """Categorical event fields with a queryable vocabulary. @@ -904,29 +1457,48 @@ class EventDimension: class EventsServiceSync: - def list(self, limit: int | None = None) -> list[Event]: ... - def create(self, input: list[Event]) -> list[Event]: ... - def by_ids(self, input: list[EventIdentifiable]) -> list[Event]: ... - def get(self, id: UUID) -> Event | None: ... - def delete(self, input: list[EventIdentifiable]) -> None: ... - def update(self, input: list[EventUpdate]) -> list[Event]: ... + def list(self, limit: int | None = None) -> builtins.list[Event]: + """ + A criteria-free page of the tenant's events. + + **The oldest ``limit`` events, not the newest.** It runs the event filter with an empty body, + whose default sort is ``eventTime`` ascending — the order the cursor pages in. The node + listings beside it (``resources.list``, ``timeseries.list``, ``datasets.list``) really are + newest-first; events are the one member of the family that reads the other way round. For + "what just happened", use ``filter(sort_by="eventTime", sort_order="desc")``. + + ``limit`` defaults to the server's 1000 and may not exceed 10000. A plain list is returned + rather than a ``Page``: there is no cursor to continue with. + """ + def create(self, input: builtins.list[Event]) -> builtins.list[Event]: ... + def by_ids(self, input: builtins.list[Event | EventIdCollection | UUID | str]) -> builtins.list[Event]: ... + def get(self, id: UUID) -> Event | None: + """ + Look up a single event by its UUID. Returns ``None`` if no such event exists. + """ + def delete(self, input: builtins.list[Event | EventIdCollection | UUID | str]) -> None: ... + def update(self, input: builtins.list[EventUpdate]) -> builtins.list[Event]: + """ + Update events in place. Each ``EventUpdate`` targets one event and carries only the fields to + change; returns the events after the update. + """ def filter( self, *, filter: EventFilter | None = None, - external_id: PatternList | None = None, - source: PatternList | None = None, - type: PatternList | None = None, - sub_type: PatternList | None = None, - status: PatternList | None = None, - data_set_id: Sequence[DataSetRef] | None = None, + external_id: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + type: str | Sequence[str] | None = None, + sub_type: str | Sequence[str] | None = None, + status: str | Sequence[str] | None = None, + data_set_id: Sequence[int | str | IdCollection] | None = None, event_time: TimeFilter | None = None, - metadata: MetadataFilter | None = None, + metadata: Mapping[str, str | None] | None = None, related_resources: Sequence[IdCollection] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, limit: int | None = None, - sort_by: SortBy | None = None, + sort_by: str | Sequence[str] | None = None, sort_order: str | None = None, cursor: str | None = None, advanced_filter: str | None = None, @@ -941,48 +1513,83 @@ class EventsServiceSync: query: str, filter: EventFilter | None = None, limit: int | None = None, - ) -> list[Event]: ... - def count(self) -> int: ... + ) -> builtins.list[Event]: + """ + Free-text search over event descriptions, ranked by relevance. + """ + def count(self) -> int: + """ + Total number of events in the tenant. + """ def list_dimension( self, dimension: EventDimension, query: str | None = None, limit: int | None = None, - ) -> list[str]: ... - def list_types(self, limit: int | None = None) -> list[str]: ... - def search_types(self, query: str, limit: int | None = None) -> list[str]: ... - def list_sub_types(self, limit: int | None = None) -> list[str]: ... - def search_sub_types(self, query: str, limit: int | None = None) -> list[str]: ... - def list_statuses(self, limit: int | None = None) -> list[str]: ... - def search_statuses(self, query: str, limit: int | None = None) -> list[str]: ... - def list_sources(self, limit: int | None = None) -> list[str]: ... - def search_sources(self, query: str, limit: int | None = None) -> list[str]: ... + ) -> builtins.list[str]: + """ + Distinct values an event field takes in this tenant. ``query`` filters by case-insensitive + substring; omit it to list everything. ``limit`` defaults to 1000 server-side and is clamped + to 1..=10000. Alphabetical, and restricted to your readable datasets. + """ + def list_types(self, limit: int | None = None) -> builtins.list[str]: + """ + Every distinct ``type`` on events you can read. + """ + def search_types(self, query: str, limit: int | None = None) -> builtins.list[str]: + """ + Distinct ``type`` values containing ``query`` (case-insensitive substring). + """ + def list_sub_types(self, limit: int | None = None) -> builtins.list[str]: + """ + Every distinct ``subType`` on events you can read. + """ + def search_sub_types(self, query: str, limit: int | None = None) -> builtins.list[str]: + """ + Distinct ``subType`` values containing ``query`` (case-insensitive substring). + """ + def list_statuses(self, limit: int | None = None) -> builtins.list[str]: + """ + Every distinct ``status`` on events you can read. + """ + def search_statuses(self, query: str, limit: int | None = None) -> builtins.list[str]: + """ + Distinct ``status`` values containing ``query`` (case-insensitive substring). + """ + def list_sources(self, limit: int | None = None) -> builtins.list[str]: + """ + Every distinct ``source`` on events you can read. + """ + def search_sources(self, query: str, limit: int | None = None) -> builtins.list[str]: + """ + Distinct ``source`` values containing ``query`` (case-insensitive substring). + """ class EventsServiceAsync: - async def list(self, limit: int | None = None) -> list[Event]: ... - async def create(self, input: list[Event]) -> list[Event]: ... - async def by_ids(self, input: list[EventIdentifiable]) -> list[Event]: ... + async def list(self, limit: int | None = None) -> builtins.list[Event]: ... + async def create(self, input: builtins.list[Event]) -> builtins.list[Event]: ... + async def by_ids(self, input: builtins.list[Event | EventIdCollection | UUID | str]) -> builtins.list[Event]: ... async def get(self, id: UUID) -> Event | None: ... - async def delete(self, input: list[EventIdentifiable]) -> None: ... - async def update(self, input: list[EventUpdate]) -> list[Event]: ... + async def delete(self, input: builtins.list[Event | EventIdCollection | UUID | str]) -> None: ... + async def update(self, input: builtins.list[EventUpdate]) -> builtins.list[Event]: ... async def filter( self, *, filter: EventFilter | None = None, - external_id: PatternList | None = None, - source: PatternList | None = None, - type: PatternList | None = None, - sub_type: PatternList | None = None, - status: PatternList | None = None, - data_set_id: Sequence[DataSetRef] | None = None, + external_id: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + type: str | Sequence[str] | None = None, + sub_type: str | Sequence[str] | None = None, + status: str | Sequence[str] | None = None, + data_set_id: Sequence[int | str | IdCollection] | None = None, event_time: TimeFilter | None = None, - metadata: MetadataFilter | None = None, + metadata: Mapping[str, str | None] | None = None, related_resources: Sequence[IdCollection] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, limit: int | None = None, - sort_by: SortBy | None = None, + sort_by: str | Sequence[str] | None = None, sort_order: str | None = None, cursor: str | None = None, advanced_filter: str | None = None, @@ -997,29 +1604,30 @@ class EventsServiceAsync: query: str, filter: EventFilter | None = None, limit: int | None = None, - ) -> list[Event]: ... + ) -> builtins.list[Event]: ... async def count(self) -> int: ... async def list_dimension( self, dimension: EventDimension, query: str | None = None, limit: int | None = None, - ) -> list[str]: ... - async def list_types(self, limit: int | None = None) -> list[str]: ... - async def search_types(self, query: str, limit: int | None = None) -> list[str]: ... - async def list_sub_types(self, limit: int | None = None) -> list[str]: ... - async def search_sub_types(self, query: str, limit: int | None = None) -> list[str]: ... - async def list_statuses(self, limit: int | None = None) -> list[str]: ... - async def search_statuses(self, query: str, limit: int | None = None) -> list[str]: ... - async def list_sources(self, limit: int | None = None) -> list[str]: ... - async def search_sources(self, query: str, limit: int | None = None) -> list[str]: ... + ) -> builtins.list[str]: ... + async def list_types(self, limit: int | None = None) -> builtins.list[str]: ... + async def search_types(self, query: str, limit: int | None = None) -> builtins.list[str]: ... + async def list_sub_types(self, limit: int | None = None) -> builtins.list[str]: ... + async def search_sub_types(self, query: str, limit: int | None = None) -> builtins.list[str]: ... + async def list_statuses(self, limit: int | None = None) -> builtins.list[str]: ... + async def search_statuses(self, query: str, limit: int | None = None) -> builtins.list[str]: ... + async def list_sources(self, limit: int | None = None) -> builtins.list[str]: ... + async def search_sources(self, query: str, limit: int | None = None) -> builtins.list[str]: ... # ====================== Datasets ====================== +@final class Dataset: - def __init__( - self, + def __new__( + cls, external_id: str, name: str | None = None, id: int | None = None, @@ -1027,7 +1635,7 @@ class Dataset: policies: list[str] | None = None, metadata: dict[str, str] | None = None, connected_data_sets: list[int] | None = None, - ) -> None: ... + ) -> Dataset: ... @property def node_type(self) -> str: """This node's type as a string ("asset", "timeseries", "function", "resource", @@ -1078,15 +1686,33 @@ class Dataset: depth: int = -1, relationship_types: list[str] | None = None, limit: int = 5000, - ) -> ResourceNetwork: ... + ) -> ResourceNetwork: + """ + Walk the graph from this dataset and return the connected sub-graph (its ``nodes``, the + ``edges`` between them, and their ``labels``). ``depth`` bounds the traversal in hops + (``-1``, the default, = the whole connected component); ``relationship_types`` filters which + edge types to follow (``None`` = all); ``limit`` caps the node count. Neighbour nodes are + modelled as ``Resource``. Blocking; see [``neighbors_async``] for the awaitable variant. + """ async def neighbors_async( self, depth: int = -1, relationship_types: list[str] | None = None, limit: int = 5000, - ) -> ResourceNetwork: ... - def related_events(self, limit: int = 100) -> list[Event]: ... - async def related_events_async(self, limit: int = 100) -> list[Event]: ... + ) -> ResourceNetwork: + """ + Awaitable variant of [``neighbors``]. + """ + def related_events(self, limit: int = 100) -> list[Event]: + """ + Fetch events whose ``related_resources`` include this + dataset (matched by graph-node id when present, else external id), via ``events.filter``. + ``limit`` caps the results (default 100). Blocking; see [``related_events_async``]. + """ + async def related_events_async(self, limit: int = 100) -> list[Event]: + """ + Awaitable variant of [``related_events``]. + """ # Criteria for `datasets.filter`. Every field is optional and they AND together, so an @@ -1094,10 +1720,12 @@ class Dataset: # no restriction rather than "match nothing". # # See the class docstring below for the pattern, label and metadata rules. +@final class DatasetFilter: """AND-combined criteria for ``datasets.filter``. - ``external_id``, ``name`` and ``source`` are pattern lists — see ``PatternList`` — so + ``external_id``, ``name`` and ``source`` are pattern lists: one string or a list of them, + where ``*`` and ``%`` are wildcards, ``_`` is literal, and matching ignores case. So ``external_id=["sap_*"]`` replaces the retired ``external_id_prefix`` and can be combined with exact ids in the same list. ``labels`` must **all** be present; names are canonicalised, so ``"pump a"`` finds the label stored as ``PUMP_A``. Every ``metadata`` entry must be present, @@ -1107,17 +1735,17 @@ class DatasetFilter: ``write_protected`` / ``deactivated`` either — both were removed server-side as inert, so a filter carrying them looked like it was narrowing and was not. """ - def __init__( - self, + def __new__( + cls, id: Sequence[int] | None = None, - external_id: PatternList | None = None, - name: PatternList | None = None, - source: PatternList | None = None, - labels: PatternList | None = None, - metadata: MetadataFilter | None = None, + external_id: str | Sequence[str] | None = None, + name: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + labels: str | Sequence[str] | None = None, + metadata: Mapping[str, str | None] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, - ) -> None: ... + ) -> DatasetFilter: ... # `limit` defaults to the server's 1000 and may not exceed 10000. There is no paging, so a filter @@ -1125,16 +1753,27 @@ class DatasetFilter: # A partial update for one dataset. `dataset` names the target; only the fields you pass are sent, # anything omitted is left untouched. There is deliberately no `policies` or `connected_data_sets` # — the update endpoint does not accept them, whatever a Dataset can carry on create. +@final class DatasetUpdate: - def __init__( - self, - dataset: Identifiable, + """ + A partial update for one dataset, mirroring the server's update form. + + ``dataset`` names the target — a ``Dataset``, an ``IdCollection``, an external id or a numeric id. + Every other argument is a field wrapper and only the ones you pass are sent; anything omitted + is left untouched. + + There is deliberately no ``policies`` or ``connected_data_sets`` here: the update endpoint does not + accept them, whatever a ``Dataset`` can carry on create. + """ + def __new__( + cls, + dataset: int | str | Dataset | IdCollection, external_id: FieldStr | None = None, name: FieldStr | None = None, description: FieldStr | None = None, metadata: MapField | None = None, labels: ListFieldStr | None = None, - ) -> None: ... + ) -> DatasetUpdate: ... @property def target_id(self) -> int | None: ... @property @@ -1147,24 +1786,29 @@ class DatasetUpdate: # # `update(...)`: there is no write_protected/deactivated — both were removed server-side as inert. class DatasetsServiceSync: - def list(self, limit: int | None = None) -> list[Dataset]: ... - def create(self, input: list[Dataset]) -> list[Dataset]: ... - def by_ids(self, input: list[Identifiable]) -> list[Dataset]: ... - def delete(self, input: list[Identifiable]) -> None: ... + def list(self, limit: int | None = None) -> builtins.list[Dataset]: + """ + Datasets in the tenant, newest first. ``limit`` defaults to the server's 1000 and may not + exceed 10000; there is no paging, so a bigger tenant is truncated rather than paged — + use ``filter`` to narrow instead. + """ + def create(self, input: builtins.list[Dataset]) -> builtins.list[Dataset]: ... + def by_ids(self, input: builtins.list[int | str | Dataset | IdCollection]) -> builtins.list[Dataset]: ... + def delete(self, input: builtins.list[int | str | Dataset | IdCollection]) -> None: ... def filter( self, *, filter: DatasetFilter | None = None, id: Sequence[int] | None = None, - external_id: PatternList | None = None, - name: PatternList | None = None, - source: PatternList | None = None, - labels: PatternList | None = None, - metadata: MetadataFilter | None = None, + external_id: str | Sequence[str] | None = None, + name: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + labels: str | Sequence[str] | None = None, + metadata: Mapping[str, str | None] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, limit: int | None = None, - sort_by: SortBy | None = None, + sort_by: str | Sequence[str] | None = None, sort_order: str | None = None, cursor: str | None = None, ) -> Page: @@ -1178,7 +1822,7 @@ class DatasetsServiceSync: query: str, filter: DatasetFilter | None = None, limit: int | None = None, - ) -> list[Dataset]: + ) -> builtins.list[Dataset]: """Free-text search for ``query``, ranked by relevance. ``filter`` takes the same criteria as ``filter()`` and only ever removes hits from the @@ -1186,29 +1830,49 @@ class DatasetsServiceSync: survives, defaulting to 100 and capping at 1000; the ``filter`` endpoints use 1000/10000, which is easy to conflate. """ - def update(self, input: list[DatasetUpdate]) -> list[Dataset]: ... - def policies(self) -> list[Resource]: ... + def update(self, input: builtins.list[DatasetUpdate]) -> builtins.list[Dataset]: + """ + Apply partial updates, returning the datasets as they stand afterwards. + + A dataset is the unit access is granted on, so the server treats editing one as an operator + action: this needs an all-datasets write grant and raises 403 without one, even for a + caller who can write the dataset's contents. + + **Do not combine a ``metadata`` change with ``write_protected`` / ``deactivated`` in one update.** + The server stores those flags as node metadata, so setting either in the same call as a + metadata delta silently drops the delta — 200, no error, half the change lost. Send two + updates. Their keys (``property:is_write_protected``, ``property:is_deactivated``) are also + visible in ``Dataset.metadata``. + """ + def policies(self) -> builtins.list[Resource]: + """ + The access policies a dataset can be associated with, as ``Resource`` objects. + + **Known to come back empty even when policies exist** — the server answers 200 with no body + at all. That is a server-side bug, not something these bindings can work around, so treat + an empty result as "unknown" rather than "none". + """ class DatasetsServiceAsync: - async def list(self, limit: int | None = None) -> list[Dataset]: ... - async def create(self, input: list[Dataset]) -> list[Dataset]: ... - async def by_ids(self, input: list[Identifiable]) -> list[Dataset]: ... - async def delete(self, input: list[Identifiable]) -> None: ... + async def list(self, limit: int | None = None) -> builtins.list[Dataset]: ... + async def create(self, input: builtins.list[Dataset]) -> builtins.list[Dataset]: ... + async def by_ids(self, input: builtins.list[int | str | Dataset | IdCollection]) -> builtins.list[Dataset]: ... + async def delete(self, input: builtins.list[int | str | Dataset | IdCollection]) -> None: ... async def filter( self, *, filter: DatasetFilter | None = None, id: Sequence[int] | None = None, - external_id: PatternList | None = None, - name: PatternList | None = None, - source: PatternList | None = None, - labels: PatternList | None = None, - metadata: MetadataFilter | None = None, + external_id: str | Sequence[str] | None = None, + name: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + labels: str | Sequence[str] | None = None, + metadata: Mapping[str, str | None] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, limit: int | None = None, - sort_by: SortBy | None = None, + sort_by: str | Sequence[str] | None = None, sort_order: str | None = None, cursor: str | None = None, ) -> Page: @@ -1222,16 +1886,17 @@ class DatasetsServiceAsync: query: str, filter: DatasetFilter | None = None, limit: int | None = None, - ) -> list[Dataset]: ... - async def update(self, input: list[DatasetUpdate]) -> list[Dataset]: ... - async def policies(self) -> list[Resource]: ... + ) -> builtins.list[Dataset]: ... + async def update(self, input: builtins.list[DatasetUpdate]) -> builtins.list[Dataset]: ... + async def policies(self) -> builtins.list[Resource]: ... # ====================== Resources ====================== +@final class Resource: - def __init__( - self, + def __new__( + cls, name: str | None = None, external_id: str | None = None, id: int | None = None, @@ -1243,7 +1908,7 @@ class Resource: labels: list[str] | None = None, related_resources: list[RelatedNode] | None = None, geolocation: dict[str, Any] | None = None, - ) -> None: ... + ) -> Resource: ... @property def node_type(self) -> str: """This node's type as a string ("asset", "timeseries", "function", "resource", @@ -1290,7 +1955,11 @@ class Resource: @related_resources.setter def related_resources(self, value: list[RelatedNode] | None) -> None: ... @property - def geolocation(self) -> dict[str, Any] | None: ... + def geolocation(self) -> dict[str, Any] | None: + """ + The GeoJSON geometry as a Python ``dict`` (e.g. + ``{"type": "Point", "coordinates": [10.75, 59.91]}``), or ``None``. + """ @geolocation.setter def geolocation(self, value: dict[str, Any] | None) -> None: ... @property @@ -1303,17 +1972,36 @@ class Resource: depth: int = -1, relationship_types: list[str] | None = None, limit: int = 5000, - ) -> ResourceNetwork: ... + ) -> ResourceNetwork: + """ + Walk the graph from this resource and return the connected sub-graph (its ``nodes``, the + ``edges`` between them, and their ``labels``). ``depth`` bounds the traversal in hops + (``-1``, the default, = the whole connected component); ``relationship_types`` filters which + edge types to follow (``None`` = all); ``limit`` caps the node count. Blocking; see + [``neighbors_async``] for the awaitable variant. + """ async def neighbors_async( self, depth: int = -1, relationship_types: list[str] | None = None, limit: int = 5000, - ) -> ResourceNetwork: ... - def related_events(self, limit: int = 100) -> list[Event]: ... - async def related_events_async(self, limit: int = 100) -> list[Event]: ... + ) -> ResourceNetwork: + """ + Awaitable variant of [``neighbors``]. + """ + def related_events(self, limit: int = 100) -> list[Event]: + """ + Fetch events whose ``related_resources`` include this + resource (matched by graph-node id when present, else external id), via ``events.filter``. + ``limit`` caps the results (default 100). Blocking; see [``related_events_async``]. + """ + async def related_events_async(self, limit: int = 100) -> list[Event]: + """ + Awaitable variant of [``related_events``]. + """ +@final class Asset: """A resource that carries a geographic location. @@ -1321,8 +2009,8 @@ class Asset: "ASSET" type-label, and only an asset ever has its `geolocation` echoed back on a read. """ - def __init__( - self, + def __new__( + cls, name: str | None = None, external_id: str | None = None, id: int | None = None, @@ -1334,7 +2022,7 @@ class Asset: labels: list[str] | None = None, related_resources: list[RelatedNode] | None = None, geolocation: dict[str, Any] | None = None, - ) -> None: ... + ) -> Asset: ... @property def node_type(self) -> str: """Always "asset".""" @@ -1406,6 +2094,7 @@ class Asset: async def related_events_async(self, limit: int = 100) -> list[Event]: ... +@final class Policy: """An access policy, as a node. @@ -1413,8 +2102,8 @@ class Policy: `data_set_id` back, so those are always None on an object that came from the server. """ - def __init__( - self, + def __new__( + cls, name: str | None = None, external_id: str | None = None, id: int | None = None, @@ -1427,7 +2116,7 @@ class Policy: data_set_id: int | None = None, source: str | None = None, labels: list[str] | None = None, - ) -> None: ... + ) -> Policy: ... @property def node_type(self) -> str: """Always "policy".""" @@ -1500,12 +2189,17 @@ class Policy: async def related_events_async(self, limit: int = 100) -> list[Event]: ... +@final class ResourceNetwork: """Connected sub-graph returned by `Resource.neighbors` (and the timeseries/dataset/ function equivalents): the reachable `nodes`, the `edges` between them, and their `labels`.""" @property - def nodes(self) -> list[Node]: ... + def nodes(self) -> list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: + """ + The nodes in the traversed sub-graph, each as its own class (``Asset``, ``TimeSeries``, + ``Dataset``, …). Typed but sparse — the graph carries only a subset of each node's columns. + """ @property def edges(self) -> list[EdgeProxy]: ... @property @@ -1517,11 +2211,12 @@ class ResourceNetwork: # is the unified node-centric relation carried by every node type (Resource, # TimeSeries, Function, ...). `EdgeProxy` is the full edge detail in graph responses. +@final class EdgeProxy: """Server-assigned edge between two resources. The `relationship_type` attribute maps to the wire field `"type"`.""" - def __init__( - self, + def __new__( + cls, id: int | None = None, start: int | None = None, end: int | None = None, @@ -1529,7 +2224,7 @@ class EdgeProxy: description: str | None = None, relationship_type_id: int | None = None, metadata: dict[str, str] | None = None, - ) -> None: ... + ) -> EdgeProxy: ... @property def id(self) -> int | None: ... @property @@ -1546,11 +2241,12 @@ class EdgeProxy: def metadata(self) -> dict[str, str]: ... +@final class RelForm: """Request-side edge form. Pair with a list of `Resource` and pass both to `ResourcesService.create()`. `relationship_type` is keyword-required.""" - def __init__( - self, + def __new__( + cls, *, relationship_type: str, from_external_id: str | None = None, @@ -1562,7 +2258,7 @@ class RelForm: metadata: dict[str, str] | None = None, data_set_id: int | None = None, description: str | None = None, - ) -> None: ... + ) -> RelForm: ... @classmethod def by_external_ids( cls, from_external_id: str, to_external_id: str, relationship_type: str @@ -1591,27 +2287,26 @@ class RelForm: def description(self) -> str | None: ... +@final class GraphResult: """Nodes and relations returned from a graph operation.""" @property - def nodes(self) -> list[Node]: ... + def nodes(self) -> list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: ... @property def relations(self) -> list[EdgeProxy]: ... -# Any node object, an external id, or a numeric id. Takes every node class, not just Resource, -# because /resources spans them all — a Dataset from filter() can be handed straight to delete(). -ResourceIdentifiable = Union["Node", str, int] +@final class ResourceUpdate: """One resource's update for `resources.update`. Target the resource by a `Resource`, its numeric id, or its external id; every field is optional and uses the same wrappers as the other update APIs (`FieldStr` for scalars, `ListFieldStr` for labels, `MapField` for metadata). Mirrors `TimeSeriesUpdate`.""" - def __init__( - self, - resource: ResourceIdentifiable, + def __new__( + cls, + resource: int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy, external_id: FieldStr | None = None, name: FieldStr | None = None, description: FieldStr | None = None, @@ -1620,7 +2315,7 @@ class ResourceUpdate: source: FieldStr | None = None, labels: ListFieldStr | None = None, geolocation: FieldGeoJson | None = None, - ) -> None: ... + ) -> ResourceUpdate: ... @property def target_id(self) -> int | None: ... @property @@ -1629,6 +2324,7 @@ class ResourceUpdate: def labels(self) -> ListFieldStr | None: ... +@final class ResourceFilter: """AND-combined criteria for ``resources.filter`` and the ``filter`` of ``resources.search``. @@ -1636,35 +2332,44 @@ class ResourceFilter: ``search`` needs, since a filter passed positionally there would be indistinguishable from the search form. """ - def __init__( - self, + def __new__( + cls, id: Sequence[int] | None = None, - external_id: PatternList | None = None, - name: PatternList | None = None, - source: PatternList | None = None, - labels: PatternList | None = None, - metadata: MetadataFilter | None = None, + external_id: str | Sequence[str] | None = None, + name: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + labels: str | Sequence[str] | None = None, + metadata: Mapping[str, str | None] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, - node_type: PatternList | None = None, + node_type: str | Sequence[str] | None = None, is_root: bool | None = None, - data_set_id: Sequence[DataSetRef] | None = None, - ) -> None: ... + data_set_id: Sequence[int | str | IdCollection] | None = None, + ) -> ResourceFilter: ... class ResourcesServiceSync: - def list(self, limit: int | None = None) -> list[Node]: ... + def list(self, limit: int | None = None) -> builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: + """ + The first ``limit`` nodes in the tenant, newest created first — the cheap "what have I got" + read, with no criteria and no paging. + + Spans every node type and answers each row as its own class, exactly as ``filter`` does, so + ``isinstance(node, TimeSeries)`` works on what comes back. ``limit`` defaults to the server's + 1000 and may not exceed 10000; a ``Page`` is not returned because there is no cursor to + continue with — narrow with ``filter`` instead of raising the number. + """ def create( - self, nodes: list[Node], relations: list[RelForm] | None = None + self, nodes: builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy], relations: builtins.list[RelForm] | None = None ) -> GraphResult: ... - def by_ids(self, input: list[ResourceIdentifiable]) -> list[Node]: ... - def delete(self, input: list[ResourceIdentifiable]) -> None: ... + def by_ids(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: ... + def delete(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> None: ... def search( self, query: str, filter: ResourceFilter | None = None, limit: int | None = None, - ) -> list[Node]: + ) -> builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: """Free-text search for ``query``, ranked by relevance. ``filter`` takes the same criteria as ``filter()`` and only ever removes hits from the @@ -1672,24 +2377,41 @@ class ResourcesServiceSync: survives, defaulting to 100 and capping at 1000; the ``filter`` endpoints use 1000/10000, which is easy to conflate. """ - def update(self, input: list[ResourceUpdate]) -> GraphResult: ... - def get_by_id(self, id: int) -> Node | None: ... + def update(self, input: builtins.list[ResourceUpdate]) -> GraphResult: + """ + Update nodes in place. Each [``ResourceUpdate``] targets one node and carries only the + fields to change; every field it can set is shared by all node types, so one update form + covers them all. + + **The echo is typed**, like every other read here: ``.nodes`` holds each node as its own + class, so a timeseries comes back as ``TimeSeries`` carrying its ``unit`` and an asset as + ``Asset`` carrying its ``geo_location``. The ``labels`` reflect what the server stored, intrinsic + type-label included. + + This used to answer with a plain ``Resource`` whatever the node's real type. The api's + node-update refactor made the pipeline per-type and the echo followed. + """ + def get_by_id(self, id: int) -> Asset | TimeSeries | Function | Resource | Dataset | Policy | None: + """ + ``GET /resources/{id}`` — one resource by numeric id. Raises when it does not exist, + unlike ``by_ids``, which silently omits what it cannot find. + """ def filter( self, filter: ResourceFilter | None = None, id: Sequence[int] | None = None, - external_id: PatternList | None = None, - name: PatternList | None = None, - source: PatternList | None = None, - labels: PatternList | None = None, - metadata: MetadataFilter | None = None, + external_id: str | Sequence[str] | None = None, + name: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + labels: str | Sequence[str] | None = None, + metadata: Mapping[str, str | None] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, - node_type: PatternList | None = None, + node_type: str | Sequence[str] | None = None, is_root: bool | None = None, - data_set_id: Sequence[DataSetRef] | None = None, + data_set_id: Sequence[int | str | IdCollection] | None = None, limit: int | None = None, - sort_by: SortBy | None = None, + sort_by: str | Sequence[str] | None = None, sort_order: str | None = None, cursor: str | None = None, ) -> Page: @@ -1708,36 +2430,36 @@ class ResourcesServiceSync: class ResourcesServiceAsync: - async def list(self, limit: int | None = None) -> list[Node]: ... + async def list(self, limit: int | None = None) -> builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: ... async def create( - self, nodes: list[Node], relations: list[RelForm] | None = None + self, nodes: builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy], relations: builtins.list[RelForm] | None = None ) -> GraphResult: ... - async def by_ids(self, input: list[ResourceIdentifiable]) -> list[Node]: ... - async def delete(self, input: list[ResourceIdentifiable]) -> None: ... + async def by_ids(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: ... + async def delete(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> None: ... async def search( self, query: str, filter: ResourceFilter | None = None, limit: int | None = None, - ) -> list[Node]: ... - async def update(self, input: list[ResourceUpdate]) -> GraphResult: ... - async def get_by_id(self, id: int) -> Node | None: ... + ) -> builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: ... + async def update(self, input: builtins.list[ResourceUpdate]) -> GraphResult: ... + async def get_by_id(self, id: int) -> Asset | TimeSeries | Function | Resource | Dataset | Policy | None: ... async def filter( self, filter: ResourceFilter | None = None, id: Sequence[int] | None = None, - external_id: PatternList | None = None, - name: PatternList | None = None, - source: PatternList | None = None, - labels: PatternList | None = None, - metadata: MetadataFilter | None = None, + external_id: str | Sequence[str] | None = None, + name: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + labels: str | Sequence[str] | None = None, + metadata: Mapping[str, str | None] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, - node_type: PatternList | None = None, + node_type: str | Sequence[str] | None = None, is_root: bool | None = None, - data_set_id: Sequence[DataSetRef] | None = None, + data_set_id: Sequence[int | str | IdCollection] | None = None, limit: int | None = None, - sort_by: SortBy | None = None, + sort_by: str | Sequence[str] | None = None, sort_order: str | None = None, cursor: str | None = None, ) -> Page: @@ -1746,19 +2468,20 @@ class ResourcesServiceAsync: # ====================== Labels ====================== +@final class Label: """A DataHub label. `name` is the identifier you set (3–512 chars, canonicalised to SNAKE_UPPER_CASE server-side); `id`/`color` are usually assigned by the server. Also the shape returned inside a `ResourceNetwork` from `resources.fetch_related` (there `color`/`i18n_code` are `None`).""" - def __init__( - self, + def __new__( + cls, name: str | None = None, id: int | None = None, description: str | None = None, color: str | None = None, i18n_code: str | None = None, - ) -> None: ... + ) -> Label: ... @property def id(self) -> int | None: ... @id.setter @@ -1781,31 +2504,75 @@ class Label: def i18n_code(self, value: str | None) -> None: ... -# Accepted as a label identifier when deleting: a Label, its numeric id, or its name. -LabelIdentifiable = Union["Label", int, str] - - +@final class LabelsServiceSync: - def list(self) -> list[Label]: ... - def get(self, id: int) -> Label | None: ... - def create(self, input: list[Label]) -> list[Label]: ... - def update(self, input: list[Label]) -> list[Label]: ... - def delete(self, input: list[LabelIdentifiable]) -> None: ... + def list(self) -> builtins.list[Label]: + """ + Every label in the tenant. + """ + def get(self, id: int) -> Label | None: + """ + A single label by numeric id, or ``None`` if it doesn't exist (the server answers an + unknown id with 404; that is absorbed into ``None``). + """ + def create(self, input: builtins.list[Label]) -> builtins.list[Label]: + """ + Create labels (each needs a unique ``name``). A duplicate name raises with status 409. + """ + def update(self, input: builtins.list[Label]) -> builtins.list[Label]: + """ + Update labels (identify each by ``id``); only the fields you set are applied. + """ + def delete(self, input: builtins.list[Label | int | str]) -> None: + """ + Delete labels by ``Label``, numeric id, or name. Rejected with status 400 if a label is + still referenced by a resource. + """ +@final class LabelsServiceAsync: - async def list(self) -> list[Label]: ... + async def list(self) -> builtins.list[Label]: ... async def get(self, id: int) -> Label | None: ... - async def create(self, input: list[Label]) -> list[Label]: ... - async def update(self, input: list[Label]) -> list[Label]: ... - async def delete(self, input: list[LabelIdentifiable]) -> None: ... + async def create(self, input: builtins.list[Label]) -> builtins.list[Label]: ... + async def update(self, input: builtins.list[Label]) -> builtins.list[Label]: ... + async def delete(self, input: builtins.list[Label | int | str]) -> None: ... # ====================== Units ====================== +@final class Unit: - def __init__( - self, + """ + Represents a Unit in the Datahub unit system + + Parameters + ---------- + id: int + internal id of the unit + external_id: str + user provided external id of the unit + name: str + name of the unit ie Celcius, Newton, + long_name: str + long name of the unit ie Temperature_Celsius, Force_Newton, + symbol: str + symbol of the unit ie C, N, + description: str + description of the unit + alias_names: list[str] + alias names of the unit ie Pascal, Newton/Meter Squared, + quantity: str + The quantity dimension of the unit ie Temperature, Mass, Energy-seconds + conversion: dict[str,float] + dict of conversion factors from this unit to other units + source: str + source of the unit + source_reference: + url to the source of the unit + """ + def __new__( + cls, id: int, external_id: str, name: str, @@ -1817,7 +2584,7 @@ class Unit: conversion: dict[str, float], source: str, source_reference: str, - ) -> None: ... + ) -> Unit: ... @property def id(self) -> int: ... @id.setter @@ -1864,23 +2631,26 @@ class Unit: def source_reference(self, value: str) -> None: ... +@final class UnitServiceSync: - def list(self) -> list[Unit]: ... - def by_ids(self, input: list[IdCollection]) -> list[Unit]: ... - def by_external_ids(self, input: str) -> list[Unit]: ... + def list(self) -> builtins.list[Unit]: ... + def by_ids(self, input: builtins.list[IdCollection]) -> builtins.list[Unit]: ... + def by_external_ids(self, input: str) -> builtins.list[Unit]: ... +@final class UnitServiceAsync: - async def list(self) -> list[Unit]: ... - async def by_ids(self, input: list[IdCollection]) -> list[Unit]: ... - async def by_external_id(self, input: str) -> list[Unit]: ... + async def list(self) -> builtins.list[Unit]: ... + async def by_ids(self, input: builtins.list[IdCollection]) -> builtins.list[Unit]: ... + async def by_external_id(self, input: str) -> builtins.list[Unit]: ... # ====================== Files ====================== +@final class INode: - def __init__( - self, + def __new__( + cls, name: str, external_id: str, path: str, @@ -1899,7 +2669,7 @@ class INode: metadata: dict[str, str] | None = None, related_resources: list[int] | None = None, security_categories: list[int] | None = None, - ) -> None: ... + ) -> INode: ... @property def id(self) -> int | None: ... @property @@ -1942,13 +2712,22 @@ class INode: def security_categories(self) -> list[int] | None: ... # --- navigation (only on inodes returned by the API; raises otherwise) --- # `related_resources` (above) returns the raw ids; these resolve them to Resource objects. - def related_resource_nodes(self) -> list[Node]: ... - async def related_resource_nodes_async(self) -> list[Node]: ... + def related_resource_nodes(self) -> list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: + """ + Fetch the resources this file references (its ``related_resources`` ids), resolved to + ``Resource`` objects via the resources service. (The ``related_resources`` *property* returns + the raw ids; this resolves them.) Blocking; see [``related_resource_nodes_async``]. + """ + async def related_resource_nodes_async(self) -> list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: + """ + Awaitable variant of [``related_resource_nodes``]. + """ +@final class FileUpload: - def __init__( - self, + def __new__( + cls, path: str, destination_path: str | None = None, external_id: str | None = None, @@ -1958,7 +2737,7 @@ class FileUpload: source: str | None = None, data_set_id: int | None = None, related_resources: list[int] | None = None, - ) -> None: ... + ) -> FileUpload: ... @classmethod def from_path(cls, path: str) -> FileUpload: ... @classmethod @@ -1989,6 +2768,7 @@ class FileUpload: def source_last_updated(self) -> datetime.datetime | None: ... +@final class FileUpdate: """A partial update for one file or folder. @@ -1996,8 +2776,8 @@ class FileUpdate: sent when given, so an omitted field is left unchanged. """ - def __init__( - self, + def __new__( + cls, external_id: str | None = None, id: int | None = None, name: str | None = None, @@ -2007,13 +2787,14 @@ class FileUpdate: source: str | None = None, metadata: dict[str, str] | None = None, related_resources: list[int] | None = None, - ) -> None: ... + ) -> FileUpdate: ... @property def external_id(self) -> str | None: ... @property def id(self) -> int | None: ... +@final class FileDownload: """A downloaded file's bytes plus what the server said they are.""" @@ -2022,38 +2803,67 @@ class FileDownload: @property def mime_type(self) -> str | None: ... @property - def content(self) -> bytes: ... + def content(self) -> bytes: + """ + The file content as ``bytes``. + """ def __len__(self) -> int: ... -FileIdentifiable = Union[INode, IdCollection, int, str] - - +@final class FilesServiceSync: def upload_file(self, file_upload: FileUpload) -> list[INode]: ... def list_root_directory(self) -> list[INode]: ... - def delete(self, input: list[FileIdentifiable]) -> None: ... + def delete(self, input: list[INode | IdCollection | int | str]) -> None: ... def list_directory_by_path(self, path: str) -> list[INode]: ... - def get_by_id(self, id: int) -> list[INode]: ... - def get_by_external_id(self, external_id: str) -> list[INode]: ... - def search(self, query: str) -> list[INode]: ... - def list_trash(self) -> list[INode]: ... - def restore(self, input: list[FileIdentifiable]) -> list[INode]: ... - def update(self, update: FileUpdate) -> list[INode]: ... - def download(self, id: int) -> FileDownload: ... - def download_to_path(self, id: int, destination: str) -> int: ... + def get_by_id(self, id: int) -> list[INode]: + """ + Metadata for one file or folder, by numeric id. + """ + def get_by_external_id(self, external_id: str) -> list[INode]: + """ + Metadata for one file or folder, by external id. + """ + def search(self, query: str) -> list[INode]: + """ + Full-text search over file and folder names and descriptions. + """ + def list_trash(self) -> list[INode]: + """ + The soft-deleted files the caller can read. + """ + def restore(self, input: list[INode | IdCollection | int | str]) -> list[INode]: + """ + Restore soft-deleted files. Identify each by numeric id: the trashed + ``DELETED_..._`` external id does not round-trip through the server's + lowercasing hash, so that route answers 404. See ``FileService::restore`` in the SDK. + """ + def update(self, update: FileUpdate) -> list[INode]: + """ + Rename, move, or edit the metadata of one file or folder. + """ + def download(self, id: int) -> FileDownload: + """ + Download a file's content into memory. + """ + def download_to_path(self, id: int, destination: str) -> int: + """ + Download a file straight to ``destination``, without holding it in memory. Returns the number + of bytes written. + """ +@final class FilesServiceAsync: async def upload_file(self, file_upload: FileUpload) -> list[INode]: ... async def list_root_directory(self) -> list[INode]: ... - async def delete(self, input: list[FileIdentifiable]) -> None: ... + async def delete(self, input: list[INode | IdCollection | int | str]) -> None: ... async def list_directory_by_path(self, path: str) -> list[INode]: ... async def get_by_id(self, id: int) -> list[INode]: ... async def get_by_external_id(self, external_id: str) -> list[INode]: ... async def search(self, query: str) -> list[INode]: ... async def list_trash(self) -> list[INode]: ... - async def restore(self, input: list[FileIdentifiable]) -> list[INode]: ... + async def restore(self, input: list[INode | IdCollection | int | str]) -> list[INode]: ... async def update(self, update: FileUpdate) -> list[INode]: ... async def download(self, id: int) -> FileDownload: ... async def download_to_path(self, id: int, destination: str) -> int: ... @@ -2061,44 +2871,46 @@ class FilesServiceAsync: # ====================== Subscriptions ====================== +@final class Subscription: - def __init__( - self, + def __new__( + cls, external_id: str, name: str, - timeseries: list[Identifiable], + timeseries: list[int | str | TimeSeries | IdCollection], id: int | None = None, - ) -> None: ... - - -SubscriptionTimeseriesId = Union[TimeSeries, IdCollection, int, str] + ) -> Subscription: ... +@final class SubscriptionFilter: - def __init__(self, timeseries: list[SubscriptionTimeseriesId] | None = None) -> None: ... + def __new__(cls, timeseries: list[TimeSeries | IdCollection | int | str] | None = None) -> SubscriptionFilter: ... @property def timeseries(self) -> list[IdCollection]: ... +@final class DataSort: - def __init__( - self, + def __new__( + cls, property: list[str] | None = None, order: str | None = None, - ) -> None: ... + ) -> DataSort: ... @property def property(self) -> list[str]: ... - @property + # `property` above shadows the builtin decorator for the rest of the class body. + @builtins.property def order(self) -> str | None: ... +@final class SubscriptionFilterForm: - def __init__( - self, + def __new__( + cls, filter: SubscriptionFilter | None = None, limit: int | None = None, sort: DataSort | None = None, - ) -> None: ... + ) -> SubscriptionFilterForm: ... @property def filter(self) -> SubscriptionFilter: ... @property @@ -2107,24 +2919,32 @@ class SubscriptionFilterForm: def sort(self) -> DataSort | None: ... +@final class EventAction: def __repr__(self) -> str: ... def __str__(self) -> str: ... +@final class EventObject: def __repr__(self) -> str: ... def __str__(self) -> str: ... +@final class WsDatapoint: @property def timestamp(self) -> str: ... @property def value(self) -> str: ... - def as_float(self) -> float: ... + def as_float(self) -> float: + """ + Parse the value as a float. Raises ValueError if the value isn't numeric (e.g. for + string-typed timeseries that share this delivery channel). + """ +@final class DataCollectionString: @property def id(self) -> int | None: ... @@ -2140,6 +2960,7 @@ class DataCollectionString: def datapoints(self) -> list[WsDatapoint]: ... +@final class DataWrapperMessage: @property def event_action(self) -> EventAction: ... @@ -2151,22 +2972,35 @@ class DataWrapperMessage: def items(self) -> list[DataCollectionString]: ... +@final class SubscriptionMessage: @property - def subscription_external_id(self) -> str: ... + def subscription_external_id(self) -> str: + """ + The subscription this message was delivered for (useful when one listener multiplexes + several subscriptions). + """ @property def message_id(self) -> str: ... @property def payload(self) -> DataWrapperMessage: ... -SubscriptionIdentifiable = Union[Subscription, IdCollection, int, str] - - +@final class SubscriptionListener: + """ + Synchronous Python wrapper around the Rust ``SubscriptionListener``. Iterating drives the + underlying WebSocket: ``for msg in listener:`` blocks until the next message or returns when + the connection closes cleanly. + """ def __iter__(self) -> SubscriptionListener: ... def __next__(self) -> SubscriptionMessage: ... - def next_message(self) -> SubscriptionMessage | None: ... + def next_message(self) -> SubscriptionMessage | None: + """ + Wait for the next message. Returns None when the connection has been closed cleanly, + raises on transport / deserialization errors. Equivalent to driving the iterator one + step but without using StopIteration as the close signal. + """ def ack(self, message_ids: list[str]) -> None: ... def nack(self, message_ids: list[str]) -> None: ... def subscribe(self, external_ids: list[str]) -> None: ... @@ -2177,7 +3011,11 @@ class SubscriptionListener: def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ... +@final class SubscriptionListenerAsync: + """ + Asynchronous Python wrapper. Use ``async for msg in listener:`` on the asyncio side. + """ def __aiter__(self) -> SubscriptionListenerAsync: ... async def __anext__(self) -> SubscriptionMessage: ... async def next_message(self) -> SubscriptionMessage | None: ... @@ -2191,42 +3029,58 @@ class SubscriptionListenerAsync: async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ... +@final class SubscriptionsServiceSync: - def create(self, input: list[Subscription]) -> list[Subscription]: ... - def list(self, limit: int | None = None) -> list[Subscription]: ... + def create(self, input: builtins.list[Subscription]) -> builtins.list[Subscription]: ... + def list(self, limit: int | None = None) -> builtins.list[Subscription]: + """ + Subscriptions in the tenant, newest first. ``limit`` defaults to the server's 1000 and may + not exceed 10000; there is no paging, so a bigger tenant is truncated rather than paged — + use ``filter`` to narrow instead. + """ def filter( self, form: SubscriptionFilterForm | None = None, - timeseries: list[SubscriptionTimeseriesId] | None = None, + timeseries: builtins.list[TimeSeries | IdCollection | int | str] | None = None, limit: int | None = None, sort: DataSort | None = None, - ) -> list[Subscription]: ... - def delete(self, input: list[SubscriptionIdentifiable]) -> None: ... - def listen(self, subscription_external_ids: list[str]) -> SubscriptionListener: ... + ) -> builtins.list[Subscription]: + """ + Subscriptions matching every criterion on the filter. + """ + def delete(self, input: builtins.list[Subscription | IdCollection | int | str]) -> None: ... + def listen(self, subscription_external_ids: builtins.list[str]) -> SubscriptionListener: + """ + Open a WebSocket listener multiplexing the named subscriptions. The ids seed the initial + set (may be empty — add more with .subscribe()). Returns a SubscriptionListener you can + iterate or call .next_message() / .ack() / .subscribe() / .close() on. + """ +@final class SubscriptionsServiceAsync: - async def create(self, input: list[Subscription]) -> list[Subscription]: ... - async def list(self, limit: int | None = None) -> list[Subscription]: ... + async def create(self, input: builtins.list[Subscription]) -> builtins.list[Subscription]: ... + async def list(self, limit: int | None = None) -> builtins.list[Subscription]: ... async def filter( self, form: SubscriptionFilterForm | None = None, - timeseries: list[SubscriptionTimeseriesId] | None = None, + timeseries: builtins.list[TimeSeries | IdCollection | int | str] | None = None, limit: int | None = None, sort: DataSort | None = None, - ) -> list[Subscription]: ... - async def delete(self, input: list[SubscriptionIdentifiable]) -> None: ... - async def listen(self, subscription_external_ids: list[str]) -> SubscriptionListenerAsync: ... + ) -> builtins.list[Subscription]: ... + async def delete(self, input: builtins.list[Subscription | IdCollection | int | str]) -> None: ... + async def listen(self, subscription_external_ids: builtins.list[str]) -> SubscriptionListenerAsync: ... # ====================== Functions ====================== +@final class Function: - def __init__( - self, + def __new__( + cls, external_id: str, name: str | None = None, - ) -> None: ... + ) -> Function: ... @property def id(self) -> int | None: ... @property @@ -2259,30 +3113,55 @@ class Function: @property def last_updated_time(self) -> datetime.datetime | None: ... @property - def related_resources(self) -> list[RelatedNode]: ... + def related_resources(self) -> list[RelatedNode]: + """ + The nodes bound into this function (e.g. its input timeseries via PROCESSED_BY + edges). Populated by the server on ``GET /functions``; the Python worker reads each + entry's ``id`` and ``relationship_type == "PROCESSED_BY"`` to build its routing map. + """ # --- navigation (only on functions returned by the API; raises otherwise) --- def neighbors( self, depth: int = -1, relationship_types: list[str] | None = None, limit: int = 5000, - ) -> ResourceNetwork: ... + ) -> ResourceNetwork: + """ + Walk the graph from this function and return the connected sub-graph (its ``nodes``, the + ``edges`` between them, and their ``labels``). ``depth`` bounds the traversal in hops + (``-1``, the default, = the whole connected component); ``relationship_types`` filters which + edge types to follow (``None`` = all); ``limit`` caps the node count. Neighbour nodes are + modelled as ``Resource``. Blocking; see [``neighbors_async``] for the awaitable variant. + """ async def neighbors_async( self, depth: int = -1, relationship_types: list[str] | None = None, limit: int = 5000, - ) -> ResourceNetwork: ... - def related_events(self, limit: int = 100) -> list[Event]: ... - async def related_events_async(self, limit: int = 100) -> list[Event]: ... - - -FunctionIdentifiable = Union[Function, IdCollection, int, str] + ) -> ResourceNetwork: + """ + Awaitable variant of [``neighbors``]. + """ + def related_events(self, limit: int = 100) -> list[Event]: + """ + Fetch events whose ``related_resources`` include this + function (matched by graph-node id when present, else external id), via ``events.filter``. + ``limit`` caps the results (default 100). Blocking; see [``related_events_async``]. + """ + async def related_events_async(self, limit: int = 100) -> list[Event]: + """ + Awaitable variant of [``related_events``]. + """ +@final class FunctionsServiceSync: - def create(self, input: list[Function]) -> list[Function]: ... - def list(self, limit: int | None = None) -> list[Function]: ... + def create(self, input: builtins.list[Function]) -> builtins.list[Function]: ... + def list(self, limit: int | None = None) -> builtins.list[Function]: + """ + The first ``limit`` functions you may read, newest first. ``limit`` defaults to the server's + 1000 and may not exceed 10000; there is no paging, so a bigger catalogue is truncated. + """ def get_by_id(self, id: int) -> Function | None: """One function by numeric id; raises on 404. @@ -2290,28 +3169,34 @@ class FunctionsServiceSync: missing rather than forbidden. Prefer this to ``by_ids`` when you have the id: functions have no ``/byids`` endpoint, so ``by_ids`` pages the listing and filters client-side. """ - def by_ids(self, input: list[FunctionIdentifiable]) -> list[Function]: ... - def by_external_id(self, external_id: str) -> Function: ... - def update(self, input: list[ResourceUpdate]) -> GraphResult: + def by_ids(self, input: builtins.list[Function | IdCollection | int | str]) -> builtins.list[Function]: ... + def by_external_id(self, external_id: str) -> Function: + """ + Convenience for the function-worker bootstrap: returns the function with the given + externalId, or raises if no such function exists. + """ + def update(self, input: builtins.list[ResourceUpdate]) -> GraphResult: """Update functions in place; ``geolocation`` is ignored, being asset-only. ``.nodes`` holds typed node objects — a function comes back as ``Function``. """ - def delete(self, input: list[FunctionIdentifiable]) -> None: ... + def delete(self, input: builtins.list[Function | IdCollection | int | str]) -> None: ... +@final class FunctionsServiceAsync: - async def create(self, input: list[Function]) -> list[Function]: ... - async def list(self, limit: int | None = None) -> list[Function]: ... + async def create(self, input: builtins.list[Function]) -> builtins.list[Function]: ... + async def list(self, limit: int | None = None) -> builtins.list[Function]: ... async def get_by_id(self, id: int) -> Function | None: ... - async def by_ids(self, input: list[FunctionIdentifiable]) -> list[Function]: ... + async def by_ids(self, input: builtins.list[Function | IdCollection | int | str]) -> builtins.list[Function]: ... async def by_external_id(self, external_id: str) -> Function: ... - async def update(self, input: list[ResourceUpdate]) -> GraphResult: ... - async def delete(self, input: list[FunctionIdentifiable]) -> None: ... + async def update(self, input: builtins.list[ResourceUpdate]) -> GraphResult: ... + async def delete(self, input: builtins.list[Function | IdCollection | int | str]) -> None: ... # ====================== Assets ====================== +@final class AssetsServiceSync: """The typed ``/assets`` family — the ``ASSET``-labelled corner of the resource graph. @@ -2320,7 +3205,7 @@ class AssetsServiceSync: back: ``Asset``, so ``geolocation`` and ``is_root`` are reachable without a type check. """ - def create(self, input: list[Asset]) -> list[Asset]: + def create(self, input: builtins.list[Asset]) -> builtins.list[Asset]: """Create assets. Each needs an ``external_id`` and a ``name``. Unlike ``resources.create``, the ``ASSET`` label need not be set by hand. Relations are @@ -2332,9 +3217,9 @@ class AssetsServiceSync: A 404 does not tell you the id is free: a node that exists but is not an asset, and an asset you may not read, are both reported as missing. """ - def by_ids(self, input: list[ResourceIdentifiable]) -> list[Asset]: + def by_ids(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> builtins.list[Asset]: """Assets by id or external id. What cannot be found is omitted, not raised.""" - def list(self, limit: int | None = None) -> list[Asset]: + def list(self, limit: int | None = None) -> builtins.list[Asset]: """The first ``limit`` assets, newest created first. Defaults to 1000, caps at 10000. A plain list rather than a ``Page``: there is no cursor to continue with, so narrow with @@ -2344,17 +3229,17 @@ class AssetsServiceSync: self, filter: ResourceFilter | None = None, id: Sequence[int] | None = None, - external_id: PatternList | None = None, - name: PatternList | None = None, - source: PatternList | None = None, - labels: PatternList | None = None, - metadata: MetadataFilter | None = None, + external_id: str | Sequence[str] | None = None, + name: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + labels: str | Sequence[str] | None = None, + metadata: Mapping[str, str | None] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, is_root: bool | None = None, - data_set_id: Sequence[DataSetRef] | None = None, + data_set_id: Sequence[int | str | IdCollection] | None = None, limit: int | None = None, - sort_by: SortBy | None = None, + sort_by: str | Sequence[str] | None = None, sort_order: str | None = None, cursor: str | None = None, ) -> Page: @@ -2371,19 +3256,19 @@ class AssetsServiceSync: query: str, filter: ResourceFilter | None = None, limit: int | None = None, - ) -> list[Asset]: + ) -> builtins.list[Asset]: """Free-text search over assets, best match first. ``filter`` only ever removes hits from the phrase's. ``limit`` defaults to 100 and caps at 1000 — ``filter`` uses 1000/10000, which is easy to conflate. """ - def update(self, input: list[ResourceUpdate]) -> GraphResult: + def update(self, input: builtins.list[ResourceUpdate]) -> GraphResult: """Update assets in place. ``geolocation`` is the field that means anything only here. ``.nodes`` holds typed node objects, not necessarily all assets — an update may touch relations whose other end is something else. """ - def delete(self, input: list[ResourceIdentifiable]) -> None: + def delete(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> None: """Delete assets, and with them all their relationships. A delete that would disconnect a surviving node from the graph root raises 409 @@ -2391,26 +3276,27 @@ class AssetsServiceSync: """ +@final class AssetsServiceAsync: - async def create(self, input: list[Asset]) -> list[Asset]: ... + async def create(self, input: builtins.list[Asset]) -> builtins.list[Asset]: ... async def get_by_id(self, id: int) -> Asset | None: ... - async def by_ids(self, input: list[ResourceIdentifiable]) -> list[Asset]: ... - async def list(self, limit: int | None = None) -> list[Asset]: ... + async def by_ids(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> builtins.list[Asset]: ... + async def list(self, limit: int | None = None) -> builtins.list[Asset]: ... async def filter( self, filter: ResourceFilter | None = None, id: Sequence[int] | None = None, - external_id: PatternList | None = None, - name: PatternList | None = None, - source: PatternList | None = None, - labels: PatternList | None = None, - metadata: MetadataFilter | None = None, + external_id: str | Sequence[str] | None = None, + name: str | Sequence[str] | None = None, + source: str | Sequence[str] | None = None, + labels: str | Sequence[str] | None = None, + metadata: Mapping[str, str | None] | None = None, created_time: TimeFilter | None = None, last_updated_time: TimeFilter | None = None, is_root: bool | None = None, - data_set_id: Sequence[DataSetRef] | None = None, + data_set_id: Sequence[int | str | IdCollection] | None = None, limit: int | None = None, - sort_by: SortBy | None = None, + sort_by: str | Sequence[str] | None = None, sort_order: str | None = None, cursor: str | None = None, ) -> Page: ... @@ -2419,13 +3305,14 @@ class AssetsServiceAsync: query: str, filter: ResourceFilter | None = None, limit: int | None = None, - ) -> list[Asset]: ... - async def update(self, input: list[ResourceUpdate]) -> GraphResult: ... - async def delete(self, input: list[ResourceIdentifiable]) -> None: ... + ) -> builtins.list[Asset]: ... + async def update(self, input: builtins.list[ResourceUpdate]) -> GraphResult: ... + async def delete(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> None: ... # ====================== Edges ====================== +@final class RelationshipType: """A relationship type in the tenant's catalogue.""" @@ -2439,6 +3326,7 @@ class RelationshipType: def i18n_code(self) -> str | None: ... +@final class RelTypeForm: """Register a relationship type up front. @@ -2446,12 +3334,12 @@ class RelTypeForm: normalises to nothing is rejected with status 400. """ - def __init__( - self, + def __new__( + cls, name: str, description: str | None = None, i18n_code: str | None = None, - ) -> None: ... + ) -> RelTypeForm: ... @property def name(self) -> str: ... @property @@ -2460,23 +3348,55 @@ class RelTypeForm: def i18n_code(self) -> str | None: ... -# Edges have no external id, so — unlike other identifiables — a string is not accepted. -EdgeIdentifiable = Union[EdgeProxy, int] +@final +class EdgesServiceSync: + def get(self, id: int) -> EdgeProxy | None: + """ + One relationship by numeric id, or ``None`` if no edge has that id. + + The server answers an unknown id with 404; that is absorbed into ``None`` here, matching the + other ``get()`` methods in these bindings. Any other error still raises. + """ + def by_ids(self, input: list[EdgeProxy | int]) -> GraphResult: + """ + Several relationships plus the resources they connect, as a ``GraphResult`` — ``nodes`` holds + both endpoints of each edge and ``relations`` the edges, so no follow-up call is needed. + """ + def create(self, input: list[RelForm]) -> list[EdgeProxy]: + """ + Link resources that already exist. To create the resources *and* their links together, use + ``resources.create(nodes, relations)`` instead. + All-or-nothing: if any relation in the batch fails, none are created. A relation targeting + a dataset must use ``BELONGS_TO``; a timeseries cannot be linked to a second dataset; you + need write access to the datasets of both endpoints. Re-creating an existing edge between + the same two resources conflicts with status 409. + """ + def delete(self, input: list[EdgeProxy | int]) -> None: + """ + Delete relationships by ``EdgeProxy`` or numeric id. Deletes the link only — the resources at + each end stay intact. Idempotent: unknown ids are silently skipped. + """ + def types(self) -> list[RelationshipType]: + """ + Every relationship type the tenant has defined. + """ + def create_types(self, input: list[RelTypeForm]) -> list[RelationshipType]: + """ + Register relationship type names up front. Names normalise to uppercase snake case. -class EdgesServiceSync: - def get(self, id: int) -> EdgeProxy | None: ... - def by_ids(self, input: list[EdgeIdentifiable]) -> GraphResult: ... - def create(self, input: list[RelForm]) -> list[EdgeProxy]: ... - def delete(self, input: list[EdgeIdentifiable]) -> None: ... - def types(self) -> list[RelationshipType]: ... - def create_types(self, input: list[RelTypeForm]) -> list[RelationshipType]: ... + A name that already exists currently makes the server fail silently — it answers 200 with + an empty body, and in a batch the valid new types are rolled back alongside the duplicate. + Treat an empty result as "something already existed and nothing was created", and use + ``types()`` to read the real state. + """ +@final class EdgesServiceAsync: async def get(self, id: int) -> EdgeProxy | None: ... - async def by_ids(self, input: list[EdgeIdentifiable]) -> GraphResult: ... + async def by_ids(self, input: list[EdgeProxy | int]) -> GraphResult: ... async def create(self, input: list[RelForm]) -> list[EdgeProxy]: ... - async def delete(self, input: list[EdgeIdentifiable]) -> None: ... + async def delete(self, input: list[EdgeProxy | int]) -> None: ... async def types(self) -> list[RelationshipType]: ... async def create_types(self, input: list[RelTypeForm]) -> list[RelationshipType]: ... diff --git a/docs-python/_ext/service_pages.py b/docs-python/_ext/service_pages.py new file mode 100644 index 0000000..05e43c9 --- /dev/null +++ b/docs-python/_ext/service_pages.py @@ -0,0 +1,220 @@ +"""Sphinx extension: one page per client service, laid out the way a caller reaches it. + +autoapi documents classes, and a service class is not something a caller ever names: it is +handed back by `client.timeseries`, never imported. So autoapi is told to skip the service +classes, and this writes their pages instead -- `timeseries.by_ids(...)` rather than +`TimeSeriesServiceSync.by_ids(...)`, methods grouped by structure.toml rather than sorted, +and the sync and async clients side by side rather than on two pages. + +Everything on those pages comes from the type stub: signatures, defaults and docstrings. +structure.toml says only which heading a method sits under. Drift between the two is a +Sphinx warning, so a build with -W fails until the structure file catches up. +""" + +from __future__ import annotations + +import ast +import re +import tomllib +from pathlib import Path + +from sphinx.application import Sphinx +from sphinx.ext.napoleon import Config as NapoleonConfig +from sphinx.ext.napoleon.docstring import NumpyDocstring +from sphinx.util import logging + +logger = logging.getLogger(__name__) + +MODULE = "intellistream_datahub_sdk" +SERVICE = re.compile(r"^(\w+)Service(Sync|Async)$") +UNLISTED = "Other" +OUT = "services" + + +def unparse_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str: + """The signature as a reader writes it. + + `builtins.` is dropped: the stub needs it inside services, where `def list` shadows the + builtin for the rest of the class body, but on the page `list[TimeSeries]` is unambiguous. + """ + args = re.sub(r"^self(,\s*)?", "", ast.unparse(node.args)) + returns = f" -> {ast.unparse(node.returns)}" if node.returns else "" + return re.sub(r"\bbuiltins\.", "", f"{node.name}({args}){returns}") + + +def returns(signature: str) -> str: + return signature.rsplit(" -> ", 1)[1] if " -> " in signature else "" + + +def parameter_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]: + a = node.args + names = [p.arg for p in a.posonlyargs + a.args + a.kwonlyargs] + return [n for n in names if n not in ("self", "cls")] + + +def read_services(stub: Path) -> tuple[dict[str, dict], dict[str, str]]: + """`base -> {"sync"|"async": {"doc", "methods": {name: node}}}`, and `base -> accessor`.""" + tree = ast.parse(stub.read_text(encoding="utf8")) + services: dict[str, dict] = {} + accessor: dict[str, str] = {} + + for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + m = SERVICE.match(node.name) + if m: + methods = { + item.name: item + for item in node.body + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and not item.name.startswith("_") + } + services.setdefault(m.group(1), {})[m.group(2).lower()] = { + "doc": ast.get_docstring(node), + "methods": methods, + } + elif node.name == "DataHubClient": + # The client's getters are what say `client.datasets` reaches DatasetsServiceSync. + for item in node.body: + if isinstance(item, ast.FunctionDef) and item.returns is not None: + r = SERVICE.match(ast.unparse(item.returns)) + if r: + accessor[r.group(1)] = item.name + + return services, accessor + + +def indent(text: str, by: str = " ") -> str: + return "\n".join(by + line if line.strip() else "" for line in text.split("\n")) + + +def docstring(text: str | None, napoleon: NapoleonConfig) -> str: + """The stub's docstring as reST, numpy-style sections included, as autoapi renders them.""" + return str(NumpyDocstring(text, napoleon)).strip() if text else "" + + +def call_cell(node, path: str, prefix: str = "") -> str: + if node is None: + return "—" + names = parameter_names(node) + shown = ", ".join(names) if len(names) <= 3 else "…" + cell = f"{prefix}:py:meth:`{node.name}({shown}) <{path}.{node.name}>`" + result = returns(unparse_signature(node)) + return cell + (f" → ``{result}``" if result else "") + + +def service_page(base: str, variants: dict, accessor: str, groups: list[dict], + text: dict[str, str], napoleon: NapoleonConfig) -> tuple[str, set[str]]: + sync = variants.get("sync", {}).get("methods", {}) + aio = variants.get("async", {}).get("methods", {}) + names = list(sync) + [n for n in aio if n not in sync] + + def group_of(name: str) -> str: + return next((g["title"] for g in groups if name in g.get("methods", ())), UNLISTED) + + by_group: dict[str, list[str]] = {} + for name in names: + by_group.setdefault(group_of(name), []).append(name) + ordered = [(g["title"], g.get("blurb", "")) for g in groups] + [(UNLISTED, text.get("unlisted_blurb", ""))] + in_order = [n for title, _ in ordered for n in by_group.get(title, [])] + + title = f"``{accessor}``" + out = [title, "=" * len(title), "", f".. py:currentmodule:: {MODULE}", ""] + doc = variants.get("sync", {}).get("doc") or variants.get("async", {}).get("doc") + if doc: + out += [docstring(doc, napoleon), ""] + out += [".. code-block:: python", "", + f" from {MODULE} import DataHubClient", "", + " client = DataHubClient.from_env()", + f" result = client.{accessor}.{in_order[0]}(...)", ""] + + out += [".. list-table::", " :header-rows: 1", " :widths: 1 1", "", + " * - ``DataHubClient``", " - ``AsyncDataHubClient``"] + for name in in_order: + out += [f" * - {call_cell(sync.get(name), accessor)}", + f" - {call_cell(aio.get(name), accessor, 'await ')}"] + out.append("") + + for title, blurb in ordered: + members = by_group.get(title) + if not members: + continue + if len(by_group) > 1: + out += [title, "-" * len(title), ""] + if blurb: + out += [blurb, ""] + for name in members: + s, a = sync.get(name), aio.get(name) + node = s or a + out += [f".. py:method:: {accessor}.{unparse_signature(node)}", ""] + body = docstring(ast.get_docstring(node) or (a and ast.get_docstring(a)), napoleon) + if body: + out += [indent(body), ""] + note = "" + if s is None: + note = text.get("async_only", "") + elif a is None: + note = text.get("sync_only", "") + elif returns(unparse_signature(s)) != returns(unparse_signature(a)): + note = text.get("async_returns", "").format(returns=returns(unparse_signature(a))) + if note: + out += [" .. note::", "", f" {note}", ""] + + return "\n".join(out) + "\n", set(names) + + +def generate(app: Sphinx) -> None: + src = Path(app.srcdir) + stub = (src / app.config.service_pages_stub).resolve() + structure = tomllib.loads((src / "structure.toml").read_text(encoding="utf8")) + order, groups, text = structure.get("order", []), structure.get("groups", []), structure.get("text", {}) + napoleon = NapoleonConfig(napoleon_use_param=True, napoleon_use_rtype=True) + + services, accessor = read_services(stub) + ranked = sorted(services, key=lambda b: (order.index(b) if b in order else len(order), b)) + + out = src / OUT + out.mkdir(exist_ok=True) + for stale in out.glob("*.rst"): + stale.unlink() + + present: set[str] = set() + pages = [] + for base in ranked: + if base not in order: + logger.warning("service %s is not in structure.toml's order", base) + if base not in accessor: + logger.warning("no DataHubClient getter returns %sServiceSync", base) + page, names = service_page(base, services[base], accessor.get(base, base.lower()), groups, text, napoleon) + present |= names + slug = accessor.get(base, base.lower()) + (out / f"{slug}.rst").write_text(page, encoding="utf8") + pages.append(slug) + + listed = {m for g in groups for m in g.get("methods", ())} + for name in sorted(present - listed): + logger.warning("method %s() is in no structure.toml group; it renders under '%s'", name, UNLISTED) + for name in sorted(listed - present): + logger.warning("structure.toml lists %s(), which no service has", name) + + # The landing page includes this list, so its services are the ones generated here. + listing = [".. rst-class:: service-list", ""] + [f"- :doc:`{OUT}/{slug}`" for slug in pages] + (out / "list.inc").write_text("\n".join(listing) + "\n", encoding="utf8") + + title = text.get("services_title", "Services") + index = [title, "=" * len(title), "", text.get("services_blurb", ""), "", + ".. toctree::", " :maxdepth: 1", ""] + [f" {p}" for p in pages] + (out / "index.rst").write_text("\n".join(index) + "\n", encoding="utf8") + + +def skip_service_classes(app, what, name, obj, skip, options): + """autoapi-skip-member: the service classes get this extension's pages instead.""" + if what == "class" and SERVICE.match(name.rsplit(".", 1)[-1]): + return True + return None + + +def setup(app: Sphinx) -> dict: + app.add_config_value("service_pages_stub", "", "env") + app.connect("builder-inited", generate) + app.connect("autoapi-skip-member", skip_service_classes) + return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/docs-python/_static/custom.css b/docs-python/_static/custom.css new file mode 100644 index 0000000..47219f6 --- /dev/null +++ b/docs-python/_static/custom.css @@ -0,0 +1,4 @@ +/* Service names on the landing page are attribute names; show them as code. */ +.service-list a { + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} diff --git a/docs-python/_templates/autoapi/python/class.rst b/docs-python/_templates/autoapi/python/class.rst new file mode 100644 index 0000000..1c3a004 --- /dev/null +++ b/docs-python/_templates/autoapi/python/class.rst @@ -0,0 +1,104 @@ +{% if obj.display %} + {% if is_own_page %} +{{ obj.short_name }} +{{ "=" * obj.short_name | length }} + + {% endif %} + {% set visible_children = obj.children|selectattr("display")|list %} + {% set own_page_children = visible_children|selectattr("type", "in", own_page_types)|list %} + {% if is_own_page and own_page_children %} +.. toctree:: + :hidden: + + {% for child in own_page_children %} + {{ child.include_path }} + {% endfor %} + + {% endif %} +.. py:{{ obj.type }}:: {% if is_own_page %}{{ obj.id }}{% else %}{{ obj.short_name }}{% endif %}{% if obj.type_params %}[{{ obj.type_params }}]{% endif %}{% if obj.args %}({{ obj.args }}){% endif %} + + {% for (args, return_annotation) in obj.overloads %} + {{ " " * (obj.type | length) }} {{ obj.short_name }}{% if args %}({{ args }}){% endif %} + + {% endfor %} + {% if obj.bases %} + {% if "show-inheritance" in autoapi_options %} + + Bases: {% for base in obj.bases %}{{ base|link_objs }}{% if not loop.last %}, {% endif %}{% endfor %} + {% endif %} + + + {% if "show-inheritance-diagram" in autoapi_options and obj.bases != ["object"] %} + .. autoapi-inheritance-diagram:: {{ obj.obj["full_name"] }} + :parts: 1 + {% if "private-members" in autoapi_options %} + :private-bases: + {% endif %} + + {% endif %} + {% endif %} + {% if obj.docstring %} + + {{ obj.docstring|indent(3) }} + {% endif %} + {% for obj_item in visible_children %} + {% if obj_item.type not in own_page_types %} + + {{ obj_item.render()|indent(3) }} + {% endif %} + {% endfor %} + {% if is_own_page and own_page_children %} + {% set visible_attributes = own_page_children|selectattr("type", "equalto", "attribute")|list %} + {% if visible_attributes %} +Attributes +---------- + +.. autoapisummary:: + + {% for attribute in visible_attributes %} + {{ attribute.id }} + {% endfor %} + + + {% endif %} + {% set visible_exceptions = own_page_children|selectattr("type", "equalto", "exception")|list %} + {% if visible_exceptions %} +Exceptions +---------- + +.. autoapisummary:: + + {% for exception in visible_exceptions %} + {{ exception.id }} + {% endfor %} + + + {% endif %} + {% set visible_classes = own_page_children|selectattr("type", "equalto", "class")|list %} + {% if visible_classes %} +Classes +------- + +.. autoapisummary:: + + {% for klass in visible_classes %} + {{ klass.id }} + {% endfor %} + + + {% endif %} + {% set visible_methods = own_page_children|selectattr("type", "equalto", "method")|list %} + {% if visible_methods %} +Methods +------- + +.. autoapisummary:: + + {% for method in visible_methods %} + {{ method.id }} + {% endfor %} + + + {% endif %} + {% endif %} +{% endif %} diff --git a/docs-python/_templates/autoapi/python/module.rst b/docs-python/_templates/autoapi/python/module.rst new file mode 100644 index 0000000..bc4c9de --- /dev/null +++ b/docs-python/_templates/autoapi/python/module.rst @@ -0,0 +1,10 @@ +{# The package page has no content of its own: classes are grouped by hand in index.rst, + so a class missing from those groups is an orphan page, and -W fails the build. #} +{% if obj.display and is_own_page %} +:orphan: + +{{ obj.id }} +{{ "=" * obj.id|length }} + +.. py:module:: {{ obj.name }} +{% endif %} diff --git a/docs-python/clients.rst b/docs-python/clients.rst new file mode 100644 index 0000000..0e46f75 --- /dev/null +++ b/docs-python/clients.rst @@ -0,0 +1,12 @@ +Clients +======= + +Where every call starts. + +.. toctree:: + :maxdepth: 1 + + api/intellistream_datahub_sdk/DataHubClient + api/intellistream_datahub_sdk/AsyncDataHubClient + api/intellistream_datahub_sdk/Page + api/intellistream_datahub_sdk/DataHubException diff --git a/docs-python/conf.py b/docs-python/conf.py new file mode 100644 index 0000000..feb4405 --- /dev/null +++ b/docs-python/conf.py @@ -0,0 +1,46 @@ +# Python client reference, built by sphinx-autoapi from the type stub. +# +# Everything is read from datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi, +# statically, so the build needs neither cargo nor the compiled module. The stub is the +# single source for Python-facing signatures and prose. + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "_ext")) + +project = "IntelliStream DataHub — Python client" +copyright = "IntelliStream" +author = "IntelliStream" + +extensions = ["autoapi.extension", "sphinx.ext.napoleon", "sphinx.ext.intersphinx", "service_pages"] + +# The service classes are skipped by autoapi and given pages of their own, named the way a +# caller reaches them (`timeseries.by_ids`); see _ext/service_pages.py and structure.toml. +service_pages_stub = "../datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi" + +autoapi_type = "python" +autoapi_dirs = ["../datahub_python_bindings/python"] +autoapi_file_patterns = ["*.pyi", "*.py"] +autoapi_root = "api" +autoapi_template_dir = "_templates/autoapi" +autoapi_add_toctree_entry = False +autoapi_member_order = "groupwise" +autoapi_own_page_level = "class" +autoapi_options = ["members", "undoc-members", "show-module-summary", "imported-members"] + +html_theme = "sphinx_rtd_theme" +html_title = project +html_show_sourcelink = False +html_static_path = ["_static"] +html_css_files = ["custom.css"] +html_theme_options = {"navigation_depth": 2, "collapse_navigation": False} + +intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} + +# Name things the way a caller writes them: `TimeSeries`, not the fully qualified path. +add_module_names = False +toc_object_entries_show_parents = "hide" +python_use_unqualified_type_names = True + +exclude_patterns = ["_build", "_templates"] diff --git a/docs-python/datapoints.rst b/docs-python/datapoints.rst new file mode 100644 index 0000000..123d880 --- /dev/null +++ b/docs-python/datapoints.rst @@ -0,0 +1,14 @@ +Datapoints +========== + +Values on a series, and the requests that read and delete them. + +.. toctree:: + :maxdepth: 1 + + api/intellistream_datahub_sdk/DatapointsCollectionString + api/intellistream_datahub_sdk/DatapointString + api/intellistream_datahub_sdk/DatapointsCollectionDatapoints + api/intellistream_datahub_sdk/Datapoint + api/intellistream_datahub_sdk/RetrieveFilter + api/intellistream_datahub_sdk/DeleteFilter diff --git a/docs-python/entities.rst b/docs-python/entities.rst new file mode 100644 index 0000000..28c3f70 --- /dev/null +++ b/docs-python/entities.rst @@ -0,0 +1,21 @@ +Entities +======== + +What the services create, read and return. + +.. toctree:: + :maxdepth: 1 + + api/intellistream_datahub_sdk/TimeSeries + api/intellistream_datahub_sdk/Event + api/intellistream_datahub_sdk/Dataset + api/intellistream_datahub_sdk/Resource + api/intellistream_datahub_sdk/Asset + api/intellistream_datahub_sdk/Function + api/intellistream_datahub_sdk/Policy + api/intellistream_datahub_sdk/Label + api/intellistream_datahub_sdk/Unit + api/intellistream_datahub_sdk/INode + api/intellistream_datahub_sdk/Subscription + api/intellistream_datahub_sdk/EdgeProxy + api/intellistream_datahub_sdk/RelationshipType diff --git a/docs-python/files.rst b/docs-python/files.rst new file mode 100644 index 0000000..4817a58 --- /dev/null +++ b/docs-python/files.rst @@ -0,0 +1,10 @@ +Files +===== + +Uploading and downloading content. + +.. toctree:: + :maxdepth: 1 + + api/intellistream_datahub_sdk/FileUpload + api/intellistream_datahub_sdk/FileDownload diff --git a/docs-python/filters-and-identifiers.rst b/docs-python/filters-and-identifiers.rst new file mode 100644 index 0000000..a33a2ec --- /dev/null +++ b/docs-python/filters-and-identifiers.rst @@ -0,0 +1,19 @@ +Filters and identifiers +======================= + +Criteria for ``filter`` and ``search``, and ways of naming an entity. + +.. toctree:: + :maxdepth: 1 + + api/intellistream_datahub_sdk/TimeSeriesFilter + api/intellistream_datahub_sdk/EventFilter + api/intellistream_datahub_sdk/DatasetFilter + api/intellistream_datahub_sdk/ResourceFilter + api/intellistream_datahub_sdk/SubscriptionFilter + api/intellistream_datahub_sdk/SubscriptionFilterForm + api/intellistream_datahub_sdk/TimeFilter + api/intellistream_datahub_sdk/DataSort + api/intellistream_datahub_sdk/EventDimension + api/intellistream_datahub_sdk/IdCollection + api/intellistream_datahub_sdk/EventIdCollection diff --git a/docs-python/graph.rst b/docs-python/graph.rst new file mode 100644 index 0000000..3b47d7c --- /dev/null +++ b/docs-python/graph.rst @@ -0,0 +1,13 @@ +Graph +===== + +Relationships between resources. + +.. toctree:: + :maxdepth: 1 + + api/intellistream_datahub_sdk/RelatedNode + api/intellistream_datahub_sdk/RelForm + api/intellistream_datahub_sdk/RelTypeForm + api/intellistream_datahub_sdk/ResourceNetwork + api/intellistream_datahub_sdk/GraphResult diff --git a/docs-python/index.rst b/docs-python/index.rst new file mode 100644 index 0000000..3ef9ac1 --- /dev/null +++ b/docs-python/index.rst @@ -0,0 +1,58 @@ +Python client reference +======================= + +Everything is reached through a configured client: + +.. code-block:: python + + from intellistream_datahub_sdk import DataHubClient + + client = DataHubClient.from_env() + client.datasets.list(limit=10) + +Services +-------- + +.. include:: services/list.inc + +Everything else +--------------- + +- :doc:`clients` — where every call starts. +- :doc:`entities` — what the services create, read and return. +- :doc:`datapoints` — values on a series, and the requests that read and delete them. +- :doc:`filters-and-identifiers` — criteria for ``filter`` and ``search``, and ways of naming an entity. +- :doc:`updates` — partial updates, and the field wrappers they are built from. +- :doc:`graph` — relationships between resources. +- :doc:`files` — uploading and downloading content. +- :doc:`subscriptions` — listening for changes as they happen. + +Async +----- + +``AsyncDataHubClient`` has the same services and methods, each one a coroutine to ``await``. +Where the two differ, the method says so. + +.. code-block:: python + + from intellistream_datahub_sdk import AsyncDataHubClient + + client = AsyncDataHubClient.from_env() + await client.datasets.list(limit=10) + +Ingesting, querying, subscriptions and the industry walkthroughs live in the +`main documentation `_. This is the reference for +what the client exposes. + +.. toctree:: + :hidden: + + clients + services/index + entities + datapoints + filters-and-identifiers + updates + graph + files + subscriptions diff --git a/docs-python/requirements.txt b/docs-python/requirements.txt new file mode 100644 index 0000000..d513fee --- /dev/null +++ b/docs-python/requirements.txt @@ -0,0 +1,5 @@ +# What Read the Docs installs to build the Python reference. +# Pinned so a release of any of these cannot change the published site on its own. +sphinx==9.1.0 +sphinx-autoapi==3.8.1 +sphinx-rtd-theme==3.1.0 diff --git a/docs-python/structure.toml b/docs-python/structure.toml new file mode 100644 index 0000000..f18a81d --- /dev/null +++ b/docs-python/structure.toml @@ -0,0 +1,113 @@ +# How the service pages are organised. Read by docs-python/_ext/service_pages.py. +# +# This file owns the STRUCTURE: which services appear, in what order, and which heading +# each method sits under. Nothing can derive that -- a tool has no idea `restore` belongs +# with `delete` rather than with `create`. +# +# It does not own the CONTENT. Signatures and prose come from the type stub. +# +# Groups are global: the same verbs repeat across the services, so `list` is declared once. +# A method lands in the first group naming it. Drift is a build warning, and the build runs +# with warnings as errors: a method the stub has and this file does not, or a name here the +# stub no longer has, fails it until this file is updated. + +# Service order in the nav. Anything missing is appended alphabetically, with a warning. +order = [ + "Datasets", + "TimeSeries", + "Events", + "Resources", + "Assets", + "Files", + "Labels", + "Edges", + "Subscriptions", + "Functions", + "Unit", +] + +[[groups]] +title = "Find" +blurb = "Reading what is already there." +methods = [ + "list", + "filter", + "search", + "count", + "by_ids", + "by_external_id", + "by_external_ids", + "get", + "get_by_id", + "get_by_external_id", +] + +[[groups]] +title = "Change" +blurb = "Creating, editing and removing." +methods = [ + "create", + "update", + "delete", + "restore", +] + +[[groups]] +title = "Datapoints" +blurb = "The values on a series, read and written in bulk." +methods = [ + "insert_datapoints", + "insert_datapoints_binary", + "insert_from_lists", + "insert_from_lists_binary", + "retrieve_datapoints", + "retrieve_latest_datapoints", + "delete_datapoints", +] + +[[groups]] +title = "Files" +blurb = "Moving file contents in and out, and the directory tree they live in." +methods = [ + "upload_file", + "download", + "download_to_path", + "list_root_directory", + "list_directory_by_path", + "list_trash", +] + +[[groups]] +title = "Vocabularies" +blurb = "The enumerations this service validates against." +methods = [ + "types", + "create_types", + "list_types", + "search_types", + "list_sub_types", + "search_sub_types", + "list_statuses", + "search_statuses", + "list_sources", + "search_sources", + "list_dimension", + "policies", +] + +[[groups]] +title = "Streaming" +blurb = "Long-lived connections." +methods = [ + "listen", +] + +# Page prose that belongs to the template rather than to any method. +[text] +services_title = "Services" +services_blurb = "Reached as attributes of a configured client -- ``client.timeseries``, ``client.events`` -- never imported. ``AsyncDataHubClient`` has the same services with every method a coroutine; each page shows both side by side." +unlisted_blurb = "Not yet placed in this service's structure." +sync_only = "Not available on ``AsyncDataHubClient``." +async_only = "Only on ``AsyncDataHubClient``." +# {returns} is the async method's return type. +async_returns = "On ``AsyncDataHubClient`` this returns ``{returns}``." diff --git a/docs-python/subscriptions.rst b/docs-python/subscriptions.rst new file mode 100644 index 0000000..3f7fa53 --- /dev/null +++ b/docs-python/subscriptions.rst @@ -0,0 +1,16 @@ +Subscriptions +============= + +Listening for changes as they happen. + +.. toctree:: + :maxdepth: 1 + + api/intellistream_datahub_sdk/SubscriptionListener + api/intellistream_datahub_sdk/SubscriptionListenerAsync + api/intellistream_datahub_sdk/SubscriptionMessage + api/intellistream_datahub_sdk/DataWrapperMessage + api/intellistream_datahub_sdk/DataCollectionString + api/intellistream_datahub_sdk/WsDatapoint + api/intellistream_datahub_sdk/EventAction + api/intellistream_datahub_sdk/EventObject diff --git a/docs-python/updates.rst b/docs-python/updates.rst new file mode 100644 index 0000000..f808811 --- /dev/null +++ b/docs-python/updates.rst @@ -0,0 +1,20 @@ +Updates +======= + +Partial updates, and the field wrappers they are built from. + +.. toctree:: + :maxdepth: 1 + + api/intellistream_datahub_sdk/TimeSeriesUpdate + api/intellistream_datahub_sdk/EventUpdate + api/intellistream_datahub_sdk/DatasetUpdate + api/intellistream_datahub_sdk/ResourceUpdate + api/intellistream_datahub_sdk/FileUpdate + api/intellistream_datahub_sdk/FieldStr + api/intellistream_datahub_sdk/FieldU64 + api/intellistream_datahub_sdk/FieldBool + api/intellistream_datahub_sdk/FieldGeoJson + api/intellistream_datahub_sdk/MapField + api/intellistream_datahub_sdk/ListFieldStr + api/intellistream_datahub_sdk/ListFieldIdCollection From dddfcffea1cea0b398f2c2badab62426a6abb196 Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Fri, 25 Sep 2026 09:43:25 +0200 Subject: [PATCH 2/4] docs(python): trim the reference build to what it uses, and say how it works - conf.py: drop the autoapi options and the TOC setting that change nothing in the output (checked by diffing the built site with and without each), and read only the stub rather than the stub and __init__.py. - The stub loses two stale comment blocks: one repeated the DatasetUpdate docstring behind two lines that belonged elsewhere, the other still claimed datasets.search is Latin-letters-only and unranked, both lifted server-side. - AGENTS.md says where Python reference prose goes, that types are spelled out, what fails the build, and how to run it. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- AGENTS.md | 24 +++++++++++++++++++ .../intellistream_datahub_sdk/__init__.pyi | 10 -------- docs-python/conf.py | 5 ++-- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index da76ca8..f4617e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -518,6 +518,30 @@ rather than green: there is no rename left to assert on. Nothing in the SDK's MC A PyO3 crate (built with maturin) that wraps this SDK as the Python package `intellistream-datahub-sdk` (import name `intellistream_datahub_sdk`). Binding modules in `datahub_python_bindings/src/` mirror the Rust subservices; the pure-Python side lives in `datahub_python_bindings/python/intellistream_datahub_sdk`. +### The Python reference is built from the stub + +`datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi` is the single source of +the Python API reference: signatures and prose alike. Read the Docs builds it from `docs-python/` +(`.readthedocs.yaml`), and IDEs show the same docstrings on hover. The guides stay in +datahub-sdk-docs; this is only the reference. + +- **Write Python-facing prose in the stub**, numpydoc style (`Parameters`, `Returns`, `Raises`, + `Examples`), in reStructuredText. The bindings' `///` comments are not read by the build. +- **Spell types out.** The stub has no type aliases: each parameter says what its Rust type + accepts, so `timeseries.by_ids` takes `int | str | TimeSeries | IdCollection`, not a shared + union that claims more. +- **Place new methods and classes.** A service method must be named in a group of + `docs-python/structure.toml`, and a new class listed in one of the `docs-python/*.rst` group + pages; the build fails until it is. + +``` +pip install -r docs-python/requirements.txt +sphinx-build -W -b html docs-python docs-python/_build +``` + +No cargo or compiled module is needed. `docs-python/_ext/service_pages.py` writes the service pages +(`timeseries`, `datasets`, …) at build time; sphinx-autoapi writes the class pages. + The Python test suite in `python_tests/` imports the **compiled** `intellistream_datahub_sdk` module, not the Rust sources — a stale `.so` silently masks source changes. Always run it through `./run_python_tests.sh`, which rebuilds via `maturin develop` first. Extra args are forwarded to pytest (`./run_python_tests.sh -k timeseries`); `--release`, `--no-build`, and `--no-deps` are consumed by the script itself. Every entity a test creates carries `TEST_PREFIX` — `pytest_` in Python, `rust_sdk_` in Rust — and diff --git a/datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi b/datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi index d1a0f77..a561bee 100644 --- a/datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi +++ b/datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi @@ -1748,11 +1748,6 @@ class DatasetFilter: ) -> DatasetFilter: ... -# `limit` defaults to the server's 1000 and may not exceed 10000. There is no paging, so a filter -# broad enough to exceed the cap is truncated — narrow it instead. -# A partial update for one dataset. `dataset` names the target; only the fields you pass are sent, -# anything omitted is left untouched. There is deliberately no `policies` or `connected_data_sets` -# — the update endpoint does not accept them, whatever a Dataset can carry on create. @final class DatasetUpdate: """ @@ -1780,11 +1775,6 @@ class DatasetUpdate: def target_external_id(self) -> str | None: ... -# `search(query, ...)`: query is 3-140 chars and Latin letters/spaces/digits only, so an external -# id with underscores is a 400 — search on words and use filter() to look up by id. Results are -# unranked. -# -# `update(...)`: there is no write_protected/deactivated — both were removed server-side as inert. class DatasetsServiceSync: def list(self, limit: int | None = None) -> builtins.list[Dataset]: """ diff --git a/docs-python/conf.py b/docs-python/conf.py index feb4405..f5bfaa0 100644 --- a/docs-python/conf.py +++ b/docs-python/conf.py @@ -21,13 +21,13 @@ autoapi_type = "python" autoapi_dirs = ["../datahub_python_bindings/python"] -autoapi_file_patterns = ["*.pyi", "*.py"] +autoapi_file_patterns = ["*.pyi"] autoapi_root = "api" autoapi_template_dir = "_templates/autoapi" autoapi_add_toctree_entry = False autoapi_member_order = "groupwise" autoapi_own_page_level = "class" -autoapi_options = ["members", "undoc-members", "show-module-summary", "imported-members"] +autoapi_options = ["members", "undoc-members"] html_theme = "sphinx_rtd_theme" html_title = project @@ -40,7 +40,6 @@ # Name things the way a caller writes them: `TimeSeries`, not the fully qualified path. add_module_names = False -toc_object_entries_show_parents = "hide" python_use_unqualified_type_names = True exclude_patterns = ["_build", "_templates"] From 5cc9874ce4e05d0f07c4a2ba187039405b7fef9a Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Fri, 25 Sep 2026 10:03:24 +0200 Subject: [PATCH 3/4] docs(python): reference prose for every service Every client service now has the numpydoc reference timeseries got first: a class docstring, and for each of its 94 methods a summary, the behaviour a caller must know, and Parameters, Returns, Raises, See Also and Examples as they apply. Each statement was taken from the backend's endpoint contract, the bindings and the Rust SDK, and every object an example constructs was built against the compiled module. Where the old prose disagreed with the code, the code won: events.search is an unranked substring match returned newest first, not ranked; a duplicate relationship type is a 409, not a silent 200; datasets.filter, events.filter and subscriptions.filter send a limit of 100 when none is given, not the server's 1000; a refused delete is a 409 (referenced / would-strand), not a 400. Two annotations change with it. files.delete and files.restore took the FileIdentifiable union's definition, which named IdCollection; the binding accepts int | str | INode | FileUpload and raises TypeError for IdCollection. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- .../intellistream_datahub_sdk/__init__.pyi | 2670 +++++++++++++++-- 1 file changed, 2407 insertions(+), 263 deletions(-) diff --git a/datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi b/datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi index a561bee..a2e969d 100644 --- a/datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi +++ b/datahub_python_bindings/python/intellistream_datahub_sdk/__init__.pyi @@ -863,8 +863,10 @@ class TimeSeriesServiceSync: Raises ------ DataHubException - ``status_code`` 400 if a series is the start of a relationship, or is still - bound to a subscription. Remove those first. + ``status_code`` 409 with ``problem_slug`` ``"referenced"`` if a series is still + bound to a subscription, or ``"would-strand"`` if deleting it would disconnect + another node from the graph. The problem's ``blockedBy`` names what is in the way; + remove that first. Examples -------- @@ -1457,30 +1459,206 @@ class EventDimension: class EventsServiceSync: + """Record, find, change and delete events, and read the vocabulary of their categorical fields. + + Reached as ``client.events`` on a configured ``DataHubClient``, never constructed. + ``AsyncDataHubClient`` has the same methods as coroutines. + + Every call that reaches the server raises ``DataHubException`` when the server refuses + it; the exception carries the HTTP ``status_code`` and, where the server explained + itself, a ``problem_slug`` to branch on. Reads return only events in data sets you may + read. Events are written asynchronously, so a read made straight after a create, update + or delete can still show the state before it. + """ def list(self, limit: int | None = None) -> builtins.list[Event]: + """List events, oldest event time first. + + This returns the *oldest* ``limit`` events, not the newest: it runs + :meth:`events.filter` with no criteria, whose default order is ``eventTime`` + ascending. For the most recent events, call :meth:`events.filter` with + ``sort_by="eventTime", sort_order="desc"``. This is a first page and nothing more: + there is no cursor to continue from. + + Parameters + ---------- + limit : int, optional + How many to return. Defaults to the server's 1000; above 10000 is refused. + + Returns + ------- + list of Event + + See Also + -------- + events.filter : Select by criteria, in any order, with paging. + + Examples + -------- + >>> earliest = client.events.list(limit=20) """ - A criteria-free page of the tenant's events. + def create(self, input: builtins.list[Event]) -> builtins.list[Event]: + """Record new events. + + The batch is all-or-nothing: if any event fails validation, none is stored. The + ``Event`` objects you pass are not modified; the returned events carry the ``id``, + ``created_time`` and resolved ``related_resources`` the server assigned. - **The oldest ``limit`` events, not the newest.** It runs the event filter with an empty body, - whose default sort is ``eventTime`` ascending — the order the cursor pages in. The node - listings beside it (``resources.list``, ``timeseries.list``, ``datasets.list``) really are - newest-first; events are the one member of the family that reads the other way round. For - "what just happened", use ``filter(sort_by="eventTime", sort_order="desc")``. + ``external_id`` is not unique. Events sharing one are treated as the lifecycle of a + single logical event, and every call that takes an external id acts on all of them. + Sending the same event twice therefore stores it twice. - ``limit`` defaults to the server's 1000 and may not exceed 10000. A plain list is returned - rather than a ``Page``: there is no cursor to continue with. + When the client was built with ``enable_buffering=True`` and the server cannot be + reached, or answers 401, 403, 408, 429 or 5xx, the call does not raise: the events + are spooled to disk, the call returns an empty list, and the spool is sent ahead of + the next ``create``. + + Parameters + ---------- + input : list of Event + The events to record. ``type`` and ``event_time`` are required. + + Returns + ------- + list of Event + The stored events. Empty when the events were buffered instead of sent. + + Raises + ------ + DataHubException + ``status_code`` 400 if ``type`` is blank, a ``data_set_id`` names no data set, + or a ``related_resources`` entry names no resource or names two different ones + by ``id`` and ``external_id``; 403 (``problem_slug`` ``"dataset-forbidden"``) if + you may not write to the event's data set. + + Examples + -------- + >>> from datetime import datetime, timezone + >>> from intellistream_datahub_sdk import Event, IdCollection + >>> [alarm] = client.events.create([ + ... Event( + ... "alarm_pump_a_2026_09_24", + ... "alarm", + ... datetime(2026, 9, 24, 14, 30, tzinfo=timezone.utc), + ... sub_type="overpressure", + ... status="open", + ... description="Discharge pressure above 40 bar", + ... related_resources=[IdCollection(external_id="pump_a")], + ... metadata={"severity": "high"}, + ... ) + ... ]) + >>> alarm.id + """ + def by_ids(self, input: builtins.list[Event | EventIdCollection | UUID | str]) -> builtins.list[Event]: + """Fetch events by id or external id. + + An external id returns every event that carries it. What is not found, or is in a + data set you may not read, is left out of the result rather than raising. + + Parameters + ---------- + input : list of Event, EventIdCollection, UUID or str + A ``UUID`` is an event id and a ``str`` an external id. An ``Event`` is looked + up by its ``id`` when it has one, otherwise by its ``external_id``. Mix freely; + at most 10000 entries. + + Returns + ------- + list of Event + + Raises + ------ + DataHubException + ``status_code`` 400 for more than 10000 entries; split the batch. + + See Also + -------- + events.get : One event by id, or ``None``. + + Examples + -------- + >>> history = client.events.by_ids(["alarm_pump_a_2026_09_24"]) """ - def create(self, input: builtins.list[Event]) -> builtins.list[Event]: ... - def by_ids(self, input: builtins.list[Event | EventIdCollection | UUID | str]) -> builtins.list[Event]: ... def get(self, id: UUID) -> Event | None: + """Fetch one event by id. + + Parameters + ---------- + id : UUID + + Returns + ------- + Event or None + ``None`` if no event has this id, or it is in a data set you may not read. The + two cases are indistinguishable by design. + + Examples + -------- + >>> from uuid import UUID + >>> event = client.events.get(UUID("0195f3a2-4c1b-7f9e-9c3a-1b2d4e6f8a90")) """ - Look up a single event by its UUID. Returns ``None`` if no such event exists. + def delete(self, input: builtins.list[Event | EventIdCollection | UUID | str]) -> None: + """Delete events. + + This cannot be undone. An external id deletes every event that carries it. Deleting + an event that is already gone is a no-op. + + Parameters + ---------- + input : list of Event, EventIdCollection, UUID or str + The events to delete. A ``UUID`` is an event id and a ``str`` an external id; an + ``Event`` is deleted by its ``id`` when it has one, otherwise by its + ``external_id``. + + Raises + ------ + DataHubException + ``status_code`` 403 (``problem_slug`` ``"dataset-forbidden"``) if you may not + write to an event's data set. + + Examples + -------- + >>> client.events.delete(["alarm_pump_a_2026_09_24"]) """ - def delete(self, input: builtins.list[Event | EventIdCollection | UUID | str]) -> None: ... def update(self, input: builtins.list[EventUpdate]) -> builtins.list[Event]: - """ - Update events in place. Each ``EventUpdate`` targets one event and carries only the fields to - change; returns the events after the update. + """Change fields on existing events. + + Only the fields named in each ``EventUpdate`` change. ``event_time`` and + ``external_id`` cannot be changed: to correct either, create a new event and delete + the old one. An update addressed by external id applies to every event that carries + it. An update whose event is not found is skipped, so compare the result with what + you sent. + + While an update is being applied, a read of the same event can briefly return the + previous version or both. Where the history matters, record a corrective event + instead of changing the original. + + Parameters + ---------- + input : list of EventUpdate + + Returns + ------- + list of Event + The events as they stand after the update. + + Raises + ------ + DataHubException + ``status_code`` 400 if ``type`` is set to null, a ``related_resources`` entry + names no resource, or a ``data_set_id`` names no data set; 403 (``problem_slug`` + ``"dataset-forbidden"``) if you may not write to the event's current or new data + set. + + Examples + -------- + >>> from intellistream_datahub_sdk import EventUpdate, FieldStr, MapField + >>> client.events.update([ + ... EventUpdate( + ... "alarm_pump_a_2026_09_24", + ... status=FieldStr("acknowledged"), + ... metadata=MapField.delta(add={"acked_by": "olav"}), + ... ) + ... ]) """ def filter( self, @@ -1503,9 +1681,103 @@ class EventsServiceSync: cursor: str | None = None, advanced_filter: str | None = None, ) -> Page: - """Pass either ``filter=`` or the individual criteria keywords; passing both is a - ``TypeError``. Paging is always given here rather than on the filter, so one filter can be - reused across calls. + """Select events by criteria, one page at a time. + + Give the criteria either as keywords or as a prepared ``filter=``, not both. + ``advanced_filter`` combines with either. Criteria combine with AND; the entries of + one list combine with OR. Pattern lists accept a single string or a list, where ``*`` + and ``%`` are wildcards, ``_`` is literal, and matching ignores case, except that an + ``external_id`` entry without a wildcard matches exactly, case included. + + ``advanced_filter`` is a boolean expression in a PostgreSQL-flavoured language, ANDed + with the criteria: ``AND``, ``OR``, ``NOT`` and parentheses over comparisons + (``=``, ``!=``, ``<>``, ``<``, ``<=``, ``>``, ``>=``), ``LIKE``, ``ILIKE``, ``IN``, + ``BETWEEN`` and ``IS NULL``. It can name ``id``, ``externalId``, ``type``, + ``subType``, ``status``, ``source``, ``description``, ``dataSetId``, ``eventTime``, + ``createdTime`` and ``lastUpdatedTime``, and a metadata value as + ``metadata['key']``. Metadata values are text; compare them as numbers, booleans or + times through ``to_number``, ``to_int``, ``to_bool``, ``to_date`` or + ``to_timestamp``, or a ``::`` cast. ``metadata['key'] IS NULL`` means the key is + absent. At most 4096 characters; a leading ``WHERE`` is ignored. + + Parameters + ---------- + filter : EventFilter, optional + The criteria as one object, reusable across calls. Exclusive with the + criteria keywords below. + external_id, source, type, sub_type, status : str or sequence of str, optional + data_set_id : sequence of int, str or IdCollection, optional + Data sets by id or external id; includes everything beneath them in the data + set hierarchy. An empty list matches nothing. + event_time, created_time, last_updated_time : TimeFilter, optional + Inclusive at both ends. ``event_time`` is when the event happened; + ``created_time`` is when it was recorded. + metadata : mapping of str to str or None, optional + Every key listed must be present; a ``None`` value matches the key alone. + related_resources : sequence of IdCollection, optional + Every resource listed must be attached to the event. + limit : int, optional + Page size. Defaults to 100; above 10000 is refused. + sort_by : str, optional + One of ``eventTime`` (the default), ``createdTime``, ``lastUpdatedTime``, + ``externalId``, ``type``, ``subType``, ``status``, ``source`` or + ``dataSetId``. Events without a value sort last ascending, first descending. + sort_order : {"asc", "desc"}, optional + Defaults to ascending. + cursor : str, optional + ``next_cursor`` from the previous page. It belongs to its sort: continuing it + under another is refused. + advanced_filter : str, optional + A filter expression, as described above. + + Returns + ------- + Page + A list-like page of ``Event``. Its ``next_cursor`` is ``None`` on the last page. + + Raises + ------ + TypeError + If both ``filter=`` and criteria keywords are given. + DataHubException + ``status_code`` 400 with ``problem_slug`` ``"filter-expression"`` if + ``advanced_filter`` does not parse; the problem's ``offset`` says where. 400 + with ``"malformed-cursor"`` for an unreadable cursor or one from another sort. + + See Also + -------- + events.search : Find events by a phrase. + + Examples + -------- + Open alarms and warnings on one pump in September, a page at a time: + + >>> from datetime import datetime, timezone + >>> from intellistream_datahub_sdk import IdCollection, TimeFilter + >>> september = TimeFilter( + ... start=datetime(2026, 9, 1, tzinfo=timezone.utc), + ... end=datetime(2026, 10, 1, tzinfo=timezone.utc), + ... ) + >>> criteria = dict( + ... type=["alarm", "warning"], + ... status="open", + ... event_time=september, + ... related_resources=[IdCollection(external_id="pump_a")], + ... ) + >>> page = client.events.filter(**criteria, limit=500) + >>> events = list(page) + >>> while page.next_cursor: + ... page = client.events.filter(**criteria, limit=500, cursor=page.next_cursor) + ... events.extend(page) + + The 50 most recent events whose ``severity`` metadata is at least 3: + + >>> client.events.filter( + ... advanced_filter="to_int(metadata['severity']) >= 3", + ... sort_by="eventTime", + ... sort_order="desc", + ... limit=50, + ... ) """ def search( @@ -1514,12 +1786,44 @@ class EventsServiceSync: filter: EventFilter | None = None, limit: int | None = None, ) -> builtins.list[Event]: - """ - Free-text search over event descriptions, ranked by relevance. + """Find events whose external id, description or a metadata value contains a phrase. + + Matching is a case-insensitive substring match, not word-aware: ``"pump"`` finds + ``"pumps"`` but not ``"pumping"``. Results are not ranked; they come newest event + time first. For structured queries without a phrase, use :meth:`events.filter`. + + Parameters + ---------- + query : str + The phrase, 3--140 characters. + filter : EventFilter, optional + Narrows the phrase's hits. It only ever removes results, never adds them. + limit : int, optional + How many to return. Defaults to 100; above 1000 is refused. + + Returns + ------- + list of Event + + Examples + -------- + >>> from intellistream_datahub_sdk import EventFilter + >>> client.events.search("bearing", filter=EventFilter(type="alarm", status="open")) """ def count(self) -> int: - """ - Total number of events in the tenant. + """Count the events in the tenant. + + The count takes no criteria and, unlike every read in this service, is not narrowed + to data sets you may read. For a count of matching events, page through + :meth:`events.filter`. + + Returns + ------- + int + + Examples + -------- + >>> total = client.events.count() """ def list_dimension( self, @@ -1527,42 +1831,185 @@ class EventsServiceSync: query: str | None = None, limit: int | None = None, ) -> builtins.list[str]: - """ - Distinct values an event field takes in this tenant. ``query`` filters by case-insensitive - substring; omit it to list everything. ``limit`` defaults to 1000 server-side and is clamped - to 1..=10000. Alphabetical, and restricted to your readable datasets. + """List the distinct values a categorical event field takes. + + Values are sorted alphabetically and drawn only from events in data sets you may + read. They are eventually consistent with the events: a new value can take a moment + to appear, and a value no event carries any more can linger. Good for a picker or a + type-ahead; not proof that an event with the value exists right now. + + The eight ``list_*`` and ``search_*`` methods are shorthands for this one. + + Parameters + ---------- + dimension : EventDimension + ``EventDimension.TYPE``, ``SUB_TYPE``, ``STATUS`` or ``SOURCE``. + query : str, optional + Keep only values containing this, ignoring case. Omit to list every value. + limit : int, optional + How many to return. Defaults to 1000; a value outside 1--10000 is moved to the + nearest end of that range rather than refused. + + Returns + ------- + list of str + + Examples + -------- + >>> from intellistream_datahub_sdk import EventDimension + >>> client.events.list_dimension(EventDimension.STATUS, query="ack") """ def list_types(self, limit: int | None = None) -> builtins.list[str]: - """ - Every distinct ``type`` on events you can read. + """List the distinct ``type`` values of events you can read. + + Parameters + ---------- + limit : int, optional + How many to return. Defaults to 1000; kept within 1--10000. + + Returns + ------- + list of str + Sorted alphabetically. + + See Also + -------- + events.list_dimension : How these values are kept, and their consistency. + events.search_types : Only the values containing a phrase. + + Examples + -------- + >>> types = client.events.list_types() """ def search_types(self, query: str, limit: int | None = None) -> builtins.list[str]: - """ - Distinct ``type`` values containing ``query`` (case-insensitive substring). + """List the distinct ``type`` values that contain a phrase, ignoring case. + + Parameters + ---------- + query : str + limit : int, optional + How many to return. Defaults to 1000; kept within 1--10000. + + Returns + ------- + list of str + Sorted alphabetically. + + See Also + -------- + events.list_dimension : How these values are kept, and their consistency. + + Examples + -------- + >>> client.events.search_types("alarm") """ def list_sub_types(self, limit: int | None = None) -> builtins.list[str]: - """ - Every distinct ``subType`` on events you can read. + """List the distinct ``sub_type`` values of events you can read. + + Parameters + ---------- + limit : int, optional + How many to return. Defaults to 1000; kept within 1--10000. + + Returns + ------- + list of str + Sorted alphabetically. + + See Also + -------- + events.list_dimension : How these values are kept, and their consistency. + events.search_sub_types : Only the values containing a phrase. """ def search_sub_types(self, query: str, limit: int | None = None) -> builtins.list[str]: - """ - Distinct ``subType`` values containing ``query`` (case-insensitive substring). + """List the distinct ``sub_type`` values that contain a phrase, ignoring case. + + Parameters + ---------- + query : str + limit : int, optional + How many to return. Defaults to 1000; kept within 1--10000. + + Returns + ------- + list of str + Sorted alphabetically. + + See Also + -------- + events.list_dimension : How these values are kept, and their consistency. """ def list_statuses(self, limit: int | None = None) -> builtins.list[str]: - """ - Every distinct ``status`` on events you can read. + """List the distinct ``status`` values of events you can read. + + Parameters + ---------- + limit : int, optional + How many to return. Defaults to 1000; kept within 1--10000. + + Returns + ------- + list of str + Sorted alphabetically. + + See Also + -------- + events.list_dimension : How these values are kept, and their consistency. + events.search_statuses : Only the values containing a phrase. """ def search_statuses(self, query: str, limit: int | None = None) -> builtins.list[str]: - """ - Distinct ``status`` values containing ``query`` (case-insensitive substring). + """List the distinct ``status`` values that contain a phrase, ignoring case. + + Parameters + ---------- + query : str + limit : int, optional + How many to return. Defaults to 1000; kept within 1--10000. + + Returns + ------- + list of str + Sorted alphabetically. + + See Also + -------- + events.list_dimension : How these values are kept, and their consistency. """ def list_sources(self, limit: int | None = None) -> builtins.list[str]: - """ - Every distinct ``source`` on events you can read. + """List the distinct ``source`` values of events you can read. + + Parameters + ---------- + limit : int, optional + How many to return. Defaults to 1000; kept within 1--10000. + + Returns + ------- + list of str + Sorted alphabetically. + + See Also + -------- + events.list_dimension : How these values are kept, and their consistency. + events.search_sources : Only the values containing a phrase. """ def search_sources(self, query: str, limit: int | None = None) -> builtins.list[str]: - """ - Distinct ``source`` values containing ``query`` (case-insensitive substring). + """List the distinct ``source`` values that contain a phrase, ignoring case. + + Parameters + ---------- + query : str + limit : int, optional + How many to return. Defaults to 1000; kept within 1--10000. + + Returns + ------- + list of str + Sorted alphabetically. + + See Also + -------- + events.list_dimension : How these values are kept, and their consistency. """ @@ -1748,43 +2195,151 @@ class DatasetFilter: ) -> DatasetFilter: ... -@final -class DatasetUpdate: - """ - A partial update for one dataset, mirroring the server's update form. +@final +class DatasetUpdate: + """ + A partial update for one dataset, mirroring the server's update form. + + ``dataset`` names the target — a ``Dataset``, an ``IdCollection``, an external id or a numeric id. + Every other argument is a field wrapper and only the ones you pass are sent; anything omitted + is left untouched. + + There is deliberately no ``policies`` or ``connected_data_sets`` here: the update endpoint does not + accept them, whatever a ``Dataset`` can carry on create. + """ + def __new__( + cls, + dataset: int | str | Dataset | IdCollection, + external_id: FieldStr | None = None, + name: FieldStr | None = None, + description: FieldStr | None = None, + metadata: MapField | None = None, + labels: ListFieldStr | None = None, + ) -> DatasetUpdate: ... + @property + def target_id(self) -> int | None: ... + @property + def target_external_id(self) -> str | None: ... + + +class DatasetsServiceSync: + """Create, find, change and delete data sets. + + Reached as ``client.datasets`` on a configured ``DataHubClient``, never constructed. + ``AsyncDataHubClient`` has the same methods as coroutines. + + A data set is the unit access is granted on, so managing one is an operator action: + :meth:`datasets.create`, :meth:`datasets.update` and :meth:`datasets.delete` need an + all-datasets write grant, and a grant on individual data sets is not enough. The reads are + not narrowed by grants: every caller sees every data set in the tenant. + + Every call that reaches the server raises ``DataHubException`` when the server refuses + it; the exception carries the HTTP ``status_code`` and, where the server explained + itself, a ``problem_slug`` to branch on. + """ + def list(self, limit: int | None = None) -> builtins.list[Dataset]: + """List data sets, newest created first. + + This is a first page and nothing more: there is no cursor to continue from. Data sets + are usually few, so this is often all of them; to go further, narrow the query with + :meth:`datasets.filter` rather than raising ``limit``. + + Parameters + ---------- + limit : int, optional + How many to return. Defaults to the server's 1000; above 10000 is refused. + + Returns + ------- + list of Dataset + + See Also + -------- + datasets.filter : Select by criteria, with paging. + + Examples + -------- + >>> datasets = client.datasets.list() + """ + def create(self, input: builtins.list[Dataset]) -> builtins.list[Dataset]: + """Store new data sets. + + Each ``external_id`` must be unused in the tenant. Other nodes join a data set through + their own ``data_set_id``. + + Parameters + ---------- + input : list of Dataset + The data sets to create. ``name`` defaults to the ``external_id`` when not given. + + Returns + ------- + list of Dataset + The stored data sets, with ``id`` and timestamps filled in by the server. + + Raises + ------ + DataHubException + ``status_code`` 403 (``problem_slug`` ``"dataset-forbidden"``) without an + all-datasets write grant; 409 if an ``external_id`` is already taken, with the + problem's ``duplicated`` member naming it. - ``dataset`` names the target — a ``Dataset``, an ``IdCollection``, an external id or a numeric id. - Every other argument is a field wrapper and only the ones you pass are sent; anything omitted - is left untouched. + Examples + -------- + >>> from intellistream_datahub_sdk import Dataset + >>> [sap] = client.datasets.create([ + ... Dataset( + ... external_id="sap_work_orders", + ... name="SAP work orders", + ... description="Work orders mirrored from SAP", + ... ) + ... ]) + """ + def by_ids(self, input: builtins.list[int | str | Dataset | IdCollection]) -> builtins.list[Dataset]: + """Fetch data sets by id or external id. - There is deliberately no ``policies`` or ``connected_data_sets`` here: the update endpoint does not - accept them, whatever a ``Dataset`` can carry on create. - """ - def __new__( - cls, - dataset: int | str | Dataset | IdCollection, - external_id: FieldStr | None = None, - name: FieldStr | None = None, - description: FieldStr | None = None, - metadata: MapField | None = None, - labels: ListFieldStr | None = None, - ) -> DatasetUpdate: ... - @property - def target_id(self) -> int | None: ... - @property - def target_external_id(self) -> str | None: ... + What is not found is left out of the result rather than raising, so compare the + result with what you asked for to detect missing data sets. + Parameters + ---------- + input : list of int, str, Dataset or IdCollection + Each entry is a numeric id, an external id, a ``Dataset`` or an ``IdCollection``. + Mix freely. -class DatasetsServiceSync: - def list(self, limit: int | None = None) -> builtins.list[Dataset]: + Returns + ------- + list of Dataset + + Examples + -------- + >>> found = client.datasets.by_ids(["sap_work_orders", 12]) """ - Datasets in the tenant, newest first. ``limit`` defaults to the server's 1000 and may not - exceed 10000; there is no paging, so a bigger tenant is truncated rather than paged — - use ``filter`` to narrow instead. + def delete(self, input: builtins.list[int | str | Dataset | IdCollection]) -> None: + """Delete data sets. + + This cannot be undone, and it does not cascade: delete or move what belongs to a data + set, child data sets included, before deleting the data set itself. The relationships + the data set takes part in go with it. The ids are not checked to be data sets: a node + of another type with a matching id or external id is deleted too. + + Parameters + ---------- + input : list of int, str, Dataset or IdCollection + The data sets to delete, by id, external id, ``Dataset`` or ``IdCollection``. + + Raises + ------ + DataHubException + ``status_code`` 403 (``problem_slug`` ``"dataset-forbidden"``) without an + all-datasets write grant. 409 while anything still belongs to the data set; with + ``problem_slug`` ``"would-strand"``, the problem's ``blockedBy`` names the nodes + the delete would disconnect from the graph. Nothing is deleted in either case. + + Examples + -------- + >>> client.datasets.delete(["sap_work_orders"]) """ - def create(self, input: builtins.list[Dataset]) -> builtins.list[Dataset]: ... - def by_ids(self, input: builtins.list[int | str | Dataset | IdCollection]) -> builtins.list[Dataset]: ... - def delete(self, input: builtins.list[int | str | Dataset | IdCollection]) -> None: ... def filter( self, *, @@ -1802,9 +2357,63 @@ class DatasetsServiceSync: sort_order: str | None = None, cursor: str | None = None, ) -> Page: - """Pass either ``filter=`` or the individual criteria keywords; passing both is a - ``TypeError``. Paging is always given here rather than on the filter, so one filter can be - reused across calls. + """Select data sets by criteria, one page at a time. + + Give the criteria either as keywords or as a prepared ``filter=``, not both. Criteria + combine with AND; the entries of one list combine with OR. Pattern lists accept a + single string or a list, where ``*`` and ``%`` are wildcards, ``_`` is literal, and + matching ignores case. With no criteria at all, every data set matches. + + Parameters + ---------- + filter : DatasetFilter, optional + The criteria as one object, reusable across calls. Exclusive with the + criteria keywords below. + id : sequence of int, optional + external_id, name, source : str or sequence of str, optional + labels : str or sequence of str, optional + Every label listed must be present. Names are canonicalised, so ``"pump a"`` + finds the label stored as ``PUMP_A``. + metadata : mapping of str to str or None, optional + Every key listed must be present; a ``None`` value matches the key alone. + created_time, last_updated_time : TimeFilter, optional + Inclusive at both ends. + limit : int, optional + Page size. Defaults to 100; above 10000 is refused. + sort_by : str, optional + One property; defaults to ``createdTime``. + sort_order : {"asc", "desc"}, optional + Defaults to descending. + cursor : str, optional + ``next_cursor`` from the previous page. It belongs to its sort: continuing it + under another is refused. + + Returns + ------- + Page + A list-like page of ``Dataset``. Its ``next_cursor`` is ``None`` on the last + page. + + Raises + ------ + TypeError + If both ``filter=`` and criteria keywords are given. + + See Also + -------- + datasets.search : Free-text search, ranked. + + Examples + -------- + Every SAP data set owned by plant A: + + >>> page = client.datasets.filter(external_id="sap_*", metadata={"owner": "plant_a"}) + >>> datasets = list(page) + >>> while page.next_cursor: + ... page = client.datasets.filter( + ... external_id="sap_*", metadata={"owner": "plant_a"}, cursor=page.next_cursor + ... ) + ... datasets.extend(page) """ def search( @@ -1813,34 +2422,82 @@ class DatasetsServiceSync: filter: DatasetFilter | None = None, limit: int | None = None, ) -> builtins.list[Dataset]: - """Free-text search for ``query``, ranked by relevance. + """Free-text search over name, external id and description. + + Matching is word-aware and fuzzy, and the last word also matches as a prefix, so a + phrase typed mid-word still finds its data set. Results are ranked, best match first. + No match is an empty list. For exact lookups use :meth:`datasets.by_ids`; for + structured queries without a phrase, use :meth:`datasets.filter`. + + Parameters + ---------- + query : str + The phrase, 3--140 characters. + filter : DatasetFilter, optional + Narrows the phrase's hits. It only ever removes results, never adds them. + limit : int, optional + How many to return. Defaults to 100; above 1000 is refused. + + Returns + ------- + list of Dataset - ``filter`` takes the same criteria as ``filter()`` and only ever removes hits from the - phrase's — it cannot widen them, so omitting it returns them as found. ``limit`` caps what - survives, defaulting to 100 and capping at 1000; the ``filter`` endpoints use 1000/10000, - which is easy to conflate. + Examples + -------- + >>> from intellistream_datahub_sdk import DatasetFilter + >>> client.datasets.search("work orders", filter=DatasetFilter(source="sap")) """ def update(self, input: builtins.list[DatasetUpdate]) -> builtins.list[Dataset]: - """ - Apply partial updates, returning the datasets as they stand afterwards. + """Change fields on existing data sets. + + Only the fields named in each ``DatasetUpdate`` change. The batch is all-or-nothing: + if one update fails, none is applied. A ``labels`` change cannot remove the data set's + intrinsic ``DATASET`` label. + + Parameters + ---------- + input : list of DatasetUpdate + + Returns + ------- + list of Dataset + The data sets as they stand after the update. - A dataset is the unit access is granted on, so the server treats editing one as an operator - action: this needs an all-datasets write grant and raises 403 without one, even for a - caller who can write the dataset's contents. + Raises + ------ + DataHubException + ``status_code`` 403 (``problem_slug`` ``"dataset-forbidden"``) without an + all-datasets write grant; 400 if a target data set does not exist; 409 if a new + ``external_id`` is already taken, with the problem's ``duplicated`` member + naming it. - **Do not combine a ``metadata`` change with ``write_protected`` / ``deactivated`` in one update.** - The server stores those flags as node metadata, so setting either in the same call as a - metadata delta silently drops the delta — 200, no error, half the change lost. Send two - updates. Their keys (``property:is_write_protected``, ``property:is_deactivated``) are also - visible in ``Dataset.metadata``. + Examples + -------- + >>> from intellistream_datahub_sdk import DatasetUpdate, FieldStr, MapField + >>> client.datasets.update([ + ... DatasetUpdate( + ... "sap_work_orders", + ... description=FieldStr("SAP work orders, live sync"), + ... metadata=MapField.delta(add={"owner": "plant_a"}), + ... ) + ... ]) """ def policies(self) -> builtins.list[Resource]: - """ - The access policies a dataset can be associated with, as ``Resource`` objects. + """List the access policies a data set can be associated with. + + Every policy in the tenant, for offering as choices for ``Dataset.policies``. + + This has been observed to come back empty while policies exist, so treat an empty + result as inconclusive rather than as "no policies". + + Returns + ------- + list of Resource + One per policy. - **Known to come back empty even when policies exist** — the server answers 200 with no body - at all. That is a server-side bug, not something these bindings can work around, so treat - an empty result as "unknown" rather than "none". + Examples + -------- + >>> names = [p.external_id for p in client.datasets.policies()] """ @@ -2339,52 +2996,275 @@ class ResourceFilter: class ResourcesServiceSync: + """Create, find, change and delete nodes of every type, and the relationships between them. + + Reached as ``client.resources`` on a configured ``DataHubClient``, never constructed. + ``AsyncDataHubClient`` has the same methods as coroutines. + + This is the generic node service. Its reads span all six node types -- assets, time + series, functions, plain resources, data sets and policies -- and return each node as its + own class, so ``isinstance(node, TimeSeries)`` works on what comes back and every node has a + ``node_type`` string to dispatch on. The typed services (:meth:`timeseries.filter`, + :meth:`assets.filter`, ...) answer for one type each. + + Every call that reaches the server raises ``DataHubException`` when the server refuses + it; the exception carries the HTTP ``status_code`` and, where the server explained + itself, a ``problem_slug`` to branch on. + """ def list(self, limit: int | None = None) -> builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: - """ - The first ``limit`` nodes in the tenant, newest created first — the cheap "what have I got" - read, with no criteria and no paging. + """List nodes of every type, newest created first. + + Only nodes in data sets you may read are returned. This is a first page and nothing + more: there is no cursor to continue from. To go further, narrow the query with + :meth:`resources.filter` rather than raising ``limit``. + + Parameters + ---------- + limit : int, optional + How many to return. Defaults to the server's 1000; above 10000 is refused. + + Returns + ------- + list of Asset, TimeSeries, Function, Resource, Dataset or Policy + Each node as its own class. + + See Also + -------- + resources.filter : Select by criteria, with paging. - Spans every node type and answers each row as its own class, exactly as ``filter`` does, so - ``isinstance(node, TimeSeries)`` works on what comes back. ``limit`` defaults to the server's - 1000 and may not exceed 10000; a ``Page`` is not returned because there is no cursor to - continue with — narrow with ``filter`` instead of raising the number. + Examples + -------- + >>> from collections import Counter + >>> recent = client.resources.list(limit=100) + >>> Counter(node.node_type for node in recent) """ def create( self, nodes: builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy], relations: builtins.list[RelForm] | None = None - ) -> GraphResult: ... - def by_ids(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: ... - def delete(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> None: ... + ) -> GraphResult: + """Store new nodes of any type, and optionally the relationships between them. + + Each node is created as the type of its class: an ``Asset`` becomes an asset, a + ``TimeSeries`` a time series, and so on. A relation may name nodes created in the same + call by external id, or nodes that already exist. The batch is all-or-nothing: if any + node or relation is refused, nothing is created. + + Every node needs an ``external_id`` (3--256 characters, unused among the tenant's + nodes, ignoring case) and a ``name`` (3--512 characters). A plain ``Resource`` also needs + at least one label of its own; the other classes carry their type label already. + Hierarchy between data sets is expressed with relations, not with ``data_set_id``. + + A ``relationship_type`` that does not exist yet is created on the fly. Relationship + types cannot be deleted, so a misspelt one stays in the catalogue for good. + + Parameters + ---------- + nodes : list of Asset, TimeSeries, Function, Resource, Dataset or Policy + The nodes to create; types may be mixed. + relations : list of RelForm, optional + The relationships to create between them. + + Returns + ------- + GraphResult + ``nodes`` holds the stored nodes, each as its own class, with ``id`` filled in and + ``related_resources`` listing the relations created in this call. ``relations`` + holds each relation as an ``EdgeProxy`` with its server-assigned ``id``. + + Raises + ------ + DataHubException + ``status_code`` 400 for a node or relation that fails validation, a relation + naming a node that does not exist, or a ``Dataset`` or ``Policy`` carrying a + ``data_set_id``; 403 when you may not write the node's data set, or when you create + a ``Dataset`` or ``Policy`` without the grant to manage all data sets; 409 if an + ``external_id`` is already taken, the problem's ``duplicated`` member naming it. + + See Also + -------- + edges.create : Link nodes that already exist. + + Examples + -------- + A site, a pump under it, and the pump's pressure series, linked in one call: + + >>> from intellistream_datahub_sdk import Asset, RelForm, Resource, TimeSeries + >>> result = client.resources.create( + ... [ + ... Resource(name="Site North", external_id="site_north", labels=["SITE"], + ... is_root=True), + ... Asset(name="Pump A", external_id="pump_a", labels=["PUMP"]), + ... TimeSeries(external_id="pump_a_pressure", name="Pump A pressure", + ... value_type="float", unit="bar"), + ... ], + ... [ + ... RelForm.by_external_ids("site_north", "pump_a", "HAS_PART"), + ... RelForm.by_external_ids("pump_a", "pump_a_pressure", "HAS_TIMESERIES"), + ... ], + ... ) + >>> [node.node_type for node in result.nodes] + """ + def by_ids(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: + """Fetch nodes of any type by id or external id. + + What is not found, or lies in a data set you may not read, is left out of the result + rather than raising, so compare the result with what you asked for to detect missing + nodes. ``related_resources`` is empty on every node returned; call a node's + ``neighbors`` for the graph around it. + + Parameters + ---------- + input : list of int, str, Asset, TimeSeries, Function, Resource, Dataset or Policy + Each entry is a numeric id, an external id, or a node object. Mix freely. + + Returns + ------- + list of Asset, TimeSeries, Function, Resource, Dataset or Policy + Each node as its own class. + + Examples + -------- + >>> found = client.resources.by_ids(["site_north", "pump_a_pressure", 42]) + """ + def delete(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> None: + """Delete nodes of any type, and every relationship they take part in. + + This cannot be undone. Identifiers that match nothing are skipped, so deleting a node + that is already gone is a no-op. The batch is all-or-nothing: if any node is refused, + nothing is deleted. + + A delete is refused when it would disconnect a surviving node from the graph root. + Include the stranded nodes in the same call, or keep another path to them. The check + reads a view of the graph that lags writes by a moment, so a node deleted immediately + after its relationships were created can be let through and strand its neighbours; + wait briefly after creating relationships before deleting across them. + + Parameters + ---------- + input : list of int, str, Asset, TimeSeries, Function, Resource, Dataset or Policy + The nodes to delete, by id, external id or node object. A node object is matched + by its ``id`` when it has one. + + Raises + ------ + DataHubException + ``status_code`` 403 when you may not write a node's data set, or delete a + ``Dataset`` or ``Policy`` without the grant to manage all data sets. 409 with + ``problem_slug`` ``"would-strand"`` when the delete would disconnect a surviving + node, the problem's ``blockedBy`` naming it; ``"referenced"`` when a time series is + still bound to a subscription, ``blockedBy`` naming the subscription. + + See Also + -------- + edges.delete : Remove one relationship and keep both nodes. + + Examples + -------- + >>> from intellistream_datahub_sdk import DataHubException + >>> try: + ... client.resources.delete(["pump_a"]) + ... except DataHubException as e: + ... if e.problem_slug != "would-strand": + ... raise + ... stranded = [b["externalId"] for b in e.problem["blockedBy"]] + ... client.resources.delete(["pump_a", *stranded]) + """ def search( self, query: str, filter: ResourceFilter | None = None, limit: int | None = None, ) -> builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy]: - """Free-text search for ``query``, ranked by relevance. + """Free-text search over nodes of every type, by name, external id and description. + + Matching is word-aware and fuzzy -- ``"pump"`` also finds ``"pumps"`` -- and results + are ranked, best match first. For exact lookups use :meth:`resources.by_ids`; for + structured queries without a phrase, use :meth:`resources.filter`. + + Parameters + ---------- + query : str + The phrase, 3--140 characters. + filter : ResourceFilter, optional + Narrows the phrase's hits, for instance to one node type. It only ever removes + results, never adds them. + limit : int, optional + How many to return. Defaults to 100; above 1000 is refused. - ``filter`` takes the same criteria as ``filter()`` and only ever removes hits from the - phrase's — it cannot widen them, so omitting it returns them as found. ``limit`` caps what - survives, defaulting to 100 and capping at 1000; the ``filter`` endpoints use 1000/10000, - which is easy to conflate. + Returns + ------- + list of Asset, TimeSeries, Function, Resource, Dataset or Policy + Each node as its own class. + + Examples + -------- + >>> from intellistream_datahub_sdk import ResourceFilter + >>> client.resources.search("feed pump", filter=ResourceFilter(node_type="asset")) """ def update(self, input: builtins.list[ResourceUpdate]) -> GraphResult: - """ - Update nodes in place. Each [``ResourceUpdate``] targets one node and carries only the - fields to change; every field it can set is shared by all node types, so one update form - covers them all. + """Change fields on existing nodes of any type. + + Only the fields named in each ``ResourceUpdate`` change. The fields it offers are + shared by every node type, except ``geolocation``, which is stored on assets only and + ignored on the rest. A node's type cannot be changed: its type label stays in place + whatever ``labels`` says. The batch is all-or-nothing. + + Parameters + ---------- + input : list of ResourceUpdate + + Returns + ------- + GraphResult + ``nodes`` holds the nodes as they stand after the update, each as its own class. + ``related_resources`` is empty on them, and ``relations`` is empty. + + Raises + ------ + DataHubException + ``status_code`` 400 if a target does not exist, or ``name`` or ``external_id`` is + set to null; 409 if a new ``external_id`` is already taken, or the node changed + under a concurrent write -- re-read it and retry. - **The echo is typed**, like every other read here: ``.nodes`` holds each node as its own - class, so a timeseries comes back as ``TimeSeries`` carrying its ``unit`` and an asset as - ``Asset`` carrying its ``geo_location``. The ``labels`` reflect what the server stored, intrinsic - type-label included. + See Also + -------- + timeseries.update : Also changes what only a time series has, such as ``unit``. - This used to answer with a plain ``Resource`` whatever the node's real type. The api's - node-update refactor made the pipeline per-type and the echo followed. + Examples + -------- + >>> from intellistream_datahub_sdk import FieldStr, ListFieldStr, MapField, ResourceUpdate + >>> result = client.resources.update([ + ... ResourceUpdate( + ... "pump_a", + ... description=FieldStr("Main feed pump"), + ... metadata=MapField.delta(add={"vendor": "Grundfos"}), + ... labels=ListFieldStr.delta(add=["CRITICAL"]), + ... ) + ... ]) """ def get_by_id(self, id: int) -> Asset | TimeSeries | Function | Resource | Dataset | Policy | None: - """ - ``GET /resources/{id}`` — one resource by numeric id. Raises when it does not exist, - unlike ``by_ids``, which silently omits what it cannot find. + """Fetch one node of any type by numeric id. + + Unlike :meth:`resources.by_ids`, a miss raises. A node in a data set you may not read + is reported as missing, so a 404 does not tell you the id is free. + + Parameters + ---------- + id : int + + Returns + ------- + Asset, TimeSeries, Function, Resource, Dataset or Policy + The node, as its own class. + + Raises + ------ + DataHubException + ``status_code`` 404 with ``problem_slug`` ``"not-found"`` if no node you may read + has this id. + + Examples + -------- + >>> node = client.resources.get_by_id(42) """ def filter( self, @@ -2405,17 +3285,74 @@ class ResourcesServiceSync: sort_order: str | None = None, cursor: str | None = None, ) -> Page: - """``POST /resources/filter`` — the generic node query; criteria combine with AND. + """Select nodes of any type by criteria, one page at a time. + + Spans every node type unless narrowed with ``node_type``. Give the criteria either as + keywords or as a prepared ``filter=``, not both. Criteria combine with AND; the entries + of one list combine with OR. Pattern lists accept a single string or a list, where + ``*`` and ``%`` are wildcards, ``_`` is literal, and matching ignores case. + + Parameters + ---------- + filter : ResourceFilter, optional + The criteria as one object, reusable across calls. Exclusive with the + criteria keywords below. + id : sequence of int, optional + external_id, name, source : str or sequence of str, optional + labels : str or sequence of str, optional + Every label listed must be present. + metadata : mapping of str to str or None, optional + Every key listed must be present; a ``None`` value matches the key alone. + created_time, last_updated_time : TimeFilter, optional + Inclusive at both ends. + node_type : str or sequence of str, optional + Any of ``"asset"``, ``"timeseries"``, ``"function"``, ``"resource"``, + ``"dataset"`` and ``"policy"``, ignoring case. Omitted means every type; a list + of only unknown names matches nothing. + is_root : bool, optional + Match on the ``is_root`` flag. Only resources and assets can be roots; every + other node matches ``False``. + data_set_id : sequence of int, str or IdCollection, optional + Data sets by id or external id; includes everything beneath them in the data + set hierarchy. ``None`` places no restriction, but ``[]`` matches nothing. + limit : int, optional + Page size. Defaults to 1000; above 10000 is refused. + sort_by : str, optional + One of ``id``, ``externalId``, ``name``, ``source``, ``description``, + ``createdTime``, ``lastUpdatedTime`` and ``dataSetId``; defaults to + ``createdTime``. + sort_order : {"asc", "desc"}, optional + Defaults to descending. + cursor : str, optional + ``next_cursor`` from the previous page. It belongs to its sort: continuing it + under another is refused. + + Returns + ------- + Page + A list-like page of nodes, each as its own class. Its ``next_cursor`` is ``None`` + on the last page. + + Raises + ------ + TypeError + If both ``filter=`` and criteria keywords are given. + + See Also + -------- + assets.filter : The same criteria, answering with assets only. + timeseries.filter : Adds the criteria only a time series has. - Spans **every node type** unless narrowed with ``node_type`` (``asset``, ``timeseries``, - ``function``, ``resource``, ``dataset``, ``policy``); every node carries its type as a - label so you can tell what came back. + Examples + -------- + Pump assets and pump series in one query, split by class: - ``external_id``, ``name`` and ``source`` are pattern lists; ``labels`` must all be - present; a ``None`` ``metadata`` value matches the key alone. ``data_set_id`` expands - down the dataset hierarchy, and ``None`` (no restriction) differs from ``[]`` - (narrow to no datasets, matching nothing). See ``TimeSeriesFilter`` for the sort and - cursor rules. + >>> from intellistream_datahub_sdk import Asset, TimeSeries + >>> page = client.resources.filter( + ... external_id="pump_*", node_type=["asset", "timeseries"], limit=500 + ... ) + >>> assets = [n for n in page if isinstance(n, Asset)] + >>> series = [n for n in page if isinstance(n, TimeSeries)] """ @@ -2496,27 +3433,140 @@ class Label: @final class LabelsServiceSync: + """Create, list, change and delete the tenant's labels. + + Reached as ``client.labels`` on a configured ``DataHubClient``, never constructed. + ``AsyncDataHubClient`` has the same methods as coroutines. + + A label comes into being the first time a node is written with it, so this service is + for pre-seeding a label's description, colour or i18n code, for renaming, and for + cleanup. Names are canonicalised to upper snake case, so ``"pump a"`` and ``"PUMP_A"`` + are the same label. The type-labels (``ASSET``, ``TIMESERIES``, ``FUNCTION``, + ``DATASET``, ``POLICY``) are listed like any other but cannot be renamed or deleted. + + Every call that reaches the server raises ``DataHubException`` when the server refuses + it; the exception carries the HTTP ``status_code`` and, where the server explained + itself, a ``problem_slug`` to branch on. + """ def list(self) -> builtins.list[Label]: - """ - Every label in the tenant. + """List every label in the tenant. + + Labels are a small set, so this returns all of them in one call. + + Returns + ------- + list of Label + + Examples + -------- + >>> names = [label.name for label in client.labels.list()] """ def get(self, id: int) -> Label | None: - """ - A single label by numeric id, or ``None`` if it doesn't exist (the server answers an - unknown id with 404; that is absorbed into ``None``). + """Fetch one label by numeric id. + + Parameters + ---------- + id : int + The label's ``id``. + + Returns + ------- + Label or None + ``None`` if no label has this id. + + Examples + -------- + >>> label = client.labels.get(17) """ def create(self, input: builtins.list[Label]) -> builtins.list[Label]: - """ - Create labels (each needs a unique ``name``). A duplicate name raises with status 409. + """Store new labels. + + Each ``name`` is canonicalised, then must be 3--128 characters and unused in the + tenant. A ``color`` that is not a hex colour such as ``"#3A9F2E"`` is replaced with a + random one, and so is an omitted one. + + Parameters + ---------- + input : list of Label + The labels to create, each with a ``name``. + + Returns + ------- + list of Label + The stored labels, with ``id`` and ``color`` filled in and ``name`` in its + canonical form. + + Raises + ------ + ValueError + If a ``Label`` has neither ``name`` nor ``id``. Nothing is sent. + DataHubException + ``status_code`` 409 (``problem_slug`` ``"duplicate"``) if a name is already + taken; the problem's ``duplicated`` member names it. 400 for a name missing or + outside 3--128 characters, or a ``color`` longer than 7 characters. + + Examples + -------- + >>> from intellistream_datahub_sdk import Label + >>> [pump] = client.labels.create([ + ... Label(name="pump", description="Centrifugal and piston pumps", color="#3A9F2E") + ... ]) + >>> pump.name + 'PUMP' """ def update(self, input: builtins.list[Label]) -> builtins.list[Label]: - """ - Update labels (identify each by ``id``); only the fields you set are applied. + """Change fields on existing labels. + + Each ``Label`` names its target by ``id``, or by ``name`` when it has no ``id``. Only + the fields that are set change; a field left ``None`` keeps its value, so a field + cannot be cleared. To rename a label, identify it by ``id`` and set the new ``name``. + The batch is all-or-nothing. + + Parameters + ---------- + input : list of Label + + Returns + ------- + list of Label + The labels as they stand after the update. + + Raises + ------ + ValueError + If a ``Label`` has neither ``name`` nor ``id``. Nothing is sent. + DataHubException + ``status_code`` 404 if a target label does not exist; 400 for renaming a + type-label, or renaming a label to one. + + Examples + -------- + >>> from intellistream_datahub_sdk import Label + >>> client.labels.update([Label(name="PUMP", color="#CC11CC")]) """ def delete(self, input: builtins.list[Label | int | str]) -> None: - """ - Delete labels by ``Label``, numeric id, or name. Rejected with status 400 if a label is - still referenced by a resource. + """Delete labels. + + This cannot be undone. The batch is all-or-nothing: if any label is still carried by + a node, nothing is deleted. Remove it from those nodes first, with a ``labels`` + delta on their update. Entries that match no label are skipped. + + Parameters + ---------- + input : list of Label, int or str + Each entry is a ``Label``, a numeric id, or a name. A name is canonicalised + before matching. A ``Label`` is matched by ``id`` when it has one, otherwise by + ``name``. + + Raises + ------ + DataHubException + ``status_code`` 400 if a label is still in use, with the problem's ``fields`` + naming the label and every node carrying it; 400 also for a type-label. + + Examples + -------- + >>> client.labels.delete(["pump", 17]) """ @@ -2623,9 +3673,88 @@ class Unit: @final class UnitServiceSync: - def list(self) -> builtins.list[Unit]: ... - def by_ids(self, input: builtins.list[IdCollection]) -> builtins.list[Unit]: ... - def by_external_ids(self, input: str) -> builtins.list[Unit]: ... + """Look up the catalogue of measurement units. + + Reached as ``client.units`` on a configured ``DataHubClient``, never constructed. + ``AsyncDataHubClient`` has the same methods as coroutines. + + The catalogue is managed centrally and is read-only: there is no call to create, change or + delete a unit. A time series names its unit by the unit's ``external_id``. Every call that + reaches the server raises ``DataHubException`` when the server refuses it; the exception + carries the HTTP ``status_code`` and, where the server explained itself, a + ``problem_slug`` to branch on. + """ + def list(self) -> builtins.list[Unit]: + """List every unit in the catalogue. + + The catalogue is small and changes rarely, so the whole of it comes back in one call and + is safe to cache. + + Returns + ------- + list of Unit + + Examples + -------- + >>> units = client.units.list() + >>> by_symbol = {u.symbol: u for u in units} + """ + def by_ids(self, input: builtins.list[IdCollection]) -> builtins.list[Unit]: + """Fetch units by id or external id. + + What is not found is left out of the result rather than raising, so compare the result + with what you asked for to detect missing units. An empty ``input`` returns an empty + list. An external id is matched exactly as stored, so ``"Pressure_Bar"`` does not find + ``pressure_bar``; :meth:`units.by_external_ids` is lenient about case. + + Parameters + ---------- + input : list of IdCollection + Each entry names a unit by ``id`` or ``external_id``. A bare int or str is not + accepted. + + Returns + ------- + list of Unit + + Raises + ------ + TypeError + If an entry is not an ``IdCollection``. + + Examples + -------- + >>> from intellistream_datahub_sdk import IdCollection + >>> found = client.units.by_ids( + ... [IdCollection(external_id="pressure_bar"), IdCollection(id=9)] + ... ) + """ + def by_external_ids(self, input: str) -> builtins.list[Unit]: + """Fetch one unit by its external id. + + Despite the plural name this takes a single external id. The server folds it to the + catalogue's form first -- lowercased, with spaces and punctuation turned into ``_`` -- + so ``"Pressure Bar"`` finds ``pressure_bar``. A unit that does not exist + is an empty list, not an error. + + Parameters + ---------- + input : str + The external id of the unit. + + Returns + ------- + list of Unit + The unit, or an empty list if there is none. + + See Also + -------- + units.by_ids : Several units at once, by id or exact external id. + + Examples + -------- + >>> [bar] = client.units.by_external_ids("pressure_bar") + """ @final @@ -2802,44 +3931,350 @@ class FileDownload: @final class FilesServiceSync: - def upload_file(self, file_upload: FileUpload) -> list[INode]: ... - def list_root_directory(self) -> list[INode]: ... - def delete(self, input: list[INode | IdCollection | int | str]) -> None: ... - def list_directory_by_path(self, path: str) -> list[INode]: ... - def get_by_id(self, id: int) -> list[INode]: + """Upload, browse, change, download and delete files organised into folders. + + Reached as ``client.files`` on a configured ``DataHubClient``, never constructed. + ``AsyncDataHubClient`` has the same methods as coroutines. + + Every call that reaches the server raises ``DataHubException`` when the server refuses + it; the exception carries the HTTP ``status_code`` and, where the server explained + itself, a ``problem_slug`` to branch on. Files are a per-tenant feature: where it is not + enabled, every call raises with ``status_code`` 403 and ``problem_slug`` + ``"feature-disabled"``. A file or folder with no data set is visible to everyone; one with + a data set only to callers who can read it. Reads treat a node you cannot see as one that + does not exist, answering 404 or leaving it out, never 403. + """ + def upload_file(self, file_upload: FileUpload) -> list[INode]: + """Store a local file on the server. + + The file's bytes are streamed from disk, not read into memory first. Missing folders on + the destination path are created. The server always turns the external id into a + lowercase slug -- letters, digits and underscores -- so read the stored one back from + the result rather than assuming ``file_upload.external_id``. The MIME type is detected + from the content when the client cannot tell. Uploading never overwrites: a taken path + or external id is refused. + + Parameters + ---------- + file_upload : FileUpload + The local file, where it goes (``destination_path``, default the root folder) and + its metadata. + + Returns + ------- + list of INode + One element: the stored file, with ``id``, ``size`` and ``checksum`` filled in. + + Raises + ------ + DataHubException + ``status_code`` 409 if a file already exists at that path or with that external + id; 403 without write access to the file's data set or to the destination + folder's; 400 for an invalid path or unreadable metadata. + + Examples + -------- + >>> from intellistream_datahub_sdk import FileUpload + >>> [node] = client.files.upload_file( + ... FileUpload( + ... "pump_a_manual.pdf", + ... destination_path="/manuals/pumps", + ... description="Operating manual", + ... ) + ... ) + >>> node.external_id + 'pump_a_manual_pdf' """ - Metadata for one file or folder, by numeric id. + def list_root_directory(self) -> list[INode]: + """List the files and folders directly under the root folder. + + Only the root's immediate children, not the whole tree. Use + :meth:`files.list_directory_by_path` to descend. + + Returns + ------- + list of INode + + See Also + -------- + files.list_directory_by_path : The children of any folder. + + Examples + -------- + >>> top = client.files.list_root_directory() """ - def get_by_external_id(self, external_id: str) -> list[INode]: + def delete(self, input: list[int | str | INode | FileUpload]) -> None: + """Move files and folders to the trash. + + Deleting a folder moves everything beneath it to the trash too. Only files can be + brought back, with :meth:`files.restore`; a deleted folder is gone for good. An entry + that matches nothing is ignored. Deleting needs write access to the data set of every + node removed, the folder's descendants included. + + Parameters + ---------- + input : list of INode, FileUpload, int or str + Each entry is a numeric id, an external id exactly as stored, an ``INode``, or the + ``FileUpload`` a file was uploaded from, which is matched by its ``external_id``. + ``IdCollection`` is not accepted. + + Raises + ------ + DataHubException + ``status_code`` 403 without write access to a node's data set. + TypeError + If an entry is of any other type. + + See Also + -------- + files.list_trash : What has been deleted. + files.restore : Bring deleted files back. + + Examples + -------- + >>> client.files.delete(["pump_a_manual_pdf"]) """ - Metadata for one file or folder, by external id. + def list_directory_by_path(self, path: str) -> list[INode]: + """List the files and folders directly under a folder. + + A path that names no folder answers an empty list rather than raising. + + Parameters + ---------- + path : str + The folder, as an absolute path starting with ``/``, such as + ``"/manuals/pumps"``. ``"/"`` is the root. + + Returns + ------- + list of INode + + Raises + ------ + DataHubException + ``status_code`` 404 for a path that is not a valid folder path, such as one that + climbs above the root. + + Examples + -------- + >>> manuals = client.files.list_directory_by_path("/manuals/pumps") """ - def search(self, query: str) -> list[INode]: + def get_by_id(self, id: int) -> list[INode]: + """Fetch the metadata of one file or folder by numeric id. + + Parameters + ---------- + id : int + + Returns + ------- + list of INode + One element. + + Raises + ------ + DataHubException + ``status_code`` 404 if there is no such node, it is in the trash, or you cannot + read its data set. + + Examples + -------- + >>> [node] = client.files.get_by_id(5677892) + """ + def get_by_external_id(self, external_id: str) -> list[INode]: + """Fetch the metadata of one file or folder by external id. + + The external id is matched exactly as stored, which is always a lowercase slug. + + Parameters + ---------- + external_id : str + + Returns + ------- + list of INode + One element. + + Raises + ------ + DataHubException + ``status_code`` 404 if there is no such node, it is in the trash, or you cannot + read its data set. + + Examples + -------- + >>> [node] = client.files.get_by_external_id("pump_a_manual_pdf") """ - Full-text search over file and folder names and descriptions. + def search(self, query: str) -> list[INode]: + """Free-text search over file and folder names and descriptions. + + Searches the whole tree, ignoring case; the last word of ``query`` also matches as a + prefix, so ``"operating man"`` finds a file described as ``"Operating manual"``. + Results are ordered by name, not ranked, and at most 100 are returned. A blank query + returns an empty list. + + Parameters + ---------- + query : str + + Returns + ------- + list of INode + + See Also + -------- + files.list_directory_by_path : Walk a folder instead. + + Examples + -------- + >>> hits = client.files.search("pump manual") """ def list_trash(self) -> list[INode]: + """List the deleted files you can read. + + Files only; deleted folders are not listed. Each keeps its ``name`` and ``path`` from + before deletion, but its ``external_id`` is rewritten to + ``DELETED___``. + + Returns + ------- + list of INode + + See Also + -------- + files.restore : Bring them back. + + Examples + -------- + >>> trashed = client.files.list_trash() """ - The soft-deleted files the caller can read. - """ - def restore(self, input: list[INode | IdCollection | int | str]) -> list[INode]: - """ - Restore soft-deleted files. Identify each by numeric id: the trashed - ``DELETED_..._`` external id does not round-trip through the server's - lowercasing hash, so that route answers 404. See ``FileService::restore`` in the SDK. + def restore(self, input: list[int | str | INode | FileUpload]) -> list[INode]: + """Bring deleted files back to where they were. + + Each file returns to its original path under its original external id. The request is + all-or-nothing and never overwrites: if any file cannot go back, nothing is restored. + A file whose folder was deleted cannot return until a folder exists at that path + again. + + Identify each file by numeric id, or by the ``INode`` from :meth:`files.list_trash`, + which carries it. The rewritten ``DELETED_...`` external id does not find the file, so + a str finds nothing. Entries that match no deleted file are ignored as long as one + does. + + Parameters + ---------- + input : list of INode, FileUpload, int or str + The deleted files, by ``id`` or as the ``INode`` from :meth:`files.list_trash`. + ``IdCollection`` is not accepted. + + Returns + ------- + list of INode + The restored files, with their original external ids. + + Raises + ------ + DataHubException + ``status_code`` 404 if none of the entries is a deleted file; 409 with + ``problem_slug`` ``"restore-refused"`` if one cannot go back, the problem's + ``reason`` saying why (``"path-taken"``, ``"external-id-taken"``, + ``"folder-missing"``, ``"not-a-file"``); 403 without write access to a file's + data set. + TypeError + If an entry is of any other type. + + Examples + -------- + >>> trashed = client.files.list_trash() + >>> client.files.restore([n for n in trashed if n.name == "pump_a_manual.pdf"]) """ def update(self, update: FileUpdate) -> list[INode]: - """ - Rename, move, or edit the metadata of one file or folder. + """Rename, move, or change the metadata of one file or folder. + + Only the fields given in ``update`` change; there is no way to clear a field. Moving + to a folder that does not exist creates it, and ``path="/"`` moves to the root. + ``metadata`` and ``related_resources`` replace what is stored rather than merging with + it. Assigning a data set to a folder also assigns it to every node beneath it that has + none. When the ``FileUpdate`` names both an ``external_id`` and an ``id``, the + external id is used, matched exactly as stored. + + Parameters + ---------- + update : FileUpdate + + Returns + ------- + list of INode + One element: the node as it stands after the update. + + Raises + ------ + DataHubException + ``status_code`` 404 if the node does not exist; 409 if a file or folder already + exists at the new path; 400 for an invalid name or path; 403 without write access + to the node's data set, the new data set or the destination folder's. + + Examples + -------- + >>> from intellistream_datahub_sdk import FileUpdate + >>> client.files.update( + ... FileUpdate("pump_a_manual_pdf", path="/manuals/archive", description="Rev. B") + ... ) """ def download(self, id: int) -> FileDownload: - """ - Download a file's content into memory. + """Download a file's content into memory. + + The whole file is held in memory; for large files use :meth:`files.download_to_path`. + + Parameters + ---------- + id : int + The file's numeric id. + + Returns + ------- + FileDownload + The bytes as ``content``, with the ``file_name`` and ``mime_type`` the server + sent. + + Raises + ------ + DataHubException + ``status_code`` 404 if there is no such file or you cannot read its data set. + + Examples + -------- + >>> download = client.files.download(5677892) + >>> data = download.content """ def download_to_path(self, id: int, destination: str) -> int: - """ - Download a file straight to ``destination``, without holding it in memory. Returns the number - of bytes written. + """Download a file straight to disk, without holding it in memory. + + ``destination`` is the file to write, not a folder: it is created if missing and + truncated if it exists. If the transfer fails part-way, the partial file is left + behind. + + Parameters + ---------- + id : int + The file's numeric id. + destination : str + The local file path to write. + + Returns + ------- + int + The number of bytes written. + + Raises + ------ + DataHubException + ``status_code`` 404 if there is no such file or you cannot read its data set; + also raised if ``destination`` cannot be written. + + Examples + -------- + >>> written = client.files.download_to_path(5677892, "/tmp/pump_a_manual.pdf") """ @@ -2847,13 +4282,13 @@ class FilesServiceSync: class FilesServiceAsync: async def upload_file(self, file_upload: FileUpload) -> list[INode]: ... async def list_root_directory(self) -> list[INode]: ... - async def delete(self, input: list[INode | IdCollection | int | str]) -> None: ... + async def delete(self, input: list[int | str | INode | FileUpload]) -> None: ... async def list_directory_by_path(self, path: str) -> list[INode]: ... async def get_by_id(self, id: int) -> list[INode]: ... async def get_by_external_id(self, external_id: str) -> list[INode]: ... async def search(self, query: str) -> list[INode]: ... async def list_trash(self) -> list[INode]: ... - async def restore(self, input: list[INode | IdCollection | int | str]) -> list[INode]: ... + async def restore(self, input: list[int | str | INode | FileUpload]) -> list[INode]: ... async def update(self, update: FileUpdate) -> list[INode]: ... async def download(self, id: int) -> FileDownload: ... async def download_to_path(self, id: int, destination: str) -> int: ... @@ -3021,12 +4456,82 @@ class SubscriptionListenerAsync: @final class SubscriptionsServiceSync: - def create(self, input: builtins.list[Subscription]) -> builtins.list[Subscription]: ... - def list(self, limit: int | None = None) -> builtins.list[Subscription]: + """Create, find and delete subscriptions, and listen to them for live datapoints. + + Reached as ``client.subscriptions`` on a configured ``DataHubClient``, never constructed. + ``AsyncDataHubClient`` has the same methods as coroutines. + + A subscription is a named, durable stream of the datapoints written to a set of time + series. :meth:`subscriptions.listen` opens a WebSocket that delivers them. What has not been + acknowledged survives a disconnect, so a listener that reconnects resumes where it left + off. + + Every call that reaches the server raises ``DataHubException`` when the server refuses + it; the exception carries the HTTP ``status_code`` and, where the server explained + itself, a ``problem_slug`` to branch on. :meth:`subscriptions.listen` is the exception: it + raises a plain ``Exception``. + """ + def create(self, input: builtins.list[Subscription]) -> builtins.list[Subscription]: + """Store new subscriptions. + + Each needs an ``external_id`` unused among the tenant's subscriptions, a ``name`` and at + least one time series, all of which must exist and be readable by you. If any + subscription in the batch is refused, none is created. A new subscription starts from + the datapoints written after it was created; to start one over, delete it and create + it again. + + Parameters + ---------- + input : list of Subscription + + Returns + ------- + list of Subscription + The stored subscriptions, with ``id`` and timestamps filled in by the server. + + Raises + ------ + DataHubException + ``status_code`` 400 if an ``external_id`` is already taken, a subscription names no + time series, or a time series does not exist; 403 if you may not read one of the + time series. + + See Also + -------- + subscriptions.listen : Receive what a subscription delivers. + + Examples + -------- + >>> from intellistream_datahub_sdk import Subscription + >>> [sub] = client.subscriptions.create([ + ... Subscription( + ... "pump_a_live", "Pump A live feed", ["pump_a_pressure", "pump_a_temperature"] + ... ) + ... ]) """ - Subscriptions in the tenant, newest first. ``limit`` defaults to the server's 1000 and may - not exceed 10000; there is no paging, so a bigger tenant is truncated rather than paged — - use ``filter`` to narrow instead. + def list(self, limit: int | None = None) -> builtins.list[Subscription]: + """List subscriptions, newest created first. + + A subscription is listed only if you may read every time series it streams. This is a + first page and nothing more: there is no cursor to continue from. To go further, narrow + the query with :meth:`subscriptions.filter` rather than raising ``limit``. + + Parameters + ---------- + limit : int, optional + How many to return. Defaults to the server's 1000; above 10000 is refused. + + Returns + ------- + list of Subscription + + See Also + -------- + subscriptions.filter : Select by the time series a subscription streams. + + Examples + -------- + >>> recent = client.subscriptions.list(limit=20) """ def filter( self, @@ -3035,15 +4540,109 @@ class SubscriptionsServiceSync: limit: int | None = None, sort: DataSort | None = None, ) -> builtins.list[Subscription]: + """Select subscriptions by the time series they stream. + + Give the criteria either as keywords or as a prepared ``form``, not both. A + subscription matches if it streams at least one of the time series named, and is + returned only if you may read every time series it streams. There is no cursor, so + this returns one page: narrow the criteria to see past ``limit``. + + Parameters + ---------- + form : SubscriptionFilterForm, optional + The criteria, limit and sort as one object. Exclusive with the keywords below. + timeseries : list of TimeSeries, IdCollection, int or str, optional + Time series by ``TimeSeries``, ``IdCollection``, numeric id or external id. Omit it + to match every subscription. + limit : int, optional + How many to return. Defaults to 100; above 10000 is refused. + sort : DataSort, optional + One property out of ``id``, ``externalId``, ``name``, ``createdTime`` and + ``lastUpdatedTime``, with ``order`` ``"asc"`` or ``"desc"``. Defaults to newest + created first. + + Returns + ------- + list of Subscription + + Raises + ------ + ValueError + If both ``form`` and keywords are given. + + Examples + -------- + Every subscription streaming the discharge pressure, oldest first: + + >>> from intellistream_datahub_sdk import DataSort + >>> subs = client.subscriptions.filter( + ... timeseries=["pump_a_pressure"], sort=DataSort(["createdTime"], "asc") + ... ) """ - Subscriptions matching every criterion on the filter. + def delete(self, input: builtins.list[Subscription | IdCollection | int | str]) -> None: + """Delete subscriptions, and whatever they have not delivered yet. + + This cannot be undone. Subscriptions that do not exist are skipped. A subscription that + a listener is still connected to is not deleted: close every listener on it first, + including listeners in other processes, then retry. + + Parameters + ---------- + input : list of Subscription, IdCollection, int or str + The subscriptions to delete, by ``Subscription``, ``IdCollection``, numeric id or + external id. + + Raises + ------ + DataHubException + ``status_code`` 400 while a listener is connected to one of the subscriptions; + nothing is deleted. + + Examples + -------- + >>> client.subscriptions.delete(["pump_a_live"]) """ - def delete(self, input: builtins.list[Subscription | IdCollection | int | str]) -> None: ... def listen(self, subscription_external_ids: builtins.list[str]) -> SubscriptionListener: - """ - Open a WebSocket listener multiplexing the named subscriptions. The ids seed the initial - set (may be empty — add more with .subscribe()). Returns a SubscriptionListener you can - iterate or call .next_message() / .ack() / .subscribe() / .close() on. + """Open a WebSocket listener for one or more subscriptions. + + One listener multiplexes any number of subscriptions; each message says which one it + came from. The list may be empty, and the set can be changed on the open listener with + ``subscribe``, ``unsubscribe`` and ``set_subscriptions``. The listener acknowledges + nothing by itself: call ``ack`` with the ids of the messages you have processed, and + anything left unacknowledged is delivered again to the next listener on that + subscription. + + A dropped connection is re-established by the listener, with a fresh token and the + current set of subscriptions, so a short outage or a server restart does not end the + iteration. A subscription that cannot be attached, because it does not exist or you + may not read it, raises from the listener's next read while the other subscriptions + keep delivering. Reads have no timeout, so close the listener when done, preferably + with a ``with`` block, or it stays connected and blocks :meth:`subscriptions.delete`. + + Parameters + ---------- + subscription_external_ids : list of str + External ids of the subscriptions to listen to. + + Returns + ------- + SubscriptionListener + Iterate it for ``SubscriptionMessage`` objects, or call ``next_message``. + + Raises + ------ + Exception + If the connection cannot be opened, for example when no token can be obtained or + the handshake is refused. + + Examples + -------- + >>> with client.subscriptions.listen(["pump_a_live"]) as listener: + ... for msg in listener: + ... for series in msg.payload.items: + ... for dp in series.datapoints: + ... print(series.external_id, dp.timestamp, dp.as_float()) + ... listener.ack([msg.message_id]) """ @@ -3144,33 +4743,198 @@ class Function: """ -@final -class FunctionsServiceSync: - def create(self, input: builtins.list[Function]) -> builtins.list[Function]: ... - def list(self, limit: int | None = None) -> builtins.list[Function]: - """ - The first ``limit`` functions you may read, newest first. ``limit`` defaults to the server's - 1000 and may not exceed 10000; there is no paging, so a bigger catalogue is truncated. - """ - def get_by_id(self, id: int) -> Function | None: - """One function by numeric id; raises on 404. +@final +class FunctionsServiceSync: + """Create, find, change and delete functions. + + Reached as ``client.functions`` on a configured ``DataHubClient``, never constructed. + ``AsyncDataHubClient`` has the same methods as coroutines. + + A function is a node in the graph like a resource, marked by its ``FUNCTION`` type label, + and is linked to the time series it processes by relationships. The functions that + :meth:`functions.create` and the reads return have an empty ``related_resources``; walk + the graph with ``Function.neighbors`` to see what a function is linked to. + + Every call that reaches the server raises ``DataHubException`` when the server refuses + it; the exception carries the HTTP ``status_code`` and, where the server explained + itself, a ``problem_slug`` to branch on. + """ + def create(self, input: builtins.list[Function]) -> builtins.list[Function]: + """Store new functions. + + Each needs an unused ``external_id`` and a ``name``: the server requires a name + although ``Function`` accepts ``None``. The batch is + all-or-nothing: if any function is refused, none is created. To link a function to + its inputs, use :meth:`edges.create`. + + Parameters + ---------- + input : list of Function + + Returns + ------- + list of Function + The stored functions, with ``id`` and timestamps filled in by the server. + + Raises + ------ + DataHubException + ``status_code`` 409 if an ``external_id`` is already taken; the problem's + ``duplicated`` member names it. + + Examples + -------- + >>> from intellistream_datahub_sdk import Function + >>> [fn] = client.functions.create( + ... [Function("pump_a_anomaly_detector", name="Pump A anomaly detector")] + ... ) + """ + def list(self, limit: int | None = None) -> builtins.list[Function]: + """List the functions you may read, newest created first. + + This is a first page and nothing more: there is no cursor to continue from. + + Parameters + ---------- + limit : int, optional + How many to return. Defaults to the server's 1000; above 10000 is refused. + + Returns + ------- + list of Function + + Examples + -------- + >>> recent = client.functions.list(limit=20) + """ + def get_by_id(self, id: int) -> Function | None: + """Fetch one function by numeric id. + + A function you may not read is reported as missing, so a 404 does not mean the id is + unused. When you have the numeric id, prefer this to :meth:`functions.by_ids`, which + searches a listing. + + Parameters + ---------- + id : int + The function's numeric id. + + Returns + ------- + Function + + Raises + ------ + DataHubException + ``status_code`` 404 if there is no function with that id that you may read, + including when the id belongs to a node of another type. + + Examples + -------- + >>> fn = client.functions.get_by_id(5677901) + """ + def by_ids(self, input: builtins.list[Function | IdCollection | int | str]) -> builtins.list[Function]: + """Fetch functions by id or external id. + + What is not found is left out of the result rather than raising. The lookup is made in + the client, over a listing of the newest 10000 functions you may read, so in a tenant + with more than that the oldest are never found. Results come in listing order, newest + created first, not in the order asked for. An entry naming both an id and an external + id matches a function that has either. + + Parameters + ---------- + input : list of Function, IdCollection, int or str + Each entry is a ``Function``, an ``IdCollection``, a numeric id or an external id. + Mix freely. + + Returns + ------- + list of Function + + See Also + -------- + functions.get_by_id : One function by numeric id, from the server directly. + functions.by_external_id : One function by external id, raising if it is missing. + + Examples + -------- + >>> found = client.functions.by_ids([5677901, "pump_a_anomaly_detector"]) + """ + def by_external_id(self, external_id: str) -> Function: + """Fetch one function by external id, raising if there is none. + + Made in the client like :meth:`functions.by_ids`, with the same limit: a function + older than the newest 10000 you may read is not found. + + Parameters + ---------- + external_id : str + + Returns + ------- + Function + + Raises + ------ + DataHubException + ``status_code`` 404 if no function you may read has that external id. The error is + raised by the client, so ``problem`` and ``problem_slug`` are ``None``. + + Examples + -------- + >>> fn = client.functions.by_external_id("pump_a_anomaly_detector") + """ + def update(self, input: builtins.list[ResourceUpdate]) -> GraphResult: + """Change fields on existing functions. + + Only the fields named in each ``ResourceUpdate`` change. ``geolocation`` is ignored, + being stored only on assets, and the ``FUNCTION`` type label cannot be removed. + + Parameters + ---------- + input : list of ResourceUpdate + One per function, naming it by ``Function``, numeric id or external id. - A 404 does not tell you the id is free — a function you may not read is reported as - missing rather than forbidden. Prefer this to ``by_ids`` when you have the id: functions - have no ``/byids`` endpoint, so ``by_ids`` pages the listing and filters client-side. - """ - def by_ids(self, input: builtins.list[Function | IdCollection | int | str]) -> builtins.list[Function]: ... - def by_external_id(self, external_id: str) -> Function: - """ - Convenience for the function-worker bootstrap: returns the function with the given - externalId, or raises if no such function exists. + Returns + ------- + GraphResult + ``nodes`` holds each updated function as it stands after the update, as a + ``Function``. + + Examples + -------- + >>> from intellistream_datahub_sdk import FieldStr, MapField, ResourceUpdate + >>> client.functions.update([ + ... ResourceUpdate( + ... "pump_a_anomaly_detector", + ... description=FieldStr("Flags pressure spikes on pump A"), + ... metadata=MapField.delta(add={"version": "2"}), + ... ) + ... ]) """ - def update(self, input: builtins.list[ResourceUpdate]) -> GraphResult: - """Update functions in place; ``geolocation`` is ignored, being asset-only. + def delete(self, input: builtins.list[Function | IdCollection | int | str]) -> None: + """Delete functions and every relationship they have. + + This cannot be undone. A delete that would leave another node disconnected from the + graph root is refused as a whole. + + Parameters + ---------- + input : list of Function, IdCollection, int or str + The functions to delete, by ``Function``, ``IdCollection``, numeric id or external + id. + + Raises + ------ + DataHubException + ``status_code`` 409 with ``problem_slug`` ``"would-strand"`` when the delete would + disconnect a node, which ``problem["blockedBy"]`` names; nothing is deleted. - ``.nodes`` holds typed node objects — a function comes back as ``Function``. + Examples + -------- + >>> client.functions.delete(["pump_a_anomaly_detector"]) """ - def delete(self, input: builtins.list[Function | IdCollection | int | str]) -> None: ... @final @@ -3188,32 +4952,131 @@ class FunctionsServiceAsync: @final class AssetsServiceSync: - """The typed ``/assets`` family — the ``ASSET``-labelled corner of the resource graph. + """Create, find, change and delete assets. + + Reached as ``client.assets`` on a configured ``DataHubClient``, never constructed. + ``AsyncDataHubClient`` has the same methods as coroutines. + + An asset is a resource that can carry a ``geolocation``, and like a resource can be a + navigation root. + Each call runs the same server pipeline as its ``client.resources`` counterpart, so the + same rules apply. Reads are pinned to assets and answer ``Asset`` objects rather than a + mix of classes; :meth:`assets.update` and :meth:`assets.delete` do not check that their + targets are assets. - Every call is the generic ``/resources`` pipeline with the type pinned server-side, so the - two paths cannot drift apart on ACLs or status codes. What differs is the shape that comes - back: ``Asset``, so ``geolocation`` and ``is_root`` are reachable without a type check. + Every call that reaches the server raises ``DataHubException`` when the server refuses + it; the exception carries the HTTP ``status_code`` and, where the server explained + itself, a ``problem_slug`` to branch on. """ def create(self, input: builtins.list[Asset]) -> builtins.list[Asset]: - """Create assets. Each needs an ``external_id`` and a ``name``. + """Store new assets. + + Each needs an ``external_id`` (3--256 characters, unused among the tenant's nodes, + ignoring case) and a ``name`` (3--512 characters). The asset type label is added for + you; labels you set are kept beside it. The batch is all-or-nothing. To create assets + together with the relationships between them, use :meth:`resources.create`. + + Parameters + ---------- + input : list of Asset + + Returns + ------- + list of Asset + The stored assets, with ``id`` and timestamps filled in by the server. + + Raises + ------ + DataHubException + ``status_code`` 400 for an asset that fails validation; 403 when you may not write + its data set; 409 if an ``external_id`` is already taken, the problem's + ``duplicated`` member naming it. + + See Also + -------- + resources.create : Assets and their relationships in one call. - Unlike ``resources.create``, the ``ASSET`` label need not be set by hand. Relations are - not creatable here — use ``resources.create`` for assets and their edges in one call. + Examples + -------- + >>> from intellistream_datahub_sdk import Asset + >>> [pump] = client.assets.create([ + ... Asset( + ... name="Pump A", + ... external_id="pump_a", + ... labels=["PUMP"], + ... geolocation={"type": "Point", "coordinates": [10.75, 59.91]}, + ... ) + ... ]) """ def get_by_id(self, id: int) -> Asset | None: - """One asset by numeric id; raises on 404. + """Fetch one asset by numeric id. + + Unlike :meth:`assets.by_ids`, a miss raises. A node that is not an asset, and an asset + in a data set you may not read, are both reported as missing, so a 404 does not tell + you the id is free. + + Parameters + ---------- + id : int + + Returns + ------- + Asset + + Raises + ------ + DataHubException + ``status_code`` 404 with ``problem_slug`` ``"not-found"`` if no asset you may + read has this id. - A 404 does not tell you the id is free: a node that exists but is not an asset, and an - asset you may not read, are both reported as missing. + Examples + -------- + >>> pump = client.assets.get_by_id(42) """ def by_ids(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> builtins.list[Asset]: - """Assets by id or external id. What cannot be found is omitted, not raised.""" + """Fetch assets by id or external id. + + What is not found, names a node of another type, or lies in a data set you may not + read is left out of the result rather than raising, so compare the result with what + you asked for to detect missing assets. + + Parameters + ---------- + input : list of int, str, Asset, TimeSeries, Function, Resource, Dataset or Policy + Each entry is a numeric id, an external id, or a node object. Mix freely. + + Returns + ------- + list of Asset + + Examples + -------- + >>> found = client.assets.by_ids(["pump_a", "pump_b", 42]) + """ def list(self, limit: int | None = None) -> builtins.list[Asset]: - """The first ``limit`` assets, newest created first. Defaults to 1000, caps at 10000. + """List assets, newest created first. + + Only assets in data sets you may read are returned. This is a first page and nothing + more: there is no cursor to continue from. To go further, narrow the query with + :meth:`assets.filter` rather than raising ``limit``. + + Parameters + ---------- + limit : int, optional + How many to return. Defaults to the server's 1000; above 10000 is refused. + + Returns + ------- + list of Asset + + See Also + -------- + assets.filter : Select by criteria, with paging. - A plain list rather than a ``Page``: there is no cursor to continue with, so narrow with - ``filter`` instead of raising the number. + Examples + -------- + >>> recent = client.assets.list(limit=20) """ def filter( self, @@ -3233,13 +5096,67 @@ class AssetsServiceSync: sort_order: str | None = None, cursor: str | None = None, ) -> Page: - """Assets matching every criterion, newest first. Criteria combine with AND. + """Select assets by criteria, one page at a time. + + The criteria of :meth:`resources.filter`, answering with assets only. Give them either + as keywords or as a prepared ``filter=``, not both; a ``node_type`` on a ``filter=`` + object is replaced by the asset type rather than combined with it. Criteria combine + with AND; the entries of one list combine with OR. Pattern lists accept a single + string or a list, where ``*`` and ``%`` are wildcards, ``_`` is literal, and matching + ignores case. + + Parameters + ---------- + filter : ResourceFilter, optional + The criteria as one object, reusable across calls. Exclusive with the + criteria keywords below. + id : sequence of int, optional + external_id, name, source : str or sequence of str, optional + labels : str or sequence of str, optional + Every label listed must be present. + metadata : mapping of str to str or None, optional + Every key listed must be present; a ``None`` value matches the key alone. + created_time, last_updated_time : TimeFilter, optional + Inclusive at both ends. + is_root : bool, optional + ``True`` for navigation roots only, ``False`` for the rest. + data_set_id : sequence of int, str or IdCollection, optional + Data sets by id or external id; includes everything beneath them in the data + set hierarchy. ``None`` places no restriction, but ``[]`` matches nothing. + limit : int, optional + Page size. Defaults to 1000; above 10000 is refused. + sort_by : str, optional + One of ``id``, ``externalId``, ``name``, ``source``, ``description``, + ``createdTime``, ``lastUpdatedTime`` and ``dataSetId``; defaults to + ``createdTime``. + sort_order : {"asc", "desc"}, optional + Defaults to descending. + cursor : str, optional + ``next_cursor`` from the previous page. It belongs to its sort: continuing it + under another is refused. + + Returns + ------- + Page + A list-like page of ``Asset``. Its ``next_cursor`` is ``None`` on the last page. + + Raises + ------ + TypeError + If both ``filter=`` and criteria keywords are given. - Pass either ``filter=`` or the individual keywords, not both. + Examples + -------- + Every root asset in one data set and those beneath it, a page at a time: - There is no ``node_type`` keyword on purpose: this endpoint answers with assets whatever - it is given, so the server replaces it. A ``node_type`` on a ``filter=`` object is - discarded the same way. + >>> page = client.assets.filter(is_root=True, data_set_id=["site_north_data"], limit=500) + >>> roots = list(page) + >>> while page.next_cursor: + ... page = client.assets.filter( + ... is_root=True, data_set_id=["site_north_data"], limit=500, + ... cursor=page.next_cursor, + ... ) + ... roots.extend(page) """ def search( self, @@ -3247,22 +5164,103 @@ class AssetsServiceSync: filter: ResourceFilter | None = None, limit: int | None = None, ) -> builtins.list[Asset]: - """Free-text search over assets, best match first. + """Free-text search over assets, by name, external id and description. + + Matching is word-aware and fuzzy -- ``"pump"`` also finds ``"pumps"`` -- and results + are ranked, best match first. A ``node_type`` in ``filter`` is replaced by the asset + type. For exact lookups use :meth:`assets.by_ids`; for structured queries without a + phrase, use :meth:`assets.filter`. - ``filter`` only ever removes hits from the phrase's. ``limit`` defaults to 100 and caps - at 1000 — ``filter`` uses 1000/10000, which is easy to conflate. + Parameters + ---------- + query : str + The phrase, 3--140 characters. + filter : ResourceFilter, optional + Narrows the phrase's hits. It only ever removes results, never adds them. + limit : int, optional + How many to return. Defaults to 100; above 1000 is refused. + + Returns + ------- + list of Asset + + Examples + -------- + >>> from intellistream_datahub_sdk import ResourceFilter + >>> client.assets.search("feed pump", filter=ResourceFilter(labels="PUMP")) """ def update(self, input: builtins.list[ResourceUpdate]) -> GraphResult: - """Update assets in place. ``geolocation`` is the field that means anything only here. + """Change fields on existing assets, including their ``geolocation``. + + Only the fields named in each ``ResourceUpdate`` change. The asset type label stays + in place whatever ``labels`` says. The batch is all-or-nothing. The targets are not + checked to be assets: an update naming a node of another type is applied to it as + :meth:`resources.update` would, and that node comes back as its own class. + + Parameters + ---------- + input : list of ResourceUpdate + + Returns + ------- + GraphResult + ``nodes`` holds the nodes as they stand after the update, each as its own class. + ``related_resources`` is empty on them, and ``relations`` is empty. + + Raises + ------ + DataHubException + ``status_code`` 400 if a target does not exist, or ``name`` or ``external_id`` is + set to null; 409 if a new ``external_id`` is already taken, or the asset changed + under a concurrent write -- re-read it and retry. - ``.nodes`` holds typed node objects, not necessarily all assets — an update may touch - relations whose other end is something else. + Examples + -------- + Move a pump, and clear the location of another: + + >>> from intellistream_datahub_sdk import FieldGeoJson, ResourceUpdate + >>> result = client.assets.update([ + ... ResourceUpdate( + ... "pump_a", + ... geolocation=FieldGeoJson({"type": "Point", "coordinates": [10.76, 59.92]}), + ... ), + ... ResourceUpdate("pump_b", geolocation=FieldGeoJson(set_null=True)), + ... ]) """ def delete(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> None: - """Delete assets, and with them all their relationships. + """Delete assets, and every relationship they take part in. + + This cannot be undone. Identifiers that match nothing are skipped, so deleting an + asset that is already gone is a no-op. The batch is all-or-nothing. The targets are + not checked to be assets: a node of another type named here is deleted as + :meth:`resources.delete` would delete it. + + A delete is refused when it would disconnect a surviving node from the graph root. + Include the stranded nodes in the same call, or keep another path to them. The check + reads a view of the graph that lags writes by a moment, so an asset deleted + immediately after its relationships were created can be let through and strand its + neighbours; wait briefly after creating relationships before deleting across them. + + Parameters + ---------- + input : list of int, str, Asset, TimeSeries, Function, Resource, Dataset or Policy + The assets to delete, by id, external id or node object. A node object is + matched by its ``id`` when it has one. - A delete that would disconnect a surviving node from the graph root raises 409 - ``would-strand``, naming the blockers on the exception's ``problem``. + Raises + ------ + DataHubException + ``status_code`` 403 when you may not write an asset's data set. 409 with + ``problem_slug`` ``"would-strand"`` when the delete would disconnect a surviving + node, the problem's ``blockedBy`` naming it. + + See Also + -------- + edges.delete : Remove one relationship and keep both nodes. + + Examples + -------- + >>> client.assets.delete(["pump_a"]) """ @@ -3340,45 +5338,191 @@ class RelTypeForm: @final class EdgesServiceSync: + """Read, create and delete relationships between existing nodes, and manage their types. + + Reached as ``client.edges`` on a configured ``DataHubClient``, never constructed. + ``AsyncDataHubClient`` has the same methods as coroutines. + + Relationships usually come into being together with their nodes, through + :meth:`resources.create`. This service is for linking nodes that already exist, for reading + or deleting a relationship on its own, and for the tenant's catalogue of relationship types. + + Every call that reaches the server raises ``DataHubException`` when the server refuses + it; the exception carries the HTTP ``status_code`` and, where the server explained + itself, a ``problem_slug`` to branch on. + """ def get(self, id: int) -> EdgeProxy | None: - """ - One relationship by numeric id, or ``None`` if no edge has that id. + """Fetch one relationship by numeric id. + + Returns ``None`` when no relationship with that id is visible to you. That covers a + relationship you may not read -- you need read access to the data sets of both of its + endpoints -- so ``None`` does not mean the id is unused. + + Parameters + ---------- + id : int + The relationship's numeric id. + + Returns + ------- + EdgeProxy or None + + See Also + -------- + edges.by_ids : Several relationships, with the nodes they connect. - The server answers an unknown id with 404; that is absorbed into ``None`` here, matching the - other ``get()`` methods in these bindings. Any other error still raises. + Examples + -------- + >>> edge = client.edges.get(341) + >>> if edge is not None: + ... print(edge.start, edge.relationship_type, edge.end) """ def by_ids(self, input: list[EdgeProxy | int]) -> GraphResult: - """ - Several relationships plus the resources they connect, as a ``GraphResult`` — ``nodes`` holds - both endpoints of each edge and ``relations`` the edges, so no follow-up call is needed. + """Fetch several relationships together with the nodes they connect. + + The result is a graph: ``relations`` holds the relationships and ``nodes`` the nodes at + both ends of each, as their own types (``Asset``, ``TimeSeries``, ...), so no follow-up + call is needed to resolve the endpoints. Ids that do not exist, and relationships with + an endpoint you may not read, are left out rather than raising; asking only for those + returns an empty graph. + + Parameters + ---------- + input : list of EdgeProxy or int + Relationships by numeric id or ``EdgeProxy``. Relationships have no external id, + so strings are not accepted. + + Returns + ------- + GraphResult + + Examples + -------- + >>> graph = client.edges.by_ids([341, 342]) + >>> names = {node.id: node.external_id for node in graph.nodes} + >>> for edge in graph.relations: + ... print(names[edge.start], edge.relationship_type, names[edge.end]) """ def create(self, input: list[RelForm]) -> list[EdgeProxy]: - """ - Link resources that already exist. To create the resources *and* their links together, use - ``resources.create(nodes, relations)`` instead. + """Link nodes that already exist. + + Name each endpoint by id or external id, and the relationship by type name. A type name + the tenant has not used before is added to the catalogue on the fly; relationship types + cannot be deleted, so a misspelt name stays in the catalogue for good. To create nodes + and their relationships together, use :meth:`resources.create`. + + The batch is all-or-nothing: if any relationship is refused, none is created. The same + type between the same two nodes can exist only once. A relationship whose target is a + data set must be of type ``BELONGS_TO``, and a time series cannot belong to a second + data set. + + Parameters + ---------- + input : list of RelForm + The relationships to create, each naming its two endpoints and a + ``relationship_type``. + + Returns + ------- + list of EdgeProxy + The stored relationships, with their ids filled in by the server. + + Raises + ------ + DataHubException + ``status_code`` 400 for an endpoint that does not exist or a relationship the rules + above forbid; 403 without write access to the data sets of both endpoints; 409 if + the relationship already exists. + + See Also + -------- + resources.create : Create nodes and their relationships in one call. - All-or-nothing: if any relation in the batch fails, none are created. A relation targeting - a dataset must use ``BELONGS_TO``; a timeseries cannot be linked to a second dataset; you - need write access to the datasets of both endpoints. Re-creating an existing edge between - the same two resources conflicts with status 409. + Examples + -------- + >>> from intellistream_datahub_sdk import RelForm + >>> [edge] = client.edges.create( + ... [RelForm.by_external_ids("pump_a", "valve_v9", "FLOWS_TO")] + ... ) """ def delete(self, input: list[EdgeProxy | int]) -> None: - """ - Delete relationships by ``EdgeProxy`` or numeric id. Deletes the link only — the resources at - each end stay intact. Idempotent: unknown ids are silently skipped. + """Delete relationships, leaving the nodes at each end in place. + + Ids that do not exist are skipped, so deleting a relationship twice is a no-op. + + A relationship that is the only route from one of its endpoints to the graph root is + not deleted on its own: the call is refused rather than leave that node disconnected. + Delete such a relationship together with the node it holds up, through + :meth:`resources.delete`, or add another connecting path first. The check reads a view + of the graph that lags writes by a moment: deleting a relationship straight after + creating it, or straight after deleting another relationship of the same node, can + succeed where it should have been refused and leave a node disconnected. Wait a moment + after such a change before deleting. + + Parameters + ---------- + input : list of EdgeProxy or int + Relationships by numeric id or ``EdgeProxy``. + + Raises + ------ + DataHubException + ``status_code`` 409 with ``problem_slug`` ``"would-strand"`` when the delete would + disconnect a node; ``problem["blockedBy"]`` names it. Nothing is deleted. + + Examples + -------- + >>> from intellistream_datahub_sdk import DataHubException + >>> try: + ... client.edges.delete([341]) + ... except DataHubException as e: + ... if e.problem_slug != "would-strand": + ... raise + ... stranded = [b["externalId"] for b in e.problem["blockedBy"]] """ def types(self) -> list[RelationshipType]: - """ - Every relationship type the tenant has defined. + """List every relationship type in the tenant's catalogue. + + Returns + ------- + list of RelationshipType + + Examples + -------- + >>> names = [t.name for t in client.edges.types()] """ def create_types(self, input: list[RelTypeForm]) -> list[RelationshipType]: - """ - Register relationship type names up front. Names normalise to uppercase snake case. + """Register relationship types up front. + + Not needed before :meth:`edges.create` or :meth:`resources.create`, which add a type + the first time it is used; register one here to seed the catalogue or to give a type + a description. Names are case-insensitive and stored in upper snake case, so + ``"Flows To"`` is stored as ``FLOWS_TO``. Types cannot be deleted. + + The batch is all-or-nothing: one name that already exists discards the new ones beside + it, so read :meth:`edges.types` first when some may exist. + + Parameters + ---------- + input : list of RelTypeForm + + Returns + ------- + list of RelationshipType + The stored types, with their normalised names. + + Raises + ------ + DataHubException + ``status_code`` 409 if a name already exists; 400 for a name that normalises to + nothing, such as one made only of symbols. - A name that already exists currently makes the server fail silently — it answers 200 with - an empty body, and in a batch the valid new types are rolled back alongside the duplicate. - Treat an empty result as "something already existed and nothing was created", and use - ``types()`` to read the real state. + Examples + -------- + >>> from intellistream_datahub_sdk import RelTypeForm + >>> [flows_to] = client.edges.create_types( + ... [RelTypeForm("Flows To", description="Fluid moves from start to end")] + ... ) """ From d307ce3b5d6d2ab9a7ee9a0fb4de30f3221466cb Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Fri, 25 Sep 2026 14:23:07 +0200 Subject: [PATCH 4/4] docs(python): list Page with the filters, since filter() is what returns it Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- docs-python/clients.rst | 1 - docs-python/filters-and-identifiers.rst | 3 ++- docs-python/index.rst | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-python/clients.rst b/docs-python/clients.rst index 0e46f75..cfe34f0 100644 --- a/docs-python/clients.rst +++ b/docs-python/clients.rst @@ -8,5 +8,4 @@ Where every call starts. api/intellistream_datahub_sdk/DataHubClient api/intellistream_datahub_sdk/AsyncDataHubClient - api/intellistream_datahub_sdk/Page api/intellistream_datahub_sdk/DataHubException diff --git a/docs-python/filters-and-identifiers.rst b/docs-python/filters-and-identifiers.rst index a33a2ec..4dd93f0 100644 --- a/docs-python/filters-and-identifiers.rst +++ b/docs-python/filters-and-identifiers.rst @@ -1,7 +1,7 @@ Filters and identifiers ======================= -Criteria for ``filter`` and ``search``, and ways of naming an entity. +Criteria for ``filter`` and ``search``, the page ``filter`` returns, and ways of naming an entity. .. toctree:: :maxdepth: 1 @@ -14,6 +14,7 @@ Criteria for ``filter`` and ``search``, and ways of naming an entity. api/intellistream_datahub_sdk/SubscriptionFilterForm api/intellistream_datahub_sdk/TimeFilter api/intellistream_datahub_sdk/DataSort + api/intellistream_datahub_sdk/Page api/intellistream_datahub_sdk/EventDimension api/intellistream_datahub_sdk/IdCollection api/intellistream_datahub_sdk/EventIdCollection diff --git a/docs-python/index.rst b/docs-python/index.rst index 3ef9ac1..2be0bc9 100644 --- a/docs-python/index.rst +++ b/docs-python/index.rst @@ -21,7 +21,7 @@ Everything else - :doc:`clients` — where every call starts. - :doc:`entities` — what the services create, read and return. - :doc:`datapoints` — values on a series, and the requests that read and delete them. -- :doc:`filters-and-identifiers` — criteria for ``filter`` and ``search``, and ways of naming an entity. +- :doc:`filters-and-identifiers` — criteria for ``filter`` and ``search``, the page ``filter`` returns, and ways of naming an entity. - :doc:`updates` — partial updates, and the field wrappers they are built from. - :doc:`graph` — relationships between resources. - :doc:`files` — uploading and downloading content.