From b0c61ed3518d02909c9b7ec0cec6c0c0242b9670 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Tue, 14 Feb 2023 13:06:55 -0600 Subject: [PATCH 01/19] Create v0 of EdFiSwagger. --- edfi_api_client/edfi_swagger.py | 111 ++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 edfi_api_client/edfi_swagger.py diff --git a/edfi_api_client/edfi_swagger.py b/edfi_api_client/edfi_swagger.py new file mode 100644 index 0000000..091de94 --- /dev/null +++ b/edfi_api_client/edfi_swagger.py @@ -0,0 +1,111 @@ +from collections import defaultdict +from typing import List + +from edfi_api_client.util import camel_to_snake + + +class EdFiSwagger: + """ + """ + def __init__(self, component: str, swagger_payload: dict): + """ + TODO: Can `component` be extracted from the swagger? + + :param component: Type of swagger payload passed (i.e., 'resources' or 'descriptors' + :param swagger_payload: + :return: + """ + self.type: str = component + self.json: dict = swagger_payload + + self.version: str = self.json.get('swagger') + self.version_url_string: str = self.json.get('basePath') + + self.token_url: str = ( + self.json + .get('securityDefinitions', {}) + .get('oauth2_client_credentials', {}) + .get('tokenUrl') + ) + + # Extract namespaces and resources, and whether there is a deletes endpoints from `paths` + _resource_deletes = self._get_namespaced_resources_and_deletes() + self.resources: list = list(_resource_deletes.keys()) + self.deletes : list = list(filter(_resource_deletes.get, _resource_deletes)) # Filter where values are True + + # Extract resource descriptions from `tags` + self.descriptions: dict = self.get_descriptions() + + # Extract surrogate keys from `definitions` + self.reference_skeys: dict = self.get_reference_skeys(exclude=['link',]) + + + def _get_namespaced_resources_and_deletes(self): + """ + Internal function to parse values in `paths`. + + Extract each Ed-Fi namespace and resource, and whether it has an optional deletes tag. + (namespace: str, resource: str) -> has_deletes: bool + + Swagger's `paths` is a dictionary of Ed-Fi pathing keys (up-to-three keys per resource/descriptor). + For example: + '/ed-fi/studentSchoolAssociations' + '/ed-fi/studentSchoolAssociations/{id}' + '/ed-fi/studentSchoolAssociations/deletes' + + :return: + """ + resource_deletes = defaultdict(bool) + + for path in self.json.get('paths', {}).keys(): + namespace = path.split('/')[1] + resource = path.split('/')[2] + has_deletes = ('/deletes' in path) + + resource_deletes[ (namespace, resource) ] |= has_deletes + + return resource_deletes + + + def get_descriptions(self): + """ + Descriptions for all EdFi resources and descriptors are found under `tags` as [name, description] JSON objects. + Their extraction is optional for YAML templates, but they look nice. + + :param swagger: Swagger JSON object + :return: + """ + return { + tag['name']: tag['description'] + for tag in self.json['tags'] + } + + + def get_reference_skeys(self, exclude: List[str]): + """ + Build surrogate key definition column mappings for each Ed-Fi reference. + + :return: + """ + skey_mapping = {} + + for key, definition in self.json.get('definitions', {}).items(): + + # Only reference surrogate keys are used + if not key.endswith('Reference'): + continue + + reference = key.split('_')[1] # e.g.`edFi_staffReference` + + columns = definition.get('properties', {}).keys() + columns = list(filter(lambda x: x not in exclude, columns)) # Remove columns to be excluded. + + skey_mapping[reference] = columns + + return skey_mapping + + +if __name__ == '__main__': + from edfi_api_client import EdFiClient + api = EdFiClient("https://dw-lex1.districts-2122.scedfi.edanalytics.org") + swaggy = api.get_swagger('resources') \ No newline at end of file From af5f355f20f0bab67b84d84a645645149ba5deac Mon Sep 17 00:00:00 2001 From: jkaiser Date: Tue, 14 Feb 2023 13:07:29 -0600 Subject: [PATCH 02/19] Update EdFiClient.get_swagger() to return an EdFiSwagger instead of just its JSON payload. --- edfi_api_client/edfi_client.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/edfi_api_client/edfi_client.py b/edfi_api_client/edfi_client.py index 298187d..64ebd7d 100644 --- a/edfi_api_client/edfi_client.py +++ b/edfi_api_client/edfi_client.py @@ -8,6 +8,7 @@ from edfi_api_client import util from edfi_api_client.edfi_endpoint import EdFiResource, EdFiComposite +from edfi_api_client.edfi_swagger import EdFiSwagger class EdFiClient: @@ -151,7 +152,7 @@ def get_data_model_version(self) -> Optional[str]: return None - def get_swagger(self, component: str = 'resources') -> dict: + def get_swagger(self, component: str = 'resources') -> EdFiSwagger: """ OpenAPI Specification describes the entire Ed-Fi API surface in a JSON payload. @@ -163,7 +164,9 @@ def get_swagger(self, component: str = 'resources') -> dict: swagger_url = util.url_join( self.base_url, 'metadata', self.version_url_string, component, 'swagger.json' ) - return requests.get(swagger_url, verify=self.verify_ssl).json() + + payload = requests.get(swagger_url, verify=self.verify_ssl).json() + return EdFiSwagger(component, payload) ### Helper methods for building elements of endpoint URLs for GETs and POSTs From 2998d24c667663456e6099859e2eed2029e340c8 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Thu, 16 Feb 2023 15:52:02 -0600 Subject: [PATCH 03/19] Add Swagger string representation (defining Swagger type). --- edfi_api_client/edfi_swagger.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/edfi_api_client/edfi_swagger.py b/edfi_api_client/edfi_swagger.py index 091de94..806b7ee 100644 --- a/edfi_api_client/edfi_swagger.py +++ b/edfi_api_client/edfi_swagger.py @@ -40,6 +40,13 @@ def __init__(self, component: str, swagger_payload: dict): self.reference_skeys: dict = self.get_reference_skeys(exclude=['link',]) + def __repr__(self): + """ + Ed-Fi {self.type} OpenAPI Swagger Specification + """ + return f"" + + def _get_namespaced_resources_and_deletes(self): """ Internal function to parse values in `paths`. From 5bbf07c95ff1c298b4b9767f4c5201440f6732eb Mon Sep 17 00:00:00 2001 From: jkaiser Date: Thu, 16 Feb 2023 16:38:30 -0600 Subject: [PATCH 04/19] Add Swagger attributes to EdFiClient that are populated automatically when 'EdFiSwagger.get_swagger()' is called. --- edfi_api_client/edfi_client.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/edfi_api_client/edfi_client.py b/edfi_api_client/edfi_client.py index 64ebd7d..bfc6787 100644 --- a/edfi_api_client/edfi_client.py +++ b/edfi_api_client/edfi_client.py @@ -70,6 +70,13 @@ def __init__(self, self.version_url_string = self._get_version_url_string() self.instance_locator = self.get_instance_locator() + # Swagger variables for populating resource metadata (retrieved lazily) + self.swaggers = { + 'resources' : None, + 'descriptors': None, + 'composites' : None, + } + # If ID and secret are passed, build a session. self.session = None @@ -166,7 +173,11 @@ def get_swagger(self, component: str = 'resources') -> EdFiSwagger: ) payload = requests.get(swagger_url, verify=self.verify_ssl).json() - return EdFiSwagger(component, payload) + swagger = EdFiSwagger(component, payload) + + # Save the swagger in memory to save time on subsequent calls. + self.swaggers[component] = swagger + return swagger ### Helper methods for building elements of endpoint URLs for GETs and POSTs From bb86ce6d6664bd9c7fa3dea28e294b663b9064b4 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Thu, 16 Feb 2023 16:39:58 -0600 Subject: [PATCH 05/19] Create new EdFiDescriptor class that inherits from EdFiResource, but defines a different swagger_type. --- edfi_api_client/edfi_client.py | 7 ++++--- edfi_api_client/edfi_endpoint.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/edfi_api_client/edfi_client.py b/edfi_api_client/edfi_client.py index bfc6787..0d24a96 100644 --- a/edfi_api_client/edfi_client.py +++ b/edfi_api_client/edfi_client.py @@ -7,7 +7,7 @@ from typing import Callable, Optional from edfi_api_client import util -from edfi_api_client.edfi_endpoint import EdFiResource, EdFiComposite +from edfi_api_client.edfi_endpoint import EdFiResource, EdFiDescriptor, EdFiComposite from edfi_api_client.edfi_swagger import EdFiSwagger @@ -333,12 +333,13 @@ def descriptor(self, params: Optional[dict] = None, **kwargs - ) -> EdFiResource: + ) -> EdFiDescriptor: """ Even though descriptors and resources are accessed via the same endpoint, this may not be known to users, so a separate method is defined. """ - return self.resource( + return EdFiDescriptor( + client=self, name=name, namespace=namespace, get_deletes=False, params=params, **kwargs ) diff --git a/edfi_api_client/edfi_endpoint.py b/edfi_api_client/edfi_endpoint.py index f4b8f85..81c0a66 100644 --- a/edfi_api_client/edfi_endpoint.py +++ b/edfi_api_client/edfi_endpoint.py @@ -25,6 +25,11 @@ class EdFiEndpoint: url: str params: EdFiParams + # Swagger name and attributes loaded lazily from Swagger + swagger_type: str + _description: Optional[str] = None + _has_deletes: Optional[bool] = None + @abc.abstractmethod def build_url(self, @@ -292,6 +297,8 @@ def __init__(self, self.url = self.build_url(self.name, namespace=self.namespace, get_deletes=self.get_deletes) self.params = EdFiParams(params, **kwargs) + self.swagger_type = 'resources' + def __repr__(self): """ @@ -433,6 +440,14 @@ def total_count(self): +class EdFiDescriptor(EdFiResource): + """ + + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.swagger_type = 'descriptors' + class EdFiComposite(EdFiEndpoint): """ @@ -464,6 +479,8 @@ def __init__(self, ) self.params = EdFiParams(params, **kwargs) + self.swagger_type = 'composites' + def __repr__(self): """ From 0e06cd591f9ba4451fe4c9d741571d11ae8320ef Mon Sep 17 00:00:00 2001 From: jkaiser Date: Thu, 16 Feb 2023 16:41:39 -0600 Subject: [PATCH 06/19] Add lazy properties for retrieving description and has-deletes status for a given resource/descriptor. --- edfi_api_client/edfi_endpoint.py | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/edfi_api_client/edfi_endpoint.py b/edfi_api_client/edfi_endpoint.py index 81c0a66..2920ad1 100644 --- a/edfi_api_client/edfi_endpoint.py +++ b/edfi_api_client/edfi_endpoint.py @@ -151,6 +151,43 @@ def total_count(self) -> int: raise NotImplementedError + @property + def description(self): + if self._description is None: + self._description = self._get_attributes_from_swagger()['description'] + return self._description + + @property + def has_deletes(self): + if self._has_deletes is None: + self._has_deletes = self._get_attributes_from_swagger()['has_deletes'] + return self._has_deletes + + + def _get_attributes_from_swagger(self): + """ + Retrieve endpoint-metadata from the Swagger document. + + Populate the respective swagger object in `self.client` if not already populated. + + :return: + """ + # Only GET the Swagger if not already populated in the client. + if self.client.swaggers.get(self.swagger_type) is None: + self.client.verbose_log( + f"`{self.swagger_type}` Swagger has not yet been retrieved. Getting now..." + ) + self.client.get_swagger(self.swagger_type) + + swagger = self.client.swaggers[self.swagger_type] + + # Populate the attributes found in the swagger. + return { + 'description': swagger.descriptions.get(self.name), + 'has_deletes': (self.namespace, self.name) in swagger.deletes, + } + + ### Internal GET response methods and error-handling def _get_response(self, url: str, From 62cac1277cc561408c41dfa185013d3858c73823 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Thu, 16 Feb 2023 17:08:37 -0600 Subject: [PATCH 07/19] Add lazy properties to EdFiClient to retrieve 'resources' and 'descriptors' from Swagger docs; move _set_swagger() helper method from EdFiEndpoint to EdFiClient. --- edfi_api_client/edfi_client.py | 39 ++++++++++++++++++++++++++++++++ edfi_api_client/edfi_endpoint.py | 7 +----- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/edfi_api_client/edfi_client.py b/edfi_api_client/edfi_client.py index 0d24a96..24efe0a 100644 --- a/edfi_api_client/edfi_client.py +++ b/edfi_api_client/edfi_client.py @@ -76,6 +76,8 @@ def __init__(self, 'descriptors': None, 'composites' : None, } + self._resources = None + self._descriptors = None # If ID and secret are passed, build a session. self.session = None @@ -159,6 +161,7 @@ def get_data_model_version(self) -> Optional[str]: return None + ### Methods related to retrieving the Swagger or attributes retrieved therein def get_swagger(self, component: str = 'resources') -> EdFiSwagger: """ OpenAPI Specification describes the entire Ed-Fi API surface in a @@ -179,6 +182,42 @@ def get_swagger(self, component: str = 'resources') -> EdFiSwagger: self.swaggers[component] = swagger return swagger + def _set_swagger(self, component: str): + """ + Populate the respective swagger object in `self.swaggers` if not already populated. + + :param component: + :return: + """ + if self.swaggers.get(component) is None: + self.verbose_log( + f"[Get {component.title()} Swagger] Retrieving Swagger into memory..." + ) + self.get_swagger(component) + + + @property + def resources(self): + """ + + :return: + """ + if self._resources is None: + self._set_swagger('resources') + self._resources = self.swaggers['resources'].resources + return self._resources + + @property + def descriptors(self): + """ + + :return: + """ + if self._descriptors is None: + self._set_swagger('descriptors') + self._descriptors = self.swaggers['descriptors'].resources + return self._descriptors + ### Helper methods for building elements of endpoint URLs for GETs and POSTs def get_instance_locator(self) -> Optional[str]: diff --git a/edfi_api_client/edfi_endpoint.py b/edfi_api_client/edfi_endpoint.py index 2920ad1..1199d97 100644 --- a/edfi_api_client/edfi_endpoint.py +++ b/edfi_api_client/edfi_endpoint.py @@ -173,12 +173,7 @@ def _get_attributes_from_swagger(self): :return: """ # Only GET the Swagger if not already populated in the client. - if self.client.swaggers.get(self.swagger_type) is None: - self.client.verbose_log( - f"`{self.swagger_type}` Swagger has not yet been retrieved. Getting now..." - ) - self.client.get_swagger(self.swagger_type) - + self.client._set_swagger(self.swagger_type) swagger = self.client.swaggers[self.swagger_type] # Populate the attributes found in the swagger. From 032fc0ea4bcb7659f10439d667715a6d3fe472c3 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Thu, 16 Feb 2023 17:15:27 -0600 Subject: [PATCH 08/19] Update CHANGELOG.md and increment version in setup.py. --- CHANGELOG.md | 8 +++++++- setup.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7e133..c3c7b11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ +# edfi_api_client v0.2.0 +## New Features +- `EdFiClient.get_swagger()` now returns an EdFiSwagger class that parses OpenAPI Swagger specification. +- `EdFiClient.resources` and `EdFiClient.descriptors` lazily retrieves lists of respective endpoints from Swagger. +- `EdFiEndpoint` child class attributes `description` and `has_deletes` lazily retrieves this metadata from Swagger. + # edfi_api_client v0.1.1 ## Fixes -- retry on 500 errors +- Retry on 500 errors # edfi_api_client v0.1.0 Initial release \ No newline at end of file diff --git a/setup.py b/setup.py index b9ae369..c218fb8 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setuptools.setup( name='edfi_api_client', - version='0.1.1', + version='0.2.0', description='Ed-Fi API client and tools', license_files=['LICENSE'], url='https://github.com/edanalytics/edfi_api_client', From b5a73c30a62f1ae611392aba219c75b08ed14133 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Fri, 17 Feb 2023 11:42:43 -0600 Subject: [PATCH 09/19] Add `resources` and `descriptors` class properties to EdFi2Client. Both raise NotImplementedErrors. --- edfi_api_client/edfi_client.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/edfi_api_client/edfi_client.py b/edfi_api_client/edfi_client.py index 24efe0a..612e2da 100644 --- a/edfi_api_client/edfi_client.py +++ b/edfi_api_client/edfi_client.py @@ -436,6 +436,18 @@ def get_swagger(self, component: str = 'resources') -> dict: "Swagger specification not implemented in Ed-Fi 2." ) + @property + def resources(self): + raise NotImplementedError( + "Resources collected from Swagger specification that is not implemented in Ed-Fi 2." + ) + + @property + def descriptors(self): + raise NotImplementedError( + "Descriptors collected from Swagger specification that is not implemented in Ed-Fi 2." + ) + ### Helper methods for building elements of endpoint URLs for GETs and POSTs def _get_version_url_string(self) -> str: From 233d05a5ae06f64bcaa14973e53d0ea7c0150f42 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Fri, 17 Feb 2023 11:50:47 -0600 Subject: [PATCH 10/19] Update README.md with new Swagger-adjacent additions to EdFiClient and EdFiEndpoint. TODO: Add documentation about EdFiSwagger. --- README.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3a38a17..b058fae 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,50 @@ Client key and secret not provided. Connection with ODS will not be attempted. Connection to ODS successful! ``` +### Attributes + +Authentication with the ODS is not required: + +
+resources + +----- + +### resources +This method is unavailable in Ed-Fi2. + +Retrieve a list of namespaced-resources from the `resources` Swagger payload. + +```python +>>> api.resources +[('ed-fi', 'academicWeeks'), ('ed-fi', 'accounts'), ('ed-fi', 'accountCodes'), ...] +``` + +----- + +
+ + +
+descriptors + +----- + +### descriptors +This method is unavailable in Ed-Fi2. + +Retrieve a list of namespaced-descriptors from the `descriptors` Swagger payload. + +```python +>>> api.descriptors +[('ed-fi', 'absenceEventCategoryDescriptors'), ('ed-fi', 'academicHonorCategoryDescriptors'), ...] +``` +----- + +
+ + + ### Methods Authentication with the ODS is not required: @@ -187,8 +231,7 @@ If `component` is unspecified, `resources` will be collected. ...} ``` -Note: the returned dictionary is large and unwieldy*. -A future update will add an `EdFiSwagger` class to assist in navigation. +Returns an `EdFiSwagger` class containing the complete JSON payload, as well as extracted metadata from the Swagger. ----- @@ -348,6 +391,46 @@ All methods that return `EdFiEndpoint` and child classes require a session with ``` +### Attributes + +
+description + +----- + +### description +This attribute retrieves the Ed-Fi endpoint's description if present in its respective Swagger payload. + +```python +>>> api.resource('bellSchedules').description +'This entity represents the schedule of class period meeting times.' +``` + + +----- + +
+ + +
+has_deletes + +----- + +### has_deletes +This attribute returns whether a deletes path is present the Ed-Fi endpoint's respective Swagger payload. + +```python +>>> api.resource('bellSchedules').has_deletes +True +``` + +----- + +
+ + + ### Methods
From 927e1e1c8651d5e7b2e80b9bc3a3e2b37bfc3264 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Fri, 17 Feb 2023 17:48:07 -0600 Subject: [PATCH 11/19] Build __init__() for EdFiEndpoint that unifies client, name, and namespace setters; streamline EdFiResource and EdFiComposite build_url() methods to use class attributes. TODO: Can build_url() be added to EdFiEndpoint init? Can EdFiEndpoint be superceded by EdFiResource? --- edfi_api_client/edfi_endpoint.py | 113 +++++++++++++------------------ 1 file changed, 48 insertions(+), 65 deletions(-) diff --git a/edfi_api_client/edfi_endpoint.py b/edfi_api_client/edfi_endpoint.py index 1199d97..6bc36e1 100644 --- a/edfi_api_client/edfi_endpoint.py +++ b/edfi_api_client/edfi_endpoint.py @@ -4,7 +4,7 @@ import time from requests.exceptions import HTTPError, RequestsWarning -from typing import Iterator, List, Optional +from typing import Iterator, List, Optional, Tuple, Union from edfi_api_client.edfi_params import EdFiParams from edfi_api_client import util @@ -20,7 +20,7 @@ class EdFiEndpoint: """ client: 'EdFiClient' name: str - namespace: str + namespace: Optional[str] url: str params: EdFiParams @@ -31,20 +31,36 @@ class EdFiEndpoint: _has_deletes: Optional[bool] = None - @abc.abstractmethod - def build_url(self, - name: str, - - *, - namespace: str = 'ed-fi', - **kwargs + def __init__(self, + client: 'EdFiClient', + name: Union[str, Tuple[str, str]], + namespace: str = 'ed-fi' ): + self.client: 'EdFiClient' = client + + # Name and namespace can be passed manually + if isinstance(name, str): + self.name: str = util.snake_to_camel(name) + self.namespace: str = namespace + + # Or as a `(namespace, name)` tuple as output from Swagger + else: + try: + self.namespace, self.name = name + except ValueError: + logging.error( + "Arguments `name` and `namespace` must be passed explicitly, or as a `(namespace, name)` tuple." + ) + + # Namespaces are not implemented in EdFi 2.x. + if self.client.is_edfi2(): + self.namespace = None + + + @abc.abstractmethod + def build_url(self): """ This method builds the endpoint URL with namespacing and optional pathing. - - :param name: - :param namespace: - :param kwargs: :return: """ raise NotImplementedError @@ -321,12 +337,10 @@ def __init__(self, params: Optional[dict] = None, **kwargs ): - self.client: 'EdFiClient' = client - self.name: str = util.snake_to_camel(name) - self.namespace: str = namespace + super().__init__(client, name, namespace) self.get_deletes: bool = get_deletes - self.url = self.build_url(self.name, namespace=self.namespace, get_deletes=self.get_deletes) + self.url = self.build_url() self.params = EdFiParams(params, **kwargs) self.swagger_type = 'resources' @@ -334,8 +348,7 @@ def __init__(self, def __repr__(self): """ - Resource (Deletes) [{namespace}/{name}] - with {N} parameters + Resource (Deletes) (with {N} parameters) [{namespace}/{name}] """ _deletes_string = " Deletes" if self.get_deletes else "" _params_string = f" with {len(self.params.keys())} parameters" if self.params else "" @@ -344,13 +357,7 @@ def __repr__(self): return f"" - def build_url(self, - name: str, - - *, - namespace: str = 'ed-fi', - get_deletes: bool = False - ) -> str: + def build_url(self) -> str: """ Build the name/descriptor URL to GET from the API. @@ -359,20 +366,14 @@ def build_url(self, :param get_deletes: :return: """ - # Namespaces are not implemented in EdFi 2.x. - if self.client.is_edfi2(): - namespace = None - # Deletes are an optional path addition. - deletes = None - if get_deletes: - deletes = 'deletes' + deletes = 'deletes' if self.get_deletes else None return util.url_join( self.client.base_url, self.client.version_url_string, self.client.instance_locator, - namespace, name, deletes + self.namespace, self.name, deletes ) @@ -474,6 +475,7 @@ def total_count(self): class EdFiDescriptor(EdFiResource): """ + Ed-Fi Descriptors are used identically to Resources, but they are listed in a separate Swagger. """ def __init__(self, *args, **kwargs): @@ -498,17 +500,12 @@ def __init__(self, params: Optional[dict] = None, **kwargs ): - self.client: 'EdFiClient' = client - self.name: str = util.snake_to_camel(name) - self.namespace: str = namespace + super().__init__(client, name, namespace) self.composite: str = composite self.filter_type: Optional[str] = filter_type self.filter_id: Optional[str] = filter_id - self.url = self.build_url( - self.name, namespace=self.namespace, composite=self.composite, - filter_type=self.filter_type, filter_id=self.filter_id - ) + self.url = self.build_url() self.params = EdFiParams(params, **kwargs) self.swagger_type = 'composites' @@ -527,40 +524,26 @@ def __repr__(self): return f"<{_composite} Composite{_params_string} [{_full_name}]{_filter_string}>" - def build_url(self, - name: str, - - *, - namespace: str = 'ed-fi', - composite: str = 'enrollment', - - filter_type: Optional[str] = None, - filter_id: Optional[str] = None, - ) -> str: + def build_url(self) -> str: """ Build the composite URL to GET from the API. - :param name: - :param namespace: - :param composite: - :param filter_type: - :param filter_id: :return: """ - # Namespaces are not implemented in EdFi 2.x. - if self.client.is_edfi2(): - namespace = None - # If a filter is applied, the URL changes to match the filter type. - if filter_type is None and filter_id is None: + if self.filter_type is None and self.filter_id is None: return util.url_join( - self.client.base_url, 'composites/v1', self.client.instance_locator, namespace, composite, name.title() + self.client.base_url, 'composites/v1', + self.client.instance_locator, + self.namespace, self.composite, self.name.title() ) - elif filter_type is not None and filter_id is not None: + elif self.filter_type is not None and self.filter_id is not None: return util.url_join( - self.client.base_url, 'composites/v1', self.client.instance_locator, namespace, composite, - filter_type, filter_id, name + self.client.base_url, 'composites/v1', + self.client.instance_locator, + self.namespace, self.composite, + self.filter_type, self.filter_id, self.name ) else: From 9f806d5af8340b4b07faa6c14f6c2216e1c2ba66 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Fri, 24 Feb 2023 16:15:01 -0600 Subject: [PATCH 12/19] Rename swagger "resources" to "endpoints". --- edfi_api_client/edfi_client.py | 4 ++-- edfi_api_client/edfi_swagger.py | 20 +++++++------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/edfi_api_client/edfi_client.py b/edfi_api_client/edfi_client.py index 612e2da..fa4418f 100644 --- a/edfi_api_client/edfi_client.py +++ b/edfi_api_client/edfi_client.py @@ -204,7 +204,7 @@ def resources(self): """ if self._resources is None: self._set_swagger('resources') - self._resources = self.swaggers['resources'].resources + self._resources = self.swaggers['resources'].endpoints return self._resources @property @@ -215,7 +215,7 @@ def descriptors(self): """ if self._descriptors is None: self._set_swagger('descriptors') - self._descriptors = self.swaggers['descriptors'].resources + self._descriptors = self.swaggers['descriptors'].endpoints return self._descriptors diff --git a/edfi_api_client/edfi_swagger.py b/edfi_api_client/edfi_swagger.py index 806b7ee..81a95a1 100644 --- a/edfi_api_client/edfi_swagger.py +++ b/edfi_api_client/edfi_swagger.py @@ -11,7 +11,7 @@ def __init__(self, component: str, swagger_payload: dict): """ TODO: Can `component` be extracted from the swagger? - :param component: Type of swagger payload passed (i.e., 'resources' or 'descriptors' + :param component: Type of swagger payload passed (i.e., 'resources' or 'descriptors') :param swagger_payload: :return: """ @@ -28,10 +28,10 @@ def __init__(self, component: str, swagger_payload: dict): .get('tokenUrl') ) - # Extract namespaces and resources, and whether there is a deletes endpoints from `paths` - _resource_deletes = self._get_namespaced_resources_and_deletes() - self.resources: list = list(_resource_deletes.keys()) - self.deletes : list = list(filter(_resource_deletes.get, _resource_deletes)) # Filter where values are True + # Extract namespaces and endpoints, and whether there is a deletes endpoint from `paths` + _endpoint_deletes = self._get_namespaced_endpoints_and_deletes() + self.endpoints: list = list(_endpoint_deletes.keys()) + self.deletes : list = list(filter(_endpoint_deletes.get, _endpoint_deletes)) # Filter where values are True # Extract resource descriptions from `tags` self.descriptions: dict = self.get_descriptions() @@ -47,7 +47,7 @@ def __repr__(self): return f"" - def _get_namespaced_resources_and_deletes(self): + def _get_namespaced_endpoints_and_deletes(self): """ Internal function to parse values in `paths`. @@ -76,7 +76,7 @@ def _get_namespaced_resources_and_deletes(self): def get_descriptions(self): """ - Descriptions for all EdFi resources and descriptors are found under `tags` as [name, description] JSON objects. + Descriptions for all EdFi endpoints are found under `tags` as [name, description] JSON objects. Their extraction is optional for YAML templates, but they look nice. :param swagger: Swagger JSON object @@ -110,9 +110,3 @@ def get_reference_skeys(self, exclude: List[str]): skey_mapping[reference] = columns return skey_mapping - - -if __name__ == '__main__': - from edfi_api_client import EdFiClient - api = EdFiClient("https://dw-lex1.districts-2122.scedfi.edanalytics.org") - swaggy = api.get_swagger('resources') \ No newline at end of file From 31d4b8b49f0e332f475d05769e14774d85fe08a5 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Wed, 16 Aug 2023 11:23:15 -0500 Subject: [PATCH 13/19] Add refresh_time to client session; add decorator to internal response-getters to reconnect if expired. --- edfi_api_client/edfi_client.py | 6 ++++-- edfi_api_client/edfi_endpoint.py | 20 +++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/edfi_api_client/edfi_client.py b/edfi_api_client/edfi_client.py index fa4418f..776cdeb 100644 --- a/edfi_api_client/edfi_client.py +++ b/edfi_api_client/edfi_client.py @@ -299,8 +299,9 @@ def connect(self) -> requests.Session: self.session = requests.Session() self.session.headers.update(req_header) - # Add new attribute to track when connection was established. + # Add new attributes to track when connection was established and when to refresh the access token. self.session.timestamp_unix = int(time.time()) + self.session.refresh_time = int(self.session.timestamp_unix + access_response.json().get('expires_in') - 120) self.session.verify = self.verify_ssl self.verbose_log("Connection to ODS successful!") @@ -506,8 +507,9 @@ def connect(self) -> requests.Session: self.session.headers.update(req_header) self.session.headers.update(json_header) - # Add new attribute to track when connection was established. + # Add new attributes to track when connection was established and when to refresh the access token. self.session.timestamp_unix = int(time.time()) + self.session.refresh_time = int(self.session.timestamp_unix + access_response.json().get('expires_in') - 120) self.session.verify = self.verify_ssl self.verbose_log("Connection to ODS successful!") diff --git a/edfi_api_client/edfi_endpoint.py b/edfi_api_client/edfi_endpoint.py index db29edb..0f63a22 100644 --- a/edfi_api_client/edfi_endpoint.py +++ b/edfi_api_client/edfi_endpoint.py @@ -3,8 +3,9 @@ import requests import time +from functools import wraps from requests.exceptions import HTTPError, RequestsWarning -from typing import Iterator, List, Optional, Tuple, Union +from typing import Callable, Iterator, List, Optional, Tuple, Union from edfi_api_client.edfi_params import EdFiParams from edfi_api_client import util @@ -200,6 +201,22 @@ def _get_attributes_from_swagger(self): ### Internal GET response methods and error-handling + def reconnect_if_expired(func: Callable) -> Callable: + """ + This decorator resets the connection with the API if expired. + + :param func: + :return: + """ + @wraps(func) + def wrapped(self, *args, **kwargs): + # Refresh token if refresh_time has passed + if self.client.session.refresh_time < int(time.time()): + self.client.connect() + return func(self, *args, **kwargs) + return wrapped + + @reconnect_if_expired def _get_response(self, url: str, params: Optional[EdFiParams] = None @@ -216,6 +233,7 @@ def _get_response(self, return response + @reconnect_if_expired def _get_response_with_exponential_backoff(self, url: str, params: Optional[EdFiParams] = None, From d76111250dbc43e26d948f9a4b006fb4d66f9901 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Wed, 16 Aug 2023 12:00:36 -0500 Subject: [PATCH 14/19] Improve exponential backoff function to automatically re-authenticate as necessary by using _get_response() behind the scenes; remove original re-authentication message and util.seconds_to_timestamp() helper. --- edfi_api_client/edfi_endpoint.py | 17 ++++------------- edfi_api_client/util.py | 24 ------------------------ 2 files changed, 4 insertions(+), 37 deletions(-) diff --git a/edfi_api_client/edfi_endpoint.py b/edfi_api_client/edfi_endpoint.py index 0f63a22..d500551 100644 --- a/edfi_api_client/edfi_endpoint.py +++ b/edfi_api_client/edfi_endpoint.py @@ -212,6 +212,9 @@ def reconnect_if_expired(func: Callable) -> Callable: def wrapped(self, *args, **kwargs): # Refresh token if refresh_time has passed if self.client.session.refresh_time < int(time.time()): + self.client.verbose_log( + "Session authentication is expired. Attempting reconnection..." + ) self.client.connect() return func(self, *args, **kwargs) return wrapped @@ -258,9 +261,7 @@ def _get_response_with_exponential_backoff(self, for n_tries in range(max_retries): try: - response = self.client.session.get(url, params=params) - self.custom_raise_for_status(response) - return response + return self._get_response(url, params=params) except RequestsWarning: # If an API call fails, it may be due to rate-limiting. @@ -268,16 +269,6 @@ def _get_response_with_exponential_backoff(self, time.sleep( min((2 ** n_tries) * 2, max_wait) ) - - # Tokens have expiry times; refresh the token if it expires mid-run. - authentication_delta = int(time.time()) - self.client.session.timestamp_unix - - self.client.verbose_log( - f"Maybe the session needs re-authentication? ({util.seconds_to_text(authentication_delta)} since last authentication)\n" - "Attempting reconnection..." - ) - self.client.connect() - logging.warning(f"Retry number: {n_tries}") # This block is reached only if max_retries has been reached. diff --git a/edfi_api_client/util.py b/edfi_api_client/util.py index 27c275f..f938b3f 100644 --- a/edfi_api_client/util.py +++ b/edfi_api_client/util.py @@ -30,27 +30,3 @@ def url_join(*args) -> str: return '/'.join( map(lambda x: str(x).rstrip('/'), filter(lambda x: x is not None, args)) ) - - -def seconds_to_text(seconds: int) -> str: - """ - - :param seconds: - :return: - """ - delta_str = str(datetime.timedelta(seconds=seconds)) - hours, minutes, seconds = list(map(int, delta_str.split(':'))) - - time_strings = [] - - if hours: - desc = "hours" if hours > 1 else "hour" - time_strings.append( f"{hours} {desc}") - if minutes: - desc = "minutes" if minutes > 1 else "minute" - time_strings.append( f"{minutes} {desc}" ) - if seconds: - desc = "seconds" if seconds > 1 else "second" - time_strings.append( f"{seconds} {desc}" ) - - return ", ".join(time_strings) From 165a0c6f2c6d950f667567a0e077ffd3f341c80a Mon Sep 17 00:00:00 2001 From: jkaiser Date: Wed, 16 Aug 2023 12:01:07 -0500 Subject: [PATCH 15/19] Make 400 and 500 HTTP errors failure-states intead of retry-states. --- edfi_api_client/edfi_endpoint.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/edfi_api_client/edfi_endpoint.py b/edfi_api_client/edfi_endpoint.py index d500551..3556251 100644 --- a/edfi_api_client/edfi_endpoint.py +++ b/edfi_api_client/edfi_endpoint.py @@ -297,8 +297,8 @@ def custom_raise_for_status(response): f"API Error: {response.status_code} {response.reason}" ) if response.status_code == 400: - raise RequestsWarning( - "400: Bad request. Check your params. Is 'limit' set too high? Does the connection need to be reset?" + raise HTTPError( + "400: Bad request. Check your params. Is 'limit' set too high?" ) elif response.status_code == 401: raise RequestsWarning( @@ -317,7 +317,7 @@ def custom_raise_for_status(response): response=response ) elif response.status_code == 500: - raise RequestsWarning( + raise HTTPError( "500: Internal server error." ) elif response.status_code == 504: From 85d10b9fa0d545a4b07cd7d335b702796640fc37 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Wed, 16 Aug 2023 13:54:17 -0500 Subject: [PATCH 16/19] Fix bug where response is undefined at end of max-retries. --- edfi_api_client/edfi_endpoint.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/edfi_api_client/edfi_endpoint.py b/edfi_api_client/edfi_endpoint.py index 3556251..f06e31a 100644 --- a/edfi_api_client/edfi_endpoint.py +++ b/edfi_api_client/edfi_endpoint.py @@ -261,7 +261,8 @@ def _get_response_with_exponential_backoff(self, for n_tries in range(max_retries): try: - return self._get_response(url, params=params) + response = self._get_response(url, params=params) + return response except RequestsWarning: # If an API call fails, it may be due to rate-limiting. From 6a15cfb28b237757318741bdb507728bc8522558 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Wed, 16 Aug 2023 15:47:31 -0500 Subject: [PATCH 17/19] Revert 500; improve 401 error message. --- edfi_api_client/edfi_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/edfi_api_client/edfi_endpoint.py b/edfi_api_client/edfi_endpoint.py index f06e31a..cf4d33a 100644 --- a/edfi_api_client/edfi_endpoint.py +++ b/edfi_api_client/edfi_endpoint.py @@ -303,7 +303,7 @@ def custom_raise_for_status(response): ) elif response.status_code == 401: raise RequestsWarning( - "401: Unauthorized for URL. The connection may need to be reset." + "401: Unauthenticated for URL. The connection may need to be reset." ) elif response.status_code == 403: # Only raise an HTTPError where the resource is impossible to access. @@ -318,7 +318,7 @@ def custom_raise_for_status(response): response=response ) elif response.status_code == 500: - raise HTTPError( + raise RequestsWarning( "500: Internal server error." ) elif response.status_code == 504: From 19f1677c47f800e6b259820504e753d03a9f0d73 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Wed, 16 Aug 2023 17:31:28 -0500 Subject: [PATCH 18/19] Update setup.py. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 47134a2..c218fb8 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setuptools.setup( name='edfi_api_client', - version='1.0.0', + version='0.2.0', description='Ed-Fi API client and tools', license_files=['LICENSE'], url='https://github.com/edanalytics/edfi_api_client', From 5860727e6f46ea51372951a7305165fd5ea1ca59 Mon Sep 17 00:00:00 2001 From: jkaiser Date: Thu, 17 Aug 2023 13:28:56 -0500 Subject: [PATCH 19/19] Remove the persistent response object from _get_response_with_exponential_backoff() in favor of raising a RuntimeError after max retries have been hit. --- edfi_api_client/edfi_endpoint.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/edfi_api_client/edfi_endpoint.py b/edfi_api_client/edfi_endpoint.py index cf4d33a..d2a9fdc 100644 --- a/edfi_api_client/edfi_endpoint.py +++ b/edfi_api_client/edfi_endpoint.py @@ -256,13 +256,10 @@ def _get_response_with_exponential_backoff(self, :return: """ # Attempt the GET until success or `max_retries` reached. - response = None - for n_tries in range(max_retries): try: - response = self._get_response(url, params=params) - return response + return self._get_response(url, params=params) except RequestsWarning: # If an API call fails, it may be due to rate-limiting. @@ -274,14 +271,14 @@ def _get_response_with_exponential_backoff(self, # This block is reached only if max_retries has been reached. else: - logging.error("API GET failed: max retries exceeded for URL.") - self.client.verbose_log(message=( f"[Get with Retry Failed] Endpoint : {url}\n" f"[Get with Retry Failed] Parameters: {params}" ), verbose=True) - self.custom_raise_for_status(response) + raise RuntimeError( + "API GET failed: max retries exceeded for URL." + ) @staticmethod