northstar-sdk 1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. northstar_sdk/__init__.py +44 -0
  2. northstar_sdk/catalogue/__init__.py +46 -0
  3. northstar_sdk/catalogue/base.py +200 -0
  4. northstar_sdk/catalogue/dashboard_folders.py +116 -0
  5. northstar_sdk/catalogue/dashboards.py +187 -0
  6. northstar_sdk/catalogue/data_sources.py +75 -0
  7. northstar_sdk/catalogue/datasets.py +73 -0
  8. northstar_sdk/catalogue/exceptions.py +13 -0
  9. northstar_sdk/catalogue/figures.py +93 -0
  10. northstar_sdk/catalogue/kpis.py +82 -0
  11. northstar_sdk/catalogue/page.py +50 -0
  12. northstar_sdk/catalogue/search.py +83 -0
  13. northstar_sdk/catalogue/teams.py +69 -0
  14. northstar_sdk/client.py +299 -0
  15. northstar_sdk/handles/__init__.py +72 -0
  16. northstar_sdk/handles/_access.py +74 -0
  17. northstar_sdk/handles/dashboard.py +372 -0
  18. northstar_sdk/handles/dashboard_folder.py +150 -0
  19. northstar_sdk/handles/data_source.py +136 -0
  20. northstar_sdk/handles/data_source_category.py +115 -0
  21. northstar_sdk/handles/dataset.py +483 -0
  22. northstar_sdk/handles/dataset_ref.py +49 -0
  23. northstar_sdk/handles/document_collection.py +153 -0
  24. northstar_sdk/handles/figure.py +179 -0
  25. northstar_sdk/handles/kpi.py +206 -0
  26. northstar_sdk/handles/organisation.py +115 -0
  27. northstar_sdk/handles/storage_system.py +182 -0
  28. northstar_sdk/handles/team.py +99 -0
  29. northstar_sdk/handles/team_ref.py +32 -0
  30. northstar_sdk/handles/user.py +364 -0
  31. northstar_sdk/handles/user_ref.py +44 -0
  32. northstar_sdk/namespaces/__init__.py +27 -0
  33. northstar_sdk/namespaces/dashboard_folders.py +67 -0
  34. northstar_sdk/namespaces/dashboards.py +126 -0
  35. northstar_sdk/namespaces/data_source_categories.py +57 -0
  36. northstar_sdk/namespaces/data_sources.py +104 -0
  37. northstar_sdk/namespaces/datasets.py +137 -0
  38. northstar_sdk/namespaces/document_collections.py +118 -0
  39. northstar_sdk/namespaces/figures.py +113 -0
  40. northstar_sdk/namespaces/kpis.py +140 -0
  41. northstar_sdk/namespaces/organisations.py +78 -0
  42. northstar_sdk/namespaces/storage_systems.py +102 -0
  43. northstar_sdk/namespaces/teams.py +47 -0
  44. northstar_sdk/namespaces/users.py +63 -0
  45. northstar_sdk/orchestration.py +326 -0
  46. northstar_sdk/py.typed +0 -0
  47. northstar_sdk/types/__init__.py +62 -0
  48. northstar_sdk/types/json.py +8 -0
  49. northstar_sdk/types/params.py +134 -0
  50. northstar_sdk/types/responses.py +59 -0
  51. northstar_sdk-1.0.dist-info/METADATA +146 -0
  52. northstar_sdk-1.0.dist-info/RECORD +53 -0
  53. northstar_sdk-1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,44 @@
1
+ """
2
+ northstar-sdk — thin HTTP client for the Northstar platform API.
3
+
4
+ Use :class:`Client` as the single entry point::
5
+
6
+ from northstar_sdk import Client
7
+
8
+ client = Client()
9
+ kpi = client.kpis.create(name="...", dataset_id="...", definition_type="MANUAL")
10
+ kpi.assign_to_team("analytics")
11
+
12
+ Catalogue records and resource handles live under ``client.*`` namespaces.
13
+ Optional: ``from northstar_sdk.catalogue import KpiRecord, NotFoundError``.
14
+ """
15
+
16
+ from northstar_sdk.catalogue import (
17
+ AmbiguousLookupError,
18
+ CatalogueError,
19
+ NotFoundError,
20
+ Page,
21
+ )
22
+ from northstar_sdk.client import Client
23
+ from northstar_sdk.orchestration import (
24
+ OrchestrationHooks,
25
+ orchestration_hooks,
26
+ send_orchestration_event,
27
+ send_workflow_event,
28
+ sync_workflow_configs,
29
+ northstar_flow,
30
+ )
31
+
32
+ __all__ = [
33
+ "AmbiguousLookupError",
34
+ "CatalogueError",
35
+ "Client",
36
+ "NotFoundError",
37
+ "OrchestrationHooks",
38
+ "Page",
39
+ "orchestration_hooks",
40
+ "send_orchestration_event",
41
+ "send_workflow_event",
42
+ "sync_workflow_configs",
43
+ "northstar_flow",
44
+ ]
@@ -0,0 +1,46 @@
1
+ """Read-only catalogue queries against the Northstar platform API."""
2
+
3
+ from northstar_sdk.catalogue.dashboard_folders import DashboardFolderCatalogue
4
+ from northstar_sdk.catalogue.dashboards import (
5
+ DashboardBrowsePage,
6
+ DashboardCatalogue,
7
+ DashboardFolderRecord,
8
+ DashboardRecord,
9
+ )
10
+ from northstar_sdk.catalogue.data_sources import DataSourceCatalogue, DataSourceRecord
11
+ from northstar_sdk.catalogue.datasets import DatasetCatalogue, DatasetRecord
12
+ from northstar_sdk.catalogue.exceptions import (
13
+ AmbiguousLookupError,
14
+ CatalogueError,
15
+ NotFoundError,
16
+ )
17
+ from northstar_sdk.catalogue.figures import FigureCatalogue, FigureRecord
18
+ from northstar_sdk.catalogue.kpis import KpiCatalogue, KpiRecord
19
+ from northstar_sdk.catalogue.page import Page
20
+ from northstar_sdk.catalogue.search import GlobalSearch, SearchGroup, SearchItem
21
+ from northstar_sdk.catalogue.teams import TeamCatalogue, TeamRecord
22
+
23
+ __all__ = [
24
+ "AmbiguousLookupError",
25
+ "CatalogueError",
26
+ "DashboardBrowsePage",
27
+ "DashboardCatalogue",
28
+ "DashboardFolderCatalogue",
29
+ "DashboardFolderRecord",
30
+ "DashboardRecord",
31
+ "DataSourceCatalogue",
32
+ "DataSourceRecord",
33
+ "DatasetCatalogue",
34
+ "DatasetRecord",
35
+ "FigureCatalogue",
36
+ "FigureRecord",
37
+ "GlobalSearch",
38
+ "KpiCatalogue",
39
+ "KpiRecord",
40
+ "NotFoundError",
41
+ "Page",
42
+ "SearchGroup",
43
+ "SearchItem",
44
+ "TeamCatalogue",
45
+ "TeamRecord",
46
+ ]
@@ -0,0 +1,200 @@
1
+ """Base catalogue client helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar, Unpack
7
+
8
+ from northstar_sdk.catalogue.exceptions import AmbiguousLookupError, NotFoundError
9
+ from northstar_sdk.catalogue.page import Page, iter_all_pages
10
+ from northstar_sdk.types.responses import CatalogueListExtraParams
11
+ from northstar_sdk.types.json import JsonObject
12
+
13
+ if TYPE_CHECKING:
14
+ from northstar_sdk.client import Client
15
+
16
+ class RecordBase:
17
+ """Common fields for catalogue read models."""
18
+
19
+ id: str
20
+ raw: dict[str, Any]
21
+
22
+ def __init__(self, raw: dict[str, Any], *, id_key: str = "id") -> None:
23
+ self.raw = raw
24
+ resolved = raw.get(id_key)
25
+ if not resolved or not isinstance(resolved, str):
26
+ raise ValueError(f"Catalogue record missing string {id_key!r}")
27
+ self.id = resolved
28
+
29
+
30
+ TRecord = TypeVar("TRecord", bound=RecordBase)
31
+
32
+ class NamedCatalogue(ABC, Generic[TRecord]):
33
+ """
34
+ List/get/find helpers for a named catalogue resource.
35
+
36
+ Subclasses implement :meth:`_parse_record` and :meth:`_fetch_list_page`.
37
+ """
38
+
39
+ name_field: str = "name"
40
+
41
+ def __init__(self, client: Client) -> None:
42
+ self._client = client
43
+
44
+ @abstractmethod
45
+ def _parse_record(self, raw: dict[str, Any]) -> TRecord:
46
+ ...
47
+
48
+ @abstractmethod
49
+ def _fetch_list_page(
50
+ self,
51
+ *,
52
+ page: int,
53
+ limit: int,
54
+ search: str | None,
55
+ **params: Any,
56
+ ) -> Page[TRecord]:
57
+ ...
58
+
59
+ @abstractmethod
60
+ def _get_raw(self, resource_id: str) -> dict[str, Any] | None:
61
+ ...
62
+
63
+ def list(
64
+ self,
65
+ *,
66
+ search: str | None = None,
67
+ page: int = 1,
68
+ limit: int = 20,
69
+ **params: Unpack[CatalogueListExtraParams],
70
+ ) -> Page[TRecord]:
71
+ """List resources visible to this API key (partial name search when ``search`` is set)."""
72
+ if page < 1:
73
+ raise ValueError("page must be >= 1")
74
+ if limit < 1:
75
+ raise ValueError("limit must be >= 1")
76
+ return self._fetch_list_page(
77
+ page=page,
78
+ limit=limit,
79
+ search=search,
80
+ **params,
81
+ )
82
+
83
+ def list_all(
84
+ self,
85
+ *,
86
+ search: str | None = None,
87
+ limit: int = 100,
88
+ **params: Unpack[CatalogueListExtraParams],
89
+ ) -> list[TRecord]:
90
+ """Fetch all pages and return a flat list."""
91
+
92
+ def fetch(p: int) -> Page[TRecord]:
93
+ return self.list(search=search, page=p, limit=limit, **params)
94
+
95
+ return list(iter_all_pages(fetch))
96
+
97
+ def get(self, resource_id: str) -> TRecord | None:
98
+ """Return one resource by id, or ``None`` if missing or forbidden."""
99
+ raw = self._get_raw(resource_id)
100
+ if raw is None:
101
+ return None
102
+ return self._parse_record(raw)
103
+
104
+ def find_by_id(self, resource_id: str) -> TRecord | None:
105
+ """Alias for :meth:`get`."""
106
+ return self.get(resource_id)
107
+
108
+ def get_or_raise(self, resource_id: str) -> TRecord:
109
+ record = self.get(resource_id)
110
+ if record is None:
111
+ raise NotFoundError(f"No accessible resource with id {resource_id!r}")
112
+ return record
113
+
114
+ def find_by_name(
115
+ self,
116
+ name: str,
117
+ *,
118
+ exact: bool = True,
119
+ limit: int = 100,
120
+ **list_params: Unpack[CatalogueListExtraParams],
121
+ ) -> TRecord:
122
+ """
123
+ Resolve a resource by display name.
124
+
125
+ Uses list ``search`` then filters client-side. Raises
126
+ :class:`NotFoundError` when none match; :class:`AmbiguousLookupError`
127
+ when more than one exact match exists.
128
+ """
129
+ needle = name.strip()
130
+ if not needle:
131
+ raise ValueError("name must be non-empty")
132
+
133
+ matches: list[TRecord] = []
134
+ page_num = 1
135
+ while True:
136
+ page = self.list(
137
+ search=needle, page=page_num, limit=limit, **list_params
138
+ )
139
+ for item in page.items:
140
+ label = _record_label(item, self.name_field)
141
+ if exact:
142
+ if label.casefold() == needle.casefold():
143
+ matches.append(item)
144
+ else:
145
+ if needle.casefold() in label.casefold():
146
+ matches.append(item)
147
+ if not page.has_next:
148
+ break
149
+ page_num += 1
150
+
151
+ if not matches:
152
+ raise NotFoundError(
153
+ f"No accessible resource with name {name!r} (exact={exact})"
154
+ )
155
+ if len(matches) > 1:
156
+ ids = ", ".join(m.id for m in matches[:5])
157
+ suffix = "…" if len(matches) > 5 else ""
158
+ raise AmbiguousLookupError(
159
+ f"Multiple resources named {name!r}: {ids}{suffix}"
160
+ )
161
+ return matches[0]
162
+
163
+
164
+ def _record_label(record: Any, name_field: str) -> str:
165
+ if hasattr(record, name_field):
166
+ value = getattr(record, name_field)
167
+ if isinstance(value, str):
168
+ return value
169
+ raw = getattr(record, "raw", None)
170
+ if isinstance(raw, dict):
171
+ candidate = raw.get(name_field)
172
+ if isinstance(candidate, str):
173
+ return candidate
174
+ return ""
175
+
176
+
177
+ def as_dict(value: Any) -> dict[str, Any]:
178
+ """Narrow an optional JSON object to ``dict`` for type checkers."""
179
+ return value if isinstance(value, dict) else {}
180
+
181
+
182
+ def as_int(value: Any, default: int = 0) -> int:
183
+ """Coerce JSON pagination counters to ``int``."""
184
+ if isinstance(value, bool):
185
+ return default
186
+ if isinstance(value, int):
187
+ return value
188
+ if isinstance(value, float) and value.is_integer():
189
+ return int(value)
190
+ if isinstance(value, str):
191
+ try:
192
+ return int(value)
193
+ except ValueError:
194
+ return default
195
+ return default
196
+
197
+
198
+ def build_params(**kwargs: Any) -> dict[str, Any]:
199
+ """Drop ``None`` values for query strings."""
200
+ return {k: v for k, v in kwargs.items() if v is not None}
@@ -0,0 +1,116 @@
1
+ """Dashboard folder catalogue queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from northstar_sdk.catalogue.dashboards import DashboardBrowsePage, DashboardFolderRecord
8
+ from northstar_sdk.catalogue.exceptions import AmbiguousLookupError, NotFoundError
9
+ from northstar_sdk.catalogue.page import Page
10
+
11
+ if TYPE_CHECKING:
12
+ from northstar_sdk.client import Client
13
+
14
+
15
+ class DashboardFolderCatalogue:
16
+ """
17
+ Browse and resolve dashboard folders visible to the API key user.
18
+
19
+ There is no flat list or single-folder GET API; use :meth:`browse` per
20
+ folder level, or :meth:`get` / :meth:`find_by_id` to walk the catalogue tree.
21
+ """
22
+
23
+ def __init__(self, client: Client) -> None:
24
+ self._client = client
25
+
26
+ def browse(
27
+ self,
28
+ *,
29
+ folder_chain: list[str] | None = None,
30
+ ) -> DashboardBrowsePage:
31
+ """List subfolders and dashboards at one catalogue level."""
32
+ from northstar_sdk.catalogue.dashboards import DashboardCatalogue
33
+
34
+ return DashboardCatalogue(self._client).browse(folder_chain=folder_chain)
35
+
36
+ def list(
37
+ self,
38
+ *,
39
+ folder_chain: list[str] | None = None,
40
+ ) -> Page[DashboardFolderRecord]:
41
+ """Subfolders at the given catalogue level (not including nested descendants)."""
42
+ browse = self.browse(folder_chain=folder_chain)
43
+ items = browse.folders
44
+ total = len(items)
45
+ return Page(items, 1, max(total, 1), total, 1 if total else 0)
46
+
47
+ def get(self, folder_id: str) -> DashboardFolderRecord | None:
48
+ """Return one folder by id, or ``None`` if missing or not visible."""
49
+ return self._find_in_tree(folder_id)
50
+
51
+ def find_by_id(self, folder_id: str) -> DashboardFolderRecord | None:
52
+ """Alias for :meth:`get`."""
53
+ return self.get(folder_id)
54
+
55
+ def get_or_raise(self, folder_id: str) -> DashboardFolderRecord:
56
+ record = self.get(folder_id)
57
+ if record is None:
58
+ raise NotFoundError(
59
+ f"No accessible dashboard folder with id {folder_id!r}"
60
+ )
61
+ return record
62
+
63
+ def find_by_name(
64
+ self,
65
+ name: str,
66
+ *,
67
+ folder_chain: list[str] | None = None,
68
+ exact: bool = True,
69
+ ) -> DashboardFolderRecord:
70
+ """
71
+ Resolve a folder by display name at one catalogue level.
72
+
73
+ ``folder_chain`` is the path from the root to the parent folder (empty =
74
+ root). Raises :class:`NotFoundError` when none match;
75
+ :class:`AmbiguousLookupError` when more than one exact match exists.
76
+ """
77
+ needle = name.strip()
78
+ if not needle:
79
+ raise ValueError("name must be non-empty")
80
+
81
+ browse = self.browse(folder_chain=folder_chain)
82
+ matches: list[DashboardFolderRecord] = []
83
+ for folder in browse.folders:
84
+ if exact:
85
+ if folder.name.casefold() == needle.casefold():
86
+ matches.append(folder)
87
+ elif needle.casefold() in folder.name.casefold():
88
+ matches.append(folder)
89
+
90
+ if not matches:
91
+ raise NotFoundError(
92
+ f"No accessible dashboard folder named {name!r} at this level"
93
+ )
94
+ if len(matches) > 1:
95
+ ids = ", ".join(m.id for m in matches[:5])
96
+ suffix = "…" if len(matches) > 5 else ""
97
+ raise AmbiguousLookupError(
98
+ f"Multiple dashboard folders named {name!r}: {ids}{suffix}"
99
+ )
100
+ return matches[0]
101
+
102
+ def _find_in_tree(
103
+ self,
104
+ folder_id: str,
105
+ folder_chain: list[str] | None = None,
106
+ ) -> DashboardFolderRecord | None:
107
+ browse = self.browse(folder_chain=folder_chain)
108
+ for folder in browse.folders:
109
+ if folder.id == folder_id:
110
+ return folder
111
+ found = self._find_in_tree(
112
+ folder_id, (folder_chain or []) + [folder.id]
113
+ )
114
+ if found is not None:
115
+ return found
116
+ return None
@@ -0,0 +1,187 @@
1
+ """Dashboard catalogue queries (browse by folder)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from northstar_sdk.catalogue.base import RecordBase, build_params
9
+ from northstar_sdk.catalogue.page import Page
10
+ from northstar_sdk.types.responses import DashboardBreadcrumbItem
11
+
12
+ if TYPE_CHECKING:
13
+ from northstar_sdk.client import Client
14
+
15
+
16
+ @dataclass
17
+ class DashboardFolderRecord(RecordBase):
18
+ name: str
19
+ color: str | None
20
+ description: str | None
21
+ parent_folder_id: str | None
22
+ dashboard_count: int
23
+
24
+ def __init__(self, raw: dict[str, Any]) -> None:
25
+ super().__init__(raw)
26
+ self.name = str(raw.get("name") or "")
27
+ color = raw.get("color")
28
+ self.color = color if isinstance(color, str) else None
29
+ desc = raw.get("description")
30
+ self.description = desc if isinstance(desc, str) else None
31
+ parent = raw.get("parentFolderId")
32
+ self.parent_folder_id = parent if isinstance(parent, str) else None
33
+ self.dashboard_count = int(raw.get("dashboardCount") or 0)
34
+
35
+
36
+ @dataclass
37
+ class DashboardRecord(RecordBase):
38
+ title: str
39
+ description: str | None
40
+ folder_id: str | None
41
+ embed_url: str | None
42
+
43
+ def __init__(self, raw: dict[str, Any]) -> None:
44
+ super().__init__(raw)
45
+ self.title = str(raw.get("title") or "")
46
+ desc = raw.get("description")
47
+ self.description = desc if isinstance(desc, str) else None
48
+ folder = raw.get("folderId")
49
+ self.folder_id = folder if isinstance(folder, str) else None
50
+ url = raw.get("embedUrl")
51
+ self.embed_url = url if isinstance(url, str) else None
52
+
53
+
54
+ @dataclass
55
+ class DashboardBrowsePage:
56
+ """One folder level in the dashboard catalogue."""
57
+
58
+ current_folder_id: str | None
59
+ folders: list[DashboardFolderRecord]
60
+ dashboards: list[DashboardRecord]
61
+ breadcrumbs: list[DashboardBreadcrumbItem]
62
+
63
+ @property
64
+ def items(self) -> list[DashboardRecord | DashboardFolderRecord]:
65
+ return [*self.folders, *self.dashboards]
66
+
67
+
68
+ class DashboardCatalogue:
69
+ """
70
+ Browse dashboards and folders visible to the API key user.
71
+
72
+ There is no flat searchable list API; use :meth:`browse` per folder level
73
+ or :class:`~northstar_sdk.catalogue.search.GlobalSearch` for cross-folder title search.
74
+ """
75
+
76
+ def __init__(self, client: Client) -> None:
77
+ self._client = client
78
+
79
+ def browse(
80
+ self,
81
+ *,
82
+ folder_chain: list[str] | None = None,
83
+ ) -> DashboardBrowsePage:
84
+ """
85
+ List folders and dashboards at one catalogue level.
86
+
87
+ ``folder_chain`` is the ordered list of folder UUIDs from the catalogue
88
+ root (empty = root).
89
+ """
90
+ chain = folder_chain or []
91
+ params = build_params(
92
+ chain=",".join(chain) if chain else None,
93
+ )
94
+ result = self._client.get("/feature/dashboards/browse", params=params)
95
+ if not isinstance(result, dict):
96
+ return DashboardBrowsePage(None, [], [], [])
97
+ data = result.get("data")
98
+ if not isinstance(data, dict):
99
+ return DashboardBrowsePage(None, [], [], [])
100
+ folders_raw = data.get("folders")
101
+ dashboards_raw = data.get("dashboards")
102
+ breadcrumbs = data.get("breadcrumbs")
103
+ folders = [
104
+ DashboardFolderRecord(row)
105
+ for row in (folders_raw if isinstance(folders_raw, list) else [])
106
+ if isinstance(row, dict)
107
+ ]
108
+ dashboards = [
109
+ DashboardRecord(row)
110
+ for row in (dashboards_raw if isinstance(dashboards_raw, list) else [])
111
+ if isinstance(row, dict)
112
+ ]
113
+ current = data.get("currentFolderId")
114
+ current_id = current if isinstance(current, str) else None
115
+ crumbs: list[DashboardBreadcrumbItem] = []
116
+ if isinstance(breadcrumbs, list):
117
+ for item in breadcrumbs:
118
+ if isinstance(item, dict):
119
+ crumbs.append(item) # type: ignore[arg-type]
120
+ return DashboardBrowsePage(current_id, folders, dashboards, crumbs)
121
+
122
+ def list(
123
+ self,
124
+ *,
125
+ folder_chain: list[str] | None = None,
126
+ ) -> Page[DashboardRecord]:
127
+ """Dashboards at the given folder level (not including subfolders)."""
128
+ browse = self.browse(folder_chain=folder_chain)
129
+ items = browse.dashboards
130
+ total = len(items)
131
+ return Page(items, 1, max(total, 1), total, 1 if total else 0)
132
+
133
+ def get(self, dashboard_id: str) -> DashboardRecord | None:
134
+ result = self._client.get_optional(f"/feature/dashboards/{dashboard_id}")
135
+ if not isinstance(result, dict):
136
+ return None
137
+ dashboard = result.get("dashboard")
138
+ if isinstance(dashboard, dict):
139
+ return DashboardRecord(dashboard)
140
+ data = result.get("data")
141
+ if isinstance(data, dict):
142
+ return DashboardRecord(data)
143
+ return None
144
+
145
+ def find_by_id(self, dashboard_id: str) -> DashboardRecord | None:
146
+ return self.get(dashboard_id)
147
+
148
+ def find_by_title(
149
+ self,
150
+ title: str,
151
+ *,
152
+ exact: bool = True,
153
+ ) -> DashboardRecord:
154
+ """Search dashboards by title via global search (min 2 characters)."""
155
+ from northstar_sdk.catalogue.exceptions import AmbiguousLookupError, NotFoundError
156
+ from northstar_sdk.catalogue.search import GlobalSearch
157
+
158
+ if len(title.strip()) < 2:
159
+ raise ValueError(
160
+ "title must be at least 2 characters for catalogue search"
161
+ )
162
+ search = GlobalSearch(self._client)
163
+ groups = search.query(title)
164
+ matches: list[DashboardRecord] = []
165
+ for group in groups:
166
+ for item in group.items:
167
+ if item.type != "dashboard":
168
+ continue
169
+ label = item.label
170
+ if exact:
171
+ if label.casefold() == title.strip().casefold():
172
+ matches.append(
173
+ DashboardRecord({"id": item.id, "title": label})
174
+ )
175
+ elif title.strip().casefold() in label.casefold():
176
+ matches.append(
177
+ DashboardRecord({"id": item.id, "title": label})
178
+ )
179
+ if not matches:
180
+ raise NotFoundError(f"No accessible dashboard with title {title!r}")
181
+ if len(matches) > 1:
182
+ raise AmbiguousLookupError(
183
+ f"Multiple dashboards titled {title!r}: "
184
+ + ", ".join(m.id for m in matches[:5])
185
+ )
186
+ full = self.get(matches[0].id)
187
+ return full if full is not None else matches[0]
@@ -0,0 +1,75 @@
1
+ """Data source catalogue queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from northstar_sdk.catalogue.base import NamedCatalogue, RecordBase, build_params
9
+ from northstar_sdk.catalogue.page import Page
10
+
11
+
12
+ @dataclass
13
+ class DataSourceRecord(RecordBase):
14
+ name: str
15
+ slug: str | None
16
+ description: str | None
17
+
18
+ def __init__(self, raw: dict[str, Any]) -> None:
19
+ super().__init__(raw)
20
+ self.name = str(raw.get("name") or "")
21
+ slug = raw.get("slug")
22
+ self.slug = slug if isinstance(slug, str) else None
23
+ desc = raw.get("description")
24
+ self.description = desc if isinstance(desc, str) else None
25
+
26
+
27
+ class DataSourceCatalogue(NamedCatalogue[DataSourceRecord]):
28
+ """Query data sources visible to the API key user."""
29
+
30
+ name_field = "name"
31
+
32
+ def _parse_record(self, raw: dict[str, Any]) -> DataSourceRecord:
33
+ return DataSourceRecord(raw)
34
+
35
+ def _get_raw(self, resource_id: str) -> dict[str, Any] | None:
36
+ result = self._client.get_optional(f"/feature/data-sources/{resource_id}")
37
+ if isinstance(result, dict):
38
+ nested = result.get("dataSource")
39
+ if isinstance(nested, dict):
40
+ return nested
41
+ return result
42
+ return None
43
+
44
+ def _fetch_list_page(
45
+ self,
46
+ *,
47
+ page: int,
48
+ limit: int,
49
+ search: str | None,
50
+ **params: Any,
51
+ ) -> Page[DataSourceRecord]:
52
+ del page, limit # API returns full list
53
+ query = build_params(
54
+ search=search or None,
55
+ categoryId=params.pop("category_id", None),
56
+ **params,
57
+ )
58
+ result = self._client.get("/feature/data-sources", params=query)
59
+ if not isinstance(result, dict):
60
+ return Page([], 1, 0, 0, 0)
61
+ rows = result.get("dataSources")
62
+ items = [
63
+ self._parse_record(row)
64
+ for row in (rows if isinstance(rows, list) else [])
65
+ if isinstance(row, dict)
66
+ ]
67
+ total = len(items)
68
+ return Page(items, 1, total, total, 1 if total else 0)
69
+
70
+ def find_by_slug(self, slug: str) -> DataSourceRecord | None:
71
+ needle = slug.strip().casefold()
72
+ for item in self.list_all():
73
+ if item.slug and item.slug.casefold() == needle:
74
+ return item
75
+ return None