hydroserverpy 1.5.1__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.
- hydroserverpy/__init__.py +7 -0
- hydroserverpy/api/__init__.py +0 -0
- hydroserverpy/api/client.py +203 -0
- hydroserverpy/api/models/__init__.py +22 -0
- hydroserverpy/api/models/base.py +207 -0
- hydroserverpy/api/models/etl/__init__.py +26 -0
- hydroserverpy/api/models/etl/data_archive.py +77 -0
- hydroserverpy/api/models/etl/data_source.py +146 -0
- hydroserverpy/api/models/etl/etl_configuration.py +224 -0
- hydroserverpy/api/models/etl/extractors/__init__.py +6 -0
- hydroserverpy/api/models/etl/extractors/base.py +52 -0
- hydroserverpy/api/models/etl/extractors/ftp_extractor.py +50 -0
- hydroserverpy/api/models/etl/extractors/http_extractor.py +28 -0
- hydroserverpy/api/models/etl/extractors/local_file_extractor.py +20 -0
- hydroserverpy/api/models/etl/factories.py +23 -0
- hydroserverpy/api/models/etl/loaders/__init__.py +4 -0
- hydroserverpy/api/models/etl/loaders/base.py +11 -0
- hydroserverpy/api/models/etl/loaders/hydroserver_loader.py +98 -0
- hydroserverpy/api/models/etl/orchestration_configuration.py +35 -0
- hydroserverpy/api/models/etl/orchestration_system.py +63 -0
- hydroserverpy/api/models/etl/schedule.py +16 -0
- hydroserverpy/api/models/etl/status.py +14 -0
- hydroserverpy/api/models/etl/timestamp_parser.py +112 -0
- hydroserverpy/api/models/etl/transformers/__init__.py +5 -0
- hydroserverpy/api/models/etl/transformers/base.py +135 -0
- hydroserverpy/api/models/etl/transformers/csv_transformer.py +88 -0
- hydroserverpy/api/models/etl/transformers/json_transformer.py +48 -0
- hydroserverpy/api/models/etl/types.py +7 -0
- hydroserverpy/api/models/iam/__init__.py +0 -0
- hydroserverpy/api/models/iam/account.py +12 -0
- hydroserverpy/api/models/iam/apikey.py +96 -0
- hydroserverpy/api/models/iam/collaborator.py +70 -0
- hydroserverpy/api/models/iam/role.py +38 -0
- hydroserverpy/api/models/iam/workspace.py +297 -0
- hydroserverpy/api/models/sta/__init__.py +0 -0
- hydroserverpy/api/models/sta/datastream.py +254 -0
- hydroserverpy/api/models/sta/observation.py +103 -0
- hydroserverpy/api/models/sta/observed_property.py +37 -0
- hydroserverpy/api/models/sta/processing_level.py +35 -0
- hydroserverpy/api/models/sta/result_qualifier.py +34 -0
- hydroserverpy/api/models/sta/sensor.py +44 -0
- hydroserverpy/api/models/sta/thing.py +113 -0
- hydroserverpy/api/models/sta/unit.py +36 -0
- hydroserverpy/api/services/__init__.py +12 -0
- hydroserverpy/api/services/base.py +118 -0
- hydroserverpy/api/services/etl/__init__.py +0 -0
- hydroserverpy/api/services/etl/data_archive.py +166 -0
- hydroserverpy/api/services/etl/data_source.py +163 -0
- hydroserverpy/api/services/etl/orchestration_system.py +66 -0
- hydroserverpy/api/services/iam/__init__.py +0 -0
- hydroserverpy/api/services/iam/role.py +38 -0
- hydroserverpy/api/services/iam/workspace.py +232 -0
- hydroserverpy/api/services/sta/__init__.py +0 -0
- hydroserverpy/api/services/sta/datastream.py +296 -0
- hydroserverpy/api/services/sta/observed_property.py +82 -0
- hydroserverpy/api/services/sta/processing_level.py +72 -0
- hydroserverpy/api/services/sta/result_qualifier.py +64 -0
- hydroserverpy/api/services/sta/sensor.py +102 -0
- hydroserverpy/api/services/sta/thing.py +195 -0
- hydroserverpy/api/services/sta/unit.py +78 -0
- hydroserverpy/api/utils.py +22 -0
- hydroserverpy/quality/__init__.py +1 -0
- hydroserverpy/quality/service.py +405 -0
- hydroserverpy-1.5.1.dist-info/METADATA +66 -0
- hydroserverpy-1.5.1.dist-info/RECORD +69 -0
- hydroserverpy-1.5.1.dist-info/WHEEL +5 -0
- hydroserverpy-1.5.1.dist-info/licenses/LICENSE +28 -0
- hydroserverpy-1.5.1.dist-info/top_level.txt +1 -0
- hydroserverpy-1.5.1.dist-info/zip-safe +1 -0
|
File without changes
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import json
|
|
3
|
+
from typing import Optional, Tuple
|
|
4
|
+
from hydroserverpy.api.services import (
|
|
5
|
+
WorkspaceService,
|
|
6
|
+
RoleService,
|
|
7
|
+
ThingService,
|
|
8
|
+
ObservedPropertyService,
|
|
9
|
+
UnitService,
|
|
10
|
+
ProcessingLevelService,
|
|
11
|
+
ResultQualifierService,
|
|
12
|
+
SensorService,
|
|
13
|
+
DatastreamService,
|
|
14
|
+
OrchestrationSystemService,
|
|
15
|
+
DataSourceService,
|
|
16
|
+
DataArchiveService,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HydroServer:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
host: str,
|
|
24
|
+
auth_route: str = "/api/auth",
|
|
25
|
+
base_route: str = "/api/data",
|
|
26
|
+
email: Optional[str] = None,
|
|
27
|
+
password: Optional[str] = None,
|
|
28
|
+
apikey: Optional[str] = None,
|
|
29
|
+
):
|
|
30
|
+
self.host = host.strip("/")
|
|
31
|
+
self.base_route = base_route
|
|
32
|
+
self.auth = (
|
|
33
|
+
(
|
|
34
|
+
email or "__key__",
|
|
35
|
+
password or apikey,
|
|
36
|
+
)
|
|
37
|
+
if (email and password) or apikey
|
|
38
|
+
else None
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
self._auth_url = f"{self.host}{auth_route}/app/session"
|
|
42
|
+
|
|
43
|
+
self._session = None
|
|
44
|
+
self._timeout = 60
|
|
45
|
+
self._auth_header = None
|
|
46
|
+
|
|
47
|
+
self._init_session()
|
|
48
|
+
|
|
49
|
+
def login(self, email: str, password: str) -> None:
|
|
50
|
+
"""Provide your HydroServer credentials to log in to your account."""
|
|
51
|
+
|
|
52
|
+
self._init_session(auth=(email, password))
|
|
53
|
+
|
|
54
|
+
def logout(self) -> None:
|
|
55
|
+
"""End your HydroServer session."""
|
|
56
|
+
|
|
57
|
+
self._session.delete(self._auth_url, timeout=self._timeout)
|
|
58
|
+
|
|
59
|
+
def request(self, method, path, *args, **kwargs) -> requests.Response:
|
|
60
|
+
"""Sends a request to HydroServer's API."""
|
|
61
|
+
|
|
62
|
+
for attempt in range(2):
|
|
63
|
+
try:
|
|
64
|
+
response = getattr(self._session, method)(
|
|
65
|
+
f"{self.host}/{path.strip('/')}",
|
|
66
|
+
timeout=self._timeout,
|
|
67
|
+
*args,
|
|
68
|
+
**kwargs,
|
|
69
|
+
)
|
|
70
|
+
self._raise_for_hs_status(response)
|
|
71
|
+
except (
|
|
72
|
+
requests.exceptions.HTTPError,
|
|
73
|
+
requests.exceptions.ConnectionError,
|
|
74
|
+
) as e:
|
|
75
|
+
if attempt == 0:
|
|
76
|
+
self._init_session()
|
|
77
|
+
continue
|
|
78
|
+
else:
|
|
79
|
+
raise e
|
|
80
|
+
|
|
81
|
+
return response
|
|
82
|
+
|
|
83
|
+
def _init_session(self, auth: Optional[Tuple[str, str]] = None) -> None:
|
|
84
|
+
if self._session is not None:
|
|
85
|
+
self.logout()
|
|
86
|
+
self._session.close()
|
|
87
|
+
|
|
88
|
+
self._session = requests.Session()
|
|
89
|
+
|
|
90
|
+
auth = auth or self.auth
|
|
91
|
+
|
|
92
|
+
if auth and auth[0] == "__key__":
|
|
93
|
+
self._session.headers.update({"X-API-Key": auth[1]})
|
|
94
|
+
elif auth:
|
|
95
|
+
self._session.headers.update(
|
|
96
|
+
{"Authorization": f"Bearer {self._authenticate(auth[0], auth[1])}"}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _authenticate(self, email: str, password: str) -> None:
|
|
100
|
+
response = self._session.post(
|
|
101
|
+
self._auth_url,
|
|
102
|
+
json={"email": email, "password": password},
|
|
103
|
+
timeout=self._timeout,
|
|
104
|
+
)
|
|
105
|
+
response.raise_for_status()
|
|
106
|
+
session_token = response.json().get("meta", {}).get("session_token")
|
|
107
|
+
|
|
108
|
+
if not session_token:
|
|
109
|
+
raise ValueError("Authentication failed: No access token returned.")
|
|
110
|
+
|
|
111
|
+
return session_token
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _raise_for_hs_status(response):
|
|
115
|
+
try:
|
|
116
|
+
response.raise_for_status()
|
|
117
|
+
except requests.HTTPError as e:
|
|
118
|
+
try:
|
|
119
|
+
http_error_msg = (
|
|
120
|
+
f"{response.status_code} Client Error: "
|
|
121
|
+
f"{str(json.loads(response.content).get('detail'))}"
|
|
122
|
+
)
|
|
123
|
+
except (
|
|
124
|
+
ValueError,
|
|
125
|
+
TypeError,
|
|
126
|
+
):
|
|
127
|
+
http_error_msg = e
|
|
128
|
+
if 400 <= response.status_code < 500:
|
|
129
|
+
raise requests.HTTPError(http_error_msg, response=response)
|
|
130
|
+
else:
|
|
131
|
+
raise requests.HTTPError(str(e), response=response)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def workspaces(self):
|
|
135
|
+
"""Utilities for managing HydroServer workspaces."""
|
|
136
|
+
|
|
137
|
+
return WorkspaceService(self)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def roles(self):
|
|
141
|
+
"""Utilities for managing HydroServer workspaces."""
|
|
142
|
+
|
|
143
|
+
return RoleService(self)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def things(self):
|
|
147
|
+
"""Utilities for managing HydroServer things."""
|
|
148
|
+
|
|
149
|
+
return ThingService(self)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def observedproperties(self):
|
|
153
|
+
"""Utilities for managing HydroServer observed properties."""
|
|
154
|
+
|
|
155
|
+
return ObservedPropertyService(self)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def units(self):
|
|
159
|
+
"""Utilities for managing HydroServer units."""
|
|
160
|
+
|
|
161
|
+
return UnitService(self)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def processinglevels(self):
|
|
165
|
+
"""Utilities for managing HydroServer processing levels."""
|
|
166
|
+
|
|
167
|
+
return ProcessingLevelService(self)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def resultqualifiers(self):
|
|
171
|
+
"""Utilities for managing HydroServer result qualifiers."""
|
|
172
|
+
|
|
173
|
+
return ResultQualifierService(self)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def sensors(self):
|
|
177
|
+
"""Utilities for managing HydroServer sensors."""
|
|
178
|
+
|
|
179
|
+
return SensorService(self)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def datastreams(self):
|
|
183
|
+
"""Utilities for managing HydroServer datastreams."""
|
|
184
|
+
|
|
185
|
+
return DatastreamService(self)
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def orchestrationsystems(self):
|
|
189
|
+
"""Utilities for managing HydroServer orchestration systems."""
|
|
190
|
+
|
|
191
|
+
return OrchestrationSystemService(self)
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def datasources(self):
|
|
195
|
+
"""Utilities for managing HydroServer data sources."""
|
|
196
|
+
|
|
197
|
+
return DataSourceService(self)
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def dataarchives(self):
|
|
201
|
+
"""Utilities for managing HydroServer data archives."""
|
|
202
|
+
|
|
203
|
+
return DataArchiveService(self)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .iam.account import Account
|
|
2
|
+
from .iam.workspace import Workspace
|
|
3
|
+
from .iam.role import Role
|
|
4
|
+
from .iam.collaborator import Collaborator
|
|
5
|
+
from .iam.apikey import APIKey
|
|
6
|
+
from .iam.account import Account
|
|
7
|
+
from .sta.datastream import Datastream
|
|
8
|
+
from .sta.observation import ObservationCollection
|
|
9
|
+
from .sta.observed_property import ObservedProperty
|
|
10
|
+
from .sta.processing_level import ProcessingLevel
|
|
11
|
+
from .sta.result_qualifier import ResultQualifier
|
|
12
|
+
from .sta.sensor import Sensor
|
|
13
|
+
from .sta.thing import Thing
|
|
14
|
+
from .sta.unit import Unit
|
|
15
|
+
from .etl.orchestration_system import OrchestrationSystem
|
|
16
|
+
from .etl.data_source import DataSource
|
|
17
|
+
from .etl.data_archive import DataArchive
|
|
18
|
+
|
|
19
|
+
Workspace.model_rebuild()
|
|
20
|
+
Role.model_rebuild()
|
|
21
|
+
Collaborator.model_rebuild()
|
|
22
|
+
APIKey.model_rebuild()
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Type, List, Dict, Any, Optional, ClassVar, TYPE_CHECKING
|
|
3
|
+
from requests import Response
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, PrivateAttr, Field
|
|
6
|
+
from pydantic.alias_generators import to_camel
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from hydroserverpy import HydroServer
|
|
10
|
+
from hydroserverpy.api.services.base import HydroServerBaseService
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HydroServerBaseModel(BaseModel):
|
|
14
|
+
uid: Optional[uuid.UUID] = Field(..., alias="id")
|
|
15
|
+
|
|
16
|
+
_client: "HydroServer" = PrivateAttr()
|
|
17
|
+
_service: "HydroServerBaseService" = PrivateAttr()
|
|
18
|
+
_server_data: Dict[str, Any] = PrivateAttr()
|
|
19
|
+
_editable_fields: ClassVar[set[str]] = set()
|
|
20
|
+
|
|
21
|
+
def __init__(self, *, client: "HydroServer", service: Optional["HydroServerBaseService"] = None, **data):
|
|
22
|
+
super().__init__(**data)
|
|
23
|
+
|
|
24
|
+
self._client = client
|
|
25
|
+
self._service = service
|
|
26
|
+
self._server_data = self.dict(by_alias=False).copy()
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def get_route(cls):
|
|
30
|
+
raise NotImplementedError("Route not defined")
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def client(self) -> "HydroServer":
|
|
34
|
+
return self._client
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def service(self) -> "HydroServerBaseService":
|
|
38
|
+
return self._service
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def unsaved_changes(self) -> dict:
|
|
42
|
+
return {
|
|
43
|
+
k: v for k, v in self.__dict__.items()
|
|
44
|
+
if k in self._editable_fields and k in self._server_data and v != self._server_data[k]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def save(self):
|
|
48
|
+
"""Saves changes to this resource to HydroServer."""
|
|
49
|
+
|
|
50
|
+
if not self.service:
|
|
51
|
+
raise NotImplementedError("Saving not enabled for this object.")
|
|
52
|
+
|
|
53
|
+
if not self.uid:
|
|
54
|
+
raise AttributeError("Data cannot be saved: UID is not set.")
|
|
55
|
+
|
|
56
|
+
if self.unsaved_changes:
|
|
57
|
+
saved_resource = self.service.update(
|
|
58
|
+
self.uid, **self.unsaved_changes
|
|
59
|
+
)
|
|
60
|
+
self._server_data = saved_resource.dict(by_alias=False).copy()
|
|
61
|
+
self.__dict__.update(saved_resource.__dict__)
|
|
62
|
+
|
|
63
|
+
def refresh(self):
|
|
64
|
+
"""Refreshes this resource from HydroServer."""
|
|
65
|
+
|
|
66
|
+
if not self.service:
|
|
67
|
+
raise NotImplementedError("Refreshing not enabled for this object.")
|
|
68
|
+
|
|
69
|
+
if self.uid is None:
|
|
70
|
+
raise ValueError("Cannot refresh data without a valid ID.")
|
|
71
|
+
|
|
72
|
+
refreshed_resource = self.service.get(self.uid)
|
|
73
|
+
self._server_data = refreshed_resource.dict(by_alias=False).copy()
|
|
74
|
+
self.__dict__.update(refreshed_resource.__dict__)
|
|
75
|
+
|
|
76
|
+
def delete(self):
|
|
77
|
+
"""Deletes this resource from HydroServer."""
|
|
78
|
+
|
|
79
|
+
if not self.service:
|
|
80
|
+
raise NotImplementedError("Deleting not enabled for this object.")
|
|
81
|
+
|
|
82
|
+
if self.uid is None:
|
|
83
|
+
raise AttributeError("Cannot delete data without a valid ID.")
|
|
84
|
+
|
|
85
|
+
self.service.delete(self.uid)
|
|
86
|
+
self.uid = None
|
|
87
|
+
|
|
88
|
+
model_config = ConfigDict(
|
|
89
|
+
validate_assignment=True,
|
|
90
|
+
populate_by_name=True,
|
|
91
|
+
str_strip_whitespace=True,
|
|
92
|
+
alias_generator=to_camel,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class HydroServerCollection:
|
|
98
|
+
items: List["HydroServerBaseModel"]
|
|
99
|
+
filters: Optional[dict[str, Any]] = None
|
|
100
|
+
order_by: Optional[List[str]] = None
|
|
101
|
+
page: Optional[int] = None
|
|
102
|
+
page_size: Optional[int] = None
|
|
103
|
+
total_pages: Optional[int] = None
|
|
104
|
+
total_count: Optional[int] = None
|
|
105
|
+
|
|
106
|
+
_service: Optional["HydroServerBaseService"] = field(init=False, repr=False)
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
model: Type["HydroServerBaseModel"],
|
|
111
|
+
client: "HydroServer",
|
|
112
|
+
service: Optional["HydroServerBaseService"] = None,
|
|
113
|
+
response: Optional[Response] = None,
|
|
114
|
+
**data
|
|
115
|
+
):
|
|
116
|
+
self._service = service
|
|
117
|
+
|
|
118
|
+
self.filters = data.get("filters")
|
|
119
|
+
self.order_by = data.get("order_by")
|
|
120
|
+
self.page = data.get("page") or (int(response.headers.get("X-Page")) if response else None)
|
|
121
|
+
self.page_size = data.get("page_size") or (int(response.headers.get("X-Page-Size")) if response else None)
|
|
122
|
+
self.total_pages = data.get("total_pages") or (int(response.headers.get("X-Total-Pages")) if response else None)
|
|
123
|
+
self.total_count = data.get("total_count") or (int(response.headers.get("X-Total-Count")) if response else None)
|
|
124
|
+
|
|
125
|
+
if "items" in data:
|
|
126
|
+
self.items = data["items"]
|
|
127
|
+
elif response is not None:
|
|
128
|
+
self.items = [model(client=client, **entity) for entity in response.json()]
|
|
129
|
+
else:
|
|
130
|
+
self.items = []
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def service(self) -> "HydroServerBaseService":
|
|
134
|
+
return self._service
|
|
135
|
+
|
|
136
|
+
def next_page(self):
|
|
137
|
+
"""Fetches the next page of data from HydroServer."""
|
|
138
|
+
|
|
139
|
+
if not self._service:
|
|
140
|
+
raise NotImplementedError("Pagination not enabled for this collection.")
|
|
141
|
+
|
|
142
|
+
return self._service.list(
|
|
143
|
+
**(self.filters or {}),
|
|
144
|
+
page=(self.page or 0) + 1,
|
|
145
|
+
page_size=self.page_size or 100,
|
|
146
|
+
order_by=self.order_by or ...
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def previous_page(self):
|
|
150
|
+
"""Fetches the previous page of data from HydroServer."""
|
|
151
|
+
|
|
152
|
+
if not self._service:
|
|
153
|
+
raise NotImplementedError("Pagination not enabled for this collection.")
|
|
154
|
+
|
|
155
|
+
if not self.page or self.page <= 1:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
return self._service.list(
|
|
159
|
+
**(self.filters or {}),
|
|
160
|
+
page=self.page - 1,
|
|
161
|
+
page_size=self.page_size or 100,
|
|
162
|
+
order_by=self.order_by or ...
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def fetch_all(self) -> "HydroServerCollection":
|
|
166
|
+
"""Fetches all pages of data from HydroServer for this collection."""
|
|
167
|
+
|
|
168
|
+
if not self._service:
|
|
169
|
+
raise NotImplementedError("Pagination not enabled for this collection.")
|
|
170
|
+
|
|
171
|
+
all_items = []
|
|
172
|
+
current_page = self.page or 1
|
|
173
|
+
page_size = self.page_size or 100
|
|
174
|
+
total_pages = self.total_pages
|
|
175
|
+
|
|
176
|
+
page_num = 1
|
|
177
|
+
while total_pages is None or page_num <= total_pages:
|
|
178
|
+
if page_num == current_page:
|
|
179
|
+
all_items.extend(self.items)
|
|
180
|
+
else:
|
|
181
|
+
page = self._service.list(
|
|
182
|
+
**(self.filters or {}),
|
|
183
|
+
page=page_num,
|
|
184
|
+
page_size=page_size,
|
|
185
|
+
order_by=self.order_by or ...
|
|
186
|
+
)
|
|
187
|
+
if not page.items:
|
|
188
|
+
break
|
|
189
|
+
all_items.extend(page.items)
|
|
190
|
+
|
|
191
|
+
if page.total_pages is not None:
|
|
192
|
+
total_pages = page.total_pages
|
|
193
|
+
|
|
194
|
+
page_num += 1
|
|
195
|
+
|
|
196
|
+
return self.__class__(
|
|
197
|
+
model=type(self.items[0]) if self.items else None,
|
|
198
|
+
client=self.items[0].client if self.items else None,
|
|
199
|
+
service=self._service,
|
|
200
|
+
items=all_items,
|
|
201
|
+
filters=self.filters,
|
|
202
|
+
order_by=self.order_by,
|
|
203
|
+
page=1,
|
|
204
|
+
page_size=len(all_items),
|
|
205
|
+
total_pages=1,
|
|
206
|
+
total_count=len(all_items)
|
|
207
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .extractors import Extractor, HTTPExtractor, LocalFileExtractor, FTPExtractor
|
|
2
|
+
from .transformers import JSONTransformer, CSVTransformer, Transformer
|
|
3
|
+
from .loaders import HydroServerLoader, Loader
|
|
4
|
+
|
|
5
|
+
from .etl_configuration import EtlConfiguration
|
|
6
|
+
from .schedule import Schedule
|
|
7
|
+
from .status import Status
|
|
8
|
+
from .orchestration_system import OrchestrationSystem
|
|
9
|
+
from .data_source import DataSource
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CSVTransformer",
|
|
13
|
+
"JSONTransformer",
|
|
14
|
+
"LocalFileExtractor",
|
|
15
|
+
"FTPExtractor",
|
|
16
|
+
"HTTPExtractor",
|
|
17
|
+
"Extractor",
|
|
18
|
+
"Transformer",
|
|
19
|
+
"Loader",
|
|
20
|
+
"HydroServerLoader",
|
|
21
|
+
"EtlConfiguration",
|
|
22
|
+
"Schedule",
|
|
23
|
+
"Status",
|
|
24
|
+
"OrchestrationSystem",
|
|
25
|
+
"DataSource",
|
|
26
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Union, ClassVar, Optional, TYPE_CHECKING, List
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from .orchestration_system import OrchestrationSystem
|
|
5
|
+
from .orchestration_configuration import OrchestrationConfigurationFields
|
|
6
|
+
from ..sta.datastream import Datastream
|
|
7
|
+
from ..base import HydroServerBaseModel
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from hydroserverpy import HydroServer
|
|
11
|
+
from hydroserverpy.api.models import Workspace
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DataArchive(
|
|
15
|
+
HydroServerBaseModel, OrchestrationConfigurationFields
|
|
16
|
+
):
|
|
17
|
+
name: str = Field(..., max_length=255)
|
|
18
|
+
settings: Optional[dict] = None
|
|
19
|
+
orchestration_system_id: uuid.UUID
|
|
20
|
+
workspace_id: uuid.UUID
|
|
21
|
+
|
|
22
|
+
_editable_fields: ClassVar[set[str]] = {
|
|
23
|
+
"name", "settings", "interval", "interval_units", "crontab", "start_time", "end_time", "last_run_successful",
|
|
24
|
+
"last_run_message", "last_run", "next_run", "paused"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
def __init__(self, client: "HydroServer", **data):
|
|
28
|
+
super().__init__(client=client, service=client.dataarchives, **data)
|
|
29
|
+
|
|
30
|
+
self._workspace = None
|
|
31
|
+
self._orchestration_system = None
|
|
32
|
+
self._datastreams = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def get_route(cls):
|
|
36
|
+
return "data-archives"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def workspace(self) -> "Workspace":
|
|
40
|
+
"""The workspace this data archive belongs to."""
|
|
41
|
+
|
|
42
|
+
if self._workspace is None:
|
|
43
|
+
self._workspace = self.client.workspaces.get(uid=self.workspace_id)
|
|
44
|
+
|
|
45
|
+
return self._workspace
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def orchestration_system(self) -> "OrchestrationSystem":
|
|
49
|
+
"""The orchestration system that manages this data archive."""
|
|
50
|
+
|
|
51
|
+
if self._orchestration_system is None:
|
|
52
|
+
self._orchestration_system = self.client.orchestrationsystems.get(uid=self.orchestration_system_id)
|
|
53
|
+
|
|
54
|
+
return self._orchestration_system
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def datastreams(self) -> List["Datastream"]:
|
|
58
|
+
"""The datastreams this data archive provides data for."""
|
|
59
|
+
|
|
60
|
+
if self._datastreams is None:
|
|
61
|
+
self._datastreams = self.client.datastreams.list(data_archive=self.uid, fetch_all=True).items
|
|
62
|
+
|
|
63
|
+
return self._datastreams
|
|
64
|
+
|
|
65
|
+
def add_datastream(self, datastream: Union["Datastream", uuid.UUID, str]):
|
|
66
|
+
"""Add a datastream to this data archive."""
|
|
67
|
+
|
|
68
|
+
self.client.dataarchives.add_datastream(
|
|
69
|
+
uid=self.uid, datastream=datastream
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def remove_datastream(self, datastream: Union["Datastream", uuid.UUID, str]):
|
|
73
|
+
"""Remove a datastream from this data archive."""
|
|
74
|
+
|
|
75
|
+
self.client.dataarchives.remove_datastream(
|
|
76
|
+
uid=self.uid, datastream=datastream
|
|
77
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
import logging
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import ClassVar, TYPE_CHECKING, List, Optional, Union
|
|
7
|
+
import croniter
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from ..base import HydroServerBaseModel
|
|
12
|
+
from ..sta.datastream import Datastream
|
|
13
|
+
from .orchestration_system import OrchestrationSystem
|
|
14
|
+
from .etl_configuration import EtlConfiguration
|
|
15
|
+
from .schedule import Schedule
|
|
16
|
+
from .status import Status
|
|
17
|
+
from .factories import extractor_factory, transformer_factory, loader_factory
|
|
18
|
+
from .loaders import HydroServerLoader
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from hydroserverpy import HydroServer
|
|
22
|
+
from hydroserverpy.api.models import Workspace
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DataSource(HydroServerBaseModel):
|
|
26
|
+
name: str = Field(..., max_length=255)
|
|
27
|
+
settings: EtlConfiguration
|
|
28
|
+
orchestration_system_id: uuid.UUID
|
|
29
|
+
schedule: Schedule
|
|
30
|
+
status: Status
|
|
31
|
+
workspace_id: uuid.UUID
|
|
32
|
+
|
|
33
|
+
_editable_fields: ClassVar[set[str]] = {
|
|
34
|
+
"name",
|
|
35
|
+
"settings",
|
|
36
|
+
"status",
|
|
37
|
+
"schedule",
|
|
38
|
+
"interval",
|
|
39
|
+
"interval_units",
|
|
40
|
+
"crontab",
|
|
41
|
+
"start_time",
|
|
42
|
+
"end_time",
|
|
43
|
+
"last_run_successful",
|
|
44
|
+
"last_run_message",
|
|
45
|
+
"last_run",
|
|
46
|
+
"next_run",
|
|
47
|
+
"paused",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def __init__(self, client: HydroServer, **data):
|
|
51
|
+
super().__init__(client=client, service=client.datasources, **data)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def get_route(cls):
|
|
55
|
+
return "data-sources"
|
|
56
|
+
|
|
57
|
+
@cached_property
|
|
58
|
+
def workspace(self) -> Workspace:
|
|
59
|
+
return self.client.workspaces.get(uid=self.workspace_id)
|
|
60
|
+
|
|
61
|
+
@cached_property
|
|
62
|
+
def orchestration_system(self) -> OrchestrationSystem:
|
|
63
|
+
return self.client.orchestrationsystems.get(uid=self.orchestration_system_id)
|
|
64
|
+
|
|
65
|
+
@cached_property
|
|
66
|
+
def datastreams(self) -> List[Datastream]:
|
|
67
|
+
return self.client.datastreams.list(data_source=self.uid, fetch_all=True).items
|
|
68
|
+
|
|
69
|
+
# TODO: Add functions like add_payload, add_mapping, etc. and don't allow the user to manually
|
|
70
|
+
# link or unlink datastreams - handle that automatically.
|
|
71
|
+
def add_datastream(self, datastream: Union["Datastream", uuid.UUID, str]):
|
|
72
|
+
"""Add a datastream to this data source."""
|
|
73
|
+
|
|
74
|
+
self.client.datasources.add_datastream(uid=self.uid, datastream=datastream)
|
|
75
|
+
|
|
76
|
+
def remove_datastream(self, datastream: Union["Datastream", uuid.UUID, str]):
|
|
77
|
+
"""Remove a datastream from this data source."""
|
|
78
|
+
|
|
79
|
+
self.client.datasources.remove_datastream(uid=self.uid, datastream=datastream)
|
|
80
|
+
|
|
81
|
+
def _next_run(self) -> Optional[str]:
|
|
82
|
+
now = datetime.now(timezone.utc)
|
|
83
|
+
if cron := self.schedule.crontab:
|
|
84
|
+
return croniter.croniter(cron, now).get_next(datetime).isoformat()
|
|
85
|
+
if iv := self.schedule.interval:
|
|
86
|
+
unit = self.schedule.interval_units or "minutes"
|
|
87
|
+
return (now + timedelta(**{unit: iv})).isoformat()
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def _update_status(self, loader: HydroServerLoader, success: bool, msg: str):
|
|
91
|
+
short_msg = msg if len(msg) <= 255 else msg[:252] + "…"
|
|
92
|
+
loader.client.datasources.update(
|
|
93
|
+
uid=self.uid,
|
|
94
|
+
last_run=datetime.now(timezone.utc).isoformat(),
|
|
95
|
+
last_run_successful=success,
|
|
96
|
+
last_run_message=short_msg,
|
|
97
|
+
next_run=self._next_run(),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def is_empty(self, data):
|
|
101
|
+
if data is None:
|
|
102
|
+
return True
|
|
103
|
+
if isinstance(data, pd.DataFrame) and data.empty:
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
def load_data(self, payload_name: str = None):
|
|
108
|
+
"""Load data for this data source."""
|
|
109
|
+
if self.status.paused is True:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if payload_name:
|
|
113
|
+
self.load_data_for_payload(payload_name)
|
|
114
|
+
else:
|
|
115
|
+
for p in self.settings.payloads:
|
|
116
|
+
self.load_data_for_payload(p.name)
|
|
117
|
+
|
|
118
|
+
def load_data_for_payload(self, payload_name: str):
|
|
119
|
+
payload = next(p for p in self.settings.payloads if p.name == payload_name)
|
|
120
|
+
|
|
121
|
+
extractor_cls = extractor_factory(self.settings.extractor)
|
|
122
|
+
transformer_cls = transformer_factory(self.settings.transformer)
|
|
123
|
+
loader_cls = loader_factory(self.settings.loader, self.client, self.uid)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
logging.info("Starting extract")
|
|
127
|
+
data = extractor_cls.extract(payload, loader_cls)
|
|
128
|
+
if self.is_empty(data):
|
|
129
|
+
self._update_status(
|
|
130
|
+
loader_cls, True, "No data returned from the extractor"
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
logging.info("Starting transform")
|
|
135
|
+
data = transformer_cls.transform(data, payload.mappings)
|
|
136
|
+
if self.is_empty(data):
|
|
137
|
+
self._update_status(
|
|
138
|
+
loader_cls, True, "No data returned from the transformer"
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
logging.info("Starting load")
|
|
143
|
+
loader_cls.load(data, payload)
|
|
144
|
+
self._update_status(loader_cls, True, "OK")
|
|
145
|
+
except Exception as e:
|
|
146
|
+
self._update_status(loader_cls, False, str(e))
|