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/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 ccde6d0..a2e969d 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,482 @@ 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`` 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 + -------- + >>> 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 +1251,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 +1283,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 +1323,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 +1339,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 +1377,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 +1426,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 +1437,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,36 +1459,325 @@ 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]: ... + """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) + """ + 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. + + ``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. + + 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 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")) + """ + 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 update(self, input: builtins.list[EventUpdate]) -> builtins.list[Event]: + """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, *, 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, ) -> 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( @@ -941,48 +1785,258 @@ class EventsServiceSync: query: str, filter: EventFilter | None = None, limit: int | None = None, - ) -> list[Event]: ... - def count(self) -> int: ... + ) -> builtins.list[Event]: + """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: + """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, 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]: + """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]: + """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]: + """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]: + """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]: + """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]: + """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]: + """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]: + """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]: + """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. + """ 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 +2051,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 +2082,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 +2133,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 +2167,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,70 +2182,238 @@ 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 -# 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: - 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 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) -> 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: ... + """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. + + 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. + + 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. + + Returns + ------- + list of Dataset + + Examples + -------- + >>> found = client.datasets.by_ids(["sap_work_orders", 12]) + """ + 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 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: - """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( @@ -1178,37 +2421,105 @@ class DatasetsServiceSync: query: str, filter: DatasetFilter | None = None, limit: int | None = None, - ) -> list[Dataset]: - """Free-text search for ``query``, ranked by relevance. + ) -> builtins.list[Dataset]: + """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 + + 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]: + """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. + + 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. + + 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]: + """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". - ``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 Resource + One per policy. + + Examples + -------- + >>> names = [p.external_id for p in client.datasets.policies()] """ - def update(self, input: list[DatasetUpdate]) -> list[Dataset]: ... - def policies(self) -> list[Resource]: ... 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 +2533,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 +2555,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 +2602,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 +2619,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 +2656,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 +2669,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 +2741,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 +2749,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 +2763,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 +2836,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 +2858,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 +2871,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 +2888,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 +2905,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 +2934,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 +2962,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 +2971,7 @@ class ResourceUpdate: def labels(self) -> ListFieldStr | None: ... +@final class ResourceFilter: """AND-combined criteria for ``resources.filter`` and the ``filter`` of ``resources.search``. @@ -1636,108 +2979,414 @@ 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]: ... + """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]: + """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. + + Examples + -------- + >>> from collections import Counter + >>> recent = client.resources.list(limit=100) + >>> Counter(node.node_type for node in recent) + """ def create( - self, nodes: list[Node], relations: list[RelForm] | None = None - ) -> GraphResult: ... - def by_ids(self, input: list[ResourceIdentifiable]) -> list[Node]: ... - def delete(self, input: list[ResourceIdentifiable]) -> None: ... + self, nodes: builtins.list[Asset | TimeSeries | Function | Resource | Dataset | Policy], relations: builtins.list[RelForm] | None = 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, - ) -> list[Node]: - """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[Asset | TimeSeries | Function | Resource | Dataset | Policy]: + """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. + + 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: + """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. + + See Also + -------- + timeseries.update : Also changes what only a time series has, such as ``unit``. + + 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: + """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 update(self, input: list[ResourceUpdate]) -> GraphResult: ... - def get_by_id(self, id: int) -> Node | None: ... 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: - """``POST /resources/filter`` — the generic node query; criteria combine with AND. - - 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. - - ``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. + """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. + + Examples + -------- + Pump assets and pump series in one query, split by class: + + >>> 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)] """ 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 +3395,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 +3431,188 @@ 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: + """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. -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: ... + 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]: + """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: + """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]: + """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]: + """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. + + 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]) + """ + + +@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 +3624,7 @@ class Unit: conversion: dict[str, float], source: str, source_reference: str, - ) -> None: ... + ) -> Unit: ... @property def id(self) -> int: ... @id.setter @@ -1864,23 +3671,105 @@ 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]: ... + """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 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 +3788,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 +3831,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 +3856,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 +3887,7 @@ class FileUpload: def source_last_updated(self) -> datetime.datetime | None: ... +@final class FileUpdate: """A partial update for one file or folder. @@ -1996,8 +3895,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 +3906,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 +3922,373 @@ 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: + """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' + """ + 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. -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 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: ... + Returns + ------- + list of INode + + See Also + -------- + files.list_directory_by_path : The children of any folder. + + Examples + -------- + >>> top = client.files.list_root_directory() + """ + 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"]) + """ + 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 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") + """ + 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() + """ + 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 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. + + 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 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") + """ + + +@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[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[FileIdentifiable]) -> 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: ... @@ -2061,44 +4296,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 +4344,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 +4385,7 @@ class DataCollectionString: def datapoints(self) -> list[WsDatapoint]: ... +@final class DataWrapperMessage: @property def event_action(self) -> EventAction: ... @@ -2151,22 +4397,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 +4436,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 +4454,222 @@ 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]: ... + """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"] + ... ) + ... ]) + """ + 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, 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]: + """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") + ... ) + """ + 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 listen(self, subscription_external_ids: builtins.list[str]) -> SubscriptionListener: + """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]) + """ +@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,158 +4702,589 @@ 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]: ... + ) -> 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``]. + """ -FunctionIdentifiable = Union[Function, IdCollection, int, str] +@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. -class FunctionsServiceSync: - def create(self, input: list[Function]) -> list[Function]: ... - def list(self, limit: int | None = None) -> list[Function]: ... - def get_by_id(self, id: int) -> Function | None: - """One function by numeric id; raises on 404. + 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. - 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. + 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 by_ids(self, input: list[FunctionIdentifiable]) -> list[Function]: ... - def by_external_id(self, external_id: str) -> Function: ... - def update(self, input: list[ResourceUpdate]) -> GraphResult: - """Update functions in place; ``geolocation`` is ignored, being asset-only. + 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. - ``.nodes`` holds typed node objects — a function comes back as ``Function``. + 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 delete(self, input: list[FunctionIdentifiable]) -> None: ... + 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. + + 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 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. + + Examples + -------- + >>> client.functions.delete(["pump_a_anomaly_detector"]) + """ + + +@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. + """Create, find, change and delete 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. - """ + 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. - def create(self, input: list[Asset]) -> list[Asset]: - """Create assets. Each needs an ``external_id`` and a ``name``. + 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. + """ - 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. + def create(self, input: builtins.list[Asset]) -> builtins.list[Asset]: + """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. + + 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: list[ResourceIdentifiable]) -> 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]: - """The first ``limit`` assets, newest created first. Defaults to 1000, caps at 10000. + def by_ids(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> builtins.list[Asset]: + """Fetch assets by id or external id. - A plain list rather than a ``Page``: there is no cursor to continue with, so narrow with - ``filter`` instead of raising the number. + 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]: + """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. + + Examples + -------- + >>> recent = client.assets.list(limit=20) """ 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: - """Assets matching every criterion, newest first. Criteria combine with AND. - - Pass either ``filter=`` or the individual keywords, not both. - - 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. + """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. + + Examples + -------- + Every root asset in one data set and those beneath it, a page at a time: + + >>> 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, query: str, filter: ResourceFilter | None = None, limit: int | None = None, - ) -> 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. + ) -> builtins.list[Asset]: + """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`. + + 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: 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 update(self, input: builtins.list[ResourceUpdate]) -> GraphResult: + """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. + + 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: list[ResourceIdentifiable]) -> None: - """Delete assets, and with them all their relationships. - - A delete that would disconnect a surviving node from the graph root raises 409 - ``would-strand``, naming the blockers on the exception's ``problem``. + def delete(self, input: builtins.list[int | str | Asset | TimeSeries | Function | Resource | Dataset | Policy]) -> None: + """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. + + 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"]) """ +@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 +5293,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 +5314,7 @@ class RelationshipType: def i18n_code(self) -> str | None: ... +@final class RelTypeForm: """Register a relationship type up front. @@ -2446,12 +5322,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 +5336,201 @@ 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: + """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. -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]: ... + 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: + """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. + + 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: + """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 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. + + 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, 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]: + """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 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. + + Examples + -------- + >>> from intellistream_datahub_sdk import RelTypeForm + >>> [flows_to] = client.edges.create_types( + ... [RelTypeForm("Flows To", description="Fluid moves from start to end")] + ... ) + """ +@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..cfe34f0 --- /dev/null +++ b/docs-python/clients.rst @@ -0,0 +1,11 @@ +Clients +======= + +Where every call starts. + +.. toctree:: + :maxdepth: 1 + + api/intellistream_datahub_sdk/DataHubClient + api/intellistream_datahub_sdk/AsyncDataHubClient + api/intellistream_datahub_sdk/DataHubException diff --git a/docs-python/conf.py b/docs-python/conf.py new file mode 100644 index 0000000..f5bfaa0 --- /dev/null +++ b/docs-python/conf.py @@ -0,0 +1,45 @@ +# 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"] +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"] + +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 +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..4dd93f0 --- /dev/null +++ b/docs-python/filters-and-identifiers.rst @@ -0,0 +1,20 @@ +Filters and identifiers +======================= + +Criteria for ``filter`` and ``search``, the page ``filter`` returns, 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/Page + 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..2be0bc9 --- /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``, 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. +- :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