mercuto-client 0.3.0__py3-none-any.whl → 0.3.4a3__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.
- mercuto_client/_tests/test_ingester/test_file_processor.py +171 -13
- mercuto_client/acl.py +20 -0
- mercuto_client/client.py +41 -8
- mercuto_client/ingester/__main__.py +11 -8
- mercuto_client/ingester/processor.py +57 -37
- mercuto_client/mocks/__init__.py +76 -2
- mercuto_client/mocks/_utility.py +6 -0
- mercuto_client/mocks/mock_core.py +44 -0
- mercuto_client/mocks/mock_media.py +117 -0
- mercuto_client/mocks/mock_notifications.py +65 -0
- mercuto_client/modules/__init__.py +8 -4
- mercuto_client/modules/core.py +117 -287
- mercuto_client/modules/data.py +39 -41
- mercuto_client/modules/fatigue.py +19 -19
- mercuto_client/modules/identity.py +31 -31
- mercuto_client/modules/media.py +324 -0
- mercuto_client/modules/notifications.py +88 -0
- mercuto_client/modules/reports.py +222 -0
- mercuto_client/util.py +1 -1
- {mercuto_client-0.3.0.dist-info → mercuto_client-0.3.4a3.dist-info}/METADATA +1 -1
- {mercuto_client-0.3.0.dist-info → mercuto_client-0.3.4a3.dist-info}/RECORD +24 -18
- {mercuto_client-0.3.0.dist-info → mercuto_client-0.3.4a3.dist-info}/WHEEL +0 -0
- {mercuto_client-0.3.0.dist-info → mercuto_client-0.3.4a3.dist-info}/licenses/LICENSE +0 -0
- {mercuto_client-0.3.0.dist-info → mercuto_client-0.3.4a3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from typing import TYPE_CHECKING, Literal, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import TypeAdapter
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ..client import MercutoClient
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
from ..exceptions import MercutoHTTPException
|
|
13
|
+
from . import PayloadType
|
|
14
|
+
from ._util import BaseModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Image(BaseModel):
|
|
18
|
+
code: str
|
|
19
|
+
project: str
|
|
20
|
+
mime_type: str
|
|
21
|
+
size_bytes: int
|
|
22
|
+
name: str
|
|
23
|
+
access_url: str
|
|
24
|
+
access_expires: datetime
|
|
25
|
+
camera: Optional[str] = None
|
|
26
|
+
timestamp: Optional[datetime] = None
|
|
27
|
+
event: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Video(BaseModel):
|
|
31
|
+
code: str
|
|
32
|
+
project: str
|
|
33
|
+
start_time: datetime
|
|
34
|
+
end_time: datetime
|
|
35
|
+
|
|
36
|
+
mime_type: str
|
|
37
|
+
size_bytes: int
|
|
38
|
+
name: str
|
|
39
|
+
|
|
40
|
+
access_url: str
|
|
41
|
+
access_expires: datetime
|
|
42
|
+
|
|
43
|
+
event: Optional[str] = None
|
|
44
|
+
camera: Optional[str] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _VideoUploadInitializeResponse(BaseModel):
|
|
48
|
+
request_id: str
|
|
49
|
+
presigned_put_url: str
|
|
50
|
+
presigned_url_expires: datetime
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Healthcheck(BaseModel):
|
|
54
|
+
status: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CameraTrigger(BaseModel):
|
|
58
|
+
trigger_channel: str
|
|
59
|
+
pre_interval: timedelta = timedelta(seconds=10)
|
|
60
|
+
post_interval: timedelta = timedelta(seconds=10)
|
|
61
|
+
enabled: bool = True
|
|
62
|
+
trigger_greater_than: Optional[float] = None
|
|
63
|
+
trigger_less_than: Optional[float] = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Camera(BaseModel):
|
|
67
|
+
code: str
|
|
68
|
+
project: str
|
|
69
|
+
label: str
|
|
70
|
+
triggers: list[CameraTrigger]
|
|
71
|
+
camera_type: Literal['BOSCH', 'DIRECT_RTSP',
|
|
72
|
+
'STATIC', 'ROCKFIELD-CAMERA-SERVER-VERSION-2']
|
|
73
|
+
encode_timestamp: bool = True
|
|
74
|
+
encode_blur: bool = False
|
|
75
|
+
blur_steps: Optional[int] = None
|
|
76
|
+
blur_sigma: Optional[float] = None
|
|
77
|
+
tunnel_address: Optional[str] = None
|
|
78
|
+
tunnel_port: Optional[int] = None
|
|
79
|
+
tunnel_username: Optional[str] = None
|
|
80
|
+
tunnel_password: Optional[str] = None
|
|
81
|
+
tunnel_key: Optional[str] = None
|
|
82
|
+
camera_ip: Optional[str] = None
|
|
83
|
+
camera_port: Optional[int] = None
|
|
84
|
+
camera_username: Optional[str] = None
|
|
85
|
+
camera_password: Optional[str] = None
|
|
86
|
+
camera_serial: Optional[str] = None
|
|
87
|
+
rtsp_url: Optional[str] = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- TypeAdapters for lists ---
|
|
91
|
+
_ImagelistAdapter = TypeAdapter(list[Image])
|
|
92
|
+
_VideolistAdapter = TypeAdapter(list[Video])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class MercutoMediaService:
|
|
96
|
+
def __init__(self, client: 'MercutoClient', path: str = '/media') -> None:
|
|
97
|
+
self._client = client
|
|
98
|
+
self._path = path
|
|
99
|
+
|
|
100
|
+
def healthcheck(self) -> Healthcheck:
|
|
101
|
+
r = self._client.request(f"{self._path}/healthcheck", "GET")
|
|
102
|
+
return Healthcheck.model_validate_json(r.text)
|
|
103
|
+
|
|
104
|
+
# --- Images ---
|
|
105
|
+
|
|
106
|
+
def list_images(self, project: str,
|
|
107
|
+
camera: Optional[str] = None,
|
|
108
|
+
event: Optional[str] = None,
|
|
109
|
+
start_time: Optional[datetime] = None,
|
|
110
|
+
end_time: Optional[datetime] = None,
|
|
111
|
+
limit: int = 10,
|
|
112
|
+
offset: int = 0,
|
|
113
|
+
ascending: bool = True) -> list[Image]:
|
|
114
|
+
params: PayloadType = {
|
|
115
|
+
'project': project,
|
|
116
|
+
'limit': limit,
|
|
117
|
+
'offset': offset,
|
|
118
|
+
'ascending': ascending
|
|
119
|
+
}
|
|
120
|
+
if camera is not None:
|
|
121
|
+
params["camera"] = camera
|
|
122
|
+
if event is not None:
|
|
123
|
+
params["event"] = event
|
|
124
|
+
if start_time is not None:
|
|
125
|
+
if start_time.tzinfo is None:
|
|
126
|
+
raise ValueError("start_time must be timezone-aware")
|
|
127
|
+
params["start_time"] = start_time.isoformat()
|
|
128
|
+
if end_time is not None:
|
|
129
|
+
if end_time.tzinfo is None:
|
|
130
|
+
raise ValueError("end_time must be timezone-aware")
|
|
131
|
+
params["end_time"] = end_time.isoformat()
|
|
132
|
+
r = self._client.request(f"{self._path}/images", "GET", params=params)
|
|
133
|
+
return _ImagelistAdapter.validate_json(r.text)
|
|
134
|
+
|
|
135
|
+
def get_image(self, image_code: str) -> Image:
|
|
136
|
+
r = self._client.request(f"{self._path}/images/{image_code}", "GET")
|
|
137
|
+
return Image.model_validate_json(r.text)
|
|
138
|
+
|
|
139
|
+
def delete_image(self, image_code: str) -> None:
|
|
140
|
+
self._client.request(f"{self._path}/images/{image_code}", "DELETE")
|
|
141
|
+
|
|
142
|
+
def upload_image(self, filename: str, project: str,
|
|
143
|
+
camera: Optional[str] = None,
|
|
144
|
+
timestamp: Optional[datetime] = None,
|
|
145
|
+
event: Optional[str] = None,
|
|
146
|
+
filedata: Optional[bytes] = None) -> Image:
|
|
147
|
+
"""
|
|
148
|
+
Upload an image to the media service.
|
|
149
|
+
Provide either filename (path to file) or filedata (bytes of file) + filename (reference only).
|
|
150
|
+
"""
|
|
151
|
+
params: PayloadType = {
|
|
152
|
+
'project': project
|
|
153
|
+
}
|
|
154
|
+
if camera is not None:
|
|
155
|
+
params["camera"] = camera
|
|
156
|
+
if timestamp is not None:
|
|
157
|
+
if timestamp.tzinfo is None:
|
|
158
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
159
|
+
params["timestamp"] = timestamp.isoformat()
|
|
160
|
+
if event is not None:
|
|
161
|
+
params["event"] = event
|
|
162
|
+
|
|
163
|
+
if filedata is not None:
|
|
164
|
+
from io import BytesIO
|
|
165
|
+
r = self._client.request(
|
|
166
|
+
f"{self._path}/images", "PUT", params=params, files={'file': (filename, BytesIO(filedata))})
|
|
167
|
+
return Image.model_validate_json(r.text)
|
|
168
|
+
else:
|
|
169
|
+
with open(filename, 'rb') as f:
|
|
170
|
+
r = self._client.request(
|
|
171
|
+
f"{self._path}/images", "PUT", params=params, files={'file': f})
|
|
172
|
+
return Image.model_validate_json(r.text)
|
|
173
|
+
|
|
174
|
+
# --- Videos ---
|
|
175
|
+
|
|
176
|
+
def list_videos(self, project: str,
|
|
177
|
+
camera: Optional[str] = None,
|
|
178
|
+
event: Optional[str] = None,
|
|
179
|
+
start_time: Optional[datetime] = None,
|
|
180
|
+
end_time: Optional[datetime] = None,
|
|
181
|
+
limit: int = 10,
|
|
182
|
+
offset: int = 0,
|
|
183
|
+
ascending: bool = True) -> list[Video]:
|
|
184
|
+
params: PayloadType = {
|
|
185
|
+
'project': project,
|
|
186
|
+
'limit': limit,
|
|
187
|
+
'offset': offset,
|
|
188
|
+
'ascending': ascending
|
|
189
|
+
}
|
|
190
|
+
if camera is not None:
|
|
191
|
+
params["camera"] = camera
|
|
192
|
+
if event is not None:
|
|
193
|
+
params["event"] = event
|
|
194
|
+
if start_time is not None:
|
|
195
|
+
if start_time.tzinfo is None:
|
|
196
|
+
raise ValueError("start_time must be timezone-aware")
|
|
197
|
+
params["start_time"] = start_time.isoformat()
|
|
198
|
+
if end_time is not None:
|
|
199
|
+
if end_time.tzinfo is None:
|
|
200
|
+
raise ValueError("end_time must be timezone-aware")
|
|
201
|
+
params["end_time"] = end_time.isoformat()
|
|
202
|
+
r = self._client.request(f"{self._path}/videos", "GET", params=params)
|
|
203
|
+
return _VideolistAdapter.validate_json(r.text)
|
|
204
|
+
|
|
205
|
+
def get_video(self, video_code: str) -> Video:
|
|
206
|
+
r = self._client.request(f"{self._path}/videos/{video_code}", "GET")
|
|
207
|
+
return Video.model_validate_json(r.text)
|
|
208
|
+
|
|
209
|
+
def upload_video(self, filename: str, project: str,
|
|
210
|
+
start_time: datetime,
|
|
211
|
+
end_time: datetime,
|
|
212
|
+
camera: Optional[str] = None,
|
|
213
|
+
event: Optional[str] = None) -> str:
|
|
214
|
+
"""
|
|
215
|
+
Upload a video file to the media service.
|
|
216
|
+
This is a multi-step process.
|
|
217
|
+
First, the request is initialized to get an upload URL.
|
|
218
|
+
Then, the file is uploaded to the provided URL.
|
|
219
|
+
Finally, the upload is finalized.
|
|
220
|
+
|
|
221
|
+
:returns: A request ID used to track the status of the uploaded video processing. Pass this to the /requests/{request_id} endpoint
|
|
222
|
+
to check the status and get the video_id once processing is complete.
|
|
223
|
+
"""
|
|
224
|
+
import mimetypes
|
|
225
|
+
mime_type = mimetypes.guess_type(filename, strict=False)[0]
|
|
226
|
+
if mime_type is None:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"Could not determine MIME type for file: {filename}")
|
|
229
|
+
if mime_type not in {'video/mp4', 'video/avi', 'video/mov', 'video/mkv'}:
|
|
230
|
+
raise ValueError(f"Unsupported video MIME type: {mime_type}")
|
|
231
|
+
|
|
232
|
+
# 1. Make the initialize upload request
|
|
233
|
+
init_payload: PayloadType = {
|
|
234
|
+
'start_time': start_time.isoformat(),
|
|
235
|
+
'end_time': end_time.isoformat(),
|
|
236
|
+
'mime_type': mime_type,
|
|
237
|
+
'filename': os.path.basename(filename)
|
|
238
|
+
}
|
|
239
|
+
if camera is not None:
|
|
240
|
+
init_payload['camera'] = camera
|
|
241
|
+
if event is not None:
|
|
242
|
+
init_payload['event'] = event
|
|
243
|
+
init_request = self._client.request(f"{self._path}/videos", "POST", json=init_payload,
|
|
244
|
+
params={'project': project, 'action': 'initialize'})
|
|
245
|
+
init_request_response = _VideoUploadInitializeResponse.model_validate_json(
|
|
246
|
+
init_request.text)
|
|
247
|
+
|
|
248
|
+
# 2. Upload the video file
|
|
249
|
+
with open(filename, 'rb') as f:
|
|
250
|
+
resp = self._client.session().put(init_request_response.presigned_put_url,
|
|
251
|
+
data=f, verify=self._client.verify_ssl)
|
|
252
|
+
if not resp.ok:
|
|
253
|
+
raise MercutoHTTPException(
|
|
254
|
+
f"Video upload failed: {resp.text}", resp.status_code)
|
|
255
|
+
|
|
256
|
+
# 3. Finalize the upload
|
|
257
|
+
self._client.request(f"{self._path}/videos", "POST", params={
|
|
258
|
+
'project': project,
|
|
259
|
+
'action': 'commit',
|
|
260
|
+
'request_id': init_request_response.request_id
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
return init_request_response.request_id
|
|
264
|
+
|
|
265
|
+
# --- Cameras ---
|
|
266
|
+
|
|
267
|
+
def create_camera(self, project: str,
|
|
268
|
+
label: str,
|
|
269
|
+
triggers: list[CameraTrigger],
|
|
270
|
+
encode_timestamp: bool = True,
|
|
271
|
+
encode_blur: bool = False,
|
|
272
|
+
blur_steps: Optional[int] = None,
|
|
273
|
+
blur_sigma: Optional[float] = None,
|
|
274
|
+
tunnel_address: Optional[str] = None,
|
|
275
|
+
tunnel_port: Optional[int] = None,
|
|
276
|
+
tunnel_username: Optional[str] = None,
|
|
277
|
+
tunnel_password: Optional[str] = None,
|
|
278
|
+
tunnel_key: Optional[str] = None,
|
|
279
|
+
camera_ip: Optional[str] = None,
|
|
280
|
+
camera_port: Optional[int] = None,
|
|
281
|
+
camera_username: Optional[str] = None,
|
|
282
|
+
camera_password: Optional[str] = None,
|
|
283
|
+
camera_serial: Optional[str] = None,
|
|
284
|
+
camera_type: Literal['BOSCH', 'DIRECT_RTSP', 'STATIC',
|
|
285
|
+
'ROCKFIELD-CAMERA-SERVER-VERSION-2'] = 'DIRECT_RTSP',
|
|
286
|
+
rtsp_url: Optional[str] = None,) -> Camera:
|
|
287
|
+
payload: PayloadType = {
|
|
288
|
+
'label': label,
|
|
289
|
+
'encode_timestamp': encode_timestamp,
|
|
290
|
+
'encode_blur': encode_blur,
|
|
291
|
+
}
|
|
292
|
+
if blur_steps is not None:
|
|
293
|
+
payload['blur_steps'] = blur_steps
|
|
294
|
+
if blur_sigma is not None:
|
|
295
|
+
payload['blur_sigma'] = blur_sigma
|
|
296
|
+
if tunnel_address is not None:
|
|
297
|
+
tunnel_payload: PayloadType = {
|
|
298
|
+
'address': tunnel_address,
|
|
299
|
+
'port': tunnel_port,
|
|
300
|
+
'username': tunnel_username,
|
|
301
|
+
'password': tunnel_password,
|
|
302
|
+
'key': tunnel_key
|
|
303
|
+
}
|
|
304
|
+
payload['ssh_tunnel'] = tunnel_payload
|
|
305
|
+
if camera_ip is not None:
|
|
306
|
+
payload['camera_ip'] = camera_ip
|
|
307
|
+
if camera_port is not None:
|
|
308
|
+
payload['camera_port'] = camera_port
|
|
309
|
+
if camera_username is not None:
|
|
310
|
+
payload['camera_username'] = camera_username
|
|
311
|
+
if camera_password is not None:
|
|
312
|
+
payload['camera_password'] = camera_password
|
|
313
|
+
if camera_serial is not None:
|
|
314
|
+
payload['camera_serial'] = camera_serial
|
|
315
|
+
payload['camera_type'] = camera_type
|
|
316
|
+
if rtsp_url is not None:
|
|
317
|
+
payload['rtsp_url'] = rtsp_url
|
|
318
|
+
|
|
319
|
+
if triggers:
|
|
320
|
+
payload['triggers'] = [trigger.model_dump(mode='json') for trigger in triggers] # type: ignore
|
|
321
|
+
|
|
322
|
+
r = self._client.request(
|
|
323
|
+
f"{self._path}/cameras", "PUT", json=payload, params={'project': project})
|
|
324
|
+
return Camera.model_validate_json(r.text)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Literal, Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import TypeAdapter
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from ..client import MercutoClient
|
|
7
|
+
|
|
8
|
+
from ._util import BaseModel
|
|
9
|
+
|
|
10
|
+
ContactMethod = Literal['EMAIL', 'SMS']
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContactGroup(BaseModel):
|
|
14
|
+
code: str
|
|
15
|
+
project: str
|
|
16
|
+
label: str
|
|
17
|
+
users: dict[str, list[ContactMethod]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NotificationAttachment(BaseModel):
|
|
21
|
+
"""
|
|
22
|
+
Attachment to include in the notification.
|
|
23
|
+
"""
|
|
24
|
+
filename: str
|
|
25
|
+
presigned_url: str # Presigned URL to fetch the attachment from.
|
|
26
|
+
mime_type: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Healthcheck(BaseModel):
|
|
30
|
+
status: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --- TypeAdapters for lists ---
|
|
34
|
+
_ContactGroupListAdapter = TypeAdapter(list[ContactGroup])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MercutoNotificationService:
|
|
38
|
+
def __init__(self, client: 'MercutoClient', path: str = '/notifications') -> None:
|
|
39
|
+
self._client = client
|
|
40
|
+
self._path = path
|
|
41
|
+
|
|
42
|
+
def healthcheck(self) -> Healthcheck:
|
|
43
|
+
r = self._client.request(f"{self._path}/healthcheck", "GET")
|
|
44
|
+
return Healthcheck.model_validate_json(r.text)
|
|
45
|
+
|
|
46
|
+
def list_contact_groups(self, project: str) -> list[ContactGroup]:
|
|
47
|
+
r = self._client.request(f"{self._path}/contact-groups", "GET", params={"project": project})
|
|
48
|
+
return _ContactGroupListAdapter.validate_json(r.text)
|
|
49
|
+
|
|
50
|
+
def get_contact_group(self, code: str) -> ContactGroup:
|
|
51
|
+
r = self._client.request(f"{self._path}/contact-groups/{code}", "GET")
|
|
52
|
+
return ContactGroup.model_validate_json(r.text)
|
|
53
|
+
|
|
54
|
+
def create_contact_group(self, project: str, label: str, users: dict[str, list[ContactMethod]]) -> ContactGroup:
|
|
55
|
+
r = self._client.request(f"{self._path}/contact-groups", "PUT", json={
|
|
56
|
+
"project": project,
|
|
57
|
+
"label": label,
|
|
58
|
+
"users": users
|
|
59
|
+
})
|
|
60
|
+
return ContactGroup.model_validate_json(r.text)
|
|
61
|
+
|
|
62
|
+
def issue_notification(self, contact_group: str, subject: str, html: str,
|
|
63
|
+
alternative_plaintext: Optional[str] = None,
|
|
64
|
+
attachments: Optional[list[NotificationAttachment]] = None,
|
|
65
|
+
unsubscribe_placeholder_text: Optional[str] = None) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Issue a notification to all contacts within a contact group, based on their contact preferences.
|
|
68
|
+
|
|
69
|
+
:param contact_group: The code of the contact group to send the notification to.
|
|
70
|
+
:param subject: The subject of the notification (i.e. email subject line).
|
|
71
|
+
:param html: The HTML content of the notification.
|
|
72
|
+
:param alternative_plaintext: Optional plaintext alternative for the notification.
|
|
73
|
+
Alternative plaintext is used for SMS notifications and for email clients that do not support HTML.
|
|
74
|
+
:param attachments: Optional list of attachments to include in the notification.
|
|
75
|
+
Only applicable for email notifications.
|
|
76
|
+
:param unsubscribe_placeholder_text: Optional placeholder text for unsubscribe links.
|
|
77
|
+
Any text matching this placeholder will be replaced with an unsubscribe link for email notifications.
|
|
78
|
+
:return: None
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
self._client.request(f"{self._path}/contact-groups/{contact_group}/notify", "POST", json={
|
|
82
|
+
"subject": subject,
|
|
83
|
+
"html": html,
|
|
84
|
+
"alternative_plaintext": alternative_plaintext,
|
|
85
|
+
"attachments": [attachment.model_dump() for attachment in attachments] if attachments else [],
|
|
86
|
+
"unsubscribe_placeholder_text": unsubscribe_placeholder_text
|
|
87
|
+
})
|
|
88
|
+
return
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import (TYPE_CHECKING, BinaryIO, Literal, Optional, Protocol,
|
|
3
|
+
TypedDict)
|
|
4
|
+
|
|
5
|
+
from pydantic import AwareDatetime, TypeAdapter
|
|
6
|
+
|
|
7
|
+
from ..exceptions import MercutoHTTPException
|
|
8
|
+
from . import PayloadType
|
|
9
|
+
from ._util import BaseModel
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ..client import MercutoClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ReportConfiguration(BaseModel):
|
|
16
|
+
code: str
|
|
17
|
+
project: str
|
|
18
|
+
label: str
|
|
19
|
+
revision: str
|
|
20
|
+
schedule: Optional[str] = None
|
|
21
|
+
contact_group: Optional[str] = None
|
|
22
|
+
last_scheduled: Optional[AwareDatetime] = None
|
|
23
|
+
custom_policy: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ReportLogStatus = Literal['PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED']
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ReportLog(BaseModel):
|
|
30
|
+
code: str
|
|
31
|
+
report_configuration: str
|
|
32
|
+
scheduled_start: Optional[AwareDatetime]
|
|
33
|
+
actual_start: AwareDatetime
|
|
34
|
+
actual_finish: Optional[AwareDatetime]
|
|
35
|
+
status: ReportLogStatus
|
|
36
|
+
message: Optional[str]
|
|
37
|
+
access_url: Optional[str]
|
|
38
|
+
mime_type: Optional[str]
|
|
39
|
+
filename: Optional[str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ReportSourceCodeRevision(BaseModel):
|
|
43
|
+
code: str
|
|
44
|
+
project: Optional[str]
|
|
45
|
+
revision_date: AwareDatetime
|
|
46
|
+
description: str
|
|
47
|
+
source_code_url: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Healthcheck(BaseModel):
|
|
51
|
+
status: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_ReportConfigurationListAdapter = TypeAdapter(list[ReportConfiguration])
|
|
55
|
+
_ReportLogListAdapter = TypeAdapter(list[ReportLog])
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
The below types are used for defining report generation functions.
|
|
59
|
+
They are provided for type-checking and helpers for users writing custom reports.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ReportHandlerResultLike(Protocol):
|
|
64
|
+
filename: str
|
|
65
|
+
mime_type: str
|
|
66
|
+
data: bytes
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ReportHandlerResult(BaseModel):
|
|
70
|
+
filename: str
|
|
71
|
+
mime_type: str
|
|
72
|
+
data: bytes
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class HandlerRequest(TypedDict):
|
|
76
|
+
timestamp: datetime
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class HandlerContext(TypedDict):
|
|
80
|
+
service_token: str
|
|
81
|
+
project_code: str
|
|
82
|
+
report_code: str
|
|
83
|
+
log_code: str
|
|
84
|
+
client: 'MercutoClient'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ReportHandler(Protocol):
|
|
88
|
+
def __call__(self,
|
|
89
|
+
request: 'HandlerRequest',
|
|
90
|
+
context: 'HandlerContext') -> 'ReportHandlerResultLike':
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class MercutoReportService:
|
|
95
|
+
def __init__(self, client: 'MercutoClient', path: str = '/reports') -> None:
|
|
96
|
+
self._client = client
|
|
97
|
+
self._path = path
|
|
98
|
+
|
|
99
|
+
def healthcheck(self) -> Healthcheck:
|
|
100
|
+
r = self._client.request(f"{self._path}/healthcheck", "GET")
|
|
101
|
+
return Healthcheck.model_validate_json(r.text)
|
|
102
|
+
|
|
103
|
+
def list_report_configurations(self, project: str) -> list['ReportConfiguration']:
|
|
104
|
+
"""
|
|
105
|
+
List scheduled reports for a specific project.
|
|
106
|
+
"""
|
|
107
|
+
params: PayloadType = {
|
|
108
|
+
'project': project
|
|
109
|
+
}
|
|
110
|
+
r = self._client.request(
|
|
111
|
+
f'{self._path}/configurations', 'GET', params=params)
|
|
112
|
+
return _ReportConfigurationListAdapter.validate_json(r.text)
|
|
113
|
+
|
|
114
|
+
def create_report_configuration(self, project: str, label: str, schedule: str, revision: str,
|
|
115
|
+
contact_group: Optional[str] = None, custom_policy: Optional[str] = None) -> ReportConfiguration:
|
|
116
|
+
"""
|
|
117
|
+
Create a new scheduled report using the provided source code revision.
|
|
118
|
+
"""
|
|
119
|
+
json: PayloadType = {
|
|
120
|
+
'project': project,
|
|
121
|
+
'label': label,
|
|
122
|
+
'schedule': schedule,
|
|
123
|
+
'revision': revision,
|
|
124
|
+
}
|
|
125
|
+
if contact_group is not None:
|
|
126
|
+
json['contact_group'] = contact_group
|
|
127
|
+
if custom_policy is not None:
|
|
128
|
+
json['custom_policy'] = custom_policy
|
|
129
|
+
r = self._client.request(
|
|
130
|
+
f'{self._path}/configurations', 'PUT', json=json)
|
|
131
|
+
return ReportConfiguration.model_validate_json(r.text)
|
|
132
|
+
|
|
133
|
+
def generate_report(self, report: str, timestamp: datetime, mark_as_scheduled: bool = False) -> ReportLog:
|
|
134
|
+
"""
|
|
135
|
+
Trigger generation of a scheduled report for a specific timestamp.
|
|
136
|
+
"""
|
|
137
|
+
r = self._client.request(f'{self._path}/configurations/{report}/generate', 'POST', json={
|
|
138
|
+
'timestamp': timestamp.isoformat(),
|
|
139
|
+
'mark_as_scheduled': mark_as_scheduled
|
|
140
|
+
})
|
|
141
|
+
return ReportLog.model_validate_json(r.text)
|
|
142
|
+
|
|
143
|
+
def list_report_logs(self, project: str, report: Optional[str] = None) -> list[ReportLog]:
|
|
144
|
+
"""
|
|
145
|
+
List report log entries for a specific project.
|
|
146
|
+
"""
|
|
147
|
+
params: PayloadType = {
|
|
148
|
+
'project': project
|
|
149
|
+
}
|
|
150
|
+
if report is not None:
|
|
151
|
+
params['configuration'] = report
|
|
152
|
+
r = self._client.request(
|
|
153
|
+
f'{self._path}/logs', 'GET', params=params)
|
|
154
|
+
return _ReportLogListAdapter.validate_json(r.text)
|
|
155
|
+
|
|
156
|
+
def get_report_log(self, log: str) -> ReportLog:
|
|
157
|
+
"""
|
|
158
|
+
Get a specific report log entry.
|
|
159
|
+
"""
|
|
160
|
+
r = self._client.request(
|
|
161
|
+
f'{self._path}/logs/{log}', 'GET')
|
|
162
|
+
return ReportLog.model_validate_json(r.text)
|
|
163
|
+
|
|
164
|
+
def create_report_revision(self, revision_date: datetime,
|
|
165
|
+
description: str,
|
|
166
|
+
project: Optional[str],
|
|
167
|
+
source_code: BinaryIO) -> ReportSourceCodeRevision:
|
|
168
|
+
"""
|
|
169
|
+
Create a new report source code revision.
|
|
170
|
+
|
|
171
|
+
A report should be a python file that defines a function called `generate_report`
|
|
172
|
+
that takes two arguments: `request` and `context`, and returns an object with
|
|
173
|
+
`filename`, `mime_type`, and `data` attributes. It can also be a package with __init__.py
|
|
174
|
+
defining the `generate_report` function.
|
|
175
|
+
|
|
176
|
+
You can use the `mercuto_client.modules.reports.ReportHandler` protocol
|
|
177
|
+
to type hint your report function. Example:
|
|
178
|
+
```python
|
|
179
|
+
from mercuto_client.modules.reports import ReportHandler, HandlerRequest, HandlerContext, ReportHandlerResult
|
|
180
|
+
def generate_report(request: HandlerRequest, context: HandlerContext) -> ReportHandlerResult:
|
|
181
|
+
# Your report generation logic here
|
|
182
|
+
return ReportHandlerResult(
|
|
183
|
+
filename="report.pdf",
|
|
184
|
+
mime_type="application/pdf",
|
|
185
|
+
data=b"PDF binary data here"
|
|
186
|
+
)
|
|
187
|
+
```
|
|
188
|
+
The request parameter contains information about the report generation request,
|
|
189
|
+
and the context parameter provides access to the Mercuto client and metadata about
|
|
190
|
+
the report being generated. The MercutoClient provided in the context can be used
|
|
191
|
+
to fetch any additional data required for the report. It will be authenticated
|
|
192
|
+
using a service token with VIEW_PROJECT permission and VIEW_TENANT permission.
|
|
193
|
+
|
|
194
|
+
Params:
|
|
195
|
+
project (str): The project code.
|
|
196
|
+
revision_date (datetime): The date of the revision.
|
|
197
|
+
description (str): A description of the revision.
|
|
198
|
+
source_code (io.BinaryIO): The report source code file, either a .py file or a .zip package.
|
|
199
|
+
|
|
200
|
+
"""
|
|
201
|
+
# Create the revision metadata
|
|
202
|
+
json: PayloadType = {
|
|
203
|
+
'revision_date': revision_date.isoformat(),
|
|
204
|
+
'description': description,
|
|
205
|
+
}
|
|
206
|
+
if project is not None:
|
|
207
|
+
json['project'] = project
|
|
208
|
+
r = self._client.request(f'{self._path}/revisions', 'PUT', json=json)
|
|
209
|
+
revision = ReportSourceCodeRevision.model_validate_json(r.text)
|
|
210
|
+
|
|
211
|
+
# Upload the source code
|
|
212
|
+
r = self._client.request(
|
|
213
|
+
f'{self._path}/revisions/{revision.code}', 'PATCH')
|
|
214
|
+
upload_url = r.json()['target_source_code_url']
|
|
215
|
+
upload_url = self._client.session().put(upload_url,
|
|
216
|
+
data=source_code, verify=self._client.verify_ssl)
|
|
217
|
+
if not upload_url.ok:
|
|
218
|
+
raise MercutoHTTPException(
|
|
219
|
+
f"Failed to upload report source code: {upload_url.status_code} {upload_url.text}",
|
|
220
|
+
upload_url.status_code
|
|
221
|
+
)
|
|
222
|
+
return revision
|
mercuto_client/util.py
CHANGED
|
@@ -53,7 +53,7 @@ def get_free_space_excluding_files(directory: str) -> int:
|
|
|
53
53
|
:return: Free bytes available in the partition after subtracting file sizes.
|
|
54
54
|
"""
|
|
55
55
|
# Get partition's free space
|
|
56
|
-
|
|
56
|
+
_, _, free = shutil.disk_usage(directory)
|
|
57
57
|
|
|
58
58
|
# Calculate the total size of files in the directory
|
|
59
59
|
files_size = get_directory_size(directory)
|