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.
Files changed (69) hide show
  1. hydroserverpy/__init__.py +7 -0
  2. hydroserverpy/api/__init__.py +0 -0
  3. hydroserverpy/api/client.py +203 -0
  4. hydroserverpy/api/models/__init__.py +22 -0
  5. hydroserverpy/api/models/base.py +207 -0
  6. hydroserverpy/api/models/etl/__init__.py +26 -0
  7. hydroserverpy/api/models/etl/data_archive.py +77 -0
  8. hydroserverpy/api/models/etl/data_source.py +146 -0
  9. hydroserverpy/api/models/etl/etl_configuration.py +224 -0
  10. hydroserverpy/api/models/etl/extractors/__init__.py +6 -0
  11. hydroserverpy/api/models/etl/extractors/base.py +52 -0
  12. hydroserverpy/api/models/etl/extractors/ftp_extractor.py +50 -0
  13. hydroserverpy/api/models/etl/extractors/http_extractor.py +28 -0
  14. hydroserverpy/api/models/etl/extractors/local_file_extractor.py +20 -0
  15. hydroserverpy/api/models/etl/factories.py +23 -0
  16. hydroserverpy/api/models/etl/loaders/__init__.py +4 -0
  17. hydroserverpy/api/models/etl/loaders/base.py +11 -0
  18. hydroserverpy/api/models/etl/loaders/hydroserver_loader.py +98 -0
  19. hydroserverpy/api/models/etl/orchestration_configuration.py +35 -0
  20. hydroserverpy/api/models/etl/orchestration_system.py +63 -0
  21. hydroserverpy/api/models/etl/schedule.py +16 -0
  22. hydroserverpy/api/models/etl/status.py +14 -0
  23. hydroserverpy/api/models/etl/timestamp_parser.py +112 -0
  24. hydroserverpy/api/models/etl/transformers/__init__.py +5 -0
  25. hydroserverpy/api/models/etl/transformers/base.py +135 -0
  26. hydroserverpy/api/models/etl/transformers/csv_transformer.py +88 -0
  27. hydroserverpy/api/models/etl/transformers/json_transformer.py +48 -0
  28. hydroserverpy/api/models/etl/types.py +7 -0
  29. hydroserverpy/api/models/iam/__init__.py +0 -0
  30. hydroserverpy/api/models/iam/account.py +12 -0
  31. hydroserverpy/api/models/iam/apikey.py +96 -0
  32. hydroserverpy/api/models/iam/collaborator.py +70 -0
  33. hydroserverpy/api/models/iam/role.py +38 -0
  34. hydroserverpy/api/models/iam/workspace.py +297 -0
  35. hydroserverpy/api/models/sta/__init__.py +0 -0
  36. hydroserverpy/api/models/sta/datastream.py +254 -0
  37. hydroserverpy/api/models/sta/observation.py +103 -0
  38. hydroserverpy/api/models/sta/observed_property.py +37 -0
  39. hydroserverpy/api/models/sta/processing_level.py +35 -0
  40. hydroserverpy/api/models/sta/result_qualifier.py +34 -0
  41. hydroserverpy/api/models/sta/sensor.py +44 -0
  42. hydroserverpy/api/models/sta/thing.py +113 -0
  43. hydroserverpy/api/models/sta/unit.py +36 -0
  44. hydroserverpy/api/services/__init__.py +12 -0
  45. hydroserverpy/api/services/base.py +118 -0
  46. hydroserverpy/api/services/etl/__init__.py +0 -0
  47. hydroserverpy/api/services/etl/data_archive.py +166 -0
  48. hydroserverpy/api/services/etl/data_source.py +163 -0
  49. hydroserverpy/api/services/etl/orchestration_system.py +66 -0
  50. hydroserverpy/api/services/iam/__init__.py +0 -0
  51. hydroserverpy/api/services/iam/role.py +38 -0
  52. hydroserverpy/api/services/iam/workspace.py +232 -0
  53. hydroserverpy/api/services/sta/__init__.py +0 -0
  54. hydroserverpy/api/services/sta/datastream.py +296 -0
  55. hydroserverpy/api/services/sta/observed_property.py +82 -0
  56. hydroserverpy/api/services/sta/processing_level.py +72 -0
  57. hydroserverpy/api/services/sta/result_qualifier.py +64 -0
  58. hydroserverpy/api/services/sta/sensor.py +102 -0
  59. hydroserverpy/api/services/sta/thing.py +195 -0
  60. hydroserverpy/api/services/sta/unit.py +78 -0
  61. hydroserverpy/api/utils.py +22 -0
  62. hydroserverpy/quality/__init__.py +1 -0
  63. hydroserverpy/quality/service.py +405 -0
  64. hydroserverpy-1.5.1.dist-info/METADATA +66 -0
  65. hydroserverpy-1.5.1.dist-info/RECORD +69 -0
  66. hydroserverpy-1.5.1.dist-info/WHEEL +5 -0
  67. hydroserverpy-1.5.1.dist-info/licenses/LICENSE +28 -0
  68. hydroserverpy-1.5.1.dist-info/top_level.txt +1 -0
  69. hydroserverpy-1.5.1.dist-info/zip-safe +1 -0
@@ -0,0 +1,7 @@
1
+ from .api.client import HydroServer
2
+ from .quality import HydroServerQualityControl
3
+
4
+ __all__ = [
5
+ "HydroServer",
6
+ "HydroServerQualityControl",
7
+ ]
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))