pynims 1.0.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.
- pynims/__init__.py +3 -0
- pynims/cli.py +402 -0
- pynims/client.py +345 -0
- pynims/config.py +19 -0
- pynims/filtering.py +277 -0
- pynims/py.typed +1 -0
- pynims/utils.py +84 -0
- pynims/workflows.py +132 -0
- pynims-1.0.0.dist-info/METADATA +201 -0
- pynims-1.0.0.dist-info/RECORD +13 -0
- pynims-1.0.0.dist-info/WHEEL +4 -0
- pynims-1.0.0.dist-info/entry_points.txt +2 -0
- pynims-1.0.0.dist-info/licenses/LICENSE.md +37 -0
pynims/__init__.py
ADDED
pynims/cli.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import typer
|
|
5
|
+
from datetime import time, timedelta
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pynims import __version__
|
|
10
|
+
from pynims.client import NIMSClient
|
|
11
|
+
from pynims.workflows import (
|
|
12
|
+
get_camera_list,
|
|
13
|
+
get_image_list_for_camera,
|
|
14
|
+
save_image_list_to_file,
|
|
15
|
+
download_images_for_camera,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
FILTER_PANEL = "Filter Options"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def version_callback(value: bool):
|
|
22
|
+
if value:
|
|
23
|
+
typer.echo(f"pynims {__version__}")
|
|
24
|
+
raise typer.Exit()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_interval(value: str) -> timedelta:
|
|
28
|
+
"""Parse interval string like '24h', '7d', '30m' into timedelta."""
|
|
29
|
+
match = re.fullmatch(r"(\d+)([dhm])", value.strip())
|
|
30
|
+
if not match:
|
|
31
|
+
raise typer.BadParameter(
|
|
32
|
+
f"Invalid interval: {value}. Use e.g. '24h', '7d', '30m'"
|
|
33
|
+
)
|
|
34
|
+
n, unit = int(match.group(1)), match.group(2)
|
|
35
|
+
if unit == "d":
|
|
36
|
+
return timedelta(days=n)
|
|
37
|
+
elif unit == "h":
|
|
38
|
+
return timedelta(hours=n)
|
|
39
|
+
else:
|
|
40
|
+
return timedelta(minutes=n)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_nth_per_group(value: str) -> tuple:
|
|
44
|
+
"""Parse nth-per-group string like 'day:first', 'month:12:00'."""
|
|
45
|
+
parts = value.split(":", 1)
|
|
46
|
+
if len(parts) != 2:
|
|
47
|
+
raise typer.BadParameter(
|
|
48
|
+
f"Invalid format: {value}. Use e.g. 'day:first', 'month:12:00'"
|
|
49
|
+
)
|
|
50
|
+
group_by = parts[0]
|
|
51
|
+
position_str = parts[1]
|
|
52
|
+
if position_str in ("first", "last"):
|
|
53
|
+
return (group_by, position_str)
|
|
54
|
+
try:
|
|
55
|
+
h, m = position_str.split(":")
|
|
56
|
+
return (group_by, time(int(h), int(m)))
|
|
57
|
+
except (ValueError, TypeError):
|
|
58
|
+
raise typer.BadParameter(
|
|
59
|
+
f"Invalid position: {position_str}. Use 'first', 'last', or 'HH:MM'"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_filters(
|
|
64
|
+
hours,
|
|
65
|
+
months,
|
|
66
|
+
days_of_week,
|
|
67
|
+
days_of_month,
|
|
68
|
+
years,
|
|
69
|
+
date_range,
|
|
70
|
+
recurring_range,
|
|
71
|
+
interval,
|
|
72
|
+
nth_per_group,
|
|
73
|
+
times_of_day,
|
|
74
|
+
times_max_delta,
|
|
75
|
+
) -> Optional[dict]:
|
|
76
|
+
"""Build a filters dict from CLI flag values. Returns None if no filters specified."""
|
|
77
|
+
filters = {}
|
|
78
|
+
if hours is not None:
|
|
79
|
+
parts = hours.split("-")
|
|
80
|
+
if len(parts) != 2:
|
|
81
|
+
raise typer.BadParameter(f"Invalid hours: {hours}. Use e.g. '8-17'")
|
|
82
|
+
try:
|
|
83
|
+
start_h, end_h = int(parts[0]), int(parts[1])
|
|
84
|
+
except ValueError:
|
|
85
|
+
raise typer.BadParameter(f"Invalid hours: {hours}. Use e.g. '8-17'")
|
|
86
|
+
if not (0 <= start_h <= 23 and 0 <= end_h <= 23):
|
|
87
|
+
raise typer.BadParameter(f"Hours must be 0-23, got: {hours}")
|
|
88
|
+
filters["hours"] = (start_h, end_h)
|
|
89
|
+
if months is not None:
|
|
90
|
+
filters["months"] = [int(m) for m in months.split(",")]
|
|
91
|
+
if days_of_week is not None:
|
|
92
|
+
filters["days_of_week"] = [int(d) for d in days_of_week.split(",")]
|
|
93
|
+
if days_of_month is not None:
|
|
94
|
+
filters["days_of_month"] = [int(d) for d in days_of_month.split(",")]
|
|
95
|
+
if years is not None:
|
|
96
|
+
filters["years"] = [int(y) for y in years.split(",")]
|
|
97
|
+
if date_range is not None:
|
|
98
|
+
parts = date_range.split(",")
|
|
99
|
+
if len(parts) != 2:
|
|
100
|
+
raise typer.BadParameter(
|
|
101
|
+
f"Invalid date-range: {date_range}. Use e.g. '2023-06-01,2023-09-30'"
|
|
102
|
+
)
|
|
103
|
+
filters["date_range"] = (parts[0], parts[1])
|
|
104
|
+
if recurring_range is not None:
|
|
105
|
+
try:
|
|
106
|
+
start_part, end_part = recurring_range.split(",")
|
|
107
|
+
sm, sd = start_part.split("-")
|
|
108
|
+
em, ed = end_part.split("-")
|
|
109
|
+
filters["recurring_range"] = (int(sm), int(sd), int(em), int(ed))
|
|
110
|
+
except (ValueError, TypeError):
|
|
111
|
+
raise typer.BadParameter(
|
|
112
|
+
f"Invalid recurring-range: {recurring_range}. Use e.g. '5-1,9-30'"
|
|
113
|
+
)
|
|
114
|
+
if interval is not None:
|
|
115
|
+
filters["interval"] = _parse_interval(interval)
|
|
116
|
+
if nth_per_group is not None:
|
|
117
|
+
filters["nth_per_group"] = _parse_nth_per_group(nth_per_group)
|
|
118
|
+
if times_of_day is not None:
|
|
119
|
+
times = []
|
|
120
|
+
for t in times_of_day.split(","):
|
|
121
|
+
h, m = t.strip().split(":")
|
|
122
|
+
times.append(time(int(h), int(m)))
|
|
123
|
+
td_args: dict[str, object] = {"times": times}
|
|
124
|
+
if times_max_delta is not None:
|
|
125
|
+
td_args["max_delta"] = _parse_interval(times_max_delta)
|
|
126
|
+
filters["times_of_day"] = td_args
|
|
127
|
+
return filters or None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
app = typer.Typer(help="NIMS Workflow CLI")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.callback()
|
|
134
|
+
def main(
|
|
135
|
+
version: Optional[bool] = typer.Option(
|
|
136
|
+
None,
|
|
137
|
+
"--version",
|
|
138
|
+
callback=version_callback,
|
|
139
|
+
is_eager=True,
|
|
140
|
+
help="Show version and exit",
|
|
141
|
+
),
|
|
142
|
+
):
|
|
143
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command()
|
|
147
|
+
def cameras(
|
|
148
|
+
full: bool = typer.Option(False, "--full", help="Show full camera info"),
|
|
149
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
150
|
+
api_key: Optional[str] = typer.Option(None, help="NIMS API key"),
|
|
151
|
+
site_id: Optional[str] = typer.Option(None, help="NWIS site ID"),
|
|
152
|
+
cam_id: Optional[str] = typer.Option(None, help="Camera ID"),
|
|
153
|
+
):
|
|
154
|
+
"""List cameras (IDs or full info)."""
|
|
155
|
+
result = get_camera_list(
|
|
156
|
+
ids_only=not full, api_key=api_key, site_id=site_id, cam_id=cam_id
|
|
157
|
+
)
|
|
158
|
+
if output_json:
|
|
159
|
+
typer.echo(json.dumps(result, indent=2))
|
|
160
|
+
else:
|
|
161
|
+
for item in result:
|
|
162
|
+
typer.echo(item if isinstance(item, str) else json.dumps(item))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command()
|
|
166
|
+
def camera(
|
|
167
|
+
camera_id: str = typer.Argument(..., help="Camera ID"),
|
|
168
|
+
api_key: Optional[str] = typer.Option(None, help="NIMS API key"),
|
|
169
|
+
):
|
|
170
|
+
"""Show details for a single camera."""
|
|
171
|
+
try:
|
|
172
|
+
with NIMSClient(api_key=api_key) as client:
|
|
173
|
+
result = client.get_camera(camera_id)
|
|
174
|
+
typer.echo(json.dumps(result, indent=2))
|
|
175
|
+
except ValueError as e:
|
|
176
|
+
typer.echo(str(e), err=True)
|
|
177
|
+
raise typer.Exit(1)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command()
|
|
181
|
+
def image_list(
|
|
182
|
+
camera_id: str = typer.Argument(..., help="Camera ID"),
|
|
183
|
+
start: Optional[str] = typer.Option(None, help="Start datetime (ISO 8601)"),
|
|
184
|
+
end: Optional[str] = typer.Option(None, help="End datetime (ISO 8601)"),
|
|
185
|
+
recursive: Optional[bool] = typer.Option(None, help="Use recursive fetching"),
|
|
186
|
+
max_results: Optional[int] = typer.Option(
|
|
187
|
+
None, help="Max number of images to include in list"
|
|
188
|
+
),
|
|
189
|
+
save_dir: Optional[Path] = typer.Option(None, help="Directory to save image list"),
|
|
190
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
191
|
+
api_key: Optional[str] = typer.Option(None, help="NIMS API key"),
|
|
192
|
+
tz: Optional[str] = typer.Option(
|
|
193
|
+
None,
|
|
194
|
+
help="Timezone for filtering (default: camera tz)",
|
|
195
|
+
rich_help_panel=FILTER_PANEL,
|
|
196
|
+
),
|
|
197
|
+
hours: Optional[str] = typer.Option(
|
|
198
|
+
None, help="Hour range, e.g. 8-17", rich_help_panel=FILTER_PANEL
|
|
199
|
+
),
|
|
200
|
+
months: Optional[str] = typer.Option(
|
|
201
|
+
None, help="Months, e.g. 6,7,8", rich_help_panel=FILTER_PANEL
|
|
202
|
+
),
|
|
203
|
+
days_of_week: Optional[str] = typer.Option(
|
|
204
|
+
None,
|
|
205
|
+
help="Days of week (0=Mon-6=Sun), e.g. 0,1,2,3,4",
|
|
206
|
+
rich_help_panel=FILTER_PANEL,
|
|
207
|
+
),
|
|
208
|
+
days_of_month: Optional[str] = typer.Option(
|
|
209
|
+
None, help="Days of month, e.g. 1,15", rich_help_panel=FILTER_PANEL
|
|
210
|
+
),
|
|
211
|
+
years: Optional[str] = typer.Option(
|
|
212
|
+
None, help="Years, e.g. 2023,2024", rich_help_panel=FILTER_PANEL
|
|
213
|
+
),
|
|
214
|
+
date_range: Optional[str] = typer.Option(
|
|
215
|
+
None,
|
|
216
|
+
help="Date range, e.g. 2023-06-01,2023-09-30",
|
|
217
|
+
rich_help_panel=FILTER_PANEL,
|
|
218
|
+
),
|
|
219
|
+
recurring_range: Optional[str] = typer.Option(
|
|
220
|
+
None,
|
|
221
|
+
help="Recurring month-day range, e.g. 5-1,9-30",
|
|
222
|
+
rich_help_panel=FILTER_PANEL,
|
|
223
|
+
),
|
|
224
|
+
interval: Optional[str] = typer.Option(
|
|
225
|
+
None, help="Sampling interval, e.g. 24h, 7d, 30m", rich_help_panel=FILTER_PANEL
|
|
226
|
+
),
|
|
227
|
+
nth_per_group: Optional[str] = typer.Option(
|
|
228
|
+
None,
|
|
229
|
+
help="Pick per group, e.g. day:first, month:12:00",
|
|
230
|
+
rich_help_panel=FILTER_PANEL,
|
|
231
|
+
),
|
|
232
|
+
times_of_day: Optional[str] = typer.Option(
|
|
233
|
+
None,
|
|
234
|
+
help="Pick nearest image to each time per day, e.g. 08:00,12:00,14:00 (no distance limit unless --times-max-delta is set)",
|
|
235
|
+
rich_help_panel=FILTER_PANEL,
|
|
236
|
+
),
|
|
237
|
+
times_max_delta: Optional[str] = typer.Option(
|
|
238
|
+
None,
|
|
239
|
+
help="Max delta for times-of-day, e.g. 2m, 30m",
|
|
240
|
+
rich_help_panel=FILTER_PANEL,
|
|
241
|
+
),
|
|
242
|
+
):
|
|
243
|
+
"""Fetch image list for a camera."""
|
|
244
|
+
filters = _build_filters(
|
|
245
|
+
hours,
|
|
246
|
+
months,
|
|
247
|
+
days_of_week,
|
|
248
|
+
days_of_month,
|
|
249
|
+
years,
|
|
250
|
+
date_range,
|
|
251
|
+
recurring_range,
|
|
252
|
+
interval,
|
|
253
|
+
nth_per_group,
|
|
254
|
+
times_of_day,
|
|
255
|
+
times_max_delta,
|
|
256
|
+
)
|
|
257
|
+
if save_dir is not None:
|
|
258
|
+
save_image_list_to_file(
|
|
259
|
+
camera_id=camera_id,
|
|
260
|
+
start=start,
|
|
261
|
+
end=end,
|
|
262
|
+
recursive=recursive,
|
|
263
|
+
max_results=max_results,
|
|
264
|
+
save_dir=save_dir,
|
|
265
|
+
api_key=api_key,
|
|
266
|
+
filters=filters,
|
|
267
|
+
tz=tz,
|
|
268
|
+
)
|
|
269
|
+
typer.echo(f"Saved image list for {camera_id} to {save_dir}")
|
|
270
|
+
else:
|
|
271
|
+
images = get_image_list_for_camera(
|
|
272
|
+
camera_id=camera_id,
|
|
273
|
+
start=start,
|
|
274
|
+
end=end,
|
|
275
|
+
recursive=recursive,
|
|
276
|
+
max_results=max_results,
|
|
277
|
+
api_key=api_key,
|
|
278
|
+
filters=filters,
|
|
279
|
+
tz=tz,
|
|
280
|
+
)
|
|
281
|
+
if output_json:
|
|
282
|
+
typer.echo(json.dumps(images, indent=2))
|
|
283
|
+
else:
|
|
284
|
+
for img in images:
|
|
285
|
+
typer.echo(img)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@app.command()
|
|
289
|
+
def download_images(
|
|
290
|
+
camera_id: Optional[str] = typer.Argument(
|
|
291
|
+
None, help="Camera ID (required unless --image-list-file is provided)"
|
|
292
|
+
),
|
|
293
|
+
start: Optional[str] = typer.Option(None, help="Start datetime (ISO 8601)"),
|
|
294
|
+
end: Optional[str] = typer.Option(None, help="End datetime (ISO 8601)"),
|
|
295
|
+
recursive: Optional[bool] = typer.Option(None, help="Use recursive fetching"),
|
|
296
|
+
max_results: Optional[int] = typer.Option(
|
|
297
|
+
None, help="Max number of images to download"
|
|
298
|
+
),
|
|
299
|
+
save_dir: Optional[Path] = typer.Option(
|
|
300
|
+
None, help="Parent directory to save images"
|
|
301
|
+
),
|
|
302
|
+
image_list_file: Optional[Path] = typer.Option(
|
|
303
|
+
None, help="JSON file containing list of image names to download"
|
|
304
|
+
),
|
|
305
|
+
api_key: Optional[str] = typer.Option(None, help="NIMS API key"),
|
|
306
|
+
size: str = typer.Option("full", help="Image size: full, thumb, or small"),
|
|
307
|
+
tz: Optional[str] = typer.Option(
|
|
308
|
+
None,
|
|
309
|
+
help="Timezone for filtering (default: camera tz)",
|
|
310
|
+
rich_help_panel=FILTER_PANEL,
|
|
311
|
+
),
|
|
312
|
+
hours: Optional[str] = typer.Option(
|
|
313
|
+
None, help="Hour range, e.g. 8-17", rich_help_panel=FILTER_PANEL
|
|
314
|
+
),
|
|
315
|
+
months: Optional[str] = typer.Option(
|
|
316
|
+
None, help="Months, e.g. 6,7,8", rich_help_panel=FILTER_PANEL
|
|
317
|
+
),
|
|
318
|
+
days_of_week: Optional[str] = typer.Option(
|
|
319
|
+
None,
|
|
320
|
+
help="Days of week (0=Mon-6=Sun), e.g. 0,1,2,3,4",
|
|
321
|
+
rich_help_panel=FILTER_PANEL,
|
|
322
|
+
),
|
|
323
|
+
days_of_month: Optional[str] = typer.Option(
|
|
324
|
+
None, help="Days of month, e.g. 1,15", rich_help_panel=FILTER_PANEL
|
|
325
|
+
),
|
|
326
|
+
years: Optional[str] = typer.Option(
|
|
327
|
+
None, help="Years, e.g. 2023,2024", rich_help_panel=FILTER_PANEL
|
|
328
|
+
),
|
|
329
|
+
date_range: Optional[str] = typer.Option(
|
|
330
|
+
None,
|
|
331
|
+
help="Date range, e.g. 2023-06-01,2023-09-30",
|
|
332
|
+
rich_help_panel=FILTER_PANEL,
|
|
333
|
+
),
|
|
334
|
+
recurring_range: Optional[str] = typer.Option(
|
|
335
|
+
None,
|
|
336
|
+
help="Recurring month-day range, e.g. 5-1,9-30",
|
|
337
|
+
rich_help_panel=FILTER_PANEL,
|
|
338
|
+
),
|
|
339
|
+
interval: Optional[str] = typer.Option(
|
|
340
|
+
None, help="Sampling interval, e.g. 24h, 7d, 30m", rich_help_panel=FILTER_PANEL
|
|
341
|
+
),
|
|
342
|
+
nth_per_group: Optional[str] = typer.Option(
|
|
343
|
+
None,
|
|
344
|
+
help="Pick per group, e.g. day:first, month:12:00",
|
|
345
|
+
rich_help_panel=FILTER_PANEL,
|
|
346
|
+
),
|
|
347
|
+
times_of_day: Optional[str] = typer.Option(
|
|
348
|
+
None,
|
|
349
|
+
help="Pick nearest image to each time per day, e.g. 08:00,12:00,14:00 (no distance limit unless --times-max-delta is set)",
|
|
350
|
+
rich_help_panel=FILTER_PANEL,
|
|
351
|
+
),
|
|
352
|
+
times_max_delta: Optional[str] = typer.Option(
|
|
353
|
+
None,
|
|
354
|
+
help="Max delta for times-of-day, e.g. 2m, 30m",
|
|
355
|
+
rich_help_panel=FILTER_PANEL,
|
|
356
|
+
),
|
|
357
|
+
):
|
|
358
|
+
"""Download images for a camera."""
|
|
359
|
+
image_list = None
|
|
360
|
+
if image_list_file:
|
|
361
|
+
image_list = json.loads(image_list_file.read_text())
|
|
362
|
+
if not camera_id and not image_list:
|
|
363
|
+
typer.echo(
|
|
364
|
+
"Error: CAMERA_ID is required unless --image-list-file is provided",
|
|
365
|
+
err=True,
|
|
366
|
+
)
|
|
367
|
+
raise typer.Exit(1)
|
|
368
|
+
filters = _build_filters(
|
|
369
|
+
hours,
|
|
370
|
+
months,
|
|
371
|
+
days_of_week,
|
|
372
|
+
days_of_month,
|
|
373
|
+
years,
|
|
374
|
+
date_range,
|
|
375
|
+
recurring_range,
|
|
376
|
+
interval,
|
|
377
|
+
nth_per_group,
|
|
378
|
+
times_of_day,
|
|
379
|
+
times_max_delta,
|
|
380
|
+
)
|
|
381
|
+
result = download_images_for_camera(
|
|
382
|
+
camera_id=camera_id,
|
|
383
|
+
start=start,
|
|
384
|
+
end=end,
|
|
385
|
+
recursive=recursive,
|
|
386
|
+
max_results=max_results,
|
|
387
|
+
save_dir=save_dir,
|
|
388
|
+
image_list=image_list,
|
|
389
|
+
api_key=api_key,
|
|
390
|
+
size=size,
|
|
391
|
+
filters=filters,
|
|
392
|
+
tz=tz,
|
|
393
|
+
)
|
|
394
|
+
typer.echo(
|
|
395
|
+
f"Downloaded {result['downloaded']}, "
|
|
396
|
+
f"skipped {result['skipped']}, "
|
|
397
|
+
f"not found {result['not_found']}"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
if __name__ == "__main__":
|
|
402
|
+
app()
|