girder-client 5.0.6.dev2__tar.gz → 5.0.6.dev10__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: girder-client
3
- Version: 5.0.6.dev2
3
+ Version: 5.0.6.dev10
4
4
  Summary: Python client for interacting with Girder servers
5
5
  Home-page: http://girder.readthedocs.org/en/latest/python-client.html
6
6
  Author: Kitware, Inc.
@@ -0,0 +1,268 @@
1
+ """
2
+ Helper functions used by the CLI. Currently this only defines
3
+ :class:`_JSONEmitter`, which we test here.
4
+
5
+ Example:
6
+ >>> from girder_client._jsonemitter import _JSONEmitter
7
+ >>> import io
8
+ >>> self = _JSONEmitter(stream=io.StringIO())
9
+ >>> self.start_dict()
10
+ >>> self.setitem('version', '1.0.0')
11
+ >>> self.start_subcontainer('info')
12
+ >>> self.start_list()
13
+ >>> self.append({})
14
+ >>> self.append([{}])
15
+ >>> self.append({})
16
+ >>> self.end_list()
17
+ >>> self.end_dict()
18
+ >>> text = self.stream.getvalue()
19
+ >>> # Test that we decode correctly
20
+ >>> import json as json
21
+ >>> print(json.loads(text))
22
+ {'version': '1.0.0', 'info': [{}, [{}], {}]}
23
+
24
+ Example:
25
+ >>> from girder_client._jsonemitter import _JSONEmitter
26
+ >>> import io
27
+ >>> self = _JSONEmitter(stream=io.StringIO())
28
+ >>> self.start_dict()
29
+ >>> self.setitem('key1', 'value1')
30
+ >>> self.start_subcontainer('subdict1')
31
+ >>> self.start_dict()
32
+ >>> self.end_dict()
33
+ >>> #
34
+ >>> self.start_subcontainer('subdict2')
35
+ >>> self.start_dict()
36
+ >>> self.setitem('subkey1', 'subvalue1')
37
+ >>> self.setitem('subkey2', 'subvalue2')
38
+ >>> self.setitem('subkey3', [1, 2, 3, {"a": "a"}])
39
+ >>> self.end_dict()
40
+ >>> #
41
+ >>> self.start_subcontainer('sublist')
42
+ >>> self.start_list()
43
+ >>> self.append('sublist_value1')
44
+ >>> self.append('sublist_value2')
45
+ >>> self.append([1, 2, 3, {"a": "a"}])
46
+ >>> self.end_list()
47
+ >>> #
48
+ >>> self.setitem('key2', 'value2')
49
+ >>> self.end_dict()
50
+ >>> #
51
+ >>> text = self.stream.getvalue()
52
+ >>> # Test that we decode correctly
53
+ >>> import json
54
+ >>> print(json.dumps(json.loads(text)))
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import json
60
+ import sys
61
+ from dataclasses import dataclass
62
+ from typing import Any, Literal, TextIO
63
+
64
+ __all__ = ['_JSONEmitter']
65
+
66
+ _ContainerKind = Literal['root', 'dict', 'list']
67
+
68
+
69
+ @dataclass
70
+ class _Context:
71
+ """State for one open JSON container."""
72
+
73
+ kind: _ContainerKind
74
+ size: int = 0
75
+ awaiting_subcontainer: bool = False
76
+
77
+
78
+ class _JSONEmitter:
79
+ """
80
+ Helps incrementally emit compliant JSON text.
81
+
82
+ Useful when the data to serialize is slowly streaming in and minimal
83
+ response time is desired.
84
+
85
+ Example:
86
+ >>> from girder_client._jsonemitter import * # NOQA
87
+ >>> self = _JSONEmitter()
88
+ >>> self.start_dict()
89
+ >>> self.end_dict()
90
+ {}
91
+ >>> self = _JSONEmitter()
92
+ >>> self.start_dict()
93
+ >>> self.setitem('k1', 'v1')
94
+ >>> self.setitem('k2', 'v2')
95
+ >>> self.setitem('k3', 'v3')
96
+ >>> self.end_dict()
97
+ """
98
+
99
+ def __init__(self, stream: TextIO | None = None, checks: bool = True) -> None:
100
+ """
101
+ Args:
102
+ stream: stream to write to (defaults to stdout)
103
+ checks: if True checks that operations are legal
104
+ """
105
+ self._stack: list[_Context] = [_Context('root')]
106
+ self.stream: TextIO = sys.stdout if stream is None else stream
107
+ self.checks: bool = checks
108
+
109
+ def _current(self) -> _Context:
110
+ """Return the current container context."""
111
+ return self._stack[-1]
112
+
113
+ def _assert_can_start_container(self, context: _Context) -> None:
114
+ """Validate that a new dict/list value may start in ``context``."""
115
+ if context.kind == 'root':
116
+ if context.size >= 1:
117
+ raise AssertionError('Top level can only contain one container')
118
+ elif context.kind == 'dict':
119
+ if not context.awaiting_subcontainer:
120
+ raise AssertionError(
121
+ 'Expected start_subcontainer(key) before starting a '
122
+ f'nested container; context={context!r}'
123
+ )
124
+ elif context.kind != 'list':
125
+ raise AssertionError(f'Unknown container context={context!r}')
126
+
127
+ def _assert_can_write_dict_item(self, context: _Context) -> None:
128
+ """Validate that a plain key/value item may be written to a dict."""
129
+ if context.kind != 'dict':
130
+ raise AssertionError(
131
+ f"Expected current context to be 'dict', but got context={context!r}"
132
+ )
133
+ if context.awaiting_subcontainer:
134
+ raise AssertionError(
135
+ 'Expected a nested dict/list after start_subcontainer(key), '
136
+ f'but got a plain dict item; context={context!r}'
137
+ )
138
+
139
+ def _assert_can_write_list_item(self, context: _Context) -> None:
140
+ """Validate that a plain value may be appended to a list."""
141
+ if context.kind != 'list':
142
+ raise AssertionError(
143
+ f"Expected current context to be 'list', but got context={context!r}"
144
+ )
145
+
146
+ def _prepare_value(self, context: _Context) -> None:
147
+ """
148
+ Write any separator needed before emitting the next JSON value.
149
+
150
+ ``start_subcontainer`` writes a dict key before the corresponding
151
+ container exists. In that one case, the dict item has already been
152
+ counted and separated, so starting the child container consumes the
153
+ pending state without writing another separator.
154
+ """
155
+ if context.awaiting_subcontainer:
156
+ context.awaiting_subcontainer = False
157
+ return
158
+
159
+ if context.size > 0:
160
+ self.stream.write(',\n')
161
+ elif context.kind != 'root':
162
+ self.stream.write('\n')
163
+ context.size += 1
164
+
165
+ def _start_container(self, kind: _ContainerKind, opener: str) -> None:
166
+ """Start a new list or dict container."""
167
+ context = self._current()
168
+ if self.checks:
169
+ self._assert_can_start_container(context)
170
+
171
+ self._prepare_value(context)
172
+ self.stream.write(opener)
173
+ self._stack.append(_Context(kind))
174
+
175
+ def _end_container(self, expected_kind: _ContainerKind, closer: str) -> None:
176
+ """End the current list or dict container."""
177
+ if len(self._stack) == 1:
178
+ raise AssertionError('No open container to end')
179
+
180
+ context = self._current()
181
+ if self.checks:
182
+ if context.awaiting_subcontainer:
183
+ raise AssertionError(
184
+ 'Expected a nested dict/list after start_subcontainer(key), '
185
+ f'but the {context.kind!r} context is ending; context={context!r}'
186
+ )
187
+ if context.kind != expected_kind:
188
+ raise AssertionError(
189
+ f'Expected current context to be {expected_kind!r}, '
190
+ f'but got context={context!r}'
191
+ )
192
+
193
+ self._stack.pop()
194
+ if context.size > 0:
195
+ self.stream.write('\n')
196
+ self.stream.write(closer)
197
+
198
+ def start_list(self) -> None:
199
+ """
200
+ Start a new list context. Must be in a list, or in a dict with a
201
+ prepared subcontainer.
202
+ """
203
+ self._start_container('list', '[')
204
+
205
+ def start_dict(self) -> None:
206
+ """
207
+ Start a new dictionary context. Must be in a list, or in a dict with a
208
+ prepared subcontainer.
209
+ """
210
+ self._start_container('dict', '{')
211
+
212
+ def end_list(self) -> None:
213
+ """End the current list context."""
214
+ self._end_container('list', ']')
215
+
216
+ def end_dict(self) -> None:
217
+ """End the current dictionary context."""
218
+ self._end_container('dict', '}')
219
+
220
+ def start_subcontainer(self, key: str) -> None:
221
+ """
222
+ Add a key to a dictionary whose value will be a new container.
223
+
224
+ Args:
225
+ key: the key that maps to the new subcontainer
226
+
227
+ Can only be called if you are inside a dict context.
228
+ The next call must start a new dict or list container.
229
+ """
230
+ context = self._current()
231
+ if self.checks:
232
+ self._assert_can_write_dict_item(context)
233
+
234
+ self._prepare_value(context)
235
+ self.stream.write(json.dumps(key))
236
+ self.stream.write(': ')
237
+ context.awaiting_subcontainer = True
238
+
239
+ def setitem(self, key: str, value: Any) -> None:
240
+ """
241
+ Add an item to a dict context.
242
+
243
+ Args:
244
+ key: a string key
245
+ value: any JSON-serializable object
246
+ """
247
+ context = self._current()
248
+ if self.checks:
249
+ self._assert_can_write_dict_item(context)
250
+
251
+ self._prepare_value(context)
252
+ self.stream.write(json.dumps(key))
253
+ self.stream.write(': ')
254
+ self.stream.write(json.dumps(value))
255
+
256
+ def append(self, item: Any) -> None:
257
+ """
258
+ Add an item to a list context.
259
+
260
+ Args:
261
+ item: any JSON-serializable object
262
+ """
263
+ context = self._current()
264
+ if self.checks:
265
+ self._assert_can_write_list_item(context)
266
+
267
+ self._prepare_value(context)
268
+ self.stream.write(json.dumps(item))
@@ -346,6 +346,283 @@ def _upload(gc, parent_type, parent_id, local_folder,
346
346
  blacklist=blacklist.split(','), dryRun=dry_run, reference=reference)
347
347
 
348
348
 
349
+ _short_help = 'List contents of a user, collection, folder, item, or file'
350
+ _long_help = """
351
+ PARENT_ID is the id of a Girder user, collection, folder, item, or file.
352
+ The command first shows the requested resource so opaque ids are identifiable,
353
+ then lists any immediate child resources: users and collections contain folders,
354
+ folders contain folders and items, items contain files, and files have no
355
+ children. In JSON output, the requested resource is emitted as this_record.
356
+
357
+ Examples:
358
+
359
+ # List USER: jon.crall
360
+ girder-client --api-url https://data.kitware.com/api/v1 list 598a19658d777f7d33e9c18b
361
+
362
+ # List COLLECTION: VIAME
363
+ girder-client --api-url https://data.kitware.com/api/v1 list 58b747ec8d777f0aef5d0f6a
364
+
365
+ # List FOLDER: kwimage_demodata
366
+ girder-client --api-url https://data.kitware.com/api/v1 list 647cfb2ca71cc6eae69303a4
367
+
368
+ # List ITEM: the paraview.png logo
369
+ girder-client --api-url https://data.kitware.com/api/v1 list 647cfb97a71cc6eae69303b5
370
+
371
+ # List FILE: the paraview.png logo
372
+ girder-client --api-url https://data.kitware.com/api/v1 list 647cfb97a71cc6eae69303b6
373
+ """.rstrip()
374
+
375
+
376
+ @main.command('list', short_help=_short_help, help='%s\n\n%s' % (_short_help, _long_help))
377
+ @click.option('--parent-type', default='auto', show_default=True,
378
+ help='type of Girder parent target',
379
+ type=click.Choice(['folder', 'collection', 'user', 'item', 'file', 'auto']))
380
+ @click.argument('parent_id')
381
+ @click.option('--limit', default=None, type=click.INT,
382
+ help='maximum number of records to list')
383
+ @click.option('--offset', default=None, type=click.INT,
384
+ help='starting offset into list')
385
+ @click.option('--json', 'as_json', default=False, is_flag=True, show_default=True,
386
+ help='output machine-readable JSON')
387
+ @click.pass_obj
388
+ def _list(gc, parent_type, parent_id, limit, offset, as_json):
389
+ if parent_type == 'auto':
390
+ parent_type = _list_lookup_parent_type(gc, parent_id)
391
+
392
+ this_record = _list_get_resource(gc, parent_type, parent_id)
393
+
394
+ if as_json:
395
+ from girder_client._jsonemitter import _JSONEmitter
396
+ emitter = _JSONEmitter()
397
+ emitter.start_dict()
398
+ else:
399
+ emitter = None
400
+
401
+ _list_record_info(gc, this_record, emitter)
402
+
403
+ if parent_type in {'collection', 'user'}:
404
+ # Collections and users can have folder children.
405
+ child_types = ['folder']
406
+ elif parent_type == 'folder':
407
+ # Folders can have folders and items as children.
408
+ child_types = ['folder', 'item']
409
+ elif parent_type == 'item':
410
+ # The requested item is already shown as this_record; list its files.
411
+ child_types = ['file']
412
+ elif parent_type == 'file':
413
+ # Files have no children.
414
+ child_types = []
415
+ else:
416
+ raise KeyError(parent_type)
417
+
418
+ if child_types:
419
+ _list_children_info(gc, parent_id, parent_type,
420
+ child_types, limit, offset, emitter)
421
+
422
+ if emitter:
423
+ emitter.end_dict()
424
+
425
+
426
+ def _list_lookup_parent_type(gc, parent_id):
427
+ """
428
+ Resolve the Girder resource type for the list command.
429
+
430
+ The shared _lookup_parent_type helper uses the resource path endpoint,
431
+ which can fail to identify users. Fall back to direct resource GETs, and
432
+ for users also try listing folders with parentType=user because some Girder
433
+ deployments allow traversing a user's public folders without allowing the
434
+ user record itself to be read anonymously.
435
+ """
436
+ lookup_errors = []
437
+ try:
438
+ parent_type = _lookup_parent_type(gc, parent_id)
439
+ except requests.HTTPError as exc_info:
440
+ lookup_errors.append('resource path lookup: %s' % _list_http_error_summary(exc_info))
441
+ else:
442
+ if parent_type is not None:
443
+ return parent_type
444
+ lookup_errors.append('resource path lookup: no match')
445
+
446
+ for candidate_type in ['folder', 'collection', 'item', 'file']:
447
+ try:
448
+ _list_get_resource(gc, candidate_type, parent_id)
449
+ return candidate_type
450
+ except requests.HTTPError as exc_info:
451
+ if exc_info.response.status_code in {400, 403, 404}:
452
+ lookup_errors.append('%s: %s' % (
453
+ candidate_type, _list_http_error_summary(exc_info)))
454
+ continue
455
+ raise
456
+
457
+ user_status = _list_probe_user_resource(gc, parent_id)
458
+ if user_status is True:
459
+ return 'user'
460
+ lookup_errors.append('user: %s' % user_status)
461
+
462
+ raise click.ClickException(
463
+ 'Could not determine Girder resource type for %s. Tried: %s' % (
464
+ parent_id, '; '.join(lookup_errors)))
465
+
466
+
467
+ def _list_http_error_summary(exc_info):
468
+ """
469
+ Return a short description of a Girder HTTP lookup failure.
470
+ """
471
+ response = exc_info.response
472
+ status_code = getattr(response, 'status_code', None)
473
+ reason = getattr(response, 'reason', '')
474
+ if status_code is None:
475
+ return str(exc_info)
476
+ if reason:
477
+ return '%s %s' % (status_code, reason)
478
+ return str(status_code)
479
+
480
+
481
+ def _list_probe_user_resource(gc, user_id):
482
+ """
483
+ Return True if user_id appears to identify a user resource.
484
+
485
+ A user record can be inaccessible while its public folders are still
486
+ listable, so direct GET /user/<id> is not the only useful probe.
487
+ """
488
+ try:
489
+ record = gc.get('user/%s' % user_id)
490
+ except requests.HTTPError as exc_info:
491
+ direct_status = _list_http_error_summary(exc_info)
492
+ if exc_info.response.status_code not in {400, 403, 404}:
493
+ raise
494
+ else:
495
+ if record:
496
+ return True
497
+ direct_status = 'empty response'
498
+
499
+ try:
500
+ records = gc.listFolder(user_id, parentFolderType='user', limit=1)
501
+ first_record = next(iter(records), None)
502
+ except requests.HTTPError as exc_info:
503
+ if exc_info.response.status_code not in {400, 403, 404}:
504
+ raise
505
+ return 'GET user failed with %s; folder probe failed with %s' % (
506
+ direct_status, _list_http_error_summary(exc_info))
507
+
508
+ if first_record is not None:
509
+ return True
510
+ return 'GET user failed with %s; folder probe returned no folders' % direct_status
511
+
512
+
513
+ def _list_get_resource(gc, resource_type, resource_id):
514
+ """
515
+ Return a Girder record for a supported list resource type.
516
+
517
+ GirderClient.getResource handles collections, folders, items, and files.
518
+ User records are fetched through the user endpoint when possible. If the
519
+ user record is not readable, return a minimal user-shaped record so the CLI
520
+ can still show the requested opaque ID and list public folders below it.
521
+ """
522
+ if resource_type == 'user':
523
+ try:
524
+ record = gc.get('user/%s' % resource_id)
525
+ except requests.HTTPError as exc_info:
526
+ if exc_info.response.status_code in {400, 403, 404}:
527
+ return {
528
+ '_id': resource_id,
529
+ '_modelType': 'user',
530
+ }
531
+ raise
532
+ record.setdefault('_modelType', 'user')
533
+ return record
534
+ return gc.getResource(resource_type, resource_id)
535
+
536
+
537
+ def _list_record_label(record):
538
+ """
539
+ Return a compact label for a Girder record.
540
+ """
541
+ return record.get('name') or record.get('login') or record.get('_id')
542
+
543
+
544
+ def _list_record_info(gc, this_record, emitter):
545
+ """
546
+ Helper for :func:`_list` to print the requested record and its parents.
547
+ """
548
+ this_type = this_record.get('_modelType')
549
+ if this_type == 'folder':
550
+ prev_record = gc.getResource(this_record['parentCollection'], this_record['parentId'])
551
+ if emitter:
552
+ emitter.setitem('parent_record', prev_record)
553
+ else:
554
+ print('Parent {_modelType}: {_id} - {}'.format(
555
+ _list_record_label(prev_record), **prev_record))
556
+ elif this_type == 'item':
557
+ prev_record = gc.getResource('folder', this_record['folderId'])
558
+ if emitter:
559
+ emitter.setitem('parent_record', prev_record)
560
+ else:
561
+ print('Parent {_modelType}: {_id} - {}'.format(
562
+ _list_record_label(prev_record), **prev_record))
563
+ elif this_type == 'file':
564
+ item_record = gc.getResource('item', this_record['itemId'])
565
+ folder_record = gc.getResource('folder', item_record['folderId'])
566
+ if emitter:
567
+ emitter.setitem('item_record', item_record)
568
+ emitter.setitem('folder_record', folder_record)
569
+ else:
570
+ print('Parent folder: {_id} - {}'.format(
571
+ _list_record_label(folder_record), **folder_record))
572
+ print('Parent item: {_id} - {}'.format(
573
+ _list_record_label(item_record), **item_record))
574
+ elif this_type in {'collection', 'user'}:
575
+ # Collections and users do not have a single parent record to show here.
576
+ pass
577
+ else:
578
+ raise KeyError(this_type)
579
+
580
+ if emitter:
581
+ emitter.setitem('this_record', this_record)
582
+ else:
583
+ print('Listing {_modelType}: {_id} - {}'.format(
584
+ _list_record_label(this_record), **this_record))
585
+
586
+
587
+ def _list_children_info(gc, parent_id, parent_type, child_types,
588
+ limit, offset, emitter):
589
+ """
590
+ Helper for :func:`_list` to print nested records.
591
+ """
592
+ import itertools
593
+ for child_type in child_types:
594
+ if child_type == 'folder':
595
+ records = gc.listFolder(parent_id, limit=limit, offset=offset,
596
+ parentFolderType=parent_type)
597
+ elif child_type == 'item':
598
+ records = gc.listItem(parent_id, limit=limit, offset=offset)
599
+ elif child_type == 'file':
600
+ records = gc.listFile(parent_id, limit=limit, offset=offset)
601
+ else:
602
+ raise NotImplementedError
603
+
604
+ # Check if records has at least one element without losing it.
605
+ records_copy, records = itertools.tee(records)
606
+ first_records = list(itertools.islice(records_copy, 1))
607
+ if first_records:
608
+ if emitter:
609
+ emitter.start_subcontainer(f'{child_type}_children')
610
+ emitter.start_list()
611
+ else:
612
+ print('=== {} ==='.format(child_type))
613
+ print('{:<24} {:<6} {:<24}'.format('ID', 'TYPE', 'NAME'))
614
+
615
+ for record in records:
616
+ if emitter:
617
+ emitter.append(record)
618
+ else:
619
+ print('{_id:<24} {_modelType:<6} {}'.format(
620
+ _list_record_label(record), **record))
621
+
622
+ if first_records and emitter:
623
+ emitter.end_list()
624
+
625
+
349
626
  if __name__ == '__main__':
350
627
  click.echo('Deprecation notice: Use "girder-client" to run the CLI.', err=True)
351
628
  main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: girder-client
3
- Version: 5.0.6.dev2
3
+ Version: 5.0.6.dev10
4
4
  Summary: Python client for interacting with Girder servers
5
5
  Home-page: http://girder.readthedocs.org/en/latest/python-client.html
6
6
  Author: Kitware, Inc.
@@ -2,6 +2,7 @@ README.rst
2
2
  pyproject.toml
3
3
  setup.py
4
4
  girder_client/__init__.py
5
+ girder_client/_jsonemitter.py
5
6
  girder_client/cli.py
6
7
  girder_client.egg-info/PKG-INFO
7
8
  girder_client.egg-info/SOURCES.txt