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