ONE-api 3.3.0__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/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 +447 -127
- {one_api-3.3.0.dist-info → one_api-3.4.0.dist-info}/METADATA +1 -1
- {one_api-3.3.0.dist-info → one_api-3.4.0.dist-info}/RECORD +11 -9
- {one_api-3.3.0.dist-info → one_api-3.4.0.dist-info}/WHEEL +0 -0
- {one_api-3.3.0.dist-info → one_api-3.4.0.dist-info}/licenses/LICENSE +0 -0
- {one_api-3.3.0.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."""
|
|
@@ -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,7 +900,8 @@ 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
|
|
@@ -581,13 +911,6 @@ class AlyxClient:
|
|
|
581
911
|
self.cache_mode = cache_rest
|
|
582
912
|
self._obj_id = id(self)
|
|
583
913
|
|
|
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
914
|
|
|
592
915
|
@property
|
|
593
916
|
def cache_dir(self):
|
|
@@ -605,49 +928,29 @@ class AlyxClient:
|
|
|
605
928
|
"""bool: Check if user logged into Alyx database; True if user is authenticated."""
|
|
606
929
|
return bool(self.user and self._token and 'Authorization' in self._headers)
|
|
607
930
|
|
|
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.
|
|
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
|
|
634
946
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
947
|
+
def list_endpoints(self):
|
|
948
|
+
endpoints = self.rest_schemes.endpoints
|
|
949
|
+
pprint(endpoints)
|
|
950
|
+
return endpoints
|
|
639
951
|
|
|
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()
|
|
952
|
+
def print_endpoint_info(self, endpoint: str, action: str = None):
|
|
953
|
+
self.rest_schemes.print_endpoint_info(endpoint, action)
|
|
651
954
|
|
|
652
955
|
@_cache_response
|
|
653
956
|
def _generic_request(self, reqfunction, rest_query, data=None, files=None):
|
|
@@ -662,12 +965,17 @@ class AlyxClient:
|
|
|
662
965
|
if files is None:
|
|
663
966
|
to_json = functools.partial(json.dumps, cls=_JSONEncoder)
|
|
664
967
|
data = to_json(data) if isinstance(data, dict) or isinstance(data, list) else data
|
|
968
|
+
# __ONE_API_VERSION__
|
|
665
969
|
headers['Content-Type'] = 'application/json'
|
|
666
970
|
if rest_query.startswith('/docs'):
|
|
667
|
-
# the mixed accept application may cause errors sometimes, only necessary for the docs
|
|
668
971
|
headers['Accept'] = 'application/coreapi+json'
|
|
669
|
-
|
|
670
|
-
|
|
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
|
+
)
|
|
671
979
|
if r and r.status_code in (200, 201):
|
|
672
980
|
return json.loads(r.text)
|
|
673
981
|
elif r and r.status_code == 204:
|
|
@@ -688,7 +996,7 @@ class AlyxClient:
|
|
|
688
996
|
message.pop('status_code', None) # Get status code from response object instead
|
|
689
997
|
message = message.get('detail') or message # Get details if available
|
|
690
998
|
_logger.debug(message)
|
|
691
|
-
except json.decoder.JSONDecodeError:
|
|
999
|
+
except json.decoder.JSONDecodeError: #nocov
|
|
692
1000
|
message = r.text
|
|
693
1001
|
raise requests.HTTPError(r.status_code, rest_query, message, response=r)
|
|
694
1002
|
|
|
@@ -725,7 +1033,8 @@ class AlyxClient:
|
|
|
725
1033
|
self._token = self._par.TOKEN[username]
|
|
726
1034
|
self._headers = {
|
|
727
1035
|
'Authorization': f'Token {list(self._token.values())[0]}',
|
|
728
|
-
'Accept': 'application/json'
|
|
1036
|
+
'Accept': 'application/json'
|
|
1037
|
+
}
|
|
729
1038
|
self.user = username
|
|
730
1039
|
return
|
|
731
1040
|
|
|
@@ -738,7 +1047,9 @@ class AlyxClient:
|
|
|
738
1047
|
'No password or cached token in silent mode. '
|
|
739
1048
|
'Please run the following to re-authenticate:\n\t'
|
|
740
1049
|
'AlyxClient(silent=False).authenticate'
|
|
741
|
-
'(username=<username>, force=True)',
|
|
1050
|
+
'(username=<username>, force=True)',
|
|
1051
|
+
UserWarning,
|
|
1052
|
+
)
|
|
742
1053
|
else:
|
|
743
1054
|
password = getpass(f'Enter Alyx password for "{username}":')
|
|
744
1055
|
# Remove previous token
|
|
@@ -758,15 +1069,18 @@ class AlyxClient:
|
|
|
758
1069
|
else:
|
|
759
1070
|
if rep.status_code == 400: # Auth error; re-raise with details
|
|
760
1071
|
redacted = '*' * len(credentials['password']) if credentials['password'] else None
|
|
761
|
-
message = (
|
|
762
|
-
|
|
1072
|
+
message = (
|
|
1073
|
+
'Alyx authentication failed with credentials: '
|
|
1074
|
+
f'user = {credentials["username"]}, password = {redacted}'
|
|
1075
|
+
)
|
|
763
1076
|
raise requests.HTTPError(rep.status_code, rep.url, message, response=rep)
|
|
764
1077
|
else:
|
|
765
1078
|
rep.raise_for_status()
|
|
766
1079
|
|
|
767
1080
|
self._headers = {
|
|
768
1081
|
'Authorization': 'Token {}'.format(list(self._token.values())[0]),
|
|
769
|
-
'Accept': 'application/json'
|
|
1082
|
+
'Accept': 'application/json',
|
|
1083
|
+
}
|
|
770
1084
|
if cache_token:
|
|
771
1085
|
# Update saved pars
|
|
772
1086
|
par = one.params.get(client=self.base_url, silent=True)
|
|
@@ -861,14 +1175,16 @@ class AlyxClient:
|
|
|
861
1175
|
target_dir=kwargs.pop('target_dir', self._par.CACHE_DIR),
|
|
862
1176
|
username=self._par.HTTP_DATA_SERVER_LOGIN,
|
|
863
1177
|
password=self._par.HTTP_DATA_SERVER_PWD,
|
|
864
|
-
**kwargs
|
|
1178
|
+
**kwargs,
|
|
865
1179
|
)
|
|
866
1180
|
try:
|
|
867
1181
|
files = download_fcn(url, **pars)
|
|
868
1182
|
except HTTPError as ex:
|
|
869
1183
|
if ex.code == 401:
|
|
870
|
-
ex.msg += (
|
|
871
|
-
|
|
1184
|
+
ex.msg += (
|
|
1185
|
+
' - please check your HTTP_DATA_SERVER_LOGIN and '
|
|
1186
|
+
'HTTP_DATA_SERVER_PWD ONE params, or username/password kwargs'
|
|
1187
|
+
)
|
|
872
1188
|
raise ex
|
|
873
1189
|
return files
|
|
874
1190
|
|
|
@@ -899,11 +1215,9 @@ class AlyxClient:
|
|
|
899
1215
|
headers = self._headers
|
|
900
1216
|
|
|
901
1217
|
with tempfile.TemporaryDirectory(dir=destination) as tmp:
|
|
902
|
-
file = http_download_file(
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
target_dir=tmp,
|
|
906
|
-
clobber=True)
|
|
1218
|
+
file = http_download_file(
|
|
1219
|
+
source, headers=headers, silent=self.silent, target_dir=tmp, clobber=True
|
|
1220
|
+
)
|
|
907
1221
|
with zipfile.ZipFile(file, 'r') as zipped:
|
|
908
1222
|
files = zipped.namelist()
|
|
909
1223
|
zipped.extractall(destination)
|
|
@@ -933,9 +1247,10 @@ class AlyxClient:
|
|
|
933
1247
|
|
|
934
1248
|
"""
|
|
935
1249
|
if url.startswith('http'): # A full URL
|
|
936
|
-
assert url.startswith(self._par.HTTP_DATA_SERVER),
|
|
937
|
-
|
|
938
|
-
|
|
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
|
+
)
|
|
939
1254
|
elif not url.startswith(self._par.HTTP_DATA_SERVER):
|
|
940
1255
|
url = self.rel_path2url(url)
|
|
941
1256
|
return url
|
|
@@ -1055,8 +1370,9 @@ class AlyxClient:
|
|
|
1055
1370
|
"""
|
|
1056
1371
|
return self._generic_request(requests.put, rest_query, data=data, files=files)
|
|
1057
1372
|
|
|
1058
|
-
def rest(
|
|
1059
|
-
|
|
1373
|
+
def rest(
|
|
1374
|
+
self, url=None, action=None, id=None, data=None, files=None, no_cache=False, **kwargs
|
|
1375
|
+
):
|
|
1060
1376
|
"""Alyx REST API wrapper.
|
|
1061
1377
|
|
|
1062
1378
|
If no arguments are passed, lists available endpoints.
|
|
@@ -1109,52 +1425,66 @@ class AlyxClient:
|
|
|
1109
1425
|
"""
|
|
1110
1426
|
# if endpoint is None, list available endpoints
|
|
1111
1427
|
if not url:
|
|
1112
|
-
pprint(self.
|
|
1428
|
+
pprint(self.rest_schemes.endpoints)
|
|
1113
1429
|
return
|
|
1114
1430
|
# remove beginning slash if any
|
|
1115
1431
|
if url.startswith('/'):
|
|
1116
1432
|
url = url[1:]
|
|
1117
1433
|
# and split to the next slash or question mark
|
|
1118
|
-
endpoint = re.findall(
|
|
1434
|
+
endpoint = re.findall('^/*[^?/]*', url)[0].replace('/', '')
|
|
1119
1435
|
# 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
|
-
|
|
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
|
+
)
|
|
1125
1445
|
# on a filter request, override the default action parameter
|
|
1126
1446
|
if '?' in url:
|
|
1127
1447
|
action = 'list'
|
|
1128
1448
|
# if action is None, list available actions for the required endpoint
|
|
1129
1449
|
if not action:
|
|
1130
|
-
pprint(
|
|
1131
|
-
self.print_endpoint_info(endpoint)
|
|
1450
|
+
pprint(self.rest_schemes.actions(endpoint))
|
|
1451
|
+
self.rest_schemes.print_endpoint_info(endpoint)
|
|
1132
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'
|
|
1133
1457
|
# make sure the desired action exists, if not throw an informative error
|
|
1134
|
-
if action not in
|
|
1135
|
-
raise ValueError(
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
+
)
|
|
1138
1469
|
# the actions below require an id in the URL, warn and help the user
|
|
1139
1470
|
if action in ['read', 'update', 'partial_update', 'delete'] and not id:
|
|
1140
|
-
_logger.warning(
|
|
1141
|
-
|
|
1471
|
+
_logger.warning(
|
|
1472
|
+
'REST action "'
|
|
1473
|
+
+ action
|
|
1474
|
+
+ '" requires an ID in the URL: '
|
|
1475
|
+
+ self.rest_schemes.url(endpoint, action)
|
|
1476
|
+
)
|
|
1142
1477
|
return
|
|
1143
1478
|
# 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'] + "': ...,")
|
|
1479
|
+
if action in ['create', 'update', 'partial_update'] and not data:
|
|
1480
|
+
rest_params = self.rest_schemes.fields(endpoint, action)
|
|
1481
|
+
pprint(rest_params)
|
|
1149
1482
|
_logger.warning('REST action "' + action + '" requires a data dict with above keys')
|
|
1150
1483
|
return
|
|
1151
1484
|
|
|
1152
1485
|
# clobber=True means remote request always made, expires=True means response is not cached
|
|
1153
1486
|
cache_args = {'clobber': no_cache, 'expires': kwargs.pop('expires', False) or no_cache}
|
|
1154
1487
|
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
1488
|
if id:
|
|
1159
1489
|
# this is a special case of the list where we query a uuid
|
|
1160
1490
|
# usually read is better but list may return fewer data and therefore be faster
|
|
@@ -1169,42 +1499,42 @@ class AlyxClient:
|
|
|
1169
1499
|
if 'django' in kwargs and kwargs['django'] is None:
|
|
1170
1500
|
del kwargs['django']
|
|
1171
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.")
|
|
1172
1506
|
query_params = {k: ','.join(map(str, ensure_list(v))) for k, v in kwargs.items()}
|
|
1173
1507
|
url = update_url_params(url, query_params)
|
|
1174
1508
|
return self.get('/' + url, **cache_args)
|
|
1175
1509
|
if not isinstance(id, str) and id is not None:
|
|
1176
1510
|
id = str(id) # e.g. may be uuid.UUID
|
|
1177
1511
|
if action == 'read':
|
|
1178
|
-
assert endpoint_scheme[action]['action'] == 'get'
|
|
1179
1512
|
return self.get('/' + endpoint + '/' + id.split('/')[-1], **cache_args)
|
|
1180
1513
|
elif action == 'create':
|
|
1181
|
-
assert endpoint_scheme[action]['action'] == 'post'
|
|
1182
1514
|
return self.post('/' + endpoint, data=data, files=files)
|
|
1183
1515
|
elif action == 'delete':
|
|
1184
|
-
assert endpoint_scheme[action]['action'] == 'delete'
|
|
1185
1516
|
return self.delete('/' + endpoint + '/' + id.split('/')[-1])
|
|
1186
1517
|
elif action == 'partial_update':
|
|
1187
|
-
assert endpoint_scheme[action]['action'] == 'patch'
|
|
1188
1518
|
return self.patch('/' + endpoint + '/' + id.split('/')[-1], data=data, files=files)
|
|
1189
1519
|
elif action == 'update':
|
|
1190
|
-
assert endpoint_scheme[action]['action'] == 'put'
|
|
1191
1520
|
return self.put('/' + endpoint + '/' + id.split('/')[-1], data=data, files=files)
|
|
1192
1521
|
|
|
1193
1522
|
# JSON field interface convenience methods
|
|
1194
1523
|
def _check_inputs(self, endpoint: str) -> None:
|
|
1195
1524
|
# 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
|
-
|
|
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
|
+
)
|
|
1200
1534
|
return
|
|
1201
1535
|
|
|
1202
1536
|
def json_field_write(
|
|
1203
|
-
|
|
1204
|
-
endpoint: str = None,
|
|
1205
|
-
uuid: str = None,
|
|
1206
|
-
field_name: str = None,
|
|
1207
|
-
data: dict = None
|
|
1537
|
+
self, endpoint: str = None, uuid: str = None, field_name: str = None, data: dict = None
|
|
1208
1538
|
) -> dict:
|
|
1209
1539
|
"""Write data to JSON field.
|
|
1210
1540
|
|
|
@@ -1235,11 +1565,7 @@ class AlyxClient:
|
|
|
1235
1565
|
return ret[field_name]
|
|
1236
1566
|
|
|
1237
1567
|
def json_field_update(
|
|
1238
|
-
|
|
1239
|
-
endpoint: str = None,
|
|
1240
|
-
uuid: str = None,
|
|
1241
|
-
field_name: str = 'json',
|
|
1242
|
-
data: dict = None
|
|
1568
|
+
self, endpoint: str = None, uuid: str = None, field_name: str = 'json', data: dict = None
|
|
1243
1569
|
) -> dict:
|
|
1244
1570
|
"""Non-destructive update of JSON field of endpoint for object.
|
|
1245
1571
|
|
|
@@ -1290,11 +1616,7 @@ class AlyxClient:
|
|
|
1290
1616
|
return ret[field_name]
|
|
1291
1617
|
|
|
1292
1618
|
def json_field_remove_key(
|
|
1293
|
-
|
|
1294
|
-
endpoint: str = None,
|
|
1295
|
-
uuid: str = None,
|
|
1296
|
-
field_name: str = 'json',
|
|
1297
|
-
key: str = None
|
|
1619
|
+
self, endpoint: str = None, uuid: str = None, field_name: str = 'json', key: str = None
|
|
1298
1620
|
) -> Optional[dict]:
|
|
1299
1621
|
"""Remove inputted key from JSON field dict and re-upload it to Alyx.
|
|
1300
1622
|
|
|
@@ -1328,9 +1650,7 @@ class AlyxClient:
|
|
|
1328
1650
|
return None
|
|
1329
1651
|
# If key not present in contents of json field cannot remove key, return contents
|
|
1330
1652
|
if current.get(key, None) is None:
|
|
1331
|
-
_logger.warning(
|
|
1332
|
-
f'{key}: Key not found in endpoint {endpoint} field {field_name}'
|
|
1333
|
-
)
|
|
1653
|
+
_logger.warning(f'{key}: Key not found in endpoint {endpoint} field {field_name}')
|
|
1334
1654
|
return current
|
|
1335
1655
|
_logger.info(f'Removing key from dict: "{key}"')
|
|
1336
1656
|
current.pop(key)
|
|
@@ -1341,7 +1661,7 @@ class AlyxClient:
|
|
|
1341
1661
|
return written
|
|
1342
1662
|
|
|
1343
1663
|
def json_field_delete(
|
|
1344
|
-
|
|
1664
|
+
self, endpoint: str = None, uuid: str = None, field_name: str = None
|
|
1345
1665
|
) -> None:
|
|
1346
1666
|
"""Set an entire field to null.
|
|
1347
1667
|
|