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/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
- with open(files[0], 'r') as f:
137
- cached, when = json.load(f)
138
- if datetime.fromisoformat(when) > datetime.now():
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 = [executor.submit(
372
- http_download_file, link, target_dir=target, **kwargs) for link, target in zipped]
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__(self, base_url=None, username=None, password=None,
542
- cache_dir=None, silent=False, cache_rest='GET'):
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 = {**self._headers, 'Accept': 'application/json'}
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
- def list_endpoints(self):
609
- """Return a list of available REST endpoints.
610
-
611
- Returns
612
- -------
613
- List of REST endpoint strings.
614
-
615
- """
616
- EXCLUDE = ('_type', '_meta', '', 'auth-token')
617
- return sorted(x for x in self.rest_schemes.keys() if x not in EXCLUDE)
618
-
619
- def print_endpoint_info(self, endpoint, action=None):
620
- """Print the available actions and query parameters for a given REST endpoint.
621
-
622
- Parameters
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
- rs = self.rest_schemes
637
- if endpoint not in rs:
638
- return print(f'Endpoint "{endpoint}" does not exist')
951
+ def list_endpoints(self):
952
+ endpoints = self.rest_schemes.endpoints
953
+ pprint(endpoints)
954
+ return endpoints
639
955
 
640
- for _action in (rs[endpoint] if action is None else [action]):
641
- doc = []
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
- r = reqfunction(self.base_url + rest_query,
670
- stream=True, headers=headers, data=data, files=files)
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)', UserWarning)
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 = ('Alyx authentication failed with credentials: '
762
- f'user = {credentials["username"]}, password = {redacted}')
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 += (' - please check your HTTP_DATA_SERVER_LOGIN and '
871
- 'HTTP_DATA_SERVER_PWD ONE params, or username/password kwargs')
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(source,
903
- headers=headers,
904
- silent=self.silent,
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
- ('remote protocol and/or hostname does not match HTTP_DATA_SERVER parameter:\n' +
938
- f'"{url[:40]}..." should start with "{self._par.HTTP_DATA_SERVER}"')
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(self, url=None, action=None, id=None, data=None, files=None,
1059
- no_cache=False, **kwargs):
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.list_endpoints())
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("^/*[^?/]*", url)[0].replace('/', '')
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.keys():
1121
- av = [k for k in self.rest_schemes.keys() if not k.startswith('_') and k]
1122
- raise ValueError('REST endpoint "' + endpoint + '" does not exist. Available ' +
1123
- 'endpoints are \n ' + '\n '.join(av))
1124
- endpoint_scheme = self.rest_schemes[endpoint]
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(list(endpoint_scheme.keys()))
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 endpoint_scheme:
1135
- raise ValueError('Action "' + action + '" for REST endpoint "' + endpoint + '" does ' +
1136
- 'not exist. Available actions are: ' +
1137
- '\n ' + '\n '.join(endpoint_scheme.keys()))
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('REST action "' + action + '" requires an ID in the URL: ' +
1141
- endpoint_scheme[action]['url'])
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
- data_required = 'fields' in endpoint_scheme[action]
1145
- if action in ['create', 'update', 'partial_update'] and data_required and not data:
1146
- pprint(endpoint_scheme[action]['fields'])
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.keys():
1197
- av = (k for k in self.rest_schemes.keys() if not k.startswith('_') and k)
1198
- raise ValueError('REST endpoint "' + endpoint + '" does not exist. Available ' +
1199
- 'endpoints are \n ' + '\n '.join(av))
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
- self,
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
- self,
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
- self,
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
- self, endpoint: str = None, uuid: str = None, field_name: str = None
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