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/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 = [executor.submit(
372
- http_download_file, link, target_dir=target, **kwargs) for link, target in zipped]
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__(self, base_url=None, username=None, password=None,
542
- cache_dir=None, silent=False, cache_rest='GET'):
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 = {**self._headers, 'Accept': 'application/json'}
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
- 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.
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
- rs = self.rest_schemes
637
- if endpoint not in rs:
638
- return print(f'Endpoint "{endpoint}" does not exist')
947
+ def list_endpoints(self):
948
+ endpoints = self.rest_schemes.endpoints
949
+ pprint(endpoints)
950
+ return endpoints
639
951
 
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()
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
- r = reqfunction(self.base_url + rest_query,
670
- stream=True, headers=headers, data=data, files=files)
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)', UserWarning)
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 = ('Alyx authentication failed with credentials: '
762
- f'user = {credentials["username"]}, password = {redacted}')
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 += (' - please check your HTTP_DATA_SERVER_LOGIN and '
871
- 'HTTP_DATA_SERVER_PWD ONE params, or username/password kwargs')
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(source,
903
- headers=headers,
904
- silent=self.silent,
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
- ('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}"')
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(self, url=None, action=None, id=None, data=None, files=None,
1059
- no_cache=False, **kwargs):
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.list_endpoints())
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("^/*[^?/]*", url)[0].replace('/', '')
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.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]
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(list(endpoint_scheme.keys()))
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 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()))
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('REST action "' + action + '" requires an ID in the URL: ' +
1141
- endpoint_scheme[action]['url'])
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
- 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'] + "': ...,")
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.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))
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
- self,
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
- self,
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
- self,
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
- self, endpoint: str = None, uuid: str = None, field_name: str = None
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