ONE-api 3.2.1__py3-none-any.whl → 3.4.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.
- one/__init__.py +1 -1
- one/alf/path.py +4 -3
- one/api.py +96 -40
- one/remote/globus.py +5 -5
- one/tests/fixtures/rest_responses/coreapi.json +11756 -0
- one/tests/fixtures/rest_responses/f530d6022f61cdc9e38cc66beb3cb71f3003c9a1 +1 -1
- one/tests/fixtures/rest_responses/openapiv3.json +15475 -0
- one/util.py +12 -4
- one/webclient.py +451 -130
- {one_api-3.2.1.dist-info → one_api-3.4.0.dist-info}/METADATA +1 -1
- {one_api-3.2.1.dist-info → one_api-3.4.0.dist-info}/RECORD +14 -12
- {one_api-3.2.1.dist-info → one_api-3.4.0.dist-info}/WHEEL +0 -0
- {one_api-3.2.1.dist-info → one_api-3.4.0.dist-info}/licenses/LICENSE +0 -0
- {one_api-3.2.1.dist-info → one_api-3.4.0.dist-info}/top_level.txt +0 -0
one/webclient.py
CHANGED
|
@@ -30,7 +30,9 @@ Download a remote file, given a local path
|
|
|
30
30
|
>>> local_path = alyx.download_file(url, target_dir='zadorlab/Subjects/flowers/2018-07-13/1/')
|
|
31
31
|
|
|
32
32
|
"""
|
|
33
|
+
|
|
33
34
|
from uuid import UUID
|
|
35
|
+
import abc
|
|
34
36
|
import json
|
|
35
37
|
import logging
|
|
36
38
|
import math
|
|
@@ -41,7 +43,7 @@ import urllib.request
|
|
|
41
43
|
from urllib.error import HTTPError
|
|
42
44
|
import urllib.parse
|
|
43
45
|
from collections.abc import Mapping
|
|
44
|
-
from typing import Optional
|
|
46
|
+
from typing import Optional, List
|
|
45
47
|
from datetime import datetime, timedelta
|
|
46
48
|
from pathlib import Path
|
|
47
49
|
from weakref import ReferenceType
|
|
@@ -57,10 +59,12 @@ from tqdm import tqdm
|
|
|
57
59
|
|
|
58
60
|
from pprint import pprint
|
|
59
61
|
import one.params
|
|
62
|
+
from one import __version__
|
|
60
63
|
from iblutil.io import hashfile
|
|
61
64
|
from iblutil.io.params import set_hidden
|
|
62
65
|
from iblutil.util import ensure_list
|
|
63
66
|
import concurrent.futures
|
|
67
|
+
|
|
64
68
|
_logger = logging.getLogger(__name__)
|
|
65
69
|
N_THREADS = int(os.environ.get('ONE_HTTP_DL_THREADS', 4))
|
|
66
70
|
"""int: The number of download threads."""
|
|
@@ -109,7 +113,7 @@ def _cache_response(method):
|
|
|
109
113
|
response will not be used on subsequent calls. If None, the default expiry is applied.
|
|
110
114
|
clobber : bool
|
|
111
115
|
If True any existing cached response is overwritten.
|
|
112
|
-
|
|
116
|
+
kwargs
|
|
113
117
|
Keyword arguments for applying to wrapped function.
|
|
114
118
|
|
|
115
119
|
Returns
|
|
@@ -123,7 +127,7 @@ def _cache_response(method):
|
|
|
123
127
|
if args[0].__name__ != mode and mode != '*':
|
|
124
128
|
return method(alyx_client, *args, **kwargs)
|
|
125
129
|
# Check cache
|
|
126
|
-
rest_cache = alyx_client.
|
|
130
|
+
rest_cache = alyx_client.rest_cache_dir
|
|
127
131
|
sha1 = hashlib.sha1()
|
|
128
132
|
sha1.update(bytes(args[1], 'utf-8'))
|
|
129
133
|
name = sha1.hexdigest()
|
|
@@ -368,8 +372,10 @@ def http_download_file_list(links_to_file_list, **kwargs):
|
|
|
368
372
|
zipped = zip(links_to_file_list, target_dir)
|
|
369
373
|
with concurrent.futures.ThreadPoolExecutor(max_workers=N_THREADS) as executor:
|
|
370
374
|
# Multithreading load operations
|
|
371
|
-
futures = [
|
|
372
|
-
http_download_file, link, target_dir=target, **kwargs)
|
|
375
|
+
futures = [
|
|
376
|
+
executor.submit(http_download_file, link, target_dir=target, **kwargs)
|
|
377
|
+
for link, target in zipped
|
|
378
|
+
]
|
|
373
379
|
zip(links_to_file_list, ensure_list(kwargs.pop('target_dir', None)))
|
|
374
380
|
# TODO Reintroduce variable timeout value based on file size and download speed of 5 Mb/s?
|
|
375
381
|
# timeout = reduce(lambda x, y: x + (y.get('file_size', 0) or 0), dsets, 0) / 625000 ?
|
|
@@ -525,6 +531,322 @@ def dataset_record_to_url(dataset_record) -> list:
|
|
|
525
531
|
return urls
|
|
526
532
|
|
|
527
533
|
|
|
534
|
+
class RestScheme(abc.ABC):
|
|
535
|
+
"""Alyx REST scheme documentation."""
|
|
536
|
+
|
|
537
|
+
EXCLUDE = ('_type', '_meta', '', 'auth-token', 'api')
|
|
538
|
+
"""list: A list of endpoint names to exclude from listings."""
|
|
539
|
+
|
|
540
|
+
def __init__(self, rest_scheme: dict):
|
|
541
|
+
self._rest_scheme = rest_scheme
|
|
542
|
+
|
|
543
|
+
def _validate(self, endpoint: str, action: str = None) -> bool:
|
|
544
|
+
"""Validate that an endpoint and action exist.
|
|
545
|
+
|
|
546
|
+
This is used by `print_endpoint_info` to print a message if the endpoint or action do not
|
|
547
|
+
exist.
|
|
548
|
+
|
|
549
|
+
Parameters
|
|
550
|
+
----------
|
|
551
|
+
endpoint : str
|
|
552
|
+
The endpoint name.
|
|
553
|
+
action : str
|
|
554
|
+
The action name.
|
|
555
|
+
|
|
556
|
+
Returns
|
|
557
|
+
-------
|
|
558
|
+
bool
|
|
559
|
+
True if the endpoint and action exist, False otherwise.
|
|
560
|
+
"""
|
|
561
|
+
if endpoint not in self.endpoints:
|
|
562
|
+
print(f'Endpoint "{endpoint}" does not exist')
|
|
563
|
+
return False
|
|
564
|
+
if action is not None and action not in self.actions(endpoint):
|
|
565
|
+
print(
|
|
566
|
+
f'Endpoint "{endpoint}" does not have action "{action}": '
|
|
567
|
+
f'available actions: {", ".join(self.actions(endpoint))}'
|
|
568
|
+
)
|
|
569
|
+
return False
|
|
570
|
+
return True
|
|
571
|
+
|
|
572
|
+
@abc.abstractmethod
|
|
573
|
+
def endpoints(self) -> list:
|
|
574
|
+
"""Return the list of available endpoints.
|
|
575
|
+
|
|
576
|
+
Returns
|
|
577
|
+
-------
|
|
578
|
+
list
|
|
579
|
+
List of available endpoints
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
@abc.abstractmethod
|
|
583
|
+
def actions(self, endpoint: str, *args) -> list:
|
|
584
|
+
"""Return a list of available actions for a given endpoint.
|
|
585
|
+
|
|
586
|
+
Parameters
|
|
587
|
+
----------
|
|
588
|
+
endpoint : str
|
|
589
|
+
The endpoint name to find actions for
|
|
590
|
+
|
|
591
|
+
Returns
|
|
592
|
+
-------
|
|
593
|
+
list
|
|
594
|
+
List of available actions for the endpoint
|
|
595
|
+
"""
|
|
596
|
+
|
|
597
|
+
@abc.abstractmethod
|
|
598
|
+
def fields(self, endpoint: str, action: str) -> List[dict]:
|
|
599
|
+
"""Return a list of fields for a given endpoint/action combination.
|
|
600
|
+
|
|
601
|
+
Parameters
|
|
602
|
+
----------
|
|
603
|
+
endpoint : str
|
|
604
|
+
The endpoint name to find fields and parameters for
|
|
605
|
+
action : str
|
|
606
|
+
The Django REST action:
|
|
607
|
+
'list', 'read', 'create', 'update', 'delete', 'partial_update'
|
|
608
|
+
|
|
609
|
+
Returns
|
|
610
|
+
-------
|
|
611
|
+
list
|
|
612
|
+
List of dictionaries containing fields for the endpoint
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
def field_names(self, endpoint: str, action: str) -> List[str]:
|
|
616
|
+
"""Return a list of fields for a given endpoint/action combination.
|
|
617
|
+
|
|
618
|
+
Parameters
|
|
619
|
+
----------
|
|
620
|
+
endpoint : str
|
|
621
|
+
The endpoint name to find fields and parameters for
|
|
622
|
+
action : str
|
|
623
|
+
The Django REST action:
|
|
624
|
+
'list', 'read', 'create', 'update', 'delete', 'partial_update'
|
|
625
|
+
|
|
626
|
+
Returns
|
|
627
|
+
-------
|
|
628
|
+
list
|
|
629
|
+
List of strings containing fields names for the endpoint
|
|
630
|
+
"""
|
|
631
|
+
return [field['name'] for field in self.fields(endpoint, action)]
|
|
632
|
+
|
|
633
|
+
@abc.abstractmethod
|
|
634
|
+
def print_endpoint_info(self, endpoint: str, action: str = None) -> None:
|
|
635
|
+
"""Prints relevant information about an endpoint and its actions.
|
|
636
|
+
|
|
637
|
+
Parameters
|
|
638
|
+
----------
|
|
639
|
+
endpoint : str
|
|
640
|
+
The endpoint name to find actions for
|
|
641
|
+
action : str
|
|
642
|
+
The Django REST action to perform:
|
|
643
|
+
'list', 'read', 'create', 'update', 'delete', 'partial_update'
|
|
644
|
+
|
|
645
|
+
Returns
|
|
646
|
+
-------
|
|
647
|
+
None
|
|
648
|
+
"""
|
|
649
|
+
|
|
650
|
+
def url(self, endpoint: str, action: str):
|
|
651
|
+
"""Returns the url for a given endpoint/action combination.
|
|
652
|
+
|
|
653
|
+
Parameters
|
|
654
|
+
----------
|
|
655
|
+
endpoint : str
|
|
656
|
+
The endpoint name to find actions for
|
|
657
|
+
action : str
|
|
658
|
+
The Django REST action to perform:
|
|
659
|
+
'list', 'read', 'create', 'update', 'delete', 'partial_update'
|
|
660
|
+
|
|
661
|
+
Returns
|
|
662
|
+
-------
|
|
663
|
+
str
|
|
664
|
+
url of the endpoint: for example /sessions/{id}
|
|
665
|
+
"""
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def validate_endpoint_action(func):
|
|
669
|
+
"""Decorator to validate endpoint and action before executing a method.
|
|
670
|
+
|
|
671
|
+
This decorator checks if the endpoint exists and, if an action is provided,
|
|
672
|
+
whether the action exists for that endpoint. If validation fails, it returns
|
|
673
|
+
None, otherwise it executes the decorated function.
|
|
674
|
+
"""
|
|
675
|
+
|
|
676
|
+
@functools.wraps(func)
|
|
677
|
+
def wrapper(self, endpoint, action=None, *args, **kwargs):
|
|
678
|
+
if not self._validate(endpoint, action):
|
|
679
|
+
return
|
|
680
|
+
return func(self, endpoint, action, *args, **kwargs)
|
|
681
|
+
|
|
682
|
+
return wrapper
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
class RestSchemeCoreApi(RestScheme):
|
|
686
|
+
"""Legacy Alyx REST scheme documentation."""
|
|
687
|
+
|
|
688
|
+
backend = 'core_api'
|
|
689
|
+
"""str: The name of the backend that generates the REST API documentation."""
|
|
690
|
+
|
|
691
|
+
@property
|
|
692
|
+
def endpoints(self) -> list:
|
|
693
|
+
return sorted(x for x in self._rest_scheme.keys() if x not in self.EXCLUDE)
|
|
694
|
+
|
|
695
|
+
@validate_endpoint_action
|
|
696
|
+
def actions(self, endpoint: str, *args) -> list:
|
|
697
|
+
return sorted(x for x in self._rest_scheme[endpoint].keys() if x not in self.EXCLUDE)
|
|
698
|
+
|
|
699
|
+
@validate_endpoint_action
|
|
700
|
+
def url(self, endpoint: str, action: str) -> str:
|
|
701
|
+
return self._rest_scheme[endpoint][action]['url']
|
|
702
|
+
|
|
703
|
+
@validate_endpoint_action
|
|
704
|
+
def fields(self, endpoint: str, action: str) -> list:
|
|
705
|
+
return self._rest_scheme[endpoint][action]['fields']
|
|
706
|
+
|
|
707
|
+
@validate_endpoint_action
|
|
708
|
+
def print_endpoint_info(self, endpoint: str, action: str = None) -> None:
|
|
709
|
+
for _action in self._rest_scheme[endpoint] if action is None else [action]:
|
|
710
|
+
doc = []
|
|
711
|
+
pprint(_action)
|
|
712
|
+
for f in self._rest_scheme[endpoint][_action]['fields']:
|
|
713
|
+
required = ' (required): ' if f.get('required', False) else ': '
|
|
714
|
+
doc.append(
|
|
715
|
+
f'\t"{f["name"]}"{required}{f["schema"]["_type"]}'
|
|
716
|
+
f', {f["schema"]["description"]}'
|
|
717
|
+
)
|
|
718
|
+
doc.sort()
|
|
719
|
+
[print(d) for d in doc if '(required)' in d]
|
|
720
|
+
[print(d) for d in doc if '(required)' not in d]
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
class RestSchemeOpenApi(RestScheme):
|
|
724
|
+
"""OpenAPI v3 Alyx REST scheme documentation."""
|
|
725
|
+
|
|
726
|
+
backend = 'open_api_v3'
|
|
727
|
+
"""str: The name of the backend that generates the REST API documentation."""
|
|
728
|
+
|
|
729
|
+
@property
|
|
730
|
+
def endpoints(self) -> list:
|
|
731
|
+
endpoints = set()
|
|
732
|
+
for path in self._rest_scheme['paths'].keys():
|
|
733
|
+
# Extract the endpoint name (word between first and second slash,
|
|
734
|
+
# or after first slash if no second slash)
|
|
735
|
+
match = re.match(r'^/([^/]+)', path)
|
|
736
|
+
if match and match.group(1) not in self.EXCLUDE:
|
|
737
|
+
endpoints.add(match.group(1))
|
|
738
|
+
return sorted(list(endpoints))
|
|
739
|
+
|
|
740
|
+
@validate_endpoint_action
|
|
741
|
+
def _get_endpoint_actions(self, endpoint: str, *args) -> dict:
|
|
742
|
+
# Find all paths that start with this endpoint
|
|
743
|
+
endpoint_paths = []
|
|
744
|
+
for path in self._rest_scheme['paths'].keys():
|
|
745
|
+
# Match either /endpoint or /endpoint/{something}
|
|
746
|
+
if path == f'/{endpoint}' or path.startswith(f'/{endpoint}/'):
|
|
747
|
+
endpoint_paths.append(path)
|
|
748
|
+
# Extract available HTTP methods (actions) for these paths
|
|
749
|
+
actions = {}
|
|
750
|
+
for path in endpoint_paths:
|
|
751
|
+
for method in self._rest_scheme['paths'][path].keys():
|
|
752
|
+
# Convert HTTP methods to standard action names
|
|
753
|
+
if method == 'get':
|
|
754
|
+
# Determine if this is a list or read action based on path pattern
|
|
755
|
+
if '{' in path: # Path has a parameter, likely a read action
|
|
756
|
+
action = 'read'
|
|
757
|
+
else:
|
|
758
|
+
action = 'list'
|
|
759
|
+
elif method == 'post':
|
|
760
|
+
action = 'create'
|
|
761
|
+
elif method == 'put':
|
|
762
|
+
action = 'update'
|
|
763
|
+
elif method == 'patch':
|
|
764
|
+
action = 'partial_update'
|
|
765
|
+
elif method == 'delete':
|
|
766
|
+
action = 'delete'
|
|
767
|
+
actions[action] = path
|
|
768
|
+
return actions
|
|
769
|
+
|
|
770
|
+
@validate_endpoint_action
|
|
771
|
+
def _endpoint_action_info(self, endpoint: str, action: str) -> dict:
|
|
772
|
+
# Find all paths that start with this endpoint
|
|
773
|
+
endpoint_paths = []
|
|
774
|
+
for path in self._rest_scheme['paths'].keys():
|
|
775
|
+
# Match either /endpoint or /endpoint/{something}
|
|
776
|
+
if path == f'/{endpoint}' and not action == 'read' or path.startswith(f'/{endpoint}/'):
|
|
777
|
+
endpoint_paths.append(path)
|
|
778
|
+
# Extract available HTTP methods (actions) for these paths
|
|
779
|
+
rest2http = {
|
|
780
|
+
'list': 'get',
|
|
781
|
+
'read': 'get',
|
|
782
|
+
'create': 'post',
|
|
783
|
+
'update': 'put',
|
|
784
|
+
'partial_update': 'patch',
|
|
785
|
+
'delete': 'delete',
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
endpoint_method_info = {'fields': {}, 'description': '', 'parameters': [], 'url': ''}
|
|
789
|
+
for path in endpoint_paths:
|
|
790
|
+
operation = self._rest_scheme['paths'][path].get(rest2http[action], None)
|
|
791
|
+
if operation is not None:
|
|
792
|
+
endpoint_method_info['url'] = path
|
|
793
|
+
if 'requestBody' in operation:
|
|
794
|
+
schema = operation['requestBody']['content']['application/json']['schema'][
|
|
795
|
+
'$ref'
|
|
796
|
+
].split('/')[-1]
|
|
797
|
+
endpoint_method_info['fields'] = self._rest_scheme['components']['schemas'][
|
|
798
|
+
schema
|
|
799
|
+
]['properties']
|
|
800
|
+
if 'description' in operation:
|
|
801
|
+
endpoint_method_info['description'] = operation['description']
|
|
802
|
+
if 'parameters' in operation:
|
|
803
|
+
endpoint_method_info['parameters'] = operation['parameters']
|
|
804
|
+
break
|
|
805
|
+
return endpoint_method_info
|
|
806
|
+
|
|
807
|
+
def fields(self, endpoint: str, action: str) -> list:
|
|
808
|
+
# in openapi there is a distinction between fields and parameters
|
|
809
|
+
params = self._endpoint_action_info(endpoint, action)['parameters']
|
|
810
|
+
fields = self._endpoint_action_info(endpoint, action)['fields']
|
|
811
|
+
fields = [{'name': k, 'schema': v} for k, v in fields.items()]
|
|
812
|
+
return params + fields
|
|
813
|
+
|
|
814
|
+
@validate_endpoint_action
|
|
815
|
+
def actions(self, endpoint: str, *args) -> list:
|
|
816
|
+
actions = self._get_endpoint_actions(endpoint)
|
|
817
|
+
return sorted(list(actions.keys()))
|
|
818
|
+
|
|
819
|
+
def url(self, endpoint: str, action: str) -> str:
|
|
820
|
+
return self._endpoint_action_info(endpoint, action)['url']
|
|
821
|
+
|
|
822
|
+
@validate_endpoint_action
|
|
823
|
+
def print_endpoint_info(self, endpoint: str, action: str = None) -> None:
|
|
824
|
+
"""Print detailed information about an endpoint's actions and parameters.
|
|
825
|
+
|
|
826
|
+
Parameters
|
|
827
|
+
----------
|
|
828
|
+
endpoint : str
|
|
829
|
+
The endpoint name to display information for
|
|
830
|
+
action : str, optional
|
|
831
|
+
If provided, only show information for this specific action
|
|
832
|
+
|
|
833
|
+
Returns
|
|
834
|
+
-------
|
|
835
|
+
None
|
|
836
|
+
"""
|
|
837
|
+
actions = self._get_endpoint_actions(endpoint) if action is None else [action]
|
|
838
|
+
for action in actions:
|
|
839
|
+
endpoint_action_info = self._endpoint_action_info(endpoint, action)
|
|
840
|
+
print(action)
|
|
841
|
+
for par in endpoint_action_info.get('parameters', []):
|
|
842
|
+
print(f'\t{par["name"]}')
|
|
843
|
+
all_fields = endpoint_action_info.get('fields', {})
|
|
844
|
+
if isinstance(all_fields, dict):
|
|
845
|
+
for field, field_info in all_fields.items():
|
|
846
|
+
print(f'\t{field}: ({field_info.get("type", "")}), '
|
|
847
|
+
f'{field_info.get("description", "")}')
|
|
848
|
+
|
|
849
|
+
|
|
528
850
|
class AlyxClient:
|
|
529
851
|
"""Class that implements simple GET/POST wrappers for the Alyx REST API.
|
|
530
852
|
|
|
@@ -538,8 +860,15 @@ class AlyxClient:
|
|
|
538
860
|
base_url = None
|
|
539
861
|
"""str: The Alyx database URL."""
|
|
540
862
|
|
|
541
|
-
def __init__(
|
|
542
|
-
|
|
863
|
+
def __init__(
|
|
864
|
+
self,
|
|
865
|
+
base_url=None,
|
|
866
|
+
username=None,
|
|
867
|
+
password=None,
|
|
868
|
+
cache_dir=None,
|
|
869
|
+
silent=False,
|
|
870
|
+
cache_rest='GET',
|
|
871
|
+
):
|
|
543
872
|
"""Create a client instance that allows to GET and POST to the Alyx server.
|
|
544
873
|
|
|
545
874
|
For One, constructor attempts to authenticate with credentials in params.py.
|
|
@@ -571,22 +900,17 @@ class AlyxClient:
|
|
|
571
900
|
self.authenticate(username, password)
|
|
572
901
|
self._rest_schemes = None
|
|
573
902
|
# the mixed accept application may cause errors sometimes, only necessary for the docs
|
|
574
|
-
self._headers = {
|
|
903
|
+
self._headers = {
|
|
904
|
+
**self._headers, 'Accept': 'application/json', 'ONE-API-Version': __version__}
|
|
575
905
|
# REST cache parameters
|
|
576
906
|
# The default length of time that cache file is valid for,
|
|
577
907
|
# The default expiry is overridden by the `expires` kwarg. If False, the caching is
|
|
578
908
|
# turned off.
|
|
579
909
|
self.default_expiry = timedelta(minutes=5)
|
|
910
|
+
self.rest_cache_dir = self.cache_dir.joinpath('.rest')
|
|
580
911
|
self.cache_mode = cache_rest
|
|
581
912
|
self._obj_id = id(self)
|
|
582
913
|
|
|
583
|
-
@property
|
|
584
|
-
def rest_schemes(self):
|
|
585
|
-
"""dict: The REST endpoints and their parameters."""
|
|
586
|
-
# Delayed fetch of rest schemes speeds up instantiation
|
|
587
|
-
if not self._rest_schemes:
|
|
588
|
-
self._rest_schemes = self.get('/docs', expires=timedelta(weeks=1))
|
|
589
|
-
return self._rest_schemes
|
|
590
914
|
|
|
591
915
|
@property
|
|
592
916
|
def cache_dir(self):
|
|
@@ -604,49 +928,29 @@ class AlyxClient:
|
|
|
604
928
|
"""bool: Check if user logged into Alyx database; True if user is authenticated."""
|
|
605
929
|
return bool(self.user and self._token and 'Authorization' in self._headers)
|
|
606
930
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
----------
|
|
623
|
-
endpoint : str
|
|
624
|
-
An Alyx REST endpoint to query.
|
|
625
|
-
action : str
|
|
626
|
-
An optional action (e.g. 'list') to print. If None, all actions are printed.
|
|
627
|
-
|
|
628
|
-
Returns
|
|
629
|
-
-------
|
|
630
|
-
dict, list
|
|
631
|
-
A dictionary of endpoint query parameter details or a list of parameter details if
|
|
632
|
-
action is not None.
|
|
931
|
+
@property
|
|
932
|
+
def rest_schemes(self) -> RestScheme:
|
|
933
|
+
"""dict: The REST endpoints and their parameters."""
|
|
934
|
+
# Delayed fetch of rest schemes speeds up instantiation
|
|
935
|
+
if not self._rest_schemes:
|
|
936
|
+
try:
|
|
937
|
+
raw_schema = self.get('/api/schema', expires=timedelta(weeks=1))
|
|
938
|
+
self._rest_schemes = RestSchemeOpenApi(raw_schema)
|
|
939
|
+
except requests.exceptions.HTTPError as e:
|
|
940
|
+
if e.response.status_code == 404:
|
|
941
|
+
raw_schema = self.get('/docs', expires=timedelta(weeks=1))
|
|
942
|
+
self._rest_schemes = RestSchemeCoreApi(raw_schema)
|
|
943
|
+
else:
|
|
944
|
+
raise e
|
|
945
|
+
return self._rest_schemes
|
|
633
946
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
947
|
+
def list_endpoints(self):
|
|
948
|
+
endpoints = self.rest_schemes.endpoints
|
|
949
|
+
pprint(endpoints)
|
|
950
|
+
return endpoints
|
|
638
951
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
pprint(_action)
|
|
642
|
-
for f in rs[endpoint][_action]['fields']:
|
|
643
|
-
required = ' (required): ' if f.get('required', False) else ': '
|
|
644
|
-
doc.append(f'\t"{f["name"]}"{required}{f["schema"]["_type"]}'
|
|
645
|
-
f', {f["schema"]["description"]}')
|
|
646
|
-
doc.sort()
|
|
647
|
-
[print(d) for d in doc if '(required)' in d]
|
|
648
|
-
[print(d) for d in doc if '(required)' not in d]
|
|
649
|
-
return (rs[endpoint] if action is None else rs[endpoint][action]).copy()
|
|
952
|
+
def print_endpoint_info(self, endpoint: str, action: str = None):
|
|
953
|
+
self.rest_schemes.print_endpoint_info(endpoint, action)
|
|
650
954
|
|
|
651
955
|
@_cache_response
|
|
652
956
|
def _generic_request(self, reqfunction, rest_query, data=None, files=None):
|
|
@@ -661,12 +965,17 @@ class AlyxClient:
|
|
|
661
965
|
if files is None:
|
|
662
966
|
to_json = functools.partial(json.dumps, cls=_JSONEncoder)
|
|
663
967
|
data = to_json(data) if isinstance(data, dict) or isinstance(data, list) else data
|
|
968
|
+
# __ONE_API_VERSION__
|
|
664
969
|
headers['Content-Type'] = 'application/json'
|
|
665
970
|
if rest_query.startswith('/docs'):
|
|
666
|
-
# the mixed accept application may cause errors sometimes, only necessary for the docs
|
|
667
971
|
headers['Accept'] = 'application/coreapi+json'
|
|
668
|
-
|
|
669
|
-
|
|
972
|
+
if rest_query.startswith('/api/schema'):
|
|
973
|
+
# the docs are now served as openapi media, so we need to change the accept header
|
|
974
|
+
headers['Accept'] = 'application/vnd.oai.openapi+json'
|
|
975
|
+
headers['Accept-Version'] = '3.0'
|
|
976
|
+
r = reqfunction(
|
|
977
|
+
self.base_url + rest_query, stream=True, headers=headers, data=data, files=files
|
|
978
|
+
)
|
|
670
979
|
if r and r.status_code in (200, 201):
|
|
671
980
|
return json.loads(r.text)
|
|
672
981
|
elif r and r.status_code == 204:
|
|
@@ -687,7 +996,7 @@ class AlyxClient:
|
|
|
687
996
|
message.pop('status_code', None) # Get status code from response object instead
|
|
688
997
|
message = message.get('detail') or message # Get details if available
|
|
689
998
|
_logger.debug(message)
|
|
690
|
-
except json.decoder.JSONDecodeError:
|
|
999
|
+
except json.decoder.JSONDecodeError: #nocov
|
|
691
1000
|
message = r.text
|
|
692
1001
|
raise requests.HTTPError(r.status_code, rest_query, message, response=r)
|
|
693
1002
|
|
|
@@ -724,7 +1033,8 @@ class AlyxClient:
|
|
|
724
1033
|
self._token = self._par.TOKEN[username]
|
|
725
1034
|
self._headers = {
|
|
726
1035
|
'Authorization': f'Token {list(self._token.values())[0]}',
|
|
727
|
-
'Accept': 'application/json'
|
|
1036
|
+
'Accept': 'application/json'
|
|
1037
|
+
}
|
|
728
1038
|
self.user = username
|
|
729
1039
|
return
|
|
730
1040
|
|
|
@@ -737,7 +1047,9 @@ class AlyxClient:
|
|
|
737
1047
|
'No password or cached token in silent mode. '
|
|
738
1048
|
'Please run the following to re-authenticate:\n\t'
|
|
739
1049
|
'AlyxClient(silent=False).authenticate'
|
|
740
|
-
'(username=<username>, force=True)',
|
|
1050
|
+
'(username=<username>, force=True)',
|
|
1051
|
+
UserWarning,
|
|
1052
|
+
)
|
|
741
1053
|
else:
|
|
742
1054
|
password = getpass(f'Enter Alyx password for "{username}":')
|
|
743
1055
|
# Remove previous token
|
|
@@ -757,15 +1069,18 @@ class AlyxClient:
|
|
|
757
1069
|
else:
|
|
758
1070
|
if rep.status_code == 400: # Auth error; re-raise with details
|
|
759
1071
|
redacted = '*' * len(credentials['password']) if credentials['password'] else None
|
|
760
|
-
message = (
|
|
761
|
-
|
|
1072
|
+
message = (
|
|
1073
|
+
'Alyx authentication failed with credentials: '
|
|
1074
|
+
f'user = {credentials["username"]}, password = {redacted}'
|
|
1075
|
+
)
|
|
762
1076
|
raise requests.HTTPError(rep.status_code, rep.url, message, response=rep)
|
|
763
1077
|
else:
|
|
764
1078
|
rep.raise_for_status()
|
|
765
1079
|
|
|
766
1080
|
self._headers = {
|
|
767
1081
|
'Authorization': 'Token {}'.format(list(self._token.values())[0]),
|
|
768
|
-
'Accept': 'application/json'
|
|
1082
|
+
'Accept': 'application/json',
|
|
1083
|
+
}
|
|
769
1084
|
if cache_token:
|
|
770
1085
|
# Update saved pars
|
|
771
1086
|
par = one.params.get(client=self.base_url, silent=True)
|
|
@@ -860,14 +1175,16 @@ class AlyxClient:
|
|
|
860
1175
|
target_dir=kwargs.pop('target_dir', self._par.CACHE_DIR),
|
|
861
1176
|
username=self._par.HTTP_DATA_SERVER_LOGIN,
|
|
862
1177
|
password=self._par.HTTP_DATA_SERVER_PWD,
|
|
863
|
-
**kwargs
|
|
1178
|
+
**kwargs,
|
|
864
1179
|
)
|
|
865
1180
|
try:
|
|
866
1181
|
files = download_fcn(url, **pars)
|
|
867
1182
|
except HTTPError as ex:
|
|
868
1183
|
if ex.code == 401:
|
|
869
|
-
ex.msg += (
|
|
870
|
-
|
|
1184
|
+
ex.msg += (
|
|
1185
|
+
' - please check your HTTP_DATA_SERVER_LOGIN and '
|
|
1186
|
+
'HTTP_DATA_SERVER_PWD ONE params, or username/password kwargs'
|
|
1187
|
+
)
|
|
871
1188
|
raise ex
|
|
872
1189
|
return files
|
|
873
1190
|
|
|
@@ -898,11 +1215,9 @@ class AlyxClient:
|
|
|
898
1215
|
headers = self._headers
|
|
899
1216
|
|
|
900
1217
|
with tempfile.TemporaryDirectory(dir=destination) as tmp:
|
|
901
|
-
file = http_download_file(
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
target_dir=tmp,
|
|
905
|
-
clobber=True)
|
|
1218
|
+
file = http_download_file(
|
|
1219
|
+
source, headers=headers, silent=self.silent, target_dir=tmp, clobber=True
|
|
1220
|
+
)
|
|
906
1221
|
with zipfile.ZipFile(file, 'r') as zipped:
|
|
907
1222
|
files = zipped.namelist()
|
|
908
1223
|
zipped.extractall(destination)
|
|
@@ -932,9 +1247,10 @@ class AlyxClient:
|
|
|
932
1247
|
|
|
933
1248
|
"""
|
|
934
1249
|
if url.startswith('http'): # A full URL
|
|
935
|
-
assert url.startswith(self._par.HTTP_DATA_SERVER),
|
|
936
|
-
|
|
937
|
-
|
|
1250
|
+
assert url.startswith(self._par.HTTP_DATA_SERVER), (
|
|
1251
|
+
'remote protocol and/or hostname does not match HTTP_DATA_SERVER parameter:\n'
|
|
1252
|
+
+ f'"{url[:40]}..." should start with "{self._par.HTTP_DATA_SERVER}"'
|
|
1253
|
+
)
|
|
938
1254
|
elif not url.startswith(self._par.HTTP_DATA_SERVER):
|
|
939
1255
|
url = self.rel_path2url(url)
|
|
940
1256
|
return url
|
|
@@ -1054,8 +1370,9 @@ class AlyxClient:
|
|
|
1054
1370
|
"""
|
|
1055
1371
|
return self._generic_request(requests.put, rest_query, data=data, files=files)
|
|
1056
1372
|
|
|
1057
|
-
def rest(
|
|
1058
|
-
|
|
1373
|
+
def rest(
|
|
1374
|
+
self, url=None, action=None, id=None, data=None, files=None, no_cache=False, **kwargs
|
|
1375
|
+
):
|
|
1059
1376
|
"""Alyx REST API wrapper.
|
|
1060
1377
|
|
|
1061
1378
|
If no arguments are passed, lists available endpoints.
|
|
@@ -1108,52 +1425,66 @@ class AlyxClient:
|
|
|
1108
1425
|
"""
|
|
1109
1426
|
# if endpoint is None, list available endpoints
|
|
1110
1427
|
if not url:
|
|
1111
|
-
pprint(self.
|
|
1428
|
+
pprint(self.rest_schemes.endpoints)
|
|
1112
1429
|
return
|
|
1113
1430
|
# remove beginning slash if any
|
|
1114
1431
|
if url.startswith('/'):
|
|
1115
1432
|
url = url[1:]
|
|
1116
1433
|
# and split to the next slash or question mark
|
|
1117
|
-
endpoint = re.findall(
|
|
1434
|
+
endpoint = re.findall('^/*[^?/]*', url)[0].replace('/', '')
|
|
1118
1435
|
# make sure the queried endpoint exists, if not throw an informative error
|
|
1119
|
-
if endpoint not in self.rest_schemes.
|
|
1120
|
-
av = [k for k in self.rest_schemes.
|
|
1121
|
-
raise ValueError(
|
|
1122
|
-
|
|
1123
|
-
|
|
1436
|
+
if endpoint not in self.rest_schemes.endpoints:
|
|
1437
|
+
av = [k for k in self.rest_schemes.endpoints if not k.startswith('_') and k]
|
|
1438
|
+
raise ValueError(
|
|
1439
|
+
'REST endpoint "'
|
|
1440
|
+
+ endpoint
|
|
1441
|
+
+ '" does not exist. Available '
|
|
1442
|
+
+ 'endpoints are \n '
|
|
1443
|
+
+ '\n '.join(av)
|
|
1444
|
+
)
|
|
1124
1445
|
# on a filter request, override the default action parameter
|
|
1125
1446
|
if '?' in url:
|
|
1126
1447
|
action = 'list'
|
|
1127
1448
|
# if action is None, list available actions for the required endpoint
|
|
1128
1449
|
if not action:
|
|
1129
|
-
pprint(
|
|
1130
|
-
self.print_endpoint_info(endpoint)
|
|
1450
|
+
pprint(self.rest_schemes.actions(endpoint))
|
|
1451
|
+
self.rest_schemes.print_endpoint_info(endpoint)
|
|
1131
1452
|
return
|
|
1453
|
+
if action == 'partial-update':
|
|
1454
|
+
# Endpoint names are hyphenated but action names are underscored;
|
|
1455
|
+
# convert for user convenience
|
|
1456
|
+
action = 'partial_update'
|
|
1132
1457
|
# make sure the desired action exists, if not throw an informative error
|
|
1133
|
-
if action not in
|
|
1134
|
-
raise ValueError(
|
|
1135
|
-
|
|
1136
|
-
|
|
1458
|
+
if action not in self.rest_schemes.actions(endpoint):
|
|
1459
|
+
raise ValueError(
|
|
1460
|
+
'Action "'
|
|
1461
|
+
+ action
|
|
1462
|
+
+ '" for REST endpoint "'
|
|
1463
|
+
+ endpoint
|
|
1464
|
+
+ '" does '
|
|
1465
|
+
+ 'not exist. Available actions are: '
|
|
1466
|
+
+ '\n '
|
|
1467
|
+
+ '\n '.join(self.rest_schemes.actions(endpoint))
|
|
1468
|
+
)
|
|
1137
1469
|
# the actions below require an id in the URL, warn and help the user
|
|
1138
1470
|
if action in ['read', 'update', 'partial_update', 'delete'] and not id:
|
|
1139
|
-
_logger.warning(
|
|
1140
|
-
|
|
1471
|
+
_logger.warning(
|
|
1472
|
+
'REST action "'
|
|
1473
|
+
+ action
|
|
1474
|
+
+ '" requires an ID in the URL: '
|
|
1475
|
+
+ self.rest_schemes.url(endpoint, action)
|
|
1476
|
+
)
|
|
1141
1477
|
return
|
|
1142
1478
|
# the actions below require a data dictionary, warn and help the user with fields list
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
pprint(
|
|
1146
|
-
for act in endpoint_scheme[action]['fields']:
|
|
1147
|
-
print("'" + act['name'] + "': ...,")
|
|
1479
|
+
if action in ['create', 'update', 'partial_update'] and not data:
|
|
1480
|
+
rest_params = self.rest_schemes.fields(endpoint, action)
|
|
1481
|
+
pprint(rest_params)
|
|
1148
1482
|
_logger.warning('REST action "' + action + '" requires a data dict with above keys')
|
|
1149
1483
|
return
|
|
1150
1484
|
|
|
1151
1485
|
# clobber=True means remote request always made, expires=True means response is not cached
|
|
1152
1486
|
cache_args = {'clobber': no_cache, 'expires': kwargs.pop('expires', False) or no_cache}
|
|
1153
1487
|
if action == 'list':
|
|
1154
|
-
# list doesn't require id nor
|
|
1155
|
-
assert endpoint_scheme[action]['action'] == 'get'
|
|
1156
|
-
# add to url data if it is a string
|
|
1157
1488
|
if id:
|
|
1158
1489
|
# this is a special case of the list where we query a uuid
|
|
1159
1490
|
# usually read is better but list may return fewer data and therefore be faster
|
|
@@ -1168,42 +1499,42 @@ class AlyxClient:
|
|
|
1168
1499
|
if 'django' in kwargs and kwargs['django'] is None:
|
|
1169
1500
|
del kwargs['django']
|
|
1170
1501
|
# Convert all lists in query params to comma separated list
|
|
1502
|
+
if len(set(kwargs.keys()) - set(self.rest_schemes.field_names(endpoint, action))):
|
|
1503
|
+
missing = set(kwargs.keys()) - set(
|
|
1504
|
+
self.rest_schemes.field_names(endpoint, action))
|
|
1505
|
+
raise ValueError(f"Error: Unsupported fields '{missing}' in query parameters.")
|
|
1171
1506
|
query_params = {k: ','.join(map(str, ensure_list(v))) for k, v in kwargs.items()}
|
|
1172
1507
|
url = update_url_params(url, query_params)
|
|
1173
1508
|
return self.get('/' + url, **cache_args)
|
|
1174
1509
|
if not isinstance(id, str) and id is not None:
|
|
1175
1510
|
id = str(id) # e.g. may be uuid.UUID
|
|
1176
1511
|
if action == 'read':
|
|
1177
|
-
assert endpoint_scheme[action]['action'] == 'get'
|
|
1178
1512
|
return self.get('/' + endpoint + '/' + id.split('/')[-1], **cache_args)
|
|
1179
1513
|
elif action == 'create':
|
|
1180
|
-
assert endpoint_scheme[action]['action'] == 'post'
|
|
1181
1514
|
return self.post('/' + endpoint, data=data, files=files)
|
|
1182
1515
|
elif action == 'delete':
|
|
1183
|
-
assert endpoint_scheme[action]['action'] == 'delete'
|
|
1184
1516
|
return self.delete('/' + endpoint + '/' + id.split('/')[-1])
|
|
1185
1517
|
elif action == 'partial_update':
|
|
1186
|
-
assert endpoint_scheme[action]['action'] == 'patch'
|
|
1187
1518
|
return self.patch('/' + endpoint + '/' + id.split('/')[-1], data=data, files=files)
|
|
1188
1519
|
elif action == 'update':
|
|
1189
|
-
assert endpoint_scheme[action]['action'] == 'put'
|
|
1190
1520
|
return self.put('/' + endpoint + '/' + id.split('/')[-1], data=data, files=files)
|
|
1191
1521
|
|
|
1192
1522
|
# JSON field interface convenience methods
|
|
1193
1523
|
def _check_inputs(self, endpoint: str) -> None:
|
|
1194
1524
|
# make sure the queried endpoint exists, if not throw an informative error
|
|
1195
|
-
if endpoint not in self.rest_schemes.
|
|
1196
|
-
av = (k for k in self.rest_schemes.
|
|
1197
|
-
raise ValueError(
|
|
1198
|
-
|
|
1525
|
+
if endpoint not in self.rest_schemes.endpoints:
|
|
1526
|
+
av = (k for k in self.rest_schemes.endpoints if not k.startswith('_') and k)
|
|
1527
|
+
raise ValueError(
|
|
1528
|
+
'REST endpoint "'
|
|
1529
|
+
+ endpoint
|
|
1530
|
+
+ '" does not exist. Available '
|
|
1531
|
+
+ 'endpoints are \n '
|
|
1532
|
+
+ '\n '.join(av)
|
|
1533
|
+
)
|
|
1199
1534
|
return
|
|
1200
1535
|
|
|
1201
1536
|
def json_field_write(
|
|
1202
|
-
|
|
1203
|
-
endpoint: str = None,
|
|
1204
|
-
uuid: str = None,
|
|
1205
|
-
field_name: str = None,
|
|
1206
|
-
data: dict = None
|
|
1537
|
+
self, endpoint: str = None, uuid: str = None, field_name: str = None, data: dict = None
|
|
1207
1538
|
) -> dict:
|
|
1208
1539
|
"""Write data to JSON field.
|
|
1209
1540
|
|
|
@@ -1234,11 +1565,7 @@ class AlyxClient:
|
|
|
1234
1565
|
return ret[field_name]
|
|
1235
1566
|
|
|
1236
1567
|
def json_field_update(
|
|
1237
|
-
|
|
1238
|
-
endpoint: str = None,
|
|
1239
|
-
uuid: str = None,
|
|
1240
|
-
field_name: str = 'json',
|
|
1241
|
-
data: dict = None
|
|
1568
|
+
self, endpoint: str = None, uuid: str = None, field_name: str = 'json', data: dict = None
|
|
1242
1569
|
) -> dict:
|
|
1243
1570
|
"""Non-destructive update of JSON field of endpoint for object.
|
|
1244
1571
|
|
|
@@ -1289,11 +1616,7 @@ class AlyxClient:
|
|
|
1289
1616
|
return ret[field_name]
|
|
1290
1617
|
|
|
1291
1618
|
def json_field_remove_key(
|
|
1292
|
-
|
|
1293
|
-
endpoint: str = None,
|
|
1294
|
-
uuid: str = None,
|
|
1295
|
-
field_name: str = 'json',
|
|
1296
|
-
key: str = None
|
|
1619
|
+
self, endpoint: str = None, uuid: str = None, field_name: str = 'json', key: str = None
|
|
1297
1620
|
) -> Optional[dict]:
|
|
1298
1621
|
"""Remove inputted key from JSON field dict and re-upload it to Alyx.
|
|
1299
1622
|
|
|
@@ -1327,9 +1650,7 @@ class AlyxClient:
|
|
|
1327
1650
|
return None
|
|
1328
1651
|
# If key not present in contents of json field cannot remove key, return contents
|
|
1329
1652
|
if current.get(key, None) is None:
|
|
1330
|
-
_logger.warning(
|
|
1331
|
-
f'{key}: Key not found in endpoint {endpoint} field {field_name}'
|
|
1332
|
-
)
|
|
1653
|
+
_logger.warning(f'{key}: Key not found in endpoint {endpoint} field {field_name}')
|
|
1333
1654
|
return current
|
|
1334
1655
|
_logger.info(f'Removing key from dict: "{key}"')
|
|
1335
1656
|
current.pop(key)
|
|
@@ -1340,7 +1661,7 @@ class AlyxClient:
|
|
|
1340
1661
|
return written
|
|
1341
1662
|
|
|
1342
1663
|
def json_field_delete(
|
|
1343
|
-
|
|
1664
|
+
self, endpoint: str = None, uuid: str = None, field_name: str = None
|
|
1344
1665
|
) -> None:
|
|
1345
1666
|
"""Set an entire field to null.
|
|
1346
1667
|
|
|
@@ -1368,5 +1689,5 @@ class AlyxClient:
|
|
|
1368
1689
|
|
|
1369
1690
|
def clear_rest_cache(self):
|
|
1370
1691
|
"""Clear all REST response cache files for the base url."""
|
|
1371
|
-
for file in self.
|
|
1692
|
+
for file in self.rest_cache_dir.glob('*'):
|
|
1372
1693
|
file.unlink()
|