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.
- northstar_sdk/__init__.py +44 -0
- northstar_sdk/catalogue/__init__.py +46 -0
- northstar_sdk/catalogue/base.py +200 -0
- northstar_sdk/catalogue/dashboard_folders.py +116 -0
- northstar_sdk/catalogue/dashboards.py +187 -0
- northstar_sdk/catalogue/data_sources.py +75 -0
- northstar_sdk/catalogue/datasets.py +73 -0
- northstar_sdk/catalogue/exceptions.py +13 -0
- northstar_sdk/catalogue/figures.py +93 -0
- northstar_sdk/catalogue/kpis.py +82 -0
- northstar_sdk/catalogue/page.py +50 -0
- northstar_sdk/catalogue/search.py +83 -0
- northstar_sdk/catalogue/teams.py +69 -0
- northstar_sdk/client.py +299 -0
- northstar_sdk/handles/__init__.py +72 -0
- northstar_sdk/handles/_access.py +74 -0
- northstar_sdk/handles/dashboard.py +372 -0
- northstar_sdk/handles/dashboard_folder.py +150 -0
- northstar_sdk/handles/data_source.py +136 -0
- northstar_sdk/handles/data_source_category.py +115 -0
- northstar_sdk/handles/dataset.py +483 -0
- northstar_sdk/handles/dataset_ref.py +49 -0
- northstar_sdk/handles/document_collection.py +153 -0
- northstar_sdk/handles/figure.py +179 -0
- northstar_sdk/handles/kpi.py +206 -0
- northstar_sdk/handles/organisation.py +115 -0
- northstar_sdk/handles/storage_system.py +182 -0
- northstar_sdk/handles/team.py +99 -0
- northstar_sdk/handles/team_ref.py +32 -0
- northstar_sdk/handles/user.py +364 -0
- northstar_sdk/handles/user_ref.py +44 -0
- northstar_sdk/namespaces/__init__.py +27 -0
- northstar_sdk/namespaces/dashboard_folders.py +67 -0
- northstar_sdk/namespaces/dashboards.py +126 -0
- northstar_sdk/namespaces/data_source_categories.py +57 -0
- northstar_sdk/namespaces/data_sources.py +104 -0
- northstar_sdk/namespaces/datasets.py +137 -0
- northstar_sdk/namespaces/document_collections.py +118 -0
- northstar_sdk/namespaces/figures.py +113 -0
- northstar_sdk/namespaces/kpis.py +140 -0
- northstar_sdk/namespaces/organisations.py +78 -0
- northstar_sdk/namespaces/storage_systems.py +102 -0
- northstar_sdk/namespaces/teams.py +47 -0
- northstar_sdk/namespaces/users.py +63 -0
- northstar_sdk/orchestration.py +326 -0
- northstar_sdk/py.typed +0 -0
- northstar_sdk/types/__init__.py +62 -0
- northstar_sdk/types/json.py +8 -0
- northstar_sdk/types/params.py +134 -0
- northstar_sdk/types/responses.py +59 -0
- northstar_sdk-1.0.dist-info/METADATA +146 -0
- northstar_sdk-1.0.dist-info/RECORD +53 -0
- 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
|