ONE-api 3.2.1__py3-none-any.whl → 3.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
one/webclient.py CHANGED
@@ -30,7 +30,9 @@ Download a remote file, given a local path
30
30
  >>> local_path = alyx.download_file(url, target_dir='zadorlab/Subjects/flowers/2018-07-13/1/')
31
31
 
32
32
  """
33
+
33
34
  from uuid import UUID
35
+ import abc
34
36
  import json
35
37
  import logging
36
38
  import math
@@ -41,7 +43,7 @@ import urllib.request
41
43
  from urllib.error import HTTPError
42
44
  import urllib.parse
43
45
  from collections.abc import Mapping
44
- from typing import Optional
46
+ from typing import Optional, List
45
47
  from datetime import datetime, timedelta
46
48
  from pathlib import Path
47
49
  from weakref import ReferenceType
@@ -57,10 +59,12 @@ from tqdm import tqdm
57
59
 
58
60
  from pprint import pprint
59
61
  import one.params
62
+ from one import __version__
60
63
  from iblutil.io import hashfile
61
64
  from iblutil.io.params import set_hidden
62
65
  from iblutil.util import ensure_list
63
66
  import concurrent.futures
67
+
64
68
  _logger = logging.getLogger(__name__)
65
69
  N_THREADS = int(os.environ.get('ONE_HTTP_DL_THREADS', 4))
66
70
  """int: The number of download threads."""
@@ -109,7 +113,7 @@ def _cache_response(method):
109
113
  response will not be used on subsequent calls. If None, the default expiry is applied.
110
114
  clobber : bool
111
115
  If True any existing cached response is overwritten.
112
- **kwargs
116
+ kwargs
113
117
  Keyword arguments for applying to wrapped function.
114
118
 
115
119
  Returns
@@ -123,7 +127,7 @@ def _cache_response(method):
123
127
  if args[0].__name__ != mode and mode != '*':
124
128
  return method(alyx_client, *args, **kwargs)
125
129
  # Check cache
126
- rest_cache = alyx_client.cache_dir.joinpath('.rest')
130
+ rest_cache = alyx_client.rest_cache_dir
127
131
  sha1 = hashlib.sha1()
128
132
  sha1.update(bytes(args[1], 'utf-8'))
129
133
  name = sha1.hexdigest()
@@ -368,8 +372,10 @@ def http_download_file_list(links_to_file_list, **kwargs):
368
372
  zipped = zip(links_to_file_list, target_dir)
369
373
  with concurrent.futures.ThreadPoolExecutor(max_workers=N_THREADS) as executor:
370
374
  # Multithreading load operations
371
- futures = [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,22 +900,17 @@ class AlyxClient:
571
900
  self.authenticate(username, password)
572
901
  self._rest_schemes = None
573
902
  # the mixed accept application may cause errors sometimes, only necessary for the docs
574
- self._headers = {**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
578
908
  # turned off.
579
909
  self.default_expiry = timedelta(minutes=5)
910
+ self.rest_cache_dir = self.cache_dir.joinpath('.rest')
580
911
  self.cache_mode = cache_rest
581
912
  self._obj_id = id(self)
582
913
 
583
- @property
584
- def rest_schemes(self):
585
- """dict: The REST endpoints and their parameters."""
586
- # Delayed fetch of rest schemes speeds up instantiation
587
- if not self._rest_schemes:
588
- self._rest_schemes = self.get('/docs', expires=timedelta(weeks=1))
589
- return self._rest_schemes
590
914
 
591
915
  @property
592
916
  def cache_dir(self):
@@ -604,49 +928,29 @@ class AlyxClient:
604
928
  """bool: Check if user logged into Alyx database; True if user is authenticated."""
605
929
  return bool(self.user and self._token and 'Authorization' in self._headers)
606
930
 
607
- def list_endpoints(self):
608
- """Return a list of available REST endpoints.
609
-
610
- Returns
611
- -------
612
- List of REST endpoint strings.
613
-
614
- """
615
- EXCLUDE = ('_type', '_meta', '', 'auth-token')
616
- return sorted(x for x in self.rest_schemes.keys() if x not in EXCLUDE)
617
-
618
- def print_endpoint_info(self, endpoint, action=None):
619
- """Print the available actions and query parameters for a given REST endpoint.
620
-
621
- Parameters
622
- ----------
623
- endpoint : str
624
- An Alyx REST endpoint to query.
625
- action : str
626
- An optional action (e.g. 'list') to print. If None, all actions are printed.
627
-
628
- Returns
629
- -------
630
- dict, list
631
- A dictionary of endpoint query parameter details or a list of parameter details if
632
- action is not None.
931
+ @property
932
+ def rest_schemes(self) -> RestScheme:
933
+ """dict: The REST endpoints and their parameters."""
934
+ # Delayed fetch of rest schemes speeds up instantiation
935
+ if not self._rest_schemes:
936
+ try:
937
+ raw_schema = self.get('/api/schema', expires=timedelta(weeks=1))
938
+ self._rest_schemes = RestSchemeOpenApi(raw_schema)
939
+ except requests.exceptions.HTTPError as e:
940
+ if e.response.status_code == 404:
941
+ raw_schema = self.get('/docs', expires=timedelta(weeks=1))
942
+ self._rest_schemes = RestSchemeCoreApi(raw_schema)
943
+ else:
944
+ raise e
945
+ return self._rest_schemes
633
946
 
634
- """
635
- rs = self.rest_schemes
636
- if endpoint not in rs:
637
- 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
638
951
 
639
- for _action in (rs[endpoint] if action is None else [action]):
640
- doc = []
641
- pprint(_action)
642
- for f in rs[endpoint][_action]['fields']:
643
- required = ' (required): ' if f.get('required', False) else ': '
644
- doc.append(f'\t"{f["name"]}"{required}{f["schema"]["_type"]}'
645
- f', {f["schema"]["description"]}')
646
- doc.sort()
647
- [print(d) for d in doc if '(required)' in d]
648
- [print(d) for d in doc if '(required)' not in d]
649
- return (rs[endpoint] if action is None else rs[endpoint][action]).copy()
952
+ def print_endpoint_info(self, endpoint: str, action: str = None):
953
+ self.rest_schemes.print_endpoint_info(endpoint, action)
650
954
 
651
955
  @_cache_response
652
956
  def _generic_request(self, reqfunction, rest_query, data=None, files=None):
@@ -661,12 +965,17 @@ class AlyxClient:
661
965
  if files is None:
662
966
  to_json = functools.partial(json.dumps, cls=_JSONEncoder)
663
967
  data = to_json(data) if isinstance(data, dict) or isinstance(data, list) else data
968
+ # __ONE_API_VERSION__
664
969
  headers['Content-Type'] = 'application/json'
665
970
  if rest_query.startswith('/docs'):
666
- # the mixed accept application may cause errors sometimes, only necessary for the docs
667
971
  headers['Accept'] = 'application/coreapi+json'
668
- r = reqfunction(self.base_url + rest_query,
669
- 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
+ )
670
979
  if r and r.status_code in (200, 201):
671
980
  return json.loads(r.text)
672
981
  elif r and r.status_code == 204:
@@ -687,7 +996,7 @@ class AlyxClient:
687
996
  message.pop('status_code', None) # Get status code from response object instead
688
997
  message = message.get('detail') or message # Get details if available
689
998
  _logger.debug(message)
690
- except json.decoder.JSONDecodeError:
999
+ except json.decoder.JSONDecodeError: #nocov
691
1000
  message = r.text
692
1001
  raise requests.HTTPError(r.status_code, rest_query, message, response=r)
693
1002
 
@@ -724,7 +1033,8 @@ class AlyxClient:
724
1033
  self._token = self._par.TOKEN[username]
725
1034
  self._headers = {
726
1035
  'Authorization': f'Token {list(self._token.values())[0]}',
727
- 'Accept': 'application/json'}
1036
+ 'Accept': 'application/json'
1037
+ }
728
1038
  self.user = username
729
1039
  return
730
1040
 
@@ -737,7 +1047,9 @@ class AlyxClient:
737
1047
  'No password or cached token in silent mode. '
738
1048
  'Please run the following to re-authenticate:\n\t'
739
1049
  'AlyxClient(silent=False).authenticate'
740
- '(username=<username>, force=True)', UserWarning)
1050
+ '(username=<username>, force=True)',
1051
+ UserWarning,
1052
+ )
741
1053
  else:
742
1054
  password = getpass(f'Enter Alyx password for "{username}":')
743
1055
  # Remove previous token
@@ -757,15 +1069,18 @@ class AlyxClient:
757
1069
  else:
758
1070
  if rep.status_code == 400: # Auth error; re-raise with details
759
1071
  redacted = '*' * len(credentials['password']) if credentials['password'] else None
760
- message = ('Alyx authentication failed with credentials: '
761
- f'user = {credentials["username"]}, password = {redacted}')
1072
+ message = (
1073
+ 'Alyx authentication failed with credentials: '
1074
+ f'user = {credentials["username"]}, password = {redacted}'
1075
+ )
762
1076
  raise requests.HTTPError(rep.status_code, rep.url, message, response=rep)
763
1077
  else:
764
1078
  rep.raise_for_status()
765
1079
 
766
1080
  self._headers = {
767
1081
  'Authorization': 'Token {}'.format(list(self._token.values())[0]),
768
- 'Accept': 'application/json'}
1082
+ 'Accept': 'application/json',
1083
+ }
769
1084
  if cache_token:
770
1085
  # Update saved pars
771
1086
  par = one.params.get(client=self.base_url, silent=True)
@@ -860,14 +1175,16 @@ class AlyxClient:
860
1175
  target_dir=kwargs.pop('target_dir', self._par.CACHE_DIR),
861
1176
  username=self._par.HTTP_DATA_SERVER_LOGIN,
862
1177
  password=self._par.HTTP_DATA_SERVER_PWD,
863
- **kwargs
1178
+ **kwargs,
864
1179
  )
865
1180
  try:
866
1181
  files = download_fcn(url, **pars)
867
1182
  except HTTPError as ex:
868
1183
  if ex.code == 401:
869
- ex.msg += (' - please check your HTTP_DATA_SERVER_LOGIN and '
870
- '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
+ )
871
1188
  raise ex
872
1189
  return files
873
1190
 
@@ -898,11 +1215,9 @@ class AlyxClient:
898
1215
  headers = self._headers
899
1216
 
900
1217
  with tempfile.TemporaryDirectory(dir=destination) as tmp:
901
- file = http_download_file(source,
902
- headers=headers,
903
- silent=self.silent,
904
- target_dir=tmp,
905
- clobber=True)
1218
+ file = http_download_file(
1219
+ source, headers=headers, silent=self.silent, target_dir=tmp, clobber=True
1220
+ )
906
1221
  with zipfile.ZipFile(file, 'r') as zipped:
907
1222
  files = zipped.namelist()
908
1223
  zipped.extractall(destination)
@@ -932,9 +1247,10 @@ class AlyxClient:
932
1247
 
933
1248
  """
934
1249
  if url.startswith('http'): # A full URL
935
- assert url.startswith(self._par.HTTP_DATA_SERVER), \
936
- ('remote protocol and/or hostname does not match HTTP_DATA_SERVER parameter:\n' +
937
- 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
+ )
938
1254
  elif not url.startswith(self._par.HTTP_DATA_SERVER):
939
1255
  url = self.rel_path2url(url)
940
1256
  return url
@@ -1054,8 +1370,9 @@ class AlyxClient:
1054
1370
  """
1055
1371
  return self._generic_request(requests.put, rest_query, data=data, files=files)
1056
1372
 
1057
- def rest(self, url=None, action=None, id=None, data=None, files=None,
1058
- no_cache=False, **kwargs):
1373
+ def rest(
1374
+ self, url=None, action=None, id=None, data=None, files=None, no_cache=False, **kwargs
1375
+ ):
1059
1376
  """Alyx REST API wrapper.
1060
1377
 
1061
1378
  If no arguments are passed, lists available endpoints.
@@ -1108,52 +1425,66 @@ class AlyxClient:
1108
1425
  """
1109
1426
  # if endpoint is None, list available endpoints
1110
1427
  if not url:
1111
- pprint(self.list_endpoints())
1428
+ pprint(self.rest_schemes.endpoints)
1112
1429
  return
1113
1430
  # remove beginning slash if any
1114
1431
  if url.startswith('/'):
1115
1432
  url = url[1:]
1116
1433
  # and split to the next slash or question mark
1117
- endpoint = re.findall("^/*[^?/]*", url)[0].replace('/', '')
1434
+ endpoint = re.findall('^/*[^?/]*', url)[0].replace('/', '')
1118
1435
  # make sure the queried endpoint exists, if not throw an informative error
1119
- if endpoint not in self.rest_schemes.keys():
1120
- av = [k for k in self.rest_schemes.keys() if not k.startswith('_') and k]
1121
- raise ValueError('REST endpoint "' + endpoint + '" does not exist. Available ' +
1122
- 'endpoints are \n ' + '\n '.join(av))
1123
- 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
+ )
1124
1445
  # on a filter request, override the default action parameter
1125
1446
  if '?' in url:
1126
1447
  action = 'list'
1127
1448
  # if action is None, list available actions for the required endpoint
1128
1449
  if not action:
1129
- pprint(list(endpoint_scheme.keys()))
1130
- self.print_endpoint_info(endpoint)
1450
+ pprint(self.rest_schemes.actions(endpoint))
1451
+ self.rest_schemes.print_endpoint_info(endpoint)
1131
1452
  return
1453
+ if action == 'partial-update':
1454
+ # Endpoint names are hyphenated but action names are underscored;
1455
+ # convert for user convenience
1456
+ action = 'partial_update'
1132
1457
  # make sure the desired action exists, if not throw an informative error
1133
- if action not in endpoint_scheme:
1134
- raise ValueError('Action "' + action + '" for REST endpoint "' + endpoint + '" does ' +
1135
- 'not exist. Available actions are: ' +
1136
- '\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
+ )
1137
1469
  # the actions below require an id in the URL, warn and help the user
1138
1470
  if action in ['read', 'update', 'partial_update', 'delete'] and not id:
1139
- _logger.warning('REST action "' + action + '" requires an ID in the URL: ' +
1140
- 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
+ )
1141
1477
  return
1142
1478
  # the actions below require a data dictionary, warn and help the user with fields list
1143
- data_required = 'fields' in endpoint_scheme[action]
1144
- if action in ['create', 'update', 'partial_update'] and data_required and not data:
1145
- pprint(endpoint_scheme[action]['fields'])
1146
- for act in endpoint_scheme[action]['fields']:
1147
- print("'" + act['name'] + "': ...,")
1479
+ if action in ['create', 'update', 'partial_update'] and not data:
1480
+ rest_params = self.rest_schemes.fields(endpoint, action)
1481
+ pprint(rest_params)
1148
1482
  _logger.warning('REST action "' + action + '" requires a data dict with above keys')
1149
1483
  return
1150
1484
 
1151
1485
  # clobber=True means remote request always made, expires=True means response is not cached
1152
1486
  cache_args = {'clobber': no_cache, 'expires': kwargs.pop('expires', False) or no_cache}
1153
1487
  if action == 'list':
1154
- # list doesn't require id nor
1155
- assert endpoint_scheme[action]['action'] == 'get'
1156
- # add to url data if it is a string
1157
1488
  if id:
1158
1489
  # this is a special case of the list where we query a uuid
1159
1490
  # usually read is better but list may return fewer data and therefore be faster
@@ -1168,42 +1499,42 @@ class AlyxClient:
1168
1499
  if 'django' in kwargs and kwargs['django'] is None:
1169
1500
  del kwargs['django']
1170
1501
  # Convert all lists in query params to comma separated list
1502
+ if len(set(kwargs.keys()) - set(self.rest_schemes.field_names(endpoint, action))):
1503
+ missing = set(kwargs.keys()) - set(
1504
+ self.rest_schemes.field_names(endpoint, action))
1505
+ raise ValueError(f"Error: Unsupported fields '{missing}' in query parameters.")
1171
1506
  query_params = {k: ','.join(map(str, ensure_list(v))) for k, v in kwargs.items()}
1172
1507
  url = update_url_params(url, query_params)
1173
1508
  return self.get('/' + url, **cache_args)
1174
1509
  if not isinstance(id, str) and id is not None:
1175
1510
  id = str(id) # e.g. may be uuid.UUID
1176
1511
  if action == 'read':
1177
- assert endpoint_scheme[action]['action'] == 'get'
1178
1512
  return self.get('/' + endpoint + '/' + id.split('/')[-1], **cache_args)
1179
1513
  elif action == 'create':
1180
- assert endpoint_scheme[action]['action'] == 'post'
1181
1514
  return self.post('/' + endpoint, data=data, files=files)
1182
1515
  elif action == 'delete':
1183
- assert endpoint_scheme[action]['action'] == 'delete'
1184
1516
  return self.delete('/' + endpoint + '/' + id.split('/')[-1])
1185
1517
  elif action == 'partial_update':
1186
- assert endpoint_scheme[action]['action'] == 'patch'
1187
1518
  return self.patch('/' + endpoint + '/' + id.split('/')[-1], data=data, files=files)
1188
1519
  elif action == 'update':
1189
- assert endpoint_scheme[action]['action'] == 'put'
1190
1520
  return self.put('/' + endpoint + '/' + id.split('/')[-1], data=data, files=files)
1191
1521
 
1192
1522
  # JSON field interface convenience methods
1193
1523
  def _check_inputs(self, endpoint: str) -> None:
1194
1524
  # make sure the queried endpoint exists, if not throw an informative error
1195
- if endpoint not in self.rest_schemes.keys():
1196
- av = (k for k in self.rest_schemes.keys() if not k.startswith('_') and k)
1197
- raise ValueError('REST endpoint "' + endpoint + '" does not exist. Available ' +
1198
- '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
+ )
1199
1534
  return
1200
1535
 
1201
1536
  def json_field_write(
1202
- self,
1203
- endpoint: str = None,
1204
- uuid: str = None,
1205
- field_name: str = None,
1206
- data: dict = None
1537
+ self, endpoint: str = None, uuid: str = None, field_name: str = None, data: dict = None
1207
1538
  ) -> dict:
1208
1539
  """Write data to JSON field.
1209
1540
 
@@ -1234,11 +1565,7 @@ class AlyxClient:
1234
1565
  return ret[field_name]
1235
1566
 
1236
1567
  def json_field_update(
1237
- self,
1238
- endpoint: str = None,
1239
- uuid: str = None,
1240
- field_name: str = 'json',
1241
- data: dict = None
1568
+ self, endpoint: str = None, uuid: str = None, field_name: str = 'json', data: dict = None
1242
1569
  ) -> dict:
1243
1570
  """Non-destructive update of JSON field of endpoint for object.
1244
1571
 
@@ -1289,11 +1616,7 @@ class AlyxClient:
1289
1616
  return ret[field_name]
1290
1617
 
1291
1618
  def json_field_remove_key(
1292
- self,
1293
- endpoint: str = None,
1294
- uuid: str = None,
1295
- field_name: str = 'json',
1296
- key: str = None
1619
+ self, endpoint: str = None, uuid: str = None, field_name: str = 'json', key: str = None
1297
1620
  ) -> Optional[dict]:
1298
1621
  """Remove inputted key from JSON field dict and re-upload it to Alyx.
1299
1622
 
@@ -1327,9 +1650,7 @@ class AlyxClient:
1327
1650
  return None
1328
1651
  # If key not present in contents of json field cannot remove key, return contents
1329
1652
  if current.get(key, None) is None:
1330
- _logger.warning(
1331
- f'{key}: Key not found in endpoint {endpoint} field {field_name}'
1332
- )
1653
+ _logger.warning(f'{key}: Key not found in endpoint {endpoint} field {field_name}')
1333
1654
  return current
1334
1655
  _logger.info(f'Removing key from dict: "{key}"')
1335
1656
  current.pop(key)
@@ -1340,7 +1661,7 @@ class AlyxClient:
1340
1661
  return written
1341
1662
 
1342
1663
  def json_field_delete(
1343
- self, endpoint: str = None, uuid: str = None, field_name: str = None
1664
+ self, endpoint: str = None, uuid: str = None, field_name: str = None
1344
1665
  ) -> None:
1345
1666
  """Set an entire field to null.
1346
1667
 
@@ -1368,5 +1689,5 @@ class AlyxClient:
1368
1689
 
1369
1690
  def clear_rest_cache(self):
1370
1691
  """Clear all REST response cache files for the base url."""
1371
- for file in self.cache_dir.joinpath('.rest').glob('*'):
1692
+ for file in self.rest_cache_dir.glob('*'):
1372
1693
  file.unlink()