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 ADDED
@@ -0,0 +1,3 @@
1
+ from .client import NIMSClient as NIMSClient
2
+
3
+ __version__ = "1.0.0"
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()