hydroserverpy 1.2.0__py3-none-any.whl → 1.3.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.

Potentially problematic release.


This version of hydroserverpy might be problematic. Click here for more details.

Files changed (47) hide show
  1. hydroserverpy/__init__.py +1 -1
  2. hydroserverpy/api/{main.py → client.py} +52 -22
  3. hydroserverpy/api/models/__init__.py +1 -2
  4. hydroserverpy/api/models/base.py +180 -47
  5. hydroserverpy/api/models/etl/data_archive.py +31 -59
  6. hydroserverpy/api/models/etl/data_source.py +34 -76
  7. hydroserverpy/api/models/etl/orchestration_system.py +23 -38
  8. hydroserverpy/api/models/iam/apikey.py +57 -38
  9. hydroserverpy/api/models/iam/collaborator.py +55 -19
  10. hydroserverpy/api/models/iam/role.py +32 -4
  11. hydroserverpy/api/models/iam/workspace.py +58 -86
  12. hydroserverpy/api/models/sta/datastream.py +122 -214
  13. hydroserverpy/api/models/sta/observation.py +101 -0
  14. hydroserverpy/api/models/sta/observed_property.py +18 -53
  15. hydroserverpy/api/models/sta/processing_level.py +16 -31
  16. hydroserverpy/api/models/sta/result_qualifier.py +16 -31
  17. hydroserverpy/api/models/sta/sensor.py +27 -88
  18. hydroserverpy/api/models/sta/thing.py +48 -152
  19. hydroserverpy/api/models/sta/unit.py +16 -29
  20. hydroserverpy/api/services/__init__.py +1 -0
  21. hydroserverpy/api/services/base.py +92 -76
  22. hydroserverpy/api/services/etl/data_archive.py +42 -72
  23. hydroserverpy/api/services/etl/data_source.py +42 -72
  24. hydroserverpy/api/services/etl/orchestration_system.py +25 -33
  25. hydroserverpy/api/services/iam/role.py +38 -0
  26. hydroserverpy/api/services/iam/workspace.py +96 -99
  27. hydroserverpy/api/services/sta/datastream.py +150 -211
  28. hydroserverpy/api/services/sta/observed_property.py +31 -49
  29. hydroserverpy/api/services/sta/processing_level.py +30 -36
  30. hydroserverpy/api/services/sta/result_qualifier.py +24 -34
  31. hydroserverpy/api/services/sta/sensor.py +34 -48
  32. hydroserverpy/api/services/sta/thing.py +96 -89
  33. hydroserverpy/api/services/sta/unit.py +30 -34
  34. hydroserverpy/api/utils.py +22 -0
  35. hydroserverpy/etl/extractors/base.py +3 -5
  36. hydroserverpy/etl/loaders/hydroserver_loader.py +1 -0
  37. hydroserverpy/etl/timestamp_parser.py +82 -48
  38. hydroserverpy/etl/transformers/base.py +6 -10
  39. hydroserverpy/etl_csv/hydroserver_etl_csv.py +18 -24
  40. {hydroserverpy-1.2.0.dist-info → hydroserverpy-1.3.0.dist-info}/METADATA +1 -1
  41. hydroserverpy-1.3.0.dist-info/RECORD +70 -0
  42. hydroserverpy/api/http.py +0 -22
  43. hydroserverpy-1.2.0.dist-info/RECORD +0 -68
  44. {hydroserverpy-1.2.0.dist-info → hydroserverpy-1.3.0.dist-info}/WHEEL +0 -0
  45. {hydroserverpy-1.2.0.dist-info → hydroserverpy-1.3.0.dist-info}/licenses/LICENSE +0 -0
  46. {hydroserverpy-1.2.0.dist-info → hydroserverpy-1.3.0.dist-info}/top_level.txt +0 -0
  47. {hydroserverpy-1.2.0.dist-info → hydroserverpy-1.3.0.dist-info}/zip-safe +0 -0
hydroserverpy/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- from .api.main import HydroServer
1
+ from .api.client import HydroServer
2
2
  from .etl.hydroserver_etl import HydroServerETL
3
3
  from .quality import HydroServerQualityControl
4
4
 
@@ -1,8 +1,9 @@
1
1
  import requests
2
+ import json
2
3
  from typing import Optional, Tuple
3
- from hydroserverpy.api.http import raise_for_hs_status
4
4
  from hydroserverpy.api.services import (
5
5
  WorkspaceService,
6
+ RoleService,
6
7
  ThingService,
7
8
  ObservedPropertyService,
8
9
  UnitService,
@@ -20,11 +21,14 @@ class HydroServer:
20
21
  def __init__(
21
22
  self,
22
23
  host: str,
24
+ auth_route: str = "/api/auth",
25
+ base_route: str = "/api/data",
23
26
  email: Optional[str] = None,
24
27
  password: Optional[str] = None,
25
28
  apikey: Optional[str] = None,
26
29
  ):
27
30
  self.host = host.strip("/")
31
+ self.base_route = base_route
28
32
  self.auth = (
29
33
  (
30
34
  email or "__key__",
@@ -34,7 +38,7 @@ class HydroServer:
34
38
  else None
35
39
  )
36
40
 
37
- self._auth_url = f"{self.host}/api/auth/app/session"
41
+ self._auth_url = f"{self.host}{auth_route}/app/session"
38
42
 
39
43
  self._session = None
40
44
  self._timeout = 60
@@ -52,6 +56,30 @@ class HydroServer:
52
56
 
53
57
  self._session.delete(self._auth_url, timeout=self._timeout)
54
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
+
55
83
  def _init_session(self, auth: Optional[Tuple[str, str]] = None) -> None:
56
84
  if self._session is not None:
57
85
  self.logout()
@@ -82,29 +110,25 @@ class HydroServer:
82
110
 
83
111
  return session_token
84
112
 
85
- def request(self, method, path, *args, **kwargs) -> requests.Response:
86
- """Sends a request to HydroServer's API."""
87
-
88
- for attempt in range(2):
113
+ @staticmethod
114
+ def _raise_for_hs_status(response):
115
+ try:
116
+ response.raise_for_status()
117
+ except requests.HTTPError as e:
89
118
  try:
90
- response = getattr(self._session, method)(
91
- f"{self.host}/{path.strip('/')}",
92
- timeout=self._timeout,
93
- *args,
94
- **kwargs,
119
+ http_error_msg = (
120
+ f"{response.status_code} Client Error: "
121
+ f"{str(json.loads(response.content).get('detail'))}"
95
122
  )
96
- raise_for_hs_status(response)
97
123
  except (
98
- requests.exceptions.HTTPError,
99
- requests.exceptions.ConnectionError,
100
- ) as e:
101
- if attempt == 0:
102
- self._init_session()
103
- continue
104
- else:
105
- raise e
106
-
107
- return response
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)
108
132
 
109
133
  @property
110
134
  def workspaces(self):
@@ -112,6 +136,12 @@ class HydroServer:
112
136
 
113
137
  return WorkspaceService(self)
114
138
 
139
+ @property
140
+ def roles(self):
141
+ """Utilities for managing HydroServer workspaces."""
142
+
143
+ return RoleService(self)
144
+
115
145
  @property
116
146
  def things(self):
117
147
  """Utilities for managing HydroServer things."""
@@ -5,6 +5,7 @@ from .iam.collaborator import Collaborator
5
5
  from .iam.apikey import APIKey
6
6
  from .iam.account import Account
7
7
  from .sta.datastream import Datastream
8
+ from .sta.observation import ObservationCollection
8
9
  from .sta.observed_property import ObservedProperty
9
10
  from .sta.processing_level import ProcessingLevel
10
11
  from .sta.result_qualifier import ResultQualifier
@@ -19,5 +20,3 @@ Workspace.model_rebuild()
19
20
  Role.model_rebuild()
20
21
  Collaborator.model_rebuild()
21
22
  APIKey.model_rebuild()
22
-
23
- Unit.model_rebuild()
@@ -1,22 +1,89 @@
1
- from typing import Optional
2
- from uuid import UUID
3
- from pydantic import BaseModel, PrivateAttr, ConfigDict, computed_field
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
4
6
  from pydantic.alias_generators import to_camel
5
7
 
8
+ if TYPE_CHECKING:
9
+ from hydroserverpy import HydroServer
10
+ from hydroserverpy.api.services.base import HydroServerBaseService
11
+
6
12
 
7
13
  class HydroServerBaseModel(BaseModel):
8
- _uid: Optional[UUID] = PrivateAttr()
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()
9
20
 
10
- def __init__(self, _uid: Optional[UUID] = None, **data):
21
+ def __init__(self, *, client: "HydroServer", service: Optional["HydroServerBaseService"] = None, **data):
11
22
  super().__init__(**data)
12
- self._uid = _uid
13
23
 
14
- @computed_field
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
+
15
40
  @property
16
- def uid(self) -> Optional[UUID]:
17
- """The unique identifier for this resource."""
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."""
18
65
 
19
- return self._uid
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
20
87
 
21
88
  model_config = ConfigDict(
22
89
  validate_assignment=True,
@@ -26,49 +93,115 @@ class HydroServerBaseModel(BaseModel):
26
93
  )
27
94
 
28
95
 
29
- class HydroServerModel(HydroServerBaseModel):
30
- _model_ref: str = PrivateAttr()
31
- _original_data: Optional[dict] = PrivateAttr()
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 = []
32
131
 
33
- def __init__(self, _connection, _model_ref, _uid: Optional[UUID] = None, **data):
34
- if isinstance(_uid, str):
35
- _uid = UUID(_uid)
132
+ @property
133
+ def service(self) -> "HydroServerBaseService":
134
+ return self._service
36
135
 
37
- super().__init__(_uid=_uid, **data)
136
+ def next_page(self):
137
+ """Fetches the next page of data from HydroServer."""
38
138
 
39
- self._connection = _connection
40
- self._model_ref = _model_ref
41
- self._original_data = self.dict(by_alias=False).copy()
139
+ if not self._service:
140
+ raise NotImplementedError("Pagination not enabled for this collection.")
42
141
 
43
- @property
44
- def _patch_data(self) -> dict:
45
- return {
46
- key: getattr(self, key)
47
- for key, value in self._original_data.items()
48
- if hasattr(self, key) and getattr(self, key) != value
49
- }
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
+ )
50
148
 
51
- def _refresh(self) -> None:
52
- """Refresh this resource from HydroServer."""
149
+ def previous_page(self):
150
+ """Fetches the previous page of data from HydroServer."""
53
151
 
54
- self._original_data = (
55
- getattr(self._connection, self._model_ref)
56
- .get(uid=self.uid)
57
- .model_dump(exclude=["uid"])
58
- )
59
- self.__dict__.update(self._original_data)
152
+ if not self._service:
153
+ raise NotImplementedError("Pagination not enabled for this collection.")
60
154
 
61
- def _save(self) -> None:
62
- if self._patch_data:
63
- entity = getattr(self._connection, self._model_ref).update(
64
- uid=self.uid, **self._patch_data
65
- )
66
- self._original_data = entity.dict(by_alias=False, exclude=["uid"])
67
- self.__dict__.update(self._original_data)
155
+ if not self.page or self.page <= 1:
156
+ return None
68
157
 
69
- def _delete(self) -> None:
70
- if not self._uid:
71
- raise AttributeError("This resource cannot be deleted: UID is not set.")
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
+ )
72
164
 
73
- getattr(self._connection, self._model_ref).delete(uid=self._uid)
74
- self._uid = None
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
+ )
@@ -1,59 +1,46 @@
1
- from typing import Union, Optional, TYPE_CHECKING, List
2
- from uuid import UUID
3
- from pydantic import BaseModel, Field
1
+ import uuid
2
+ from typing import Union, ClassVar, Optional, TYPE_CHECKING, List
3
+ from pydantic import Field
4
4
  from .orchestration_system import OrchestrationSystem
5
5
  from .orchestration_configuration import OrchestrationConfigurationFields
6
6
  from ..sta.datastream import Datastream
7
- from ..base import HydroServerModel
7
+ from ..base import HydroServerBaseModel
8
8
 
9
9
  if TYPE_CHECKING:
10
10
  from hydroserverpy import HydroServer
11
11
  from hydroserverpy.api.models import Workspace
12
12
 
13
13
 
14
- class DataArchiveFields(BaseModel):
14
+ class DataArchive(
15
+ HydroServerBaseModel, OrchestrationConfigurationFields
16
+ ):
15
17
  name: str = Field(..., max_length=255)
16
18
  settings: Optional[dict] = None
19
+ orchestration_system_id: uuid.UUID
20
+ workspace_id: uuid.UUID
17
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
+ }
18
26
 
19
- class DataArchive(
20
- HydroServerModel, DataArchiveFields, OrchestrationConfigurationFields
21
- ):
22
- def __init__(self, _connection: "HydroServer", _uid: Union[UUID, str], **data):
23
- super().__init__(
24
- _connection=_connection, _model_ref="dataarchives", _uid=_uid, **data
25
- )
26
-
27
- self._workspace_id = str(data.get("workspace_id") or data["workspaceId"])
28
- self._orchestration_system_id = str(
29
- data.get("orchestration_system_id") or data["orchestrationSystem"]["id"]
30
- )
27
+ def __init__(self, client: "HydroServer", **data):
28
+ super().__init__(client=client, service=client.dataarchives, **data)
31
29
 
32
30
  self._workspace = None
31
+ self._orchestration_system = None
32
+ self._datastreams = None
33
33
 
34
- if data.get("orchestrationSystem"):
35
- self._orchestration_system = OrchestrationSystem(
36
- _connection=_connection,
37
- _uid=self._orchestration_system_id,
38
- **data["orchestrationSystem"]
39
- )
40
- else:
41
- self._orchestration_system = None
42
-
43
- if data.get("datastreams"):
44
- self._datastreams = [
45
- Datastream(_connection=_connection, _uid=datastream["id"], **datastream)
46
- for datastream in data["datastreams"]
47
- ]
48
- else:
49
- self._datastreams = []
34
+ @classmethod
35
+ def get_route(cls):
36
+ return "data-archives"
50
37
 
51
38
  @property
52
39
  def workspace(self) -> "Workspace":
53
40
  """The workspace this data archive belongs to."""
54
41
 
55
- if self._workspace is None and self._workspace_id:
56
- self._workspace = self._connection.workspaces.get(uid=self._workspace_id)
42
+ if self._workspace is None:
43
+ self._workspace = self.client.workspaces.get(uid=self.workspace_id)
57
44
 
58
45
  return self._workspace
59
46
 
@@ -61,10 +48,8 @@ class DataArchive(
61
48
  def orchestration_system(self) -> "OrchestrationSystem":
62
49
  """The orchestration system that manages this data archive."""
63
50
 
64
- if self._orchestration_system is None and self._orchestration_system_id:
65
- self._orchestration_system = self._connection.orchestration_systems.get(
66
- uid=self._orchestration_system_id
67
- )
51
+ if self._orchestration_system is None:
52
+ self._orchestration_system = self.client.orchestrationsystems.get(uid=self.orchestration_system_id)
68
53
 
69
54
  return self._orchestration_system
70
55
 
@@ -72,34 +57,21 @@ class DataArchive(
72
57
  def datastreams(self) -> List["Datastream"]:
73
58
  """The datastreams this data archive provides data for."""
74
59
 
75
- return self._datastreams
60
+ if self._datastreams is None:
61
+ self._datastreams = self.client.datastreams.list(data_archive=self.uid, fetch_all=True).items
76
62
 
77
- def refresh(self):
78
- """Refresh this data archive from HydroServer."""
79
-
80
- super()._refresh()
81
- self._workspace = None
82
-
83
- def save(self):
84
- """Save changes to this data archive to HydroServer."""
85
-
86
- super()._save()
87
-
88
- def delete(self):
89
- """Delete this data archive from HydroServer."""
90
-
91
- super()._delete()
63
+ return self._datastreams
92
64
 
93
- def add_datastream(self, datastream: Union["Datastream", UUID, str]):
65
+ def add_datastream(self, datastream: Union["Datastream", uuid.UUID, str]):
94
66
  """Add a datastream to this data archive."""
95
67
 
96
- self._connection.dataarchives.add_datastream(
68
+ self.client.dataarchives.add_datastream(
97
69
  uid=self.uid, datastream=datastream
98
70
  )
99
71
 
100
- def remove_datastream(self, datastream: Union["Datastream", UUID, str]):
72
+ def remove_datastream(self, datastream: Union["Datastream", uuid.UUID, str]):
101
73
  """Remove a datastream from this data archive."""
102
74
 
103
- self._connection.dataarchives.remove_datastream(
75
+ self.client.dataarchives.remove_datastream(
104
76
  uid=self.uid, datastream=datastream
105
77
  )