From 928438bfd71c762a1bd45a18cbb6920fd1910d14 Mon Sep 17 00:00:00 2001 From: kevin-presalytics Date: Sun, 6 Dec 2020 10:49:38 -0800 Subject: [PATCH 01/27] add device auth handler --- CHANGELOG.md | 7 +++++++ presalytics/client/oidc.py | 20 ++++++++++++-------- setup.py | 2 +- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81db622..3f64234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## v0.6.0 + +* Add handle_device_code_response to provide entrypoint for other devices to override and leverage device authorization (e.g., workspace, native apps) +* Add `presalytics.lib.tools.workflow.create_workspace` +* Change file handling support beyond ooxml files +* Change loader to allow for explicit loading via settings.py + ## v0.5.23 * Fix cli initial pull in empty workspace diff --git a/presalytics/client/oidc.py b/presalytics/client/oidc.py index c2bff46..7db6dd4 100644 --- a/presalytics/client/oidc.py +++ b/presalytics/client/oidc.py @@ -75,6 +75,17 @@ def __init__(self, client_id=None, client_secret=None, validate_tokens=True, *ar "slow_down" ] + def handle_device_code_response(self, device_code_response): + user_code_message = "This device's user code is: {}. Please verify this code when logging in.".format(device_code_response["user_code"]) + print(user_code_message) + cli_message = "Please open a webrowser to {0} and login.".format(device_code_response["verification_uri_complete"]) + print(cli_message) + try: + webbrowser.open_new_tab(device_code_response["verification_uri_complete"]) + except: + pass + + def token(self, username, password=None, audience=None, scope=None, **kwargs) -> typing.Dict: """ Get an access token @@ -105,14 +116,7 @@ def token(self, username, password=None, audience=None, scope=None, **kwargs) -> } device_code_response = self._post(self.device_endpoint, device_data) - user_code_message = "This device's user code is: {}. Please verify this code when logging in.".format(device_code_response["user_code"]) - print(user_code_message) - cli_message = "Please open a webrowser to {0} and login.".format(device_code_response["verification_uri_complete"]) - print(cli_message) - try: - webbrowser.open_new_tab(device_code_response["verification_uri_complete"]) - except: - pass + self.handle_device_code_response(device_code_response) sleep_interval = device_code_response["interval"] auth_data = { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", diff --git a/setup.py b/setup.py index d071195..a0df7a2 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ from setuptools import setup, find_packages # noqa: H301 NAME = "presalytics" -VERSION = "0.5.23" +VERSION = "0.6.0" # To install the library, run the following # From 82e7039913b933e86d1f0892a2cbd82d6d2bc8f9 Mon Sep 17 00:00:00 2001 From: kevin-presalytics Date: Sun, 6 Dec 2020 11:31:55 -0800 Subject: [PATCH 02/27] add poll_for_token method --- presalytics/client/oidc.py | 72 ++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/presalytics/client/oidc.py b/presalytics/client/oidc.py index 7db6dd4..1d7581f 100644 --- a/presalytics/client/oidc.py +++ b/presalytics/client/oidc.py @@ -85,6 +85,43 @@ def handle_device_code_response(self, device_code_response): except: pass + def poll_for_token(self, device_code_response): + sleep_interval = device_code_response["interval"] + auth_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code_response["device_code"], + "client_id": self.client_id + } + headers = { + 'content-type': 'application/x-www-form-urlencoded' + } + repoll = True + while repoll: + token_response = requests.post(self.token_endpoint, auth_data, headers=headers) + if token_response.status_code != 200: + err_resp = token_response.json() + err_msg = err_resp["error"] + if err_msg in self.repoll_errors: + time.sleep(sleep_interval) + if err_msg == "slow_down": + time.sleep(sleep_interval) + logger.debug("User has not yet logged in. Repolling...") + else: + message = "Error: {0} -- {1}".format(err_msg, err_resp["error_description"]) + raise presalytics.lib.exceptions.ApiError(message=message, status_code=token_response.status_code) + else: + repoll = False + token_data = token_response.json() + if token_data.get('access_token', None): + print("Login Success! Please continue with your work.") + logger.debug("User logged in successfully.") + else: + message = "Error: {0} -- {1}".format(err_msg, err_resp["error_description"]) + raise presalytics.lib.exceptions.ApiError(message=message, status_code=token_response.status_code) + if self.validate_tokens: + self.validate_token(token_data["access_token"]) + + def token(self, username, password=None, audience=None, scope=None, **kwargs) -> typing.Dict: """ @@ -117,40 +154,7 @@ def token(self, username, password=None, audience=None, scope=None, **kwargs) -> device_code_response = self._post(self.device_endpoint, device_data) self.handle_device_code_response(device_code_response) - sleep_interval = device_code_response["interval"] - auth_data = { - "grant_type": "urn:ietf:params:oauth:grant-type:device_code", - "device_code": device_code_response["device_code"], - "client_id": self.client_id - } - headers = { - 'content-type': 'application/x-www-form-urlencoded' - } - repoll = True - while repoll: - token_response = requests.post(self.token_endpoint, auth_data, headers=headers) - if token_response.status_code != 200: - err_resp = token_response.json() - err_msg = err_resp["error"] - if err_msg in self.repoll_errors: - time.sleep(sleep_interval) - if err_msg == "slow_down": - time.sleep(sleep_interval) - logger.debug("User has not yet logged in. Repolling...") - else: - message = "Error: {0} -- {1}".format(err_msg, err_resp["error_description"]) - raise presalytics.lib.exceptions.ApiError(message=message, status_code=token_response.status_code) - else: - repoll = False - token_data = token_response.json() - if token_data.get('access_token', None): - print("Login Success! Please continue with your work.") - logger.debug("User logged in successfully.") - else: - message = "Error: {0} -- {1}".format(err_msg, err_resp["error_description"]) - raise presalytics.lib.exceptions.ApiError(message=message, status_code=token_response.status_code) - if self.validate_tokens: - self.validate_token(token_data["access_token"]) + token_data = self.poll_for_token(device_code_response) return token_data def validate_token(self, token): From 5946b3195e5d1342652291ef59ce81eadd0915f3 Mon Sep 17 00:00:00 2001 From: kevin-presalytics Date: Sun, 20 Dec 2020 10:46:17 -0800 Subject: [PATCH 03/27] update changelog --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b2b36..6b9d4b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,13 @@ -<<<<<<< HEAD ## v0.6.0 * Add handle_device_code_response to provide entrypoint for other devices to override and leverage device authorization (e.g., workspace, native apps) * Add `presalytics.lib.tools.workflow.create_workspace` * Change file handling support beyond ooxml files * Change loader to allow for explicit loading via settings.py -======= + ## v0.5.24 * Eliminate overwrite error in cli ->>>>>>> master ## v0.5.23 From cc5cd869b10f28afc895af8dd01e8c5aac2b2770 Mon Sep 17 00:00:00 2001 From: kevin-presalytics Date: Sun, 20 Dec 2020 11:13:17 -0800 Subject: [PATCH 04/27] fix return of token_data for polling --- presalytics/client/oidc.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/presalytics/client/oidc.py b/presalytics/client/oidc.py index 1d7581f..05bdcd1 100644 --- a/presalytics/client/oidc.py +++ b/presalytics/client/oidc.py @@ -120,8 +120,7 @@ def poll_for_token(self, device_code_response): raise presalytics.lib.exceptions.ApiError(message=message, status_code=token_response.status_code) if self.validate_tokens: self.validate_token(token_data["access_token"]) - - + return token_data def token(self, username, password=None, audience=None, scope=None, **kwargs) -> typing.Dict: """ From e620f8e938a9bd16e0cb0824d42dc169553fb4a1 Mon Sep 17 00:00:00 2001 From: Kevin Hannegan Date: Fri, 26 Feb 2021 11:22:06 -0800 Subject: [PATCH 05/27] add direct_import for registries as default --- CHANGELOG.md | 95 +++++---- env_config.py | 30 --- presalytics/__init__.py | 161 ++++----------- presalytics/cli.py | 82 ++++---- presalytics/client/api.py | 47 ++--- presalytics/client/auth.py | 22 +- presalytics/client/websocket.py | 40 ++++ presalytics/lib/config_loader.py | 118 ++++++++++- presalytics/lib/constants.py | 7 +- presalytics/lib/default_settings.py | 252 +++++++++++++++++++++++ presalytics/lib/plugins/base.py | 5 +- presalytics/lib/plugins/external.py | 7 +- presalytics/lib/registry.py | 50 ++--- presalytics/lib/tools/component_tools.py | 8 +- presalytics/lib/tools/ooxml_tools.py | 4 +- presalytics/lib/tools/workflows.py | 2 +- presalytics/lib/util.py | 56 ++++- presalytics/lib/widgets/ooxml.py | 8 +- presalytics/lib/widgets/ooxml_editors.py | 4 +- presalytics/story/components.py | 12 +- presalytics/story/revealer.py | 5 +- presalytics/story/util.py | 42 +--- requirements.txt | 67 ++++++ setup.cfg | 31 +++ setup.py | 40 ++-- test/test_client.py | 8 +- test/test_story.py | 7 +- 27 files changed, 805 insertions(+), 405 deletions(-) delete mode 100644 env_config.py create mode 100644 presalytics/client/websocket.py create mode 100644 presalytics/lib/default_settings.py create mode 100644 requirements.txt create mode 100644 setup.cfg diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9d4b2..e01d49c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,122 +1,129 @@ +# Changes by Version + ## v0.6.0 -* Add handle_device_code_response to provide entrypoint for other devices to override and leverage device authorization (e.g., workspace, native apps) -* Add `presalytics.lib.tools.workflow.create_workspace` -* Change file handling support beyond ooxml files -* Change loader to allow for explicit loading via settings.py +- [ ] Add `presalytics.lib.tools.workflow.create_workspace` +- [ ] Change file handling support beyond ooxml files +- [ ] Change loader to allow for explicit loading via settings.py +- [ ] Turn off logger, autodiscovery, and token caching by default +- [ ] Create `presalytics.settings` module for default settings, incorprate into `__init__.py` +- [ ] Create websocket for event listener and forward events to localhost +- [ ] Create `presalytics.story.ClientSideRenderer` to set up caching and return meta for client-side apps to render stories +- [ ] Add CLI commands for websocket +- [x] Incorporate `requirements.txt` into `setup.py` ## v0.5.24 -* Eliminate overwrite error in cli +- [x] Eliminate overwrite error in cli ## v0.5.23 -* Fix cli initial pull in empty workspace -* Fix cli default to CACHE_TOKENS = True +- [x] Fix cli initial pull in empty workspace +- [x] Fix cli default to CACHE_TOKENS = True ## v0.5.22 (2020-10-13) -* Add events handling to widgets with nested iframes +- [x] Add events handling to widgets with nested iframes ## v0.5.21 (2020-10-13) -* Fix page css clases +- [x] Fix page css clases ## v0.5.20 (2020-10-13) -* Fix json encoder in `presalytics.lib.widgets.datatable.DataTableWidget` -* update Story Api Endpoints +- [x] Fix json encoder in `presalytics.lib.widgets.datatable.DataTableWidget` +- [x] update Story Api Endpoints ## v0.5.19 (2020-09-20) -* Point `ooxml.js` to static folder on main site in `presalytics.lib.plugins.external.ApprovedExternalScripts` -* Add `presalytics.lib.widgets.url.UrlWidget` -* Add `presalytics.lib.widgets.chart.ChartWidget` -* Add `presalytics.lib.widgets.datatable.DataTableWidget` -* Fix immutability bug in `presalytics.lib.plugins.reveal.RevealConfigPlugin.default_config` +- [x] Point `ooxml.js` to static folder on main site in `presalytics.lib.plugins.external.ApprovedExternalScripts` +- [x] Add `presalytics.lib.widgets.url.UrlWidget` +- [x] Add `presalytics.lib.widgets.chart.ChartWidget` +- [x] Add `presalytics.lib.widgets.datatable.DataTableWidget` +- [x] Fix immutability bug in `presalytics.lib.plugins.reveal.RevealConfigPlugin.default_config` ## v0.5.18 (2020-09-01) -* Update `ooxml_editors.TextReplace` to enable child object editing +- [x] Update `ooxml_editors.TextReplace` to enable child object editing ## v0.5.17 (2020-08-30) -* Fix font-awesome for CORS/CDN in `external.py` +- [x] Fix font-awesome for CORS/CDN in `external.py` ## v0.5.16 (2020-08-28) -* Add toolbar to reveal plugin -* Change reveal.js approved links from cdn to presalytics.io +- [x] Add toolbar to reveal plugin +- [x] Change reveal.js approved links from cdn to presalytics.io ## v0.5.15 (2020-08-24) -* Fix color insert libreoffice compatibility bug in `presalytics.lib.widgets.ooxml_editors.ChangeShapeColor` +- [x] Fix color insert libreoffice compatibility bug in `presalytics.lib.widgets.ooxml_editors.ChangeShapeColor` ## v0.5.14 (2020-08-24) -* Increase match greediness in `presalytics.lib.widgets.ooxml_editors.TextReplace` +- [x] Increase match greediness in `presalytics.lib.widgets.ooxml_editors.TextReplace` ## v0.5.13 (2020-08-18) -* add new story api endpoints +- [x] add new story api endpoints ## v0.5.12 (2020-08-18) -* Fix api_name and external_root_url bug -* Incorporate async to workflows +- [x] Fix api_name and external_root_url bug +- [x] Incorporate async to workflows ## v0.5.11 (2020-08-13) -* Fix single-page rendering bugs +- [x] Fix single-page rendering bugs ## v0.5.10 (2020-08-13) -* Enable single-page rendering +- [x] Enable single-page rendering ## v0.5.9 (2020-08-11) -* Add methods to support async in the story api -* Update tests to support async -* Remove jwts from html generation +- [x] Add methods to support async in the story api +- [x] Update tests to support async +- [x] Remove jwts from html generation ## v0.5.8 (2020-07-30) -* Add cloning functionality for ooxml documents -* Fix camelCase bug in `presalytics.lib.plugins.ooxml` +- [x] Add cloning functionality for ooxml documents +- [x] Fix camelCase bug in `presalytics.lib.plugins.ooxml` ## v0.5.7 (2020-07-21) -* Update `presalytics.story.revealer.Revealer` to hide controls for single page stories -* Fix auth bug introduced to token caching with switch to 3rd party auth +- [x] Update `presalytics.story.revealer.Revealer` to hide controls for single page stories +- [x] Fix auth bug introduced to token caching with switch to 3rd party auth ## v0.5.6 (2020-07-17) -* Add `external_root_url` to `AuthenticationMixIn`, implement in `D3Widget` +- [x] Add `external_root_url` to `AuthenticationMixIn`, implement in `D3Widget` ## v0.5.5 (2020-07-17) -* Add D3Widget to `__init__.py` -* Bug fix to `presalytics.lib.tools.workflows` +- [x] Add D3Widget to `__init__.py` +- [x] Bug fix to `presalytics.lib.tools.workflows` ## v0.5.4 (2020-07-14) -* Update D3pipWidget to include custom html, css from files +- [x] Update D3pipWidget to include custom html, css from files ## v0.5.3 (2020-07-14) -* Add D3Widget, Content Secuirty Policies +- [x] Add D3Widget, Content Secuirty Policies ## v0.5.2 (2020-07-14) -* Fix page order bug in `presalytics.lib.tools.ooxml_tools.create_pages_from_ooxml_document` +- [x] Fix page order bug in `presalytics.lib.tools.ooxml_tools.create_pages_from_ooxml_document` ## v0.5.1 (2020-05-21) -* Add json endpoint to presaltyics story +- [x] Add json endpoint to presaltyics story ## v0.5.0 (2020-05-21) -* Refactor authentication / authorization for 3rd Party Provider (Auth0) -* Add `presalytics.client.oidc.OidcClent` to manage token acquisition -* Improve fault tolerance of token handling and refresh \ No newline at end of file +- [x] Refactor authentication / authorization for 3rd Party Provider (Auth0) +- [x] Add `presalytics.client.oidc.OidcClent` to manage token acquisition +- [x] Improve fault tolerance of token handling and refresh \ No newline at end of file diff --git a/env_config.py b/env_config.py deleted file mode 100644 index 5957c8f..0000000 --- a/env_config.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Base Configuration File for presalytics client - -By default, the presalytics client attempts to read its configuration values from -a `config.py`. Users can use this file to read a configuration from the host machine's -environment variables. Just rename it config.py. This way, user do not have to leaving configuration -data in plain text on their host machines and create potential security risks. -""" - -import os -from environs import Env - -env = Env() -try: - env.read_env() -except: - pass - -PRESALYTICS = { - 'USERNAME': os.environ['PRESALYTICS_USERNAME'], - 'HOSTS': {} -} - -try: - user_pass = { - 'PASSWORD': os.environ['PRESALYTICS_PASSWORD'] - } - PRESALYTICS.update(user_pass) -except KeyError: - pass diff --git a/presalytics/__init__.py b/presalytics/__init__.py index 8b9c32d..008e52c 100644 --- a/presalytics/__init__.py +++ b/presalytics/__init__.py @@ -4,22 +4,22 @@ # Overview -The Presalytics Python Library streamlines analytic operations across analysts, executives, consultants, -and developers. These tools enable for a simplified workflow for analysts to rapidly generate -client-ready presentation materials and web content that update in real-time and easily scale across your user-base. +The Presalytics Python Library streamlines analytic operations across analysts, executives, consultants, +and developers. These tools enable for a simplified workflow for analysts to rapidly generate +client-ready presentation materials and web content that update in real-time and easily scale across your user-base. -Our objective when building this platform is make the analyst experience as simple as possible. -Set up time for new user should take less than half an hour, and all features are self-service and self-explanatory. +Our objective when building this platform is make the analyst experience as simple as possible. +Set up time for new user should take less than half an hour, and all features are self-service and self-explanatory. -Of course, if you have any questions or need help to get going, you can [contact us](/contact-us) or -quickly get the help you need on our [slack channel](https://presalytics.slack.com) +Of course, if you have any questions or need help to get going, you can [contact us](/contact-us) or +quickly get the help you need on our [slack channel](https://presalytics.slack.com) ([Join Here!](https://join.slack.com/t/presalytics/shared_invite/enQtODExMjc3MDE1Nzc5LWU0ZDlhZTgwZTM3MzQ4Yzc4Nzk4Zjc0NmQ3YjgzNTEwODdlYjM0ZjFkZWI4Y2ZhNzBmOTZhMzA2MzE3YjFiZTg)). -To get going quickly, you can browse our [Getting Started](https://presalytics.io/docs/getting-started/) page and review +To get going quickly, you can browse our [Getting Started](https://presalytics.io/docs/getting-started/) page and review some [examples](https://presalytics.io/docs/examples). -For more advanced users and developers, you can learn more about the API by reviewing -the [service structure](https://presalytics.io/docs/how-it-works) to build a better understanding +For more advanced users and developers, you can learn more about the API by reviewing +the [service structure](https://presalytics.io/docs/how-it-works) to build a better understanding of the API and its [security](https://presalytics.io/docs/develpers/security) features. # Installation @@ -33,8 +33,8 @@ # Contributing Presalytics.io is on [Github](https://github.com/presalytics). Bug reports and pull requests are strongly encouraged at -the package [repository](https://github.com/presalytics/python-client). If you encounter any problems or have any suggestions for -the API endpoints that this libary interacts with at https://api.presalytics.io, please open an issue in the +the package [repository](https://github.com/presalytics/python-client). If you encounter any problems or have any suggestions for +the API endpoints that this libary interacts with at https://api.presalytics.io, please open an issue in the [API repository](https://github.com/presalytics/Presalytics-API). # License @@ -44,9 +44,6 @@ information on licensing, please contact [inquires@presalytics.io](mailto:inquires@presalytics.io). """ -import os -import environs -import logging import pkg_resources import presalytics.lib import presalytics.lib.logger @@ -56,120 +53,36 @@ import presalytics.story import presalytics.story.components -env = environs.Env() -env.read_env() - -# A comma-separated list of paths to search of config.py, plugin classes, and component classes -autodiscover_paths = env.list('AUTODISCOVER_PATHS', []) - -CONFIG = presalytics.lib.config_loader.load_config(additional_paths=autodiscover_paths) +settings = presalytics.lib.config_loader.Settings() """ -Nested `dict` containing runtime configuration values for the Presalytics Python Library. -Typically, these are reuseable values stored in separate file that are loaded when the -module in imported via the `import presalytics` command. This top-level module then -runs the `presalytics.lib.config_loader.load_config` method into load values into the -the `CONFIG` global variable. Modules through the package use the `CONFIG` variable to -simplify their API calls store conststants for use throughout the package. See -`presalytics.lib.config_loader.load_config` for ways to programmatically load the -`CONFIG`. - -Configuration Values ----------- - -USE_LOGGER : bool, optional - Toggles whether the presalytics verbose file logger should be used. Helpful for - tracing exceptions while writing code. Default is True. - -LOG_LEVEL : str, optional - Defaults to `DEBUG` - -USERNAME : str, optional - The user's Presalytics API email/username. This is the email address that the user uses when logging in at - https://login.presalytics.io. Will be passed to instances of the `presalytics.client.api.Client` object. - -PASSWORD : str, optional - The user's Presalytics API username. Will be passed to instances of the - `presalytics.client.api.Client` object. If running in an insecure or - multiuser environment, leave this blank and let the `presalytics.client.api.Client` - object handle token acquisition via browser-based login. - -DELEGATE_LOGIN: bool, optional - Defaults to False. Indicates whether the client would redirect to a browser to - acquire an API token. If `DELEGATE_LOGIN` is `True`, when the `presalytics.client.api.Client` does not have - access to a valid API token, the client will raise a `presalytics.lib.exceptions.InvalidTokenException`. - The default operation will automatically open a new browser tab to acquire a new token - via website client from the presalytics.io login page. Putting this setting to True is - useful for server-side development. - -CACHE_TOKENS: bool, optional - Defaults to True. Indicates whether the `presalytics.client.api.Client` should store - tokens in the current working directory in a file call "token.json". This should be - set to False for mutli-user environments. - -CLIENT_ID : str, optional - For developer use. Allows developers to implement a `client_credentials` OpenID - Connect login. Defaults to "python-client". +The Presalytics Python Client settings instance. -CLIENT_SECRET : str, optional - For developer use. Allows developers to implement a `client_credentials` OpenID - Connect login. Defaults to None. +Settings are are loaded into this instance via package defaults. Users can override +default setting by (in order of precedence): -VERIFY_HTTPS : bool, optional - For developer use. Allows for unencrypted connections. Defaults to True. No - reason to turn this to False unless you're in a complex development scenario - and you know what you're doing. + 1. Settings variable using a `settings.py` file in the current working directory + 2. Using a .env file + 3. Setting environment variables with same key as default setting -HOSTS : dict, optional - For developer use. Allows API class to target hosts other than api.presalytics.io - -REDIRECT_URI : string, optional - For developer use. Useful if implementing authorization code flow for and OpenID Connect client. - Redirect URIs must be approved by Presalytics API devops for use in client applications. - -RESERVED_NAMES: list of str, optional - A list of filenames for *.py files in the current workspace that should be ignored by the - registries. - -IGNORE_PATHS: list of str, optional - A list of paths to not to include in registry autosdiscover - -BROWSER_API_HOST: dict, optional - If present, the root url for browser-based api calls. Each service can have an independent browser host. - Service keys include `OOXML_AUTOMATION`, `STORY`, `DOC_CONVERTER`, and `SITE`. May be required when services are running on a cluster that - deletegates certificate authentication to an external service or in debug/test environments. - See for more info: https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content - -The object can also take on values for user-defined extensions, and please consult -the documentation for those package for those vairables definition. +For a comprehsive list of settings values, please review `presalytics.lib.default_settings`. """ +presalytics.lib.logger.configure_logger(log_level=settings.LOG_LEVEL, file_logger=settings.USE_LOGGER) # type: ignore[attr-defined] -file_logger = CONFIG.get("USE_LOGGER", True) -log_level = CONFIG.get("LOG_LEVEL", logging.DEBUG) - -presalytics.lib.logger.configure_logger(log_level=log_level, file_logger=file_logger) - -registry_kwargs = { - 'show_errors': False, - 'autodiscover_paths': autodiscover_paths, - 'reserved_names': CONFIG.get("RESERVED_NAMES", []), - 'ignore_paths': CONFIG.get("IGNORE_PATHS", []) -} - -PLUGINS = presalytics.lib.plugins.base.PluginRegistry(**registry_kwargs) +PLUGINS = presalytics.lib.plugins.base.PluginRegistry() """ Instance of `presalytics.lib.plugins.base.PluginRegistry`. A container listing the -Presalytics Library Plugins available and loaded in this environment. This instance is used +Presalytics Library Plugins available and loaded in this environment. This instance is used by `presalytics.story.components.Renderer` subclasses (e.g., `presalytics.story.revealer.Revealer`) to write scripts and links into stories. """ -COMPONENTS = presalytics.story.components.ComponentRegistry(**registry_kwargs) +COMPONENTS = presalytics.story.components.ComponentRegistry() """ -Instance of `presalytics.story.components.ComponentRegistry`. Registry for Library components and -component instances. A container listing the Presalytics Library components and instances available -and loaded in this environment. This instance is used by `presalytics.story.components.Renderer` subclasses +Instance of `presalytics.story.components.ComponentRegistry`. Registry for Library components and +component instances. A container listing the Presalytics Library components and instances available +and loaded in this environment. This instance is used by `presalytics.story.components.Renderer` subclasses (e.g., `presalytics.story.revealer.Revealer`) to convert widgets, pages, and themes into stories. """ @@ -179,18 +92,18 @@ __version__ = "build" from presalytics.client.api import Client -from presalytics.lib.plugins.base import PluginBase +from presalytics.lib.plugins.base import PluginBase # noqa: F401 from presalytics.lib.plugins.external import ApprovedExternalLinks, ApprovedExternalScripts -from presalytics.lib.plugins.jinja import JinjaPluginMakerMixin +from presalytics.lib.plugins.jinja import JinjaPluginMakerMixin # noqa: F401 from presalytics.lib.plugins.local import LocalStylesPlugin -from presalytics.lib.plugins.matplotlib import Mpld3Plugin +from presalytics.lib.plugins.matplotlib import Mpld3Plugin # noqa: F401 from presalytics.lib.plugins.ooxml import OoxmlTheme from presalytics.lib.plugins.reveal import RevealConfigPlugin from presalytics.lib.plugins.reveal_theme import RevealCustomTheme from presalytics.lib.plugins.scss import ScssPlugin from presalytics.lib.templates.base import ( JinjaTemplateBuilder, - BootstrapCustomTemplate + BootstrapCustomTemplate # noqa: F401 ) from presalytics.lib.widgets.matplotlib import MatplotlibFigure, MatplotlibResponsiveFigure from presalytics.lib.widgets.d3 import ( @@ -199,10 +112,10 @@ from presalytics.lib.widgets.chart import ChartWidget from presalytics.lib.widgets.data_table import DataTableWidget from presalytics.lib.widgets.url import UrlWidget -from presalytics.lib.widgets.markdown import MarkdownWidget +from presalytics.lib.widgets.markdown import MarkdownWidget # noqa: F401 from presalytics.lib.widgets.ooxml import ( OoxmlWidgetBase, - OoxmlFileWidget, + OoxmlFileWidget, OoxmlEndpointMap, ChartUpdaterWidget, TableUpdaterWidget @@ -227,9 +140,7 @@ ) - __all__ = [ - 'CONFIG', 'COMPONENTS', 'PLUGINS', 'Client', @@ -238,6 +149,8 @@ 'Revealer', 'MatplotlibFigure', 'MatplotlibResponsiveFigure', + 'Mpld3Plugin', + 'MarkdownWidget', 'OoxmlFileWidget', 'OoxmlEndpointMap', 'OoxmlWidgetBase', @@ -248,6 +161,7 @@ 'UrlWidget', 'ChartUpdaterWidget', 'TableUpdaterWidget', + 'BootstrapCustomTemplate', 'XmlTransformBase', 'ChangeShapeColor', 'TextReplace', @@ -262,8 +176,9 @@ 'WidgetBase', 'PageTemplateBase', 'ScssPlugin', + 'ThemeBase', 'create_story_from_ooxml_file', 'story_post_file_bytes', 'create_outline_from_page', 'create_outline_from_widget' -] \ No newline at end of file +] diff --git a/presalytics/cli.py b/presalytics/cli.py index 815b6bb..35462f9 100644 --- a/presalytics/cli.py +++ b/presalytics/cli.py @@ -31,7 +31,7 @@ Please review the push, pull, and create subcommands for more options. -For more information about the Presalytics API, please visit +For more information about the Presalytics API, please visit or send your questions to inquires@presalytics.io. Command Line Instructions @@ -59,7 +59,7 @@ yaml_help = "Writes file updates to YAML format. Yaml is the default." json_help = "Writes file updates to JSON format" overwrite_help = "Forces (o)verwrite of file with returned Story Outline (if exists)" -username_help = "Overrides the username in presalytics.CONFIG (if present)" +username_help = "Overrides the username in presalytics.settings (if present)" password_help = "The user's Presalytics API password" subparsers = parser.add_subparsers(title='Story API Commands', prog='presalytics', dest='story_api') @@ -84,8 +84,8 @@ id_help = """ The Preslytics API Story Service Id for the story (type: UUID-v4) -If not supplied, the tool searches the file from the --file option -for a 'storyId' attribute +If not supplied, the tool searches the file from the --file option +for a 'storyId' attribute """ pull = subparsers.add_parser('pull', description=pull_description, help='Pull a Story Outline revision') @@ -100,9 +100,9 @@ create_description = """ -The widget or page instance in the [name] arguemnt must be avialable in -`presalytics.COMPONENTS` at run-time. Widget and page instances are loaded -into `presalytics.COMPONENTS` from the current working directory and other +The widget or page instance in the [name] arguemnt must be avialable in +`presalytics.COMPONENTS` at run-time. Widget and page instances are loaded +into `presalytics.COMPONENTS` from the current working directory and other configured folders at import of the presalytics module. """ @@ -123,13 +123,13 @@ create.add_argument('-o', '--overwrite', default=False, action='store_true', help=overwrite_help) create.add_argument('-u', '--username', default=None, action='store', help=username_help) create.add_argument('-p', '--password', default=None, action='store', help=password_help) -create.add_argument('-s', '--source', default=None, action='store', help="The module containing the instance. Needed only if instance not auto-loaded into presalytics.CONFIG") +create.add_argument('-s', '--source', default=None, action='store', help="The module containing the instance. Needed only if instance not auto-loaded into presalytics.settings") create_output_options = create.add_mutually_exclusive_group(required=False) create_output_options.add_argument('-y', '--yaml', default=False, action='store_true', help=yaml_help) create_output_options.add_argument('-j', '--json', default=False, action='store_true', help=json_help) update_description = """ -Update the Story outline from instances contained in scripts in the active workspace +Update the Story outline from instances contained in scripts in the active workspace """ update = subparsers.add_parser('update', description=update_description, help='Update a Story Outline From Local Scripts') @@ -144,15 +144,16 @@ You can either 'add' or 'remove' a widget to or from a page in story outline. -For more complex operations, you can apply a JSON 'patch' to to the story outline per -[RFC 6902](https://tools.ietf.org/html/rfc6902). You can find good exmaples at +For more complex operations, you can apply a JSON 'patch' to to the story outline per +[RFC 6902](https://tools.ietf.org/html/rfc6902). You can find good exmaples at www.jsonpatch.com -""" +""" + modify = subparsers.add_parser('modify', description=modify_description, help='Modify a Story Outline') modify.add_argument('action', choices=['add', 'remove', 'patch'], action='store', help="You can either add or remove a widget (quick & easy), or apply a json patch (more complex)") modify.add_argument('-n', '--name', default=None, action='store', help="The name of the widget you would like to add or remove") modify.add_argument('--position', default=None, action='store', type=int, help="The position in the widget list to place the widget") -modify.add_argument('--page_number', default=None, action='store', type=int, help="The page number to add or remove the widget to/from" ) +modify.add_argument('--page_number', default=None, action='store', type=int, help="The page number to add or remove the widget to/from") modify.add_argument('--patch', default=None, action='store', help="The json patch (per RFC 6902) you want to apply to the Story Outline.") modify_output_options = modify.add_mutually_exclusive_group(required=False) modify_output_options.add_argument('-y', '--yaml', default=False, action='store_true', help=yaml_help) @@ -205,6 +206,7 @@ config.add_argument('-s', "--set", metavar="KEY=VALUE", default=None, nargs='+', help="Pass config values to to `config.py` with KEY=VALUE stucture (e.g., '-s USE_LOGGER=False'") config.add_argument('-o', '--overwrite', default=False, action='store_true', help=overwrite_help) + def parse_var(s): """ Parse a key, value pair, separated by '=' @@ -216,13 +218,13 @@ def parse_var(s): foo="hello world" """ items = s.split('=') - key = items[0].strip() # we remove blanks around keys, as is logical + key = items[0].strip() # we remove blanks around keys, as is logical if len(items) > 1: # rejoin the rest: value = '='.join(items[1:]) if value == 'True' or value == 'true': value = True - if value == 'False'or value == 'false': + if value == 'False' or value == 'false': value = False return (key, value) @@ -239,6 +241,7 @@ def parse_vars(items): d[key] = value return d + def _load_file(filename): if filename.endswith('yaml') or filename.endswith('yml'): outline = presalytics.StoryOutline.import_yaml(filename) @@ -252,22 +255,25 @@ def _load_file(filename): def _make_url(story_id, url_type): route = "/story/{0}/{1}/".format(url_type, story_id) try: - host = presalytics.CONFIG["HOSTS"]["SITE"] + host = presalytics.settings.HOST_SITE except (KeyError, AttributeError): host = presalytics.lib.constants.SITE_HOST return urllib.parse.urljoin(host, route) + def _open_page(story_id, url_type): url = _make_url(story_id, url_type) webbrowser.open_new_tab(url) + def _write(outline, filename, json=False): if json: with open(filename, 'w') as f: f.write(outline.dump()) else: outline.export_yaml(filename) - + + def _dump(outline, filename, overwrite=False, json=False): if os.path.exists(filename): if not overwrite: @@ -280,25 +286,20 @@ def _dump(outline, filename, overwrite=False, json=False): def main(): - """ Command-line entry point - + """ Command-line entry point + Run the following from the command line for more information: python3 -m presalytics --help - + or inside a python virtual environment: presalytics -h - + """ try: args = parser.parse_args() filename = args.file - file_extension = filename.split(".")[-1] - if file_extension == "yaml" or file_extension == "yml": - original_file_is_yaml = True - else: - original_file_is_yaml - False lgs = [logging.getLogger(n) for n in logging.root.manager.loggerDict] if args.verbose or args.quiet: for lg in lgs: @@ -368,14 +369,14 @@ def main(): push = False pull = False write = False - #load story outline from file + # load story outline from file if config: set_dict = {} if not args.set else parse_vars(args.set) if 'CACHE_TOKENS' not in set_dict.keys(): set_dict['CACHE_TOKENS'] = True - presalytics.lib.tools.workflows.create_config_file(args.username, - password=args.password, - set_dict=set_dict, + presalytics.lib.tools.workflows.create_config_file(args.username, + password=args.password, + set_dict=set_dict, overwrite=args.overwrite) logger.info("File 'config.py creating in folder " + os.getcwd()) return @@ -417,14 +418,14 @@ def main(): patch = json.loads(args.patch) except json.JSONDecodeError: patch = ast.literal_eval(args.patch) - except Exception as ex: + except Exception: logger.error("A patch could not be created from [--patch]: {}".format(args.patch)) return outline = presalytics.lib.tools.workflows.apply_json_patch(outline, patch) _dump(outline, filename, True, args.json) if update: - outline = presalytics.lib.tools.workflows.update_outline(outline, filename=filename, message=args.message) - _dump(outline, filename, True, args.json) + outline = presalytics.lib.tools.workflows.update_outline(outline, filename=filename, message=args.message) + _dump(outline, filename, True, args.json) if push: if not message: pretty_time = datetime.datetime.now().strftime("%d-%m-%Y at %H:%M") @@ -433,7 +434,7 @@ def main(): outline = presalytics.lib.tools.workflows.push_outline(outline, username=args.username, password=args.password) try: story_id = outline.story_id - except: + except Exception: logger.error("A story outline could not be found or created. Please use the [--file] option to designate a target outline.") return if ooxml: @@ -451,7 +452,7 @@ def main(): else: logger.error("Could not find a path to file: {0}".format(ooxml_file)) return - if args.action == "add" or args.action == "replace": + if args.action == "add" or args.action == "replace": presalytics.lib.tools.ooxml_tools.add_ooxml_document_to_story(story_id, ooxml_file, replace_id=args.replace_id, username=args.username, password=args.password) if pull: if story_id == "empty": @@ -463,7 +464,6 @@ def main(): else: _id = outline.story_id outline = presalytics.lib.tools.workflows.pull_outline(_id, username=args.username, password=args.password) - if write: _dump(outline, filename, args.overwrite, args.json) if account: @@ -474,11 +474,11 @@ def main(): presalytics.lib.tools.workflows.delete_by_id(args.id, username=args.username, password=args.password) if share: presalytics.lib.tools.workflows.share_story(story_id, - emails=args.emails, - user_ids=args.user_ids, - username=args.username, - password=args.password, - collaborator_type=args.collaborator_type) + emails=args.emails, + user_ids=args.user_ids, + username=args.username, + password=args.password, + collaborator_type=args.collaborator_type) if story_id != 'empty': try: if args.view: diff --git a/presalytics/client/api.py b/presalytics/client/api.py index 40c2f35..165eaad 100644 --- a/presalytics/client/api.py +++ b/presalytics/client/api.py @@ -1,20 +1,13 @@ import os import cgi -import webbrowser import time -import requests -import urllib.parse -import importlib.util import logging -import json import environs import wsgi_microservice_middleware import functools -import six import mimetypes import typing import io -import time import presalytics import presalytics.lib.exceptions import presalytics.lib.constants as cnst @@ -56,7 +49,7 @@ class Client(object): every time an API call is made. If building a client to operate in a multi-user environment, this behavior should be turned off so that one user cannot not pull one another's tokens. To do this, ensure the following parameters are pass to the configuration either - via initialization or in a `presalytics.CONFIG` file: + via initialization or in a `presaltyics.settings`: cache_tokens = False, delegate_login = True @@ -70,13 +63,13 @@ class Client(object): username : str, optional Defaults to None. The user's Presalytics API username. This keyword will take precedence over a passed to the client - via `presalytics.CONFIG`. The username must either be present in `presalytics.CONFIG` or be passed in + via `presalytics.settings`. The username must either be present in `presalytics.settings` or be passed in via keyword, otherwise the client will raise a `presalytics.lib.exceptions.MissingConfigException`. password : str, optional Defaults to None. The user's Presalytics API password. This useful for quickly testing scripts, but in most scenario users should not be passing plaintext into the client via this keyword. In a secure, single-user - environment, passwords are better placed in the `presalytics.CONFIG` object for reuseability. A more secure + environment, passwords are better placed in the `presalytics.settings` object for reuseability. A more secure is to leave passwords out of the configuration, keep `delegate_login` = `False`, and acquire tokens via the browser. delegate_login : bool, optional @@ -108,7 +101,7 @@ class Client(object): direct_grant : bool Indicates whether an token will be acquire via the "direct_grant" OpenID Connect flow. Usually indicates - whether the user has supplied a passwork to the client either through `presalytics.CONFIG` ro + whether the user has supplied a passwork to the client either through `presalytics.settings` ro during object initialization. doc_converter : presalytics.client.presalytics_doc_converter.api.default_api.DefaultApi @@ -194,13 +187,13 @@ class Client(object): A handler for managing an caching tokens acquired from auth.presalytics.io. site_host : str - The login site host for acquiring tokens. Set from `presalytics.CONFIG` with keyword `["SITE"]["HOST"]`. + The login site host for acquiring tokens. Set from `presalytics.settings` with keyword `HOST_SITE`. Defaults to https://presalytics.io. redirect_uri : str Useful if implementing authorization code flow for and OpenID Connect client. Redirect URIs must be approved by Presalytics API devops for use in client applications. Set from Set from - `presalytics.CONFIG` with keyword `["REDIRECT_URI"]`. Defaults to https://presalytics.io/user/login-success. + `presalytics.settings` with keyword `REDIRECT_URI`. Defaults to https://presalytics.io/user/login-success. login_sleep_interval : int The duration (in seconds) between attempts to acquire a token after browser-based authentication. Defaults @@ -227,7 +220,7 @@ def __init__( self.username = username else: try: - self.username = presalytics.CONFIG['USERNAME'] + self.username = presalytics.settings.USERNAME except KeyError: if token: self.username = None @@ -238,7 +231,7 @@ def __init__( if password: self.password = password else: - self.password = presalytics.CONFIG['PASSWORD'] + self.password = presalytics.settings.PASSWORD self.direct_grant = True except KeyError: self.password = None @@ -247,30 +240,29 @@ def __init__( if client_id: self.client_id = client_id else: - self.client_id = presalytics.CONFIG['CLIENT_ID'] + self.client_id = presalytics.settings.CLIENT_ID except KeyError: self.client_id = cnst.DEFAULT_CLIENT_ID try: if client_secret: self.client_secret = client_secret else: - self.client_secret = presalytics.CONFIG['CLIENT_SECRET'] + self.client_secret = presalytics.settings.CLIENT_SECRET self.confidential_client = True except KeyError: self.client_secret = None self.confidential_client = False try: - self.site_host = presalytics.CONFIG["HOSTS"]["SITE"] + self.site_host = presalytics.settings.HOST_SITE except KeyError: self.site_host = cnst.SITE_HOST - try: - self.redirect_uri = presalytics.CONFIG["REDIRECT_URI"] + self.redirect_uri = presalytics.settings.REDIRECT_URI except KeyError: self.redirect_uri = cnst.REDIRECT_URI - if delegate_login or presalytics.CONFIG.get("DELEGATE_LOGIN", False): + if delegate_login or presalytics.settings.DELEGATE_LOGIN: self._delegate_login = True else: self._delegate_login = False @@ -278,8 +270,8 @@ def __init__( client_id=self.client_id, client_secret=self.client_secret ) - if presalytics.CONFIG.get("CACHE_TOKENS", None): - cache_tokens = presalytics.CONFIG.get("CACHE_TOKENS") + if presalytics.settings.CACHE_TOKENS: + cache_tokens = presalytics.settings.CACHE_TOKENS self.token_util = presalytics.client.auth.TokenUtil(token_cache=cache_tokens) if token: # Assume if token is passed as string, then it's an access token @@ -318,7 +310,6 @@ def login(self): self.token_util.process_token(token) return self.token_util.token - def refresh_token(self): """ Obtains a new access token if the access token is expired. if refresh token is expired, @@ -439,7 +430,7 @@ def upload_file_and_await_outline(self, repoll_max_cycles: int = None): """ Useful for testing """ if type(file) is str: - content_type = mimetypes.guess_type(file, False)[0] # type: ignore + content_type = mimetypes.guess_type(file, False)[0] # type: ignore with open(file, 'rb') as f: # type: ignore stream = io.BytesIO(f.read()) file = FileStorage( @@ -482,10 +473,6 @@ def await_outline(self, return self.story.story_id_outline_get(story_id) - - - - class DocConverterApiClientWithAuth(presalytics.client.auth.AuthenticationMixIn, presalytics.client.presalytics_doc_converter.api_client.ApiClient): """ Wraps `presalytics.client.presalytics_doc_converter.api_client.ApiClient` with @@ -534,7 +521,7 @@ def api_name(self): @functools.lru_cache(maxsize=None) def get_client(): """ - Caches a client instance for default parameters set in `presalytics.CONFIG`. + Caches a client instance for default parameters set in `presalytics.settings`. DO NOT use in server-side operation """ diff --git a/presalytics/client/auth.py b/presalytics/client/auth.py index 60b07e8..8e48bef 100644 --- a/presalytics/client/auth.py +++ b/presalytics/client/auth.py @@ -8,7 +8,6 @@ import dateutil.parser import datetime import six -import posixpath import presalytics import presalytics.lib.exceptions import presalytics.lib.constants @@ -67,7 +66,7 @@ def is_api_access_token_expired(self): return False except Exception as ex: logger.exception(ex) - return True # Get a new token on unknown errors + return True # Get a new token on unknown errors def _load_token_file(self): try: @@ -216,8 +215,7 @@ def update_configuration(self): if self.configuration is None: raise presalytics.lib.exceptions.MissingConfigException("Base API not yet configured, please reconstruct API initialization") self.user_agent = AuthenticationMixIn._get_user_agent - if presalytics.CONFIG.get("HOSTS", None): - self.set_host(presalytics.CONFIG.get('HOSTS')) + self.set_host() @staticmethod def get_user_agent(): @@ -227,17 +225,18 @@ def get_user_agent(): VER = 'build' return "presalytics-python-client/{0}".format(VER) - _get_user_agent = get_user_agent.__func__() #type: ignore + _get_user_agent = get_user_agent.__func__() # type: ignore - def set_host(self, hosts_dict): + def set_host(self): for parent_cls in self.__class__.__bases__: if parent_cls.__name__ == 'ApiClient': + hosts_dict = {k: v for (k, v) in presalytics.settings.__dict__.items() if "HOST_" in k} for k, v in hosts_dict.items(): if k.lower() in parent_cls.__module__: host_key = k break try: - self.configuration.host = hosts_dict[host_key] + self.configuration.host = getattr(presalytics.settings, "HOST_" + host_key.Upper()) except (KeyError, UnboundLocalError): pass @@ -254,14 +253,15 @@ def _ApiClient__deserialize_datetime(self, string): @property def external_root_url(self): service_key = self.api_name.replace("-", "_").upper() - if presalytics.CONFIG.get("BROWSER_API_HOST", dict()).get(service_key, None): - service_host = presalytics.CONFIG["BROWSER_API_HOST"][service_key] - target = service_host + "/" + self.api_name + browser_host = getattr(presalytics.settings, "BROWSER_API_HOST_" + service_key) + if not browser_host: + broswer_host = getattr(presalytics.settings, "HOST_" + service_key) + if broswer_host: + target = browser_host + "/" + self.api_name else: target = self.configuration.host return target - def files_parameters(self, files=None): """Builds form parameters. diff --git a/presalytics/client/websocket.py b/presalytics/client/websocket.py new file mode 100644 index 0000000..63637b6 --- /dev/null +++ b/presalytics/client/websocket.py @@ -0,0 +1,40 @@ +# import logging +# import sys +# from signalrcore.hub_connection_builder import HubConnectionBuilder + + +# def input_with_default(input_text, default_value): +# value = input(input_text.format(default_value)) +# return default_value if value is None or value.strip() == "" else value + + +# server_url = input_with_default('Enter your server url(default: {0}): ', "wss://localhost:44376/chatHub") +# username = input_with_default('Enter your username (default: {0}): ', "mandrewcito") +# handler = logging.StreamHandler() +# handler.setLevel(logging.DEBUG) +# hub_connection = HubConnectionBuilder()\ +# .with_url(server_url, options={"verify_ssl": False}) \ +# .configure_logging(logging.DEBUG, socket_trace=True, handler=handler) \ +# .with_automatic_reconnect({ +# "type": "interval", +# "keep_alive_interval": 10, +# "intervals": [1, 3, 5, 6, 7, 87, 3] +# }).build() + +# hub_connection.on_open(lambda: print("connection opened and handshake received ready to send messages")) +# hub_connection.on_close(lambda: print("connection closed")) + +# hub_connection.on("ReceiveEvent", print) +# hub_connection.start() +# message = None + +# # Do login + +# while message != "exit()": +# message = input(">> ") +# if message is not None and message != "" and message != "exit()": +# hub_connection.send("SendMessage", [username, message]) + +# hub_connection.stop() + +# sys.exit(0) \ No newline at end of file diff --git a/presalytics/lib/config_loader.py b/presalytics/lib/config_loader.py index 23f61a7..9d38ddb 100644 --- a/presalytics/lib/config_loader.py +++ b/presalytics/lib/config_loader.py @@ -1,9 +1,13 @@ -import pkgutil +import six import importlib import os import typing +import types import logging import importlib.util +import environs +import presalytics.lib.default_settings +import presalytics.lib.util logger = logging.getLogger(__name__) @@ -39,7 +43,7 @@ def load_config(additional_paths: typing.List[str] = []) -> typing.Dict: config_path = os.path.join(path, name) config_spec = importlib.util.spec_from_file_location("config", config_path) config_mod = importlib.util.module_from_spec(config_spec) - config_spec.loader.exec_module(config_mod) #type: ignore + config_spec.loader.exec_module(config_mod) # type: ignore config_dict = getattr(config_mod, "PRESALYTICS", None) if not config_dict: config_dict = config_mod.__dict__ @@ -48,10 +52,116 @@ def load_config(additional_paths: typing.List[str] = []) -> typing.Dict: if config_dict: break if config_dict: - return config_dict + return config_dict # type: ignore[no-any-return] else: return {} except Exception as ex: logger.exception(ex) return {} - + + +SETTING_ALLOWED_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ_' + + +def is_setting(key: str): + for letter in key: + if letter not in SETTING_ALLOWED_CHARS: + return False + return True + + +class SettingsMeta(type): + def __dir__(cls): + return [x for x in dir(presalytics.lib.default_settings) if is_setting(x)] + + +@six.add_metaclass(SettingsMeta) +class Settings(object): + USE_LOGGER: bool + LOG_LEVEL: int + DEBUG: bool + USERNAME: typing.Optional[str] + PASSWORD: typing.Optional[str] + DELEGATE_LOGIN: bool + CACHE_TOKENS: bool + CLIENT_ID: str + CLIENT_SECRET: typing.Optional[str] + VERIFY_HTTPS: bool + REDIRECT_URI: str + RESERVED_NAMES: typing.List[str] + USE_AUTODISCOVER: bool + IGNORE_PATHS: typing.List[str] + PRESALYTICS_SETTINGS_MODULE: str + INSTALLED_PACKAGES: typing.List[str] + HOST_EVENTS: str + HOST_STORY: str + HOST_OOXML_AUTOMATION: str + HOST_WORKSPACE_API: str + HOST_SITE: str + BROWSER_API_HOST_EVENTS: typing.Optional[str] + BROWSER_API_HOST_STORY: typing.Optional[str] + BROWSER_API_HOST_OOXML_AUTOMATION: typing.Optional[str] + BROWSER_API_HOST_SITE: typing.Optional[str] + BROWSER_API_HOST_WORKSPACE_API: typing.Optional[str] + OVERRIDE_REGISTRY_DEFAULTS: bool + COMPONENTS: typing.List[str] + PLUGINS: typing.List[str] + XML_TRANSFORMS: typing.List[str] + + def __init__(self, *args, **kwargs): + self.get_settings_from_module(presalytics.lib.default_settings) + self.get_subpackage_settings() + self.get_settings_from_environment() + self.get_workspace_settings() + + def __dir__(self): + return [x for x in dir(presalytics.lib.default_settings) if is_setting(x)] + + def get_settings_from_module(self, mod: types.ModuleType): + for k, v in mod.__dict__.items(): + if is_setting(k): + setattr(self, k, v) + + def get_settings_from_environment(self): + """ + Note: requires default settings already be loaded + """ + env = environs.Env() + env.read_env() + for k, v in self.__dict__.items(): + if os.environ.get(k, None): + if is_setting(k): + if isinstance(v, list): + setattr(self, k, env.list(k)) + elif isinstance(v, bool): + setattr(self, k, env.bool(k)) + elif isinstance(v, int): + setattr(self, k, env.int(k)) + else: + setattr(self, k, env(k)) + + def get_subpackage_settings(self): + for pkg_name in self.INSTALLED_PACKAGES: + if pkg_name != 'presalytics': + try: + mod = presalytics.lib.util.import_string(pkg_name + ".settings") + if isinstance(mod, types.ModuleType): + self.get_settings_from_module(mod) + else: + raise ValueError + except ImportError: + logger.error("Module [{}] do not contain a top-level settings attribute for Presalytics settings.".format(pkg_name)) + except ValueError: + logger.error("settings must be a Python module type.") + + def get_workspace_settings(self): + try: + import settings # type: ignore + if isinstance(settings, types.ModuleType): + self.get_settings_from_module(settings) + else: + raise ValueError + except ImportError: + pass + except ValueError: + logger.error("settings must be a Python module type.") diff --git a/presalytics/lib/constants.py b/presalytics/lib/constants.py index 16bc31a..e012f51 100644 --- a/presalytics/lib/constants.py +++ b/presalytics/lib/constants.py @@ -16,4 +16,9 @@ API_LOGGEDIN_NEXT_URL = "/user/api-logged-in/" STORY_VIEW_URL = "/story/view/{0}/" STORY_MANAGE_URL = "/story/manage/{0}/" -DEFAULT_AUDIENCE = "https://api.presalytics.io/" \ No newline at end of file +DEFAULT_AUDIENCE = "https://api.presalytics.io/" +DEFAULT_HOST_EVENTS = "https://api.presalytics.io/events" +DEFAULT_HOST_STORY = "https://api.presalytics.io/story" +DEFAULT_HOST_OOXML_AUTOMATION = "https://api.presalytics.io/ooxml-automation" +DEFAULT_HOST_SITE = "https://presalytics.io" +DEFAULT_HOST_WORKSPACE_API = "https://api.presalytics.io/workspace" diff --git a/presalytics/lib/default_settings.py b/presalytics/lib/default_settings.py new file mode 100644 index 0000000..ef6f403 --- /dev/null +++ b/presalytics/lib/default_settings.py @@ -0,0 +1,252 @@ +""" +Default settings for the Presalytics Python Client + +This module contains a comprehesive set of values that can used to control the Preslaytics +Python client's behavior. The `presalytics.lib.loader` module contains to load these +settings into the `presalytics.settings` instance on initialization. User should not reference +the settings in this module directly, but rather use the `presaltyics.settings` instacne in their +scripts and applications. Settings can be referring to as `presaltyics.settings.[SETTING_NAME]`. + +Users can override these default settings via two methods: + + 1. Environment Variables: Evironment variables (or a .env file in the working directory) with + the same key as the variable in this file will override this, provide the value can be parsed by + the [environs](https://pypi.org/project/environs/) python package. + + 2. `settings.py` file: A file named `settings.py` in the user's current working directory. The active working + direcotry can be determine by using the `os.getcwd()` command. This `settings.py` file takes the highest + priority. Settings defined in this file will override both the `default_settings.py` file and any enviroment variables. +""" +import logging +import typing +import presalytics.lib.constants + + +################################# +# GENERAL CONFIGURATION # +################################# + +USE_LOGGER: bool = False +""" +Toggles whether the presalytics verbose file logger should be used. Helpful for +tracing exceptions while writing code. +""" + +LOG_LEVEL = logging.DEBUG +""" +Sets the logging verbosity +""" + +DEBUG = False +""" +Use debugging features. Useful for rendering widgets and pages. +""" + +USERNAME: typing.Optional[str] = None +""" +The user's Presalytics API email/username. This is the email address that the user uses when logging in at +https://login.presalytics.io. Will be passed to instances of the `presalytics.client.api.Client` object. +""" + +PASSWORD: typing.Optional[str] = None +""" +The user's Presalytics API username. Will be passed to instances of the +`presalytics.client.api.Client` object. If running in an insecure or +multiuser environment, leave this blank and let the `presalytics.client.api.Client` +object handle token acquisition via browser-based login. +""" + +DELEGATE_LOGIN: bool = False +""" +Defaults to False. Indicates whether the client would redirect to a browser to +acquire an API token. If `DELEGATE_LOGIN` is `True`, when the `presalytics.client.api.Client` does not have +access to a valid API token, the client will raise a `presalytics.lib.exceptions.InvalidTokenException`. +The default operation will automatically open a new browser tab to acquire a new token +via website client from the presalytics.io login page. Putting this setting to True is +useful for server-side development. +""" + +CACHE_TOKENS: bool = False +""" +Indicates whether the `presalytics.client.api.Client` should store +tokens in the current working directory in a file call "token.json". This should be +set to False for mutli-user environments. +""" + +CLIENT_ID: str = presalytics.lib.constants.DEFAULT_CLIENT_ID +""" +For developer use. Allows developers to implement a `client_credentials` OpenID +Connect login. Defaults to "python-client". +""" + +CLIENT_SECRET: typing.Optional[str] = None +""" +For developer use. Allows developers to implement a `client_credentials` OpenID +Connect login. Defaults to None. +""" + +VERIFY_HTTPS: bool = True +""" +For developer use. Allows for unencrypted connections. Defaults to True. No +reason to turn this to False unless you're in a complex development scenario +and you know what you're doing. +""" + + +REDIRECT_URI: str = presalytics.lib.constants.REDIRECT_URI +""" +For developer use. Useful if implementing authorization code flow for and OpenID Connect client. +Redirect URIs must be approved by Presalytics API devops for use in client applications. +""" + +RESERVED_NAMES: typing.List[str] = [] +""" +A list of filenames for *.py files in the current workspace that should be ignored by the +registries. +""" + +USE_AUTODISCOVER: bool = False +""" +Allow registries to recurisively serach working directory and virtual environments for presalytics componets. +Good for development, but degrades performance. +""" + +IGNORE_PATHS: typing.List[str] = [] +""" +A list of paths to not to include in registry autosdiscover +""" + +PRESALYTICS_SETTINGS_MODULE: str = 'settings.py' +""" +File path to the python module in the user's workspace with the user overrides for these settings. +Should should be change if naming conflict exist with another package (e.g., a Django `settings.py` file) +""" + +INSTALLED_PACKAGES: typing.List[str] = ['presalytics'] +""" +List of strings containing name of package containing Presalytics settings, plugins and components +""" + +################# +# HOSTS # +################# + +HOST_EVENTS: str = presalytics.lib.constants.DEFAULT_HOST_EVENTS +""" +The base url for calls to the Events API +""" + +HOST_STORY: str = presalytics.lib.constants.DEFAULT_HOST_STORY +""" +The base url for calls into the Story API +""" + +HOST_OOXML_AUTOMATION: str = presalytics.lib.constants.DEFAULT_HOST_OOXML_AUTOMATION +""" +The base url for calls into the Ooxml Automation API +""" + +HOST_WORKSPACE_API: str = presalytics.lib.constants.DEFAULT_HOST_WORKSPACE_API +""" +The base url calls into the Workspace API +""" + +HOST_SITE: str = presalytics.lib.constants.DEFAULT_HOST_SITE +""" +The base url for API calls into the presalytics website +""" + +BROWSER_API_HOST_EVENTS: typing.Optional[str] = None +""" +The base url web broswers should use to make API calls into Events API. Useful during rendering of stories. +Defaults to the `HOST_EVENTS` setting if not present. +""" + +BROWSER_API_HOST_STORY: typing.Optional[str] = None +""" +The base url web broswers should use to make API calls into Story API. Useful during rendering of stories. +Defaults to the `HOST_STORY` setting if not present. +""" + +BROWSER_API_HOST_OOXML_AUTOMATION: typing.Optional[str] = None +""" +The base url web broswers should use to make API calls into Ooxml Automation API. Useful during rendering of stories. +Defaults to the `HOST_OOXML_AUTOMATION` setting if not present. +""" + +BROWSER_API_HOST_SITE: typing.Optional[str] = None +""" +The base url web broswers should use to make API calls into the presalytics website. Mainly useful for custom authentication schemes. +Defaults to the `HOST_SITE` setting if not present. +""" + +BROWSER_API_HOST_WORKSPACE_API: typing.Optional[str] = None +""" +The base url web broswers should use to make API calls into the Workspace API. Useful during rendering of stories. +Defaults to the `HOST_WORKSPACE_API` setting if not present. +""" + + +###################### +# REGISTRIES # +###################### + +OVERRIDE_REGISTRY_DEFAULTS: bool = False +""" +By default, registry settings are additive --> Registries import the default classes from this file and any `settings.py` +files found in packages in the `INSTALLED_PACKAGES` setting. +For a performance boost, a user can limit the imported list of classes in their registries to a defined +list in their `settings.py` file by setting `OVERRIDE_REGISTRY_DEFAULTS` to `True` +""" + +COMPONENTS: typing.List[str] = [ + 'presalytics.lib.widgets.chart.ChartWidget', + 'presalytics.lib.widgets.d3.D3Widget', + 'presalytics.lib.widgets.data_table.DataTableWidget', + 'presalytics.lib.widgets.markdown.MarkdownWidget', + 'presalytics.lib.widgets.matplotlib.MatplotlibFigure', + 'presalytics.lib.widgets.matplotlib.MatplotlibResponsiveFigure', + 'presalytics.lib.widgets.ooxml.OoxmlFileWidget', + 'presalytics.lib.widgets.ooxml.ChartUpdaterWidget', + 'presalytics.lib.widgets.ooxml.TableUpdaterWidget', + 'preslaytics.lib.widgets.url.UrlWidget', + 'presalytics.lib.themes.OoxmlTheme', + 'presalytics.lib.templates.base.WidgetPage', + 'presalytics.lib.templates.base.JinjaTemplateBuilder', + 'presalytics.lib.templates.base.TitleWithSingleItem', + 'presalytics.lib.templates.base.TwoUpWithTitle', + 'presalytics.lib.templates.base.BootstrapCustomTemplate' +] +""" +A list of string containing the dotted path names of Components that should be imported into the +Presalytics component registry at `presalytics.COMPONENTS`. The dotted path name is the same name path used for an import statement +at the top of a python file +""" + +PLUGINS: typing.List[str] = [ + 'presalytics.lib.plugins.external.ApprovedExternalLinks', + 'presalytics.lib.plugins.external.ApprovedExternalScripts', + 'presalytics.lib.plugins.local.LocalStylesPlugin', + 'presalytics.lib.plugins.matplotlib.Mpld3Plugin', + 'presalytics.lib.plugins.matplotlib.Mpld3Plugin', + 'presalytics.lib.plugins.ooxml.OoxmlTheme', + 'presalytics.lib.plugins.reveal_theme.RevealCustomTheme', + 'presalytics.lib.plugins.reveal.RevealConfigPlugin', + 'presalytics.lib.plugins.scss.ScssPlugin' +] +""" +A list of string containing the dotted path names of Plugins that should be imported into the +Presalytics plugins registry at `presalytics.PLUGINS`. The dotted path name is the same name path used for an import statement +at the top of a python file +""" + +XML_TRANSFORMS: typing.List[str] = [ + 'presalytics.lib.widgets.ooxml_editors.ChangeShapeColor', + 'presalytics.lib.widgets.ooxml_editors.TextReplace', + 'presalytics.lib.widgets.ooxml_editors.MultiXmlTransform' +] +""" +A list of string containing the dotted path names of Components that should be imported into the +Presalytics component registry. The dotted path name is the same name path used for an import statement +at the top of a python file +""" \ No newline at end of file diff --git a/presalytics/lib/plugins/base.py b/presalytics/lib/plugins/base.py index 25c477d..244dee6 100644 --- a/presalytics/lib/plugins/base.py +++ b/presalytics/lib/plugins/base.py @@ -176,6 +176,9 @@ def get_name(self, klass): def get_type(self, klass): return getattr(klass, "__plugin_kind__", None) + def get_settings_object(self): + return presalytics.settings.PLUGINS + class Graph(): """ @@ -331,7 +334,7 @@ def render_plugins(self, plugin_kind: str) -> typing.List[str]: except Exception as ex: logger.exception(ex) t, v, tb = sys.exc_info() - if not presalytics.CONFIG.get("DEBUG", False): + if not presalytics.settings.DEBUG: div = presalytics.lib.exceptions.RenderExceptionHandler(ex, "plugin", traceback=tb).render_exception() template = lxml.html.Element('template') template.extend(list(lxml.html.fragment_fromstring(div))) diff --git a/presalytics/lib/plugins/external.py b/presalytics/lib/plugins/external.py index 5bb199b..3a2b2b4 100644 --- a/presalytics/lib/plugins/external.py +++ b/presalytics/lib/plugins/external.py @@ -3,11 +3,8 @@ import presalytics.lib.plugins.base import presalytics.lib.exceptions -site_host = "https://presalytics.io" -try: - site_host = presalytics.CONFIG.get("BROWSER_API_HOST", {}).get('SITE', "https://presalytics.io") #type: ignore -except (KeyError, AttributeError, ImportError, ModuleNotFoundError): - pass + +site_host = presalytics.settings.SITE_HOST class AttrDict(dict): diff --git a/presalytics/lib/registry.py b/presalytics/lib/registry.py index 2a9e74d..2bceb96 100644 --- a/presalytics/lib/registry.py +++ b/presalytics/lib/registry.py @@ -8,9 +8,9 @@ import typing import abc import re -import types -import ast +import presalytics import presalytics.lib.exceptions +import presalytics.lib.util logger = logging.getLogger('presalytics.lib.registry') @@ -29,24 +29,16 @@ class RegistryBase(abc.ABC): deferred_modules: typing.List[typing.Dict[str, typing.Any]] show_errors = False - def __init__(self, - show_errors=False, - autodiscover_paths=[], - reserved_names: typing.List[str] = None, - ignore_paths: typing.List[str] = None, - **kwargs): + def __init__(self, show_errors=False, **kwargs): RegistryBase.show_errors = show_errors self.error_class = presalytics.lib.exceptions.RegistryError - self.autodiscover_paths = autodiscover_paths - self.ignore_paths = ignore_paths if ignore_paths else [] + self.use_autodiscover = presalytics.settings.USE_AUTODISCOVER + self.autodiscover_paths = presalytics.settings.AUTODISCOVER_PATHS + self.ignore_paths = presalytics.settings.IGNORE_PATHS self.registry = {} self.reserved_names = ["config.py", "setup.py"] self.deferred_modules = [] # modules to load at at runtime, if theres a ciruclat dependency at import-time - try: - if reserved_names: - self.reserved_names.extend(reserved_names) - except Exception: - pass + self.reserved_names.extend(presalytics.settings.RESERVED_NAMES) remove_paths = [] for path in self.ignore_paths: for search_path in self.autodiscover_paths: @@ -54,7 +46,8 @@ def __init__(self, remove_paths.append(search_path) for remove_path in remove_paths: self.autodiscover_paths.remove(remove_path) - self.discover() + if self.use_autodiscover: + self.discover() self.key_regex = re.compile(r'(.*)\.(.*)') def raise_error(self, message): @@ -68,6 +61,18 @@ def get_type(self, klass): def get_name(self, klass): raise NotImplementedError + @abc.abstractmethod + def get_settings_object(self): + raise NotImplementedError + + def create_static_registry(self): + for klass_path in self.get_settings_object(): + self.direct_import(klass_path) + + def direct_import(self, klass_import_path): + klass = presalytics.lib.util.import_string(klass_import_path) + self.register(klass) + @staticmethod def onerror(name): if RegistryBase.show_errors: @@ -130,7 +135,6 @@ def get_classes(self, module): if inspect.isclass(val) or isinstance(val, abc.ABC): self.load_class(val) - def load_deferred_modules(self): if len(self.deferred_modules) > 0: new_deferred = [] @@ -150,9 +154,8 @@ def load_deferred_modules(self): message = "Failure to execute deferred load on module '{}'. Please check exception message and review for errors.".format(mod.get("name", None)) logger.error(message) new_deferred.append(mod) - self.deferred_modules = new_deferred # removes modules successfully loaded from the list - - + self.deferred_modules = new_deferred # removes modules successfully loaded from the list + def discover(self): current_path = os.getcwd() if current_path not in self.autodiscover_paths: @@ -178,7 +181,7 @@ def discover(self): "module": mod, "spec": mod_spec }) - except (AttributeError, ImportError) as circ: + except (AttributeError, ImportError): # Checks for targets of circular imports, and defer those imports to runtime message = "Likely circular import in module '{}'. Deferring import to run-time.".format(mod.__name__) logger.info(message) @@ -239,10 +242,9 @@ def module_is_in_stackframe(self, module_name, frame=None) -> bool: del frame return in_stack - def register(self, klass): self.load_class(klass) - + def unregister(self, klass): key = self.get_registry_key(klass) if key: @@ -255,7 +257,7 @@ def find_class(self, string_with_key_or_name) -> typing.List[str]: else: self.load_deferred_modules() return [x for x in self.registry.keys() if string_with_key_or_name in x] - + diff --git a/presalytics/lib/tools/component_tools.py b/presalytics/lib/tools/component_tools.py index 7c81c7f..6963426 100644 --- a/presalytics/lib/tools/component_tools.py +++ b/presalytics/lib/tools/component_tools.py @@ -38,8 +38,8 @@ def create_outline_from_widget(widget: 'WidgetBase', revision="0", date_created=datetime.datetime.now().astimezone(datetime.timezone.utc).isoformat(), date_modified=datetime.datetime.now().astimezone(datetime.timezone.utc).isoformat(), - created_by=presalytics.CONFIG["USERNAME"], - modified_by=presalytics.CONFIG["USERNAME"], + created_by=presalytics.settings.USERNAME, # type: ignore + modified_by=presalytics.settings.USERNAME, # type: ignore revision_notes="Created by 'create_outline_from_widget' method" ) @@ -94,8 +94,8 @@ def create_outline_from_page(page: 'PageTemplateBase', revision="0", date_created=datetime.datetime.now().astimezone(datetime.timezone.utc).isoformat(), date_modified=datetime.datetime.now().astimezone(datetime.timezone.utc).isoformat(), - created_by=presalytics.CONFIG["USERNAME"], - modified_by=presalytics.CONFIG["USERNAME"], + created_by=presalytics.settings.USERNAME, # type: ignore[attr-defined] + modified_by=presalytics.settings.USERNAME, # type: ignore[attr-defined] revision_notes="Created by 'create_outline_from_page' method" ) diff --git a/presalytics/lib/tools/ooxml_tools.py b/presalytics/lib/tools/ooxml_tools.py index 862468a..d72a57b 100644 --- a/presalytics/lib/tools/ooxml_tools.py +++ b/presalytics/lib/tools/ooxml_tools.py @@ -138,8 +138,8 @@ def create_outline_from_ooxml_document(story_api: 'Story', revision=0, date_created=datetime.datetime.utcnow().isoformat(), date_modified=datetime.datetime.utcnow().isoformat(), - created_by=presalytics.CONFIG.get("USERNAME", ""), - modified_by=presalytics.CONFIG.get("USERNAME", ""), + created_by=presalytics.settings.USERNAME, # type: ignore[attr-defined] + modified_by=presalytics.settings.USERNAME, # type: ignore[attr-defined] revision_notes='Created by via "create_outline_from_ooxml_file" method' ) diff --git a/presalytics/lib/tools/workflows.py b/presalytics/lib/tools/workflows.py index 45ff01d..48c0c2c 100644 --- a/presalytics/lib/tools/workflows.py +++ b/presalytics/lib/tools/workflows.py @@ -26,7 +26,7 @@ def update_components(filename=None): autodiscover_paths.append(abs_filedir) if len(autodiscover_paths) > len(presalytics.COMPONENTS.autodiscover_paths): presalytics.COMPONENTS = presalytics.story.components.ComponentRegistry(autodiscover_paths=autodiscover_paths, - reserved_names=presalytics.CONFIG.get("RESERVED_NAMES", [])) + reserved_names=presalytics.settings.RESERVED_NAMES def get_component(name, filename=None): inst: 'ComponentBase' diff --git a/presalytics/lib/util.py b/presalytics/lib/util.py index d8945f8..31f2261 100644 --- a/presalytics/lib/util.py +++ b/presalytics/lib/util.py @@ -1,6 +1,9 @@ import datetime +import re +import importlib import presalytics.lib.constants + class classproperty(property): def __get__(self, obj, objtype=None): return super(classproperty, self).__get__(objtype) @@ -16,13 +19,60 @@ def roundup_date_modified(current_datetime: datetime.datetime): one_second = datetime.timedelta(seconds=1) rounddown = current_datetime.replace(microsecond=0) return rounddown + one_second - + + def get_site_host(): site_host = presalytics.lib.constants.SITE_HOST try: - site_host = presalytics.CONFIG["HOSTS"]["SITE"] + site_host = presalytics.settings.HOST_SITE except (KeyError, AttributeError): pass return site_host - + +def import_string(dotted_path) -> type: + """ + Import a dotted module path and return the attribute/class designated by the + last name in the path. Raise ImportError if the import failed. + """ + try: + module_path, class_name = dotted_path.rsplit('.', 1) + except ValueError as err: + raise ImportError("%s doesn't look like a module path" % dotted_path) from err + + module = importlib.import_module(module_path) + + try: + return getattr(module, class_name) + except AttributeError as err: + raise ImportError('Module "%s" does not define a "%s" attribute/class' % ( + module_path, class_name) + ) from err + + +def to_snake_case(camel_case_str): + s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel_case_str) + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() + + +def to_camel_case(snake_str): + components = snake_str.split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + +def to_title_case(name_string): + try: + components = re.split('_| ', name_string) + return ''.join(x[0].upper() + x[1:] for x in components) + except Exception: + return name_string.replace(" ", "") + + +def camel_case_split(str): + words = [[str[0]]] + for c in str[1:]: + if words[-1][-1].islower() and c.isupper(): + words.append(list(c)) + else: + words[-1].append(c) + return [''.join(word) for word in words] diff --git a/presalytics/lib/widgets/ooxml.py b/presalytics/lib/widgets/ooxml.py index 6d687cf..ff88f6d 100644 --- a/presalytics/lib/widgets/ooxml.py +++ b/presalytics/lib/widgets/ooxml.py @@ -76,11 +76,9 @@ def __init__(self, endpoint_id, baseurl: str = None): self.endpoint_id = endpoint_id if not baseurl: self.baseurl = OoxmlEndpointMap._BASE_URL - custom_hosts = presalytics.CONFIG.get("HOSTS", None) - if custom_hosts: - ooxml_host = custom_hosts.get("OOXML_AUTOMATION", None) - if ooxml_host: - self.baseurl = ooxml_host + ooxml_host = presalytics.settings.HOST_OOXML_AUTOMATION # type: ignore[attr-defined] + if ooxml_host: + self.baseurl = ooxml_host else: self.baseurl = baseurl diff --git a/presalytics/lib/widgets/ooxml_editors.py b/presalytics/lib/widgets/ooxml_editors.py index b7aa46b..abbf4e5 100644 --- a/presalytics/lib/widgets/ooxml_editors.py +++ b/presalytics/lib/widgets/ooxml_editors.py @@ -7,7 +7,6 @@ import abc import re import requests -import collections import presalytics import presalytics.lib.registry import presalytics.lib.exceptions @@ -315,6 +314,9 @@ def get_name(self, klass): def get_type(self, klass): return getattr(klass, "__xml_transform_kind__", None) + + def get_settings_object(self): + return presalytics.settings.XML_TRANSFORMS XML_TRANSFORM_REGISTRY = None diff --git a/presalytics/story/components.py b/presalytics/story/components.py index 2b29d27..6eed24e 100644 --- a/presalytics/story/components.py +++ b/presalytics/story/components.py @@ -322,7 +322,7 @@ def load_widget(self, widget: 'Widget'): raise presalytics.lib.exceptions.MissingConfigException(message) except Exception as ex: logger.exception(ex) - if not presalytics.CONFIG.get("DEBUG", False): + if not presalytics.settings.DEBUG: widget_instance = presalytics.lib.exceptions.RenderExceptionHandler(ex) else: t, v, tb = sys.exc_info() @@ -372,8 +372,8 @@ class Renderer(ComponentBase): objects into html and rendering them over the web With this class, users can push changes to their `presalytics.story.outline.StoryOutline` - to the Presalytics API and web clients. Renderer class contains a couplemethods for - syncing changes from component instances in the `presalytics.CONFIG` to the Presalytics API + to the Presalytics API and web clients. Renderer class contains a couplemethods for + syncing changes from component instances in the `presalytics.settings` to the Presalytics API Story service. * The `view` method allows users programattically view their stories at https://presalytics.io @@ -416,7 +416,7 @@ def __init__(self, story_outline : 'StoryOutline', **kwargs): super(Renderer, self).__init__(**kwargs) self.story_outline = story_outline try: - self.site_host = presalytics.CONFIG["HOSTS"]["SITE"] + self.site_host = presalytics.settings.HOST_SITE #type: ignore[attr-defined] except (KeyError, AttributeError): self.site_host = presalytics.lib.constants.SITE_HOST try: @@ -662,6 +662,9 @@ def get_instance_name(self, klass): """ return getattr(klass, "name", None) + def get_settings_object(self): + return presalytics.settings.COMPONENTS + def get_instance_registry_key(self, klass): """ Creates a registry key from a class instance by concatenating the @@ -680,7 +683,6 @@ def get_instance_registry_key(self, klass): logger.error(message) return key - def load_class(self, klass): """ Loads a class or instance into the registry diff --git a/presalytics/story/revealer.py b/presalytics/story/revealer.py index 2e46c85..cb285a3 100644 --- a/presalytics/story/revealer.py +++ b/presalytics/story/revealer.py @@ -138,7 +138,8 @@ def get_meta_tags(self, body=tuple()): hosts = ["https://presalytics.io", "https://*.presalytics.io"] approved = presalytics.lib.plugins.external.ApprovedExternalLinks().attr_dict.flatten() approved.update(presalytics.lib.plugins.external.ApprovedExternalScripts().attr_dict.flatten()) - approved.update(presalytics.CONFIG.get("BROWSER_API_HOST", {})) + browser_hosts = {k: v for (k, v) in presalytics.settings.__dict__.items() if "BROWER_API_HOST" in k and v is not None} + approved.update(browser_hosts) for _, val in approved.items(): url = urllib.parse.urlparse(val) host = "{0}://{1}".format(url.scheme, url.netloc) @@ -258,7 +259,7 @@ def render_page(self, page: 'Page') -> str: except Exception as ex: logger.exception(ex) t, v, tb = sys.exc_info() - if not presalytics.CONFIG.get("DEBUG", False): + if not presalytics.settings.DEBUG: #type: ignore[attr-defined] page_html = presalytics.lib.exceptions.RenderExceptionHandler(ex, "page", traceback=tb).render_exception() else: six.reraise(t, v, tb) diff --git a/presalytics/story/util.py b/presalytics/story/util.py index 8ca5cdf..c00b092 100644 --- a/presalytics/story/util.py +++ b/presalytics/story/util.py @@ -1,38 +1,10 @@ """ Utility functions for the presalytics.story module +for backward compatibility """ -import re -import logging -import typing - -logger = logging.getLogger('presalytics.story.util') - - -def to_snake_case(camel_case_str): - s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel_case_str) - return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() - - -def to_camel_case(snake_str): - components = snake_str.split('_') - return components[0] + ''.join(x.title() for x in components[1:]) - - -def to_title_case(name_string): - try: - components = re.split('_| ', name_string) - return ''.join(x[0].upper() + x[1:] for x in components) - except Exception: - return name_string.replace(" ", "") - - -def camel_case_split(str): - words = [[str[0]]] - - for c in str[1:]: - if words[-1][-1].islower() and c.isupper(): - words.append(list(c)) - else: - words[-1].append(c) - - return [''.join(word) for word in words] +from presalytics.lib.util import ( # noqa: F401 + to_snake_case, + to_camel_case, + to_title_case, + camel_case_split +) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cbb9b8a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,67 @@ +attrs==20.3.0 +bleach==3.2.1 +cachetools==4.1.0 +certifi==2020.12.5 +cffi==1.14.4 +chardet==3.0.4 +click==7.1.2 +colorama==0.4.4 +cryptography==3.2.1 +cycler==0.10.0 +docutils==0.16 +ecdsa==0.14.1 +environs==9.3.1 +Flask==1.1.2 +idna==2.10 +importlib-metadata==3.1.1 +itsdangerous==1.1.0 +jeepney==0.6.0 +Jinja2==2.11.3 +jsonpatch==1.28 +jsonpointer==2.0 +jsonschema==3.2.0 +keyring==21.5.0 +kiwisolver==1.3.1 +libsass==0.20.1 +lxml==4.6.2 +Markdown==3.3.4 +MarkupSafe==1.1.1 +marshmallow==3.10.0 +matplotlib==3.3.4 +mpld3==0.5.2 +msgpack==1.0.2 +numpy==1.19.5 +packaging==20.7 +pandas==1.1.5 +Pillow==8.1.0 +pkg-resources==0.0.0 +pkginfo==1.6.1 +py-gfm==1.0.2 +pyasn1==0.4.8 +pycparser==2.20 +Pygments==2.7.3 +pyparsing==2.4.7 +pyrsistent==0.17.3 +python-dateutil==2.8.1 +python-dotenv==0.15.0 +python-jose==3.2.0 +python-json-logger==2.0.1 +pytz==2021.1 +PyYAML==5.4.1 +readme-renderer==28.0 +requests==2.25.0 +requests-toolbelt==0.9.1 +rfc3986==1.4.0 +rsa==4.7 +SecretStorage==3.3.0 +semantic-version==2.8.5 +signalrcore==0.8.8 +six==1.15.0 +tqdm==4.54.1 +twine==3.2.0 +urllib3==1.26.2 +webencodings==0.5.1 +websocket-client==0.57.0 +Werkzeug==1.0.1 +wsgi-microservice-middleware==0.1.6 +zipp==3.4.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..4ad4ce5 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,31 @@ +[mypy] +python_version = 3.6 +warn_unused_configs = True +warn_return_any = True +ignore_missing_imports = True +show_error_codes = True + +[flake8] +ignore = E501, D203, E402 +exclude = + .git, # repo files + __pycache__, # autogenerated + .mypy_cache, # mypy data + .vscode, # vscode settings + gulp-tasks, # task runner (not python) + venv, # don't check subpackages + .env, # dont' check .env file + .env-template, + .git-ignore, + dockerfile, + README.md, + requirements.txt, + uwsgi.ini, + build, + dist, + presalytics.egg-info, + LICENSE, + make_docs.sh, + requirements.txt, + setup.cfg +max-complexity = 10 \ No newline at end of file diff --git a/setup.py b/setup.py index a0df7a2..6106174 100644 --- a/setup.py +++ b/setup.py @@ -5,8 +5,7 @@ Tools to interfacing with the Presalytics.io API. """ - - +import os from setuptools import setup, find_packages # noqa: H301 NAME = "presalytics" @@ -19,33 +18,22 @@ # prerequisite: setuptools # http://pypi.python.org/pypi/setuptools -REQUIRES = [ - "urllib3 >= 1.15", - "six >= 1.10", - "certifi", - "python-dateutil", - "flask", - "requests", - "environs", - "matplotlib", - "lxml", - "pyyaml", - "mpld3", - "libsass", - "jsonschema", - "semantic_version", - "jsonpatch", - "pandas", - "wsgi_microservice_middleware>=0.1.5", - "cachetools==4.1.0", - "python-jose", - 'markdown', - 'py-gfm' -] + +def get_requirements(): + thelibFolder = os.path.dirname(os.path.realpath(__file__)) + requirementPath = thelibFolder + '/requirements.txt' + install_requires = [] + if os.path.isfile(requirementPath): + with open(requirementPath) as f: + install_requires = f.read().splitlines() + return install_requires + + +REQUIRES = get_requirements() with open("README.md", "r") as fh: long_description = fh.read() - + setup( name=NAME, diff --git a/test/test_client.py b/test/test_client.py index 824dcca..346b7f9 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -19,7 +19,7 @@ def test_client(self): """ Tests for client configuration into presalytics api via device grant """ - if not presalytics.CONFIG.get("PASSWORD", None): + if not presalytics.settings.PASSWORD: client = presalytics.client.api.Client(config_file=self.config_file) username = os.environ["PRESALYTICS_USERNAME"] self.assertEqual(client.username, username) @@ -30,9 +30,9 @@ def test_password_grant(self): """ tests if password grant works """ - if presalytics.CONFIG.get("PASSWORD", None): - username = presalytics.CONFIG.get("USERNAME") - password = presalytics.CONFIG.get("PASSWORD") + if presalytics.settings.PASSWORD: + username = presalytics.settings.USERNAME + password = presalytics.seetings.PASSWORD client_id = os.environ.get("CLIENT_ID") client_secret = os.environ.get("CLIENT_SECRET") client = presalytics.client.api.Client( diff --git a/test/test_story.py b/test/test_story.py index be1afe7..79d064e 100644 --- a/test/test_story.py +++ b/test/test_story.py @@ -19,7 +19,7 @@ class TestStory(unittest.TestCase): Test module features thatr render stories to dashboards or other formats. Please note that these are integration tests, the development environment and - `presaltytics.CONFIG` must be set up appropriately for these test execute + `presaltytics.settings` must be set up appropriately for these test execute sucessfully. """ def setUp(self): @@ -80,13 +80,14 @@ def test_create_outline_from_widget(self): def test_render_page_exception(self): test_file = os.path.join(os.path.dirname(__file__), 'files', 'bad-outline.yaml') - _debug = presalytics.CONFIG.pop("DEBUG", None) + _debug = presalytics.settings.DEBUG + presalytics.settings.DEBUG = True outline = presalytics.story.outline.StoryOutline.import_yaml(test_file) revealer = presalytics.story.revealer.Revealer(outline) html = revealer.package_as_standalone().decode('utf-8') self.assertTrue("Oops!" in html) if _debug: - presalytics.CONFIG.update({"DEBUG": _debug}) + presalytics.settings.DEBUG = _debug def text_replace_transform_test(self): test_file = os.path.join(os.path.dirname(__file__), 'files', 'ooxml_test_2.xml') From 79f9f9256573b4e4e9557517ed3663dbd3e2c7fd Mon Sep 17 00:00:00 2001 From: Kevin Hannegan Date: Fri, 26 Feb 2021 12:58:49 -0800 Subject: [PATCH 06/27] clean via autopep8, add AUTODISCOVER_PATHS --- .gitignore | 2 - .vscode/settings.json | 14 ++ presalytics/__main__.py | 2 +- presalytics/cli.py | 10 +- presalytics/client/__init__.py | 12 +- presalytics/client/api.py | 137 +++++++------ presalytics/client/auth.py | 38 ++-- presalytics/client/oidc.py | 29 +-- .../presalytics_doc_converter/__init__.py | 1 - .../presalytics_doc_converter/api_client.py | 4 +- .../configuration.py | 2 +- .../presalytics_ooxml_automation/__init__.py | 1 - .../api_client.py | 22 +- .../client/presalytics_story/__init__.py | 1 - .../client/presalytics_story/api_client.py | 22 +- presalytics/client/websocket.py | 2 +- presalytics/lib/__init__.py | 2 +- presalytics/lib/config_loader.py | 1 + presalytics/lib/default_settings.py | 45 +++-- presalytics/lib/exceptions.py | 13 +- presalytics/lib/logger.py | 4 +- presalytics/lib/plugins/base.py | 10 +- presalytics/lib/plugins/external.py | 21 +- presalytics/lib/plugins/jinja.py | 12 +- presalytics/lib/plugins/local.py | 5 +- presalytics/lib/plugins/matplotlib.py | 5 +- presalytics/lib/plugins/ooxml.py | 3 +- presalytics/lib/plugins/reveal.py | 30 +-- presalytics/lib/plugins/reveal_theme.py | 4 +- presalytics/lib/plugins/scss.py | 11 +- presalytics/lib/registry.py | 22 +- presalytics/lib/templates/base.py | 57 +++--- presalytics/lib/themes/__init__.py | 2 +- presalytics/lib/themes/ooxml.py | 9 +- presalytics/lib/tools/__init__.py | 2 +- presalytics/lib/tools/component_tools.py | 11 +- presalytics/lib/tools/ooxml_tools.py | 52 ++--- presalytics/lib/tools/story_tools.py | 14 +- presalytics/lib/tools/workflows.py | 164 ++++++++------- presalytics/lib/util.py | 2 +- presalytics/lib/widgets/chart.py | 20 +- presalytics/lib/widgets/d3.py | 36 ++-- presalytics/lib/widgets/data_table.py | 22 +- presalytics/lib/widgets/markdown.py | 13 +- presalytics/lib/widgets/matplotlib.py | 17 +- presalytics/lib/widgets/ooxml.py | 188 +++++++++--------- presalytics/lib/widgets/ooxml_editors.py | 101 +++++----- presalytics/lib/widgets/url.py | 7 +- presalytics/story/__init__.py | 2 +- presalytics/story/components.py | 70 +++---- presalytics/story/outline.py | 88 ++++---- presalytics/story/revealer.py | 21 +- presalytics/story/server.py | 20 +- 53 files changed, 684 insertions(+), 721 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index ae28162..fb95492 100644 --- a/.gitignore +++ b/.gitignore @@ -105,8 +105,6 @@ bld/ # Visual Studio 2015/2017 cache/options directory .vs/ -#Visual Studio code files -.vscode/ #Uncomment if you have tasks that create the project's static files in wwwroot wwwroot/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..959d386 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + "python.linting.mypyEnabled": true, + "python.linting.enabled": true, + "python.linting.flake8Enabled": true, + "editor.formatOnSave": true, + "editor.formatOnSaveTimeout": 1500, + "python.formatting.provider": "autopep8", + "python.formatting.autopep8Args": [ + "--in-place", + "--agressive", + "--global-config", + "setup.cfg" + ] +} \ No newline at end of file diff --git a/presalytics/__main__.py b/presalytics/__main__.py index 4719bca..3afcad5 100755 --- a/presalytics/__main__.py +++ b/presalytics/__main__.py @@ -2,4 +2,4 @@ from presalytics.cli import main if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/presalytics/cli.py b/presalytics/cli.py index 35462f9..26156ad 100644 --- a/presalytics/cli.py +++ b/presalytics/cli.py @@ -301,7 +301,7 @@ def main(): args = parser.parse_args() filename = args.file lgs = [logging.getLogger(n) for n in logging.root.manager.loggerDict] - if args.verbose or args.quiet: + if args.verbose: for lg in lgs: lg.setLevel(logging.DEBUG) elif args.quiet: @@ -473,7 +473,7 @@ def main(): else: presalytics.lib.tools.workflows.delete_by_id(args.id, username=args.username, password=args.password) if share: - presalytics.lib.tools.workflows.share_story(story_id, + presalytics.lib.tools.workflows.share_story(story_id, emails=args.emails, user_ids=args.user_ids, username=args.username, @@ -487,7 +487,7 @@ def main(): _open_page(story_id, "manage") if args.show_story: story = presalytics.lib.tools.workflows.get_story(story_id) - + except webbrowser.Error: logger.error("This environment does not have a webrowser loaded for use with python.") return @@ -499,7 +499,7 @@ def main(): presalytics.lib.tools.workflows.create_cron_target() except Exception as ex: if isinstance(ex, presalytics.lib.exceptions.PresalyticsBaseException): - logger.error(ex.message) # noqa + logger.error(ex.message) # noqa else: logger.exception(ex) finally: @@ -507,4 +507,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/presalytics/client/__init__.py b/presalytics/client/__init__.py index 6b15f76..83c8ab6 100644 --- a/presalytics/client/__init__.py +++ b/presalytics/client/__init__.py @@ -1,13 +1,13 @@ """ This module conatains objects for interacting with the Presalytics API. It has three submodules: -`presalytics.client.presalytics_doc_converter`, 'presalytics.client.presalytics_ooxml_automation`, and -`presalytics.client.presalytics_story` contain that auto-generate code using the -[OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator) and specifications from +`presalytics.client.presalytics_doc_converter`, 'presalytics.client.presalytics_ooxml_automation`, and +`presalytics.client.presalytics_story` contain that auto-generate code using the +[OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator) and specifications from http://api.presalytics.io . -Modules contained at this level are middlewares to simplify user interaction with the generated -code base and the Presalytics API. +Modules contained at this level are middlewares to simplify user interaction with the generated +code base and the Presalytics API. - `presalytics.client.auth` contains authentication and request handling handling middleware - `presalytics.client.api` conatains wrapper classes and convenience extensions so that users only need to instantiate one object to get access to all public microservices in the Presalytics API -""" \ No newline at end of file +""" diff --git a/presalytics/client/api.py b/presalytics/client/api.py index 165eaad..07ef226 100644 --- a/presalytics/client/api.py +++ b/presalytics/client/api.py @@ -27,34 +27,34 @@ class Client(object): """ Class for interacting with Presalytics API endpoints - The Client class creates a simple interface for user to interactive with the - Presalytics API and is the primary building block for user-built automation of stories, + The Client class creates a simple interface for user to interactive with the + Presalytics API and is the primary building block for user-built automation of stories, dashboards, and interactive presentations. A client instance wraps python functions around Presalytics API endpoints and - manages user authentication. On initialization, he client checks the status of + manages user authentication. On initialization, he client checks the status of a user authentication the expiry of their refresh an access tokens. When needed, the client will open a browser to prompt the user to login at the presalytics.io - login page (or raise an `presalytics.lib.exceptions.InvalidTokenException` when + login page (or raise an `presalytics.lib.exceptions.InvalidTokenException` when `delegate_login` is `True`). - After authenication, users can call the methods bound to the story, ooxml_automation, + After authenication, users can call the methods bound to the story, ooxml_automation, and doc_converter attributes to make calls in into the Presalytics API. *A note for server-side development*: - The client class can automatically cache tokens in a file called + The client class can automatically cache tokens in a file called "token.json", located in the python's current working directory. This is done so - users running scripts accross multiple client instances do not have to acquire a new token - every time an API call is made. If building a client to operate in a multi-user environment, + users running scripts accross multiple client instances do not have to acquire a new token + every time an API call is made. If building a client to operate in a multi-user environment, this behavior should be turned off so that one user cannot not pull one another's tokens. - To do this, ensure the following parameters are pass to the configuration either - via initialization or in a `presaltyics.settings`: - + To do this, ensure the following parameters are pass to the configuration either + via initialization or in a `presaltyics.settings`: + cache_tokens = False, delegate_login = True - - When delegate login is True, the client assumes that the application creating + + When delegate login is True, the client assumes that the application creating instances of the client object will handle user authentication. The simplest way to do this is to pass a token to the client via the "token" keyword argument. @@ -63,31 +63,31 @@ class Client(object): username : str, optional Defaults to None. The user's Presalytics API username. This keyword will take precedence over a passed to the client - via `presalytics.settings`. The username must either be present in `presalytics.settings` or be passed in + via `presalytics.settings`. The username must either be present in `presalytics.settings` or be passed in via keyword, otherwise the client will raise a `presalytics.lib.exceptions.MissingConfigException`. password : str, optional - Defaults to None. The user's Presalytics API password. This useful for quickly testing scripts, but in most - scenario users should not be passing plaintext into the client via this keyword. In a secure, single-user + Defaults to None. The user's Presalytics API password. This useful for quickly testing scripts, but in most + scenario users should not be passing plaintext into the client via this keyword. In a secure, single-user environment, passwords are better placed in the `presalytics.settings` object for reuseability. A more secure is to leave passwords out of the configuration, keep `delegate_login` = `False`, and acquire tokens via the browser. - + delegate_login : bool, optional - Defaults to False. Indicates whether the client would redirect to a browser to - acquire an API token. If `DELEGATE_LOGIN` is `True`, when the `presalytics.client.api.Client` does not have + Defaults to False. Indicates whether the client would redirect to a browser to + acquire an API token. If `DELEGATE_LOGIN` is `True`, when the `presalytics.client.api.Client` does not have access to a valid API token, the client will raise a `presalytics.lib.exceptions.InvalidTokenException`. - The default operation will automatically open a new browser tab to acquire a new token + The default operation will automatically open a new browser tab to acquire a new token via website client from the presalytics.io login page. Putting this setting to True is useful for server-side development. token : dict, optional - Defaults to None. A dictionary contain information about tokens acquire from auth.presalytics.io. The - dictionary must contain an `access_token`, a `refresh_token`, and entries contiaing information about token expiry. + Defaults to None. A dictionary contain information about tokens acquire from auth.presalytics.io. The + dictionary must contain an `access_token`, a `refresh_token`, and entries contiaing information about token expiry. Token expiry information can either passed in ISO 8601 formatted string with a UTC offset as dictionary keys - `access_token_expire_time` and `refresh_token_expire_time` or an integer in seconds with the corresponding + `access_token_expire_time` and `refresh_token_expire_time` or an integer in seconds with the corresponding dictionary keys`expires_in` and `refresh_expires_in`. - + if the `dict` passed in via this keywork does is not have the correct entries, the client will raise an `presalytics.lib.exceptions.InvalidTokenException`. @@ -101,7 +101,7 @@ class Client(object): direct_grant : bool Indicates whether an token will be acquire via the "direct_grant" OpenID Connect flow. Usually indicates - whether the user has supplied a passwork to the client either through `presalytics.settings` ro + whether the user has supplied a passwork to the client either through `presalytics.settings` ro during object initialization. doc_converter : presalytics.client.presalytics_doc_converter.api.default_api.DefaultApi @@ -113,17 +113,17 @@ class Client(object): client = presalytics.Client() api_obj = client.doc_converter.{operation_id}(*args) - where `{operation_id}` is the `operationId` assocated with the endpoint specified the [Doc Converter + where `{operation_id}` is the `operationId` assocated with the endpoint specified the [Doc Converter Service OpenAPI Contract](https://presalytics.io/docs/api-specifications/doc-converter/) , and *args are the corresponding arguments that are passed to the method. A complete list of the avialable methods is shown on the `presalytics.client.presalytics_doc_converter.api.default_api.DefaultApi` object. - + *Note*: - This attribute contains automatically generated methods via - the [OpenAPI generator](https://github.com/OpenAPITools/openapi-generator). The + This attribute contains automatically generated methods via + the [OpenAPI generator](https://github.com/OpenAPITools/openapi-generator). The `presalytics.client.presalytics_doc_converter.api.default_api.DefaultApi` has been passed an an `api_client` - keyword argument with an instance of `presalytics.client.api.DocConverterApiClientWithAuth`, which adds - an authentication and request processing middleware layer to the default sub package + keyword argument with an instance of `presalytics.client.api.DocConverterApiClientWithAuth`, which adds + an authentication and request processing middleware layer to the default sub package built via code generatation. ooxml_automation : presalytics.client.presalytics_ooxml_automation.api.default_api.DefaultApi @@ -139,13 +139,13 @@ class Client(object): Service OpenAPI Contract](https://presalytics.io/docs/api-specifications/ooxml-automation/) , and *args are the corresponding arguments that are passed to the method. A complete list of the avialable methods is shown on the `presalytics.client.presalytics_ooxml_automation.api.default_api.DefaultApi` object. - + *Note*: - This attribute contains automatically generated methods via - the [OpenAPI generator](https://github.com/OpenAPITools/openapi-generator). The + This attribute contains automatically generated methods via + the [OpenAPI generator](https://github.com/OpenAPITools/openapi-generator). The `presalytics.client.presalytics_ooxml_automation.api.default_api.DefaultApi` has been passed an an `api_client` - keyword argument with an instance of `presalytics.client.api.OoxmlAutomationApiClientWithAuth`, which adds - an authentication and request processing middleware layer to the default sub package + keyword argument with an instance of `presalytics.client.api.OoxmlAutomationApiClientWithAuth`, which adds + an authentication and request processing middleware layer to the default sub package built via code generatation. story : presalytics.client.presalytics_story.api.default_api.DefaultApi @@ -161,24 +161,24 @@ class Client(object): Service OpenAPI Contract](https://presalytics.io/docs/api-specifications/story/) , and *args are the corresponding arguments that are passed to the method. A complete list of the avialable methods is shown on the `presalytics.client.presalytics_story.api.default_api.DefaultApi` object. - + *Note*: - This attribute contains automatically generated methods via - the [OpenAPI generator](https://github.com/OpenAPITools/openapi-generator). The + This attribute contains automatically generated methods via + the [OpenAPI generator](https://github.com/OpenAPITools/openapi-generator). The `presalytics.client.presalytics_story.api.default_api.DefaultApi` has been passed an an `api_client` - keyword argument with an instance of `presalytics.client.api.StoryApiClientWithAuth`, which adds - an authentication and request processing middleware layer to the default sub package + keyword argument with an instance of `presalytics.client.api.StoryApiClientWithAuth`, which adds + an authentication and request processing middleware layer to the default sub package built via code generatation. client_id : str - The client_id that is used OpenID Connect login. Defaults to "python-client". + The client_id that is used OpenID Connect login. Defaults to "python-client". client_secret : str, optional The client_secret used during OpenID Connect login. Useful `confidential_client` is True. confidential_client : bool Indicates whether a this client can obtain tokens from auth.presalytics.io without a user under - OpenID Connect grant type "confidential_client". Requires a `client_secret`. Default is False. + OpenID Connect grant type "confidential_client". Requires a `client_secret`. Default is False. oidc : `presalytics.client.oidc.OidcClient` A middleware class to help acquire and validate tokens from login.presalytics.io. @@ -191,20 +191,21 @@ class Client(object): Defaults to https://presalytics.io. redirect_uri : str - Useful if implementing authorization code flow for and OpenID Connect client. Redirect URIs must - be approved by Presalytics API devops for use in client applications. Set from Set from - `presalytics.settings` with keyword `REDIRECT_URI`. Defaults to https://presalytics.io/user/login-success. + Useful if implementing authorization code flow for and OpenID Connect client. Redirect URIs must + be approved by Presalytics API devops for use in client applications. Set from Set from + `presalytics.settings` with keyword `REDIRECT_URI`. Defaults to https://presalytics.io/user/login-success. login_sleep_interval : int The duration (in seconds) between attempts to acquire a token after browser-based authentication. Defaults - to 5 seconds. + to 5 seconds. login_timeout : int - Defaults to 60 seconds. The amount of time the client will attempt to acquire a token after the + Defaults to 60 seconds. The amount of time the client will attempt to acquire a token after the https://presalytics.io authenicates a user. Raises a `presalytics.lib.exceptions.LoginTimeout` if the user has not authenticated by the time the interval has expired. """ + def __init__( self, username=None, @@ -215,7 +216,7 @@ def __init__( client_id=None, client_secret=None, **kwargs): - + if username: self.username = username else: @@ -277,7 +278,7 @@ def __init__( # Assume if token is passed as string, then it's an access token if isinstance(token, str): self.token_util.token = {"access_token": token} - + # if token is a dictionary with an 'access_token_expire_time' key, it's previous been processed / deserialized elif token.get('access_token_expire_time', None): self.token_util.token = token @@ -312,7 +313,7 @@ def login(self): def refresh_token(self): """ - Obtains a new access token if the access token is expired. if refresh token is expired, + Obtains a new access token if the access token is expired. if refresh token is expired, this method prompt user to re-authenticate when `delegate_login` is `False` or raise an `presalytics.lib.exceptions.InvalidTokenException` when `deletegate_login` is True. """ @@ -353,14 +354,14 @@ def get_auth_header(self): def get_request_id_header(self): """ Creates an 'X-Request-Id' token header for tracing requests through Presalytics API - services. If deployed alongside the [WSGI Microservice Middleware](https://github.com/presalytics/WSGI-Microservice-Middleware) + services. If deployed alongside the [WSGI Microservice Middleware](https://github.com/presalytics/WSGI-Microservice-Middleware) package, this method will pull the request id from the call stack. - + Returns ---------- A `dict` header representation with an 'X-Request-Id' key to be attached to an API request """ - + current_request_id = wsgi_microservice_middleware.current_request_id() if not current_request_id: current_request_id = str(uuid4()) @@ -377,14 +378,14 @@ def download_file(self, story_id, ooxml_automation_id, download_folder=None, fil --------- story : str The id of the Presalytics Story API object that manages access to document - + ooxml_automation_id : str The id of the Presalytics API Ooxml Automation service object that you want to download - + download_folder : str, optional - The filepath to the local directory that you want to download the file to. Defaults to the + The filepath to the local directory that you want to download the file to. Defaults to the current working directory. - + filename: str, optional The name of the downloaded file. Defaults to the original filename the the object was created. @@ -423,13 +424,13 @@ def get_client_info(self): STATUS_REPOLL_SECONDS = 2 STATUS_REPOLL_MAX_CYCLES = 20 - def upload_file_and_await_outline(self, + def upload_file_and_await_outline(self, file: typing.Union[FileStorage, str], include_relationships=True, - status_repoll_seconds: int = None, + status_repoll_seconds: int = None, repoll_max_cycles: int = None): """ Useful for testing """ - if type(file) is str: + if isinstance(file, str): content_type = mimetypes.guess_type(file, False)[0] # type: ignore with open(file, 'rb') as f: # type: ignore stream = io.BytesIO(f.read()) @@ -437,7 +438,7 @@ def upload_file_and_await_outline(self, stream=stream, # type: ignore filename=file, # type: ignore content_type=content_type, - content_length=stream.__sizeof__() + content_length=stream.__sizeof__() ) if not status_repoll_seconds: status_repoll_seconds = self.STATUS_REPOLL_SECONDS @@ -449,7 +450,7 @@ def upload_file_and_await_outline(self, def await_outline(self, story_id, - status_repoll_seconds: int = None, + status_repoll_seconds: int = None, repoll_max_cycles: int = None): task_running = True repoll_cycle_count = 0 @@ -475,9 +476,10 @@ def await_outline(self, class DocConverterApiClientWithAuth(presalytics.client.auth.AuthenticationMixIn, presalytics.client.presalytics_doc_converter.api_client.ApiClient): """ - Wraps `presalytics.client.presalytics_doc_converter.api_client.ApiClient` with + Wraps `presalytics.client.presalytics_doc_converter.api_client.ApiClient` with `presalytics.client.auth.AuthenticationMixIn` middleware """ + def __init__(self, parent: Client, **kwargs): presalytics.client.auth.AuthenticationMixIn.__init__(self, parent, **kwargs) presalytics.client.presalytics_doc_converter.api_client.ApiClient.__init__(self) @@ -493,6 +495,7 @@ class OoxmlAutomationApiClientWithAuth(presalytics.client.auth.AuthenticationMix Wraps `presalytics.client.presalytics_ooxml_automation.api_client.ApiClient` with `presalytics.client.auth.AuthenticationMixIn` middleware """ + def __init__(self, parent: Client, **kwargs): presalytics.client.auth.AuthenticationMixIn.__init__(self, parent, **kwargs) presalytics.client.presalytics_ooxml_automation.api_client.ApiClient.__init__(self) @@ -508,6 +511,7 @@ class StoryApiClientWithAuth(presalytics.client.auth.AuthenticationMixIn, presal Wraps `presalytics.client.presalytics_story.api_client.ApiClient` with `presalytics.client.auth.AuthenticationMixIn` middleware """ + def __init__(self, parent: Client, **kwargs): presalytics.client.auth.AuthenticationMixIn.__init__(self, parent, **kwargs) presalytics.client.presalytics_story.api_client.ApiClient.__init__(self) @@ -527,8 +531,3 @@ def get_client(): """ client = presalytics.Client() return client - - - - - diff --git a/presalytics/client/auth.py b/presalytics/client/auth.py index 8e48bef..be5e7a4 100644 --- a/presalytics/client/auth.py +++ b/presalytics/client/auth.py @@ -36,7 +36,7 @@ def __init__(self, token=None, token_file=None, token_cache=False): pass if token is not None: try: - self.process_token(token) + self.process_token(token) if self.token_cache: self._put_token_file() except Exception: @@ -90,7 +90,7 @@ def process_token(self, token): if token.get('expires_in', None): access_token_expire_time = datetime.datetime.utcnow().astimezone(datetime.timezone.utc) + datetime.timedelta(seconds=token['expires_in']) else: - pass # TODO: add logic to introspect token for expire time + pass # TODO: add logic to introspect token for expire time else: access_token_expire_time = token["access_token_expire_time"] if access_token_expire_time: @@ -140,7 +140,7 @@ def call_api( """ if self.parent() is None: message = """ - Missing reference to Client class. Client was like garbage collected by the intepreter.\n + Missing reference to Client class. Client was like garbage collected by the intepreter.\n Please initialize the Client class on its own line to avoid this error. For example:\n\n client = presalytics.Client()\n story = client.story.story_id_get(story_id) @@ -160,21 +160,21 @@ def call_api( endpoint = self.configuration.host + resource_path logger.info("Sending {0} message to {1}. Request Id: {2}".format(method, endpoint, request_id)) call_args = ( - resource_path, - method, + resource_path, + method, path_params, - query_params, - header_params, - body, - post_params, - files, + query_params, + header_params, + body, + post_params, + files, response_type, - auth_settings, - async_req, - _return_http_data_only, - collection_formats, + auth_settings, + async_req, + _return_http_data_only, + collection_formats, _preload_content, - _request_timeout, + _request_timeout, _host ) response = super(AuthenticationMixIn, self).call_api(*call_args) @@ -277,15 +277,15 @@ def files_parameters(self, files=None): for k, v in six.iteritems(files): if not v: continue - if type(v) is str or type(v) is list: - file_names = v if type(v) is list else [v] + if isinstance(v, str) or isinstance(v, list): + file_names = v if isinstance(v, list) else [v] for n in file_names: with open(n, 'rb') as f: filename = os.path.basename(f.name) filedata = f.read() else: - if type(v) is FileStorage: + if isinstance(v, FileStorage): filename = v.filename v.stream.seek(0) filedata = v.stream.read() @@ -294,4 +294,4 @@ def files_parameters(self, files=None): mimetype = (mimetypes.guess_type(filename)[0] or 'application/octet-stream') params.append(tuple([k, tuple([filename, filedata, mimetype])])) - return params \ No newline at end of file + return params diff --git a/presalytics/client/oidc.py b/presalytics/client/oidc.py index 05bdcd1..93c26bb 100644 --- a/presalytics/client/oidc.py +++ b/presalytics/client/oidc.py @@ -33,7 +33,6 @@ def get_jwks(): raise presalytics.lib.exceptions.ApiError(message="Could not get jwks from Uri", status_code=r.status_code) - class OidcClient(object): """ A helper class for negotiating tokens from an oidc provider, defalting to https://login.presalytics.io @@ -57,6 +56,7 @@ class OidcClient(object): """ + def __init__(self, client_id=None, client_secret=None, validate_tokens=True, *args, **kwargs): self.auth_host = kwargs.get("auth_host", cnst.OIDC_AUTH_HOST) self.well_known_endpoint = posixpath.join(self.auth_host, kwargs.get("well_known_path", ".well-known/openid-configuration")) @@ -82,7 +82,7 @@ def handle_device_code_response(self, device_code_response): print(cli_message) try: webbrowser.open_new_tab(device_code_response["verification_uri_complete"]) - except: + except BaseException: pass def poll_for_token(self, device_code_response): @@ -105,7 +105,7 @@ def poll_for_token(self, device_code_response): time.sleep(sleep_interval) if err_msg == "slow_down": time.sleep(sleep_interval) - logger.debug("User has not yet logged in. Repolling...") + logger.debug("User has not yet logged in. Repolling...") else: message = "Error: {0} -- {1}".format(err_msg, err_resp["error_description"]) raise presalytics.lib.exceptions.ApiError(message=message, status_code=token_response.status_code) @@ -131,7 +131,7 @@ def token(self, username, password=None, audience=None, scope=None, **kwargs) -> if not audience: audience = self.audience if password and self.client_secret: - #use password grant if present (not recommended) + # use password grant if present (not recommended) data = { "grant_type": "password", "username": username, @@ -142,7 +142,7 @@ def token(self, username, password=None, audience=None, scope=None, **kwargs) -> "scope": scope } token_data = self._post(self.token_endpoint, data) - + else: # Use device grant as default device_data = { @@ -189,10 +189,8 @@ def validate_token(self, token): raise presalytics.lib.exceptions.ApiError(message="invalid token (likely malformed)", status_code=401) logger.debug("Access token validated.") return payload - - raise presalytics.lib.exceptions.ApiError(message="invalid_header: could not find key in jwks",status_code=401) - + raise presalytics.lib.exceptions.ApiError(message="invalid_header: could not find key in jwks", status_code=401) def refresh_token(self, refresh_token, scope=None): """ @@ -212,7 +210,7 @@ def refresh_token(self, refresh_token, scope=None): } token_data = self._post(self.token_endpoint, data) - + if self.validate_tokens: self.validate_token(token_data["access_token"]) return token_data @@ -228,7 +226,6 @@ def _post(self, endpoint, data, headers={}): return self._handle_response(response) - def _handle_response(self, response): if response.status_code == 401: raise presalytics.lib.exceptions.ApiError(message="Unauthorized", status_code=401) @@ -258,7 +255,7 @@ def _handle_response(self, response): except Exception: pass return data - + def client_credentials_token(self, audience=None, scope=None): if not self.client_secret: raise presalytics.lib.exceptions.ApiError(message="Must have client secret for client credentials grant", status_code=400) @@ -277,13 +274,3 @@ def client_credentials_token(self, audience=None, scope=None): def get_user_id(self, token) -> str: payload = presalytics.client.oidc.OidcClient().validate_token(token) return payload.get('https://api.presalytics.io/api_user_id', None) - - - - - - - - - - \ No newline at end of file diff --git a/presalytics/client/presalytics_doc_converter/__init__.py b/presalytics/client/presalytics_doc_converter/__init__.py index 4d4dc10..59f9c60 100644 --- a/presalytics/client/presalytics_doc_converter/__init__.py +++ b/presalytics/client/presalytics_doc_converter/__init__.py @@ -31,4 +31,3 @@ # import models into sdk package from presalytics.client.presalytics_doc_converter.models.file_to_convert import FileToConvert from presalytics.client.presalytics_doc_converter.models.file_url import FileUrl - diff --git a/presalytics/client/presalytics_doc_converter/api_client.py b/presalytics/client/presalytics_doc_converter/api_client.py index fa4ab49..bdd6071 100644 --- a/presalytics/client/presalytics_doc_converter/api_client.py +++ b/presalytics/client/presalytics_doc_converter/api_client.py @@ -275,7 +275,7 @@ def __deserialize(self, data, klass): if data is None: return None - if type(klass) == str: + if isinstance(klass, str): if klass.startswith('list['): sub_kls = re.match(r'list\[(.*)\]', klass).group(1) return [self.__deserialize(sub_data, sub_kls) @@ -467,7 +467,7 @@ def files_parameters(self, files=None): for k, v in six.iteritems(files): if not v: continue - file_names = v if type(v) is list else [v] + file_names = v if isinstance(v, list) else [v] for n in file_names: with open(n, 'rb') as f: filename = os.path.basename(f.name) diff --git a/presalytics/client/presalytics_doc_converter/configuration.py b/presalytics/client/presalytics_doc_converter/configuration.py index 1e9709e..13c5575 100644 --- a/presalytics/client/presalytics_doc_converter/configuration.py +++ b/presalytics/client/presalytics_doc_converter/configuration.py @@ -342,8 +342,8 @@ def get_host_settings(self): 'enum_values': [ "https" ] - } } + } } ] diff --git a/presalytics/client/presalytics_ooxml_automation/__init__.py b/presalytics/client/presalytics_ooxml_automation/__init__.py index f9a485a..2c7dcc5 100644 --- a/presalytics/client/presalytics_ooxml_automation/__init__.py +++ b/presalytics/client/presalytics_ooxml_automation/__init__.py @@ -142,4 +142,3 @@ from presalytics.client.presalytics_ooxml_automation.models.theme_line_map_details import ThemeLineMapDetails from presalytics.client.presalytics_ooxml_automation.models.theme_themes import ThemeThemes from presalytics.client.presalytics_ooxml_automation.models.theme_themes_details import ThemeThemesDetails - diff --git a/presalytics/client/presalytics_ooxml_automation/api_client.py b/presalytics/client/presalytics_ooxml_automation/api_client.py index a473da1..3b6eca1 100644 --- a/presalytics/client/presalytics_ooxml_automation/api_client.py +++ b/presalytics/client/presalytics_ooxml_automation/api_client.py @@ -262,7 +262,7 @@ def __deserialize(self, data, klass): if data is None: return None - if type(klass) == str: + if isinstance(klass, str): if klass.startswith('list['): sub_kls = re.match(r'list\[(.*)\]', klass).group(1) return [self.__deserialize(sub_data, sub_kls) @@ -341,15 +341,15 @@ def call_api(self, resource_path, method, _preload_content, _request_timeout, _host) else: thread = self.pool.apply_async(self.__call_api, (resource_path, - method, path_params, query_params, - header_params, body, - post_params, files, - response_type, auth_settings, - _return_http_data_only, - collection_formats, - _preload_content, - _request_timeout, - _host)) + method, path_params, query_params, + header_params, body, + post_params, files, + response_type, auth_settings, + _return_http_data_only, + collection_formats, + _preload_content, + _request_timeout, + _host)) return thread def request(self, method, url, query_params=None, headers=None, @@ -455,7 +455,7 @@ def files_parameters(self, files=None): for k, v in six.iteritems(files): if not v: continue - file_names = v if type(v) is list else [v] + file_names = v if isinstance(v, list) else [v] for n in file_names: with open(n, 'rb') as f: filename = os.path.basename(f.name) diff --git a/presalytics/client/presalytics_story/__init__.py b/presalytics/client/presalytics_story/__init__.py index 159ed06..6a2c260 100644 --- a/presalytics/client/presalytics_story/__init__.py +++ b/presalytics/client/presalytics_story/__init__.py @@ -57,4 +57,3 @@ from presalytics.client.presalytics_story.models.story_outline_history_all_of import StoryOutlineHistoryAllOf from presalytics.client.presalytics_story.models.view import View from presalytics.client.presalytics_story.models.view_all_of import ViewAllOf - diff --git a/presalytics/client/presalytics_story/api_client.py b/presalytics/client/presalytics_story/api_client.py index b2f17b9..4385e2f 100644 --- a/presalytics/client/presalytics_story/api_client.py +++ b/presalytics/client/presalytics_story/api_client.py @@ -262,7 +262,7 @@ def __deserialize(self, data, klass): if data is None: return None - if type(klass) == str: + if isinstance(klass, str): if klass.startswith('list['): sub_kls = re.match(r'list\[(.*)\]', klass).group(1) return [self.__deserialize(sub_data, sub_kls) @@ -341,15 +341,15 @@ def call_api(self, resource_path, method, _preload_content, _request_timeout, _host) else: thread = self.pool.apply_async(self.__call_api, (resource_path, - method, path_params, query_params, - header_params, body, - post_params, files, - response_type, auth_settings, - _return_http_data_only, - collection_formats, - _preload_content, - _request_timeout, - _host)) + method, path_params, query_params, + header_params, body, + post_params, files, + response_type, auth_settings, + _return_http_data_only, + collection_formats, + _preload_content, + _request_timeout, + _host)) return thread def request(self, method, url, query_params=None, headers=None, @@ -455,7 +455,7 @@ def files_parameters(self, files=None): for k, v in six.iteritems(files): if not v: continue - file_names = v if type(v) is list else [v] + file_names = v if isinstance(v, list) else [v] for n in file_names: with open(n, 'rb') as f: filename = os.path.basename(f.name) diff --git a/presalytics/client/websocket.py b/presalytics/client/websocket.py index 63637b6..bf1fffc 100644 --- a/presalytics/client/websocket.py +++ b/presalytics/client/websocket.py @@ -37,4 +37,4 @@ # hub_connection.stop() -# sys.exit(0) \ No newline at end of file +# sys.exit(0) diff --git a/presalytics/lib/__init__.py b/presalytics/lib/__init__.py index 5520ee8..94dd435 100644 --- a/presalytics/lib/__init__.py +++ b/presalytics/lib/__init__.py @@ -1,3 +1,3 @@ """ Contains configuration and library objects for users to build upon when create story objects -""" \ No newline at end of file +""" diff --git a/presalytics/lib/config_loader.py b/presalytics/lib/config_loader.py index 9d38ddb..0cd4bff 100644 --- a/presalytics/lib/config_loader.py +++ b/presalytics/lib/config_loader.py @@ -107,6 +107,7 @@ class Settings(object): COMPONENTS: typing.List[str] PLUGINS: typing.List[str] XML_TRANSFORMS: typing.List[str] + AUTODISCOVER_PATHS = typing.List[str] def __init__(self, *args, **kwargs): self.get_settings_from_module(presalytics.lib.default_settings) diff --git a/presalytics/lib/default_settings.py b/presalytics/lib/default_settings.py index ef6f403..a2134da 100644 --- a/presalytics/lib/default_settings.py +++ b/presalytics/lib/default_settings.py @@ -4,7 +4,7 @@ This module contains a comprehesive set of values that can used to control the Preslaytics Python client's behavior. The `presalytics.lib.loader` module contains to load these settings into the `presalytics.settings` instance on initialization. User should not reference -the settings in this module directly, but rather use the `presaltyics.settings` instacne in their +the settings in this module directly, but rather use the `presaltyics.settings` instacne in their scripts and applications. Settings can be referring to as `presaltyics.settings.[SETTING_NAME]`. Users can override these default settings via two methods: @@ -13,8 +13,8 @@ the same key as the variable in this file will override this, provide the value can be parsed by the [environs](https://pypi.org/project/environs/) python package. - 2. `settings.py` file: A file named `settings.py` in the user's current working directory. The active working - direcotry can be determine by using the `os.getcwd()` command. This `settings.py` file takes the highest + 2. `settings.py` file: A file named `settings.py` in the user's current working directory. The active working + direcotry can be determine by using the `os.getcwd()` command. This `settings.py` file takes the highest priority. Settings defined in this file will override both the `default_settings.py` file and any enviroment variables. """ import logging @@ -28,7 +28,7 @@ USE_LOGGER: bool = False """ -Toggles whether the presalytics verbose file logger should be used. Helpful for +Toggles whether the presalytics verbose file logger should be used. Helpful for tracing exceptions while writing code. """ @@ -44,24 +44,24 @@ USERNAME: typing.Optional[str] = None """ -The user's Presalytics API email/username. This is the email address that the user uses when logging in at +The user's Presalytics API email/username. This is the email address that the user uses when logging in at https://login.presalytics.io. Will be passed to instances of the `presalytics.client.api.Client` object. """ PASSWORD: typing.Optional[str] = None """ -The user's Presalytics API username. Will be passed to instances of the -`presalytics.client.api.Client` object. If running in an insecure or +The user's Presalytics API username. Will be passed to instances of the +`presalytics.client.api.Client` object. If running in an insecure or multiuser environment, leave this blank and let the `presalytics.client.api.Client` object handle token acquisition via browser-based login. """ DELEGATE_LOGIN: bool = False """ -Defaults to False. Indicates whether the client would redirect to a browser to -acquire an API token. If `DELEGATE_LOGIN` is `True`, when the `presalytics.client.api.Client` does not have +Defaults to False. Indicates whether the client would redirect to a browser to +acquire an API token. If `DELEGATE_LOGIN` is `True`, when the `presalytics.client.api.Client` does not have access to a valid API token, the client will raise a `presalytics.lib.exceptions.InvalidTokenException`. -The default operation will automatically open a new browser tab to acquire a new token +The default operation will automatically open a new browser tab to acquire a new token via website client from the presalytics.io login page. Putting this setting to True is useful for server-side development. """ @@ -82,12 +82,12 @@ CLIENT_SECRET: typing.Optional[str] = None """ For developer use. Allows developers to implement a `client_credentials` OpenID -Connect login. Defaults to None. +Connect login. Defaults to None. """ VERIFY_HTTPS: bool = True """ -For developer use. Allows for unencrypted connections. Defaults to True. No +For developer use. Allows for unencrypted connections. Defaults to True. No reason to turn this to False unless you're in a complex development scenario and you know what you're doing. """ @@ -101,8 +101,8 @@ RESERVED_NAMES: typing.List[str] = [] """ -A list of filenames for *.py files in the current workspace that should be ignored by the -registries. +A list of filenames for *.py files in the current workspace that should be ignored by the +registries. """ USE_AUTODISCOVER: bool = False @@ -111,6 +111,13 @@ Good for development, but degrades performance. """ +AUTODISCOVER_PATHS: typing.List[str] = [] +""" +A list of extra paths to search when looking for classes to add to a registry. By default, registries +already search the current directory and virtual environment folders when `USE_AUTODISCOVER` is +set to true. +""" + IGNORE_PATHS: typing.List[str] = [] """ A list of paths to not to include in registry autosdiscover @@ -196,7 +203,7 @@ By default, registry settings are additive --> Registries import the default classes from this file and any `settings.py` files found in packages in the `INSTALLED_PACKAGES` setting. For a performance boost, a user can limit the imported list of classes in their registries to a defined -list in their `settings.py` file by setting `OVERRIDE_REGISTRY_DEFAULTS` to `True` +list in their `settings.py` file by setting `OVERRIDE_REGISTRY_DEFAULTS` to `True` """ COMPONENTS: typing.List[str] = [ @@ -218,7 +225,7 @@ 'presalytics.lib.templates.base.BootstrapCustomTemplate' ] """ -A list of string containing the dotted path names of Components that should be imported into the +A list of string containing the dotted path names of Components that should be imported into the Presalytics component registry at `presalytics.COMPONENTS`. The dotted path name is the same name path used for an import statement at the top of a python file """ @@ -235,7 +242,7 @@ 'presalytics.lib.plugins.scss.ScssPlugin' ] """ -A list of string containing the dotted path names of Plugins that should be imported into the +A list of string containing the dotted path names of Plugins that should be imported into the Presalytics plugins registry at `presalytics.PLUGINS`. The dotted path name is the same name path used for an import statement at the top of a python file """ @@ -246,7 +253,7 @@ 'presalytics.lib.widgets.ooxml_editors.MultiXmlTransform' ] """ -A list of string containing the dotted path names of Components that should be imported into the +A list of string containing the dotted path names of Components that should be imported into the Presalytics component registry. The dotted path name is the same name path used for an import statement at the top of a python file -""" \ No newline at end of file +""" diff --git a/presalytics/lib/exceptions.py b/presalytics/lib/exceptions.py index 3c1dabf..9a2a857 100644 --- a/presalytics/lib/exceptions.py +++ b/presalytics/lib/exceptions.py @@ -80,7 +80,7 @@ def __init__(self, message=None): class RegistryError(PresalyticsBaseException): - def __init__(self, registry, message=None): + def __init__(self, registry, message=None): if not message: message = "The was an unknown error in inside the registry" message = "{0} Error: ".format(registry.__class__.__name__) + message @@ -93,6 +93,7 @@ def __init__(self, message=None): message = "One of the arguments supplied to this method is invalid." super().__init__(message) + class ApiException(PresalyticsBaseException): def __init__(self, default_exception=None): if default_exception is not None: @@ -120,14 +121,14 @@ def __init__(self, exception: Exception, target_type="widget", traceback=None): self.exception_type = self.exception.__class__.__name__ self.line_no = None - try: + try: first_frame = self.get_source_frame(traceback) self.source_module = first_frame.tb_frame.f_globals['__name__'] self.line_no = first_frame.tb_lineno except Exception: self.source_module = "unidentified" self.line_no = "unknown" - + if isinstance(self.exception, PresalyticsBaseException): self.message = self.exception.message else: @@ -142,7 +143,6 @@ def get_source_frame(self, tb): return self.get_source_frame(next) else: return tb - def render_exception(self): container = lxml.html.Element("div", { @@ -163,10 +163,9 @@ def render_exception(self): note = lxml.html.Element("p") note.text = "If you have trouble understainding this error message, try building your story using " \ "with the presalytics.Revealer's `present()` method. If should give you more thorough error logging." - + container.extend([header, message, _type, exception_message, source, note]) return lxml.html.tostring(container).decode('utf-8') - + def to_html(self): return self.render_exception() - \ No newline at end of file diff --git a/presalytics/lib/logger.py b/presalytics/lib/logger.py index 2a46986..e87293a 100644 --- a/presalytics/lib/logger.py +++ b/presalytics/lib/logger.py @@ -8,6 +8,7 @@ USE_LOGGER = False + def configure_logger(log_path=default_log_path, log_level='DEBUG', file_logger=True): logging.config.dictConfig({ 'version': 1, @@ -39,9 +40,10 @@ def configure_logger(log_path=default_log_path, log_level='DEBUG', file_logger=T log_file = os.path.join(log_dir, 'presalytics.log') file_handler = logging.FileHandler(log_file) logger.addHandler(file_handler) - + USE_LOGGER = file_logger + def handle_exception(exc_type, exc_value, exc_traceback): """ Catches unhandled exceptions for logger """ try: diff --git a/presalytics/lib/plugins/base.py b/presalytics/lib/plugins/base.py index 244dee6..ee1ef50 100644 --- a/presalytics/lib/plugins/base.py +++ b/presalytics/lib/plugins/base.py @@ -25,7 +25,7 @@ class PluginBase(abc.ABC): ---------- __plugin_kind__ : str - The __plugin_kind__ is a static string that instructs classes + The __plugin_kind__ is a static string that instructs classes the render story outlines (e.g., presalytics.story.revealer.Revealer) where to where render the plugin (i.e., at the bottom of the html body for scripts). @@ -36,7 +36,7 @@ class PluginBase(abc.ABC): __dependencies__ : list of dict A list of plugs that should be rendered above this plugin in an html document. This ensures the needed javascript or css is loaded prior user's plugin runs. - + For example, if a user creates plugin requires d3.js to function, dependencies should include the following configuration: @@ -98,7 +98,7 @@ def get_tag(self, config: typing.Dict[str, typing.Any], **kwargs) -> str: class ScriptPlugin(PluginBase): """ - A script plugin incorporates whitelisted or local ` """ """ - The ` """) data = json.dumps(self.d3_data) # dont use hyphens in data keys - script = base64.b64decode(self.script64).decode('utf-8') #type: ignore #Required - extra_css = base64.b64decode(self.css64).decode('utf-8') if self.css64 else D3Widget.DEFAULT_CSS #type: ignore - html_fragment = base64.b64decode(self.html64).decode('utf-8') if self.html64 else None #type: ignore # disable nested iframes + script = base64.b64decode(self.script64).decode('utf-8') # type: ignore #Required + extra_css = base64.b64decode(self.css64).decode('utf-8') if self.css64 else D3Widget.DEFAULT_CSS # type: ignore + html_fragment = base64.b64decode(self.html64).decode('utf-8') if self.html64 else None # type: ignore # disable nested iframes context = { "id": self.id, "d3_url": presalytics.lib.plugins.external.ApprovedExternalScripts().attr_dict.flatten().get('d3'), @@ -279,4 +277,4 @@ def standalone_html(self) -> str: "html_fragment": html_fragment, "events_url": presalytics.lib.plugins.external.ApprovedExternalScripts().attr_dict.flatten().get('events'), } - return SIMPLE_HTML.render(**context) \ No newline at end of file + return SIMPLE_HTML.render(**context) diff --git a/presalytics/lib/widgets/data_table.py b/presalytics/lib/widgets/data_table.py index 137636a..c635d92 100644 --- a/presalytics/lib/widgets/data_table.py +++ b/presalytics/lib/widgets/data_table.py @@ -36,13 +36,13 @@ class DataTableWidget(presalytics.story.components.WidgetBase): data: dict Data that will be loaded into the `c3.generate()` method. Please go - to [c3js.org](https://c3js.org/gettingstarted.html) for more information + to [c3js.org](https://c3js.org/gettingstarted.html) for more information on how to configure this object. css64 : str. optional A base64-encoded string of the css styles to apply to the d3 document. Used for server-to-server transport over https. - + css_filename: str, optional A css file containing styles that will be applied to d3 @@ -51,13 +51,12 @@ class DataTableWidget(presalytics.story.components.WidgetBase): """ __component_kind__ = 'data-table' - - def __init__(self, + def __init__(self, name: str, table_data: typing.Dict, css64: str = None, - css_filename: str = None, + css_filename: str = None, *args, **kwargs): self.table_data = table_data @@ -80,13 +79,12 @@ def read_file(self, filename) -> typing.Optional[str]: if os.path.exists(fpath): with open(fpath, 'rb') as f: data = f.read() - data64 = base64.b64encode(data).decode('utf-8') #type: ignore + data64 = base64.b64encode(data).decode('utf-8') # type: ignore break if not data64: logger.debug("File {0} could not be found".format(filename)) return data64 - def to_html(self, data=None, **kwargs) -> str: """ Renders the sandboxed iframe with will contain the d3 script widget @@ -121,7 +119,7 @@ def deserialize(cls, outline, **kwargs): table_data = outline.data.get("table_data") return cls(outline.name, table_data=table_data, - **kwargs) + **kwargs) def serialize(self, **kwargs): data = { @@ -187,9 +185,9 @@ def standalone_html(self) -> str:
- - - + + + """) @@ -241,4 +238,4 @@ def standalone_html(self) -> str: "figid": self.figure_id, "figure_json": figure_json } - return SIMPLE_HTML.render(**context) \ No newline at end of file + return SIMPLE_HTML.render(**context) diff --git a/presalytics/lib/widgets/ooxml.py b/presalytics/lib/widgets/ooxml.py index ff88f6d..b44bfbe 100644 --- a/presalytics/lib/widgets/ooxml.py +++ b/presalytics/lib/widgets/ooxml.py @@ -31,21 +31,21 @@ class OoxmlEndpointMap(object): """ Mapping class that bridges Presalytics API Ooxml Automation service endpoints - and component class that consume those endpoints (typically subclasses of + and component class that consume those endpoints (typically subclasses of `presalytics.lib.widgets.ooxml.OoxmlWidgetBase`) - The classmethods on this class are conveninece methods to help users + The classmethods on this class are conveninece methods to help users quickly inform their widget which endpoint their of a Ooxml Document their targets. - Instance methods on this class are used by widget to generate urls and + Instance methods on this class are used by widget to generate urls and lookup against object tree for target objects. Parameters ---------- endpoint_id : str A unique string for identifying the object_type related to the enpoint - + baseurl : str For developer use. Allows this to generate urls for non-standard instances of the Ooxml Automation service. Defaults to https://api.presalytics.io/ooxml-automation/ @@ -54,7 +54,7 @@ class OoxmlEndpointMap(object): ---------- root_url : str the home url for the class instance. Typically `http://api.presalytics.io/ooxml-automation/{object_type}` - + OBJECT_TYPE_MAP : str A mapping table for object_types and object tree lookup keys """ @@ -79,13 +79,11 @@ def __init__(self, endpoint_id, baseurl: str = None): ooxml_host = presalytics.settings.HOST_OOXML_AUTOMATION # type: ignore[attr-defined] if ooxml_host: self.baseurl = ooxml_host - + else: self.baseurl = baseurl self.root_url = posixpath.join(self.baseurl, self.endpoint_id) self.OBJECT_TYPE_MAP = self._build_object_type_map() - - def _build_object_type_map(self): return { @@ -95,7 +93,7 @@ def _build_object_type_map(self): "Slide": [ OoxmlEndpointMap._GROUP, OoxmlEndpointMap._SHAPE, - OoxmlEndpointMap._SHAPETREE, + OoxmlEndpointMap._SHAPETREE, OoxmlEndpointMap._CONNECTION_SHAPE, OoxmlEndpointMap._SLIDE ], @@ -112,7 +110,7 @@ def _build_object_type_map(self): OoxmlEndpointMap._DOCUMENT ] } - + def get_object_type(self): """ Returns the Ooxml Automation service object type for this endpoint @@ -168,7 +166,7 @@ def document(cls, baseurl=None): targeting Document objects """ return cls(OoxmlEndpointMap._DOCUMENT, baseurl) - + @classmethod def group(cls, baseurl=None): """ @@ -228,7 +226,7 @@ def theme(cls, baseurl=None): class OoxmlWidgetBase(presalytics.story.components.WidgetBase): """ - Base class for creating widgets from objects at endpoints in the + Base class for creating widgets from objects at endpoints in the Presalytics API Ooxml Automation service. Parameters @@ -237,11 +235,11 @@ class OoxmlWidgetBase(presalytics.story.components.WidgetBase): The widget name. If not provided, will be the `object_name` or `filename` story_id : str, optional - The the id of the story in the Presalytics API Story service. If not provided, - a new story will be created. Do not supply if this object has not yet been created. - + The the id of the story in the Presalytics API Story service. If not provided, + a new story will be created. Do not supply if this object has not yet been created. + object_ooxml_id : str, optional - The identifier of the Ooxml Automation service object bound the Story. Do not supply if this + The identifier of the Ooxml Automation service object bound the Story. Do not supply if this object has not yet been created. endpoint_map : presalytics.lib.widgets.ooxml.OoxmlEndpointMap, optional @@ -275,7 +273,7 @@ class OoxmlWidgetBase(presalytics.story.components.WidgetBase): } ] - def __init__(self, + def __init__(self, name, story_id=None, object_ooxml_id=None, @@ -300,8 +298,8 @@ def create_container(self, **kwargs): 'data-object-type': self.endpoint_map.endpoint_id, 'data-object-id': self.object_ooxml_id }) - preloader_container_div = lxml.html.Element( "div", {"class":"preloader-container"}) - preloader_row_div = lxml.etree.SubElement(preloader_container_div, "div", attrib={"class":"preloader-row"}) + preloader_container_div = lxml.html.Element("div", {"class": "preloader-container"}) + preloader_row_div = lxml.etree.SubElement(preloader_container_div, "div", attrib={"class": "preloader-row"}) preloader_file = os.path.join(os.path.dirname(__file__), "img", "preloader.svg") svg = lxml.html.parse(preloader_file) preloader_row_div.append(svg.getroot()) @@ -311,7 +309,6 @@ def create_container(self, **kwargs): empty_parent_div.extend([svg_container_div, preloader_container_div]) return lxml.html.tostring(empty_parent_div) - def to_html(self, **kwargs): """ Returns an html string that will render the object at the endpoint @@ -370,8 +367,8 @@ def serialize(self): @classmethod def deserialize(cls, component, **kwargs): return cls( - component.name, - component.data["story_id"], + component.name, + component.data["story_id"], component.data["object_id"], OoxmlEndpointMap(component.data["endpoint_id"]), **kwargs @@ -383,16 +380,16 @@ class OoxmlFileWidget(OoxmlWidgetBase): Builds a `widget` from a Presentation or Spreadsheet document This class interacts with the Presalytics API to extract SVG objects from - Presentation and spreadsheet documents, identify them, and render them + Presentation and spreadsheet documents, identify them, and render them into a story. The file is uploaded to Presalytics API Ooxml Automation service, - which then processes the file and scans for objects in the file's object tree + which then processes the file and scans for objects in the file's object tree (As seen in the 'Selection Pane' in PowerPoint) for objects matching the 'object_name'. - When rendered, this widget retrieves an SVG of the identified object for rendering within - the story. + When rendered, this widget retrieves an SVG of the identified object for rendering within + the story. - Please note that the Presalytics API Ooxml Automation object will be created overwritten - each time this widget is initialized, and replaced within the corresponding - `presalytics.story.outline.StoryOutline`. For in-place editing of widgets Ooxml Automation objects + Please note that the Presalytics API Ooxml Automation object will be created overwritten + each time this widget is initialized, and replaced within the corresponding + `presalytics.story.outline.StoryOutline`. For in-place editing of widgets Ooxml Automation objects that are already bound to the `Story`, please see `presalytics.lib.widgets.ooxml_editors.OoxmlEditorWidget` Parameters @@ -402,15 +399,15 @@ class OoxmlFileWidget(OoxmlWidgetBase): the object to be rendered name : str, optional - The widget name. If not provided, attribute will be set as the `object_name` + The widget name. If not provided, attribute will be set as the `object_name` or `filename` story_id : str, optional - The the id of the story in the Presalytics API Story service. If not provided, - a new story will be created. Do not supply if this object has not yet been created. - + The the id of the story in the Presalytics API Story service. If not provided, + a new story will be created. Do not supply if this object has not yet been created. + object_ooxml_id : str, optional - The identifier of the Ooxml Automation service object bound the Story. Do not supply if this + The identifier of the Ooxml Automation service object bound the Story. Do not supply if this object has not yet been created. endpoint_map : presalytics.lib.widgets.ooxml.OoxmlEndpointMap, optional @@ -421,7 +418,7 @@ class OoxmlFileWidget(OoxmlWidgetBase): The name of the object in the file's object tree the will be rendered previous_ooxml_version : str, optional - The id Ooxml Automation service document object that was previously used to + The id Ooxml Automation service document object that was previously used to occupy this widget in the `presalytics.story.outline.StoryOutline` file_last_modified : str, optional @@ -432,7 +429,7 @@ class OoxmlFileWidget(OoxmlWidgetBase): document_ooxml_id : str, optional The identifier for the parent "Document" object in the Ooxml Automation service for the object idenitifiable by a combinatation of `object_ooxml_id` and `endpoint_map`. - + """ object_name: typing.Optional[str] ooxml_id: str @@ -534,7 +531,6 @@ def update(self): self.object_ooxml_id = target_dto.entity_id self.file_last_modified = presalytics.lib.util.roundup_date_modified(this_file_last_modified) - @classmethod def deserialize(cls, component, **kwargs): init_args = { @@ -610,11 +606,11 @@ def serialize(self): class UpdaterWidgetBase(OoxmlWidgetBase): """ - Abstract class for create simple interfaces to update widgets from a simple data table. + Abstract class for create simple interfaces to update widgets from a simple data table. - This class simplifies updates to Ooxml Automation service endpoints, allowing updates to - ooxml object data and its underlying xml via simple data transfer objects definted in the - Presalytics Ooxml Automation server. + This class simplifies updates to Ooxml Automation service endpoints, allowing updates to + ooxml object data and its underlying xml via simple data transfer objects definted in the + Presalytics Ooxml Automation server. Inheriting classes must override the `_get_dto_class`, `_get_endpoint_path`, and `_get_dto_table_name` methods @@ -625,11 +621,11 @@ class UpdaterWidgetBase(OoxmlWidgetBase): The widget name. If not provided, will be the `object_name` or `filename` story_id : str - The the id of the story in the Presalytics API Story service. If not provided, - a new story will be created. Do not supply if this object has not yet been created. - + The the id of the story in the Presalytics API Story service. If not provided, + a new story will be created. Do not supply if this object has not yet been created. + object_id : str - The identifier of the Ooxml Automation service object bound the Story. Do not supply if this + The identifier of the Ooxml Automation service object bound the Story. Do not supply if this object has not yet been created. endpoint_map : presalytics.lib.widgets.ooxml.OoxmlEndpointMap @@ -645,20 +641,20 @@ class UpdaterWidgetBase(OoxmlWidgetBase): the subclass' `_get_dto_table_name` method. """ - def __init__(self, - name, - story_id: str, - object_id: str, - endpoint_map: OoxmlEndpointMap, - dto=None, - data_table=None, - **kwargs): + + def __init__(self, + name, + story_id: str, + object_id: str, + endpoint_map: OoxmlEndpointMap, + dto=None, + data_table=None, + **kwargs): super(UpdaterWidgetBase, self).__init__(name, story_id, object_id, endpoint_map, **kwargs) self.dto = dto self.data_table = data_table self.object_id = object_id - - + @abc.abstractmethod def _get_dto_class(self) -> typing.Type: """ @@ -669,8 +665,8 @@ def _get_dto_class(self) -> typing.Type: @abc.abstractmethod def _get_endpoint_path(self) -> str: """ - Returns the relative path to the the endpoint used , starting from the `root_url` of the - `presalytics.lib.widgets.ooxml.OoxmlEndpointMap` object. + Returns the relative path to the the endpoint used , starting from the `root_url` of the + `presalytics.lib.widgets.ooxml.OoxmlEndpointMap` object. """ return NotImplemented @@ -687,7 +683,7 @@ def build_endpoint(self) -> str: Returns the endpoints used for Api calls """ return posixpath.join(self.endpoint_map.root_url, self._get_endpoint_path(), self.object_ooxml_id) - + def get_dto(self): """ Returns an instance of the dto object from the OoxmlAutomation Service API @@ -751,23 +747,23 @@ def serialize(self): @classmethod def deserialize(cls, component, **kwargs): return cls( - component.name, - component.data["story_id"], + component.name, + component.data["story_id"], component.data["object_id"], component.data.get("dto", None), component.data.get("data_table", None), **kwargs ) - + class ChartUpdaterWidget(UpdaterWidgetBase): """ Updates a Chart in the Ooxml Automation service API at the the endpoint '/Chart/ChartUpdate/' - This class simplifies chart updates, for charts residing in the Ooxml Automation Service, + This class simplifies chart updates, for charts residing in the Ooxml Automation Service, allowing updates to ooxml object data and its underlying xml either via a list of lists or the `presalytics.client.presalytics_ooxml_automation.models.chart_chart_data_dto.ChartChartDataDTO` - object. + object. Parameters ---------- @@ -775,10 +771,10 @@ class ChartUpdaterWidget(UpdaterWidgetBase): A name for the widget. story_id : str - The the id of the story in the Presalytics API Story service. - + The the id of the story in the Presalytics API Story service. + chart_id : str - The identifier of the Ooxml Automation Chart service object. + The identifier of the Ooxml Automation Chart service object. dto: presalytics.client.presalytics_ooxml_automation.models.chart_chart_data_dto.ChartChartDataDTO, optional A an instance of the data transfer object model. The class of this object is defined by the @@ -790,18 +786,17 @@ class ChartUpdaterWidget(UpdaterWidgetBase): """ __component_kind__ = "chart-updater" - - def __init__(self, - name, - story_id: str, - chart_id: str, - dto: 'ChartChartDataDTO' = None, - data_table: typing.Sequence[typing.Sequence] = None, - **kwargs): + + def __init__(self, + name, + story_id: str, + chart_id: str, + dto: 'ChartChartDataDTO' = None, + data_table: typing.Sequence[typing.Sequence] = None, + **kwargs): super().__init__(name, story_id, chart_id, OoxmlEndpointMap.chart(), dto=dto, data_table=data_table, **kwargs) self.chart_id = chart_id - def _get_dto_class(self): return presalytics.client.presalytics_ooxml_automation.models.chart_chart_data_dto.ChartChartDataDTO @@ -813,10 +808,10 @@ def _get_dto_table_name(self): def get_dataframe(self) -> pandas.DataFrame: """ - Returns a panda datagrame of the + Returns a panda datagrame of the """ data: collections.OrderedDict - + if not self.dto: self.dto = self.get_dto() data = collections.OrderedDict() @@ -825,26 +820,25 @@ def get_dataframe(self) -> pandas.DataFrame: self.dto.series_names[i]: pandas.Series(self.dto.data_points[i], self.dto.category_names) }) return pandas.DataFrame(data) - + def put_dataframe(self, df: pandas.DataFrame): data_dict = df.to_dict('split') data_points = list(map(list, zip(*data_dict['data']))) - dto = self._get_dto_class()(chart_id=self.chart_id, - series_names=data_dict["columns"], - category_names=data_dict["index"], - data_points=data_points) + dto = self._get_dto_class()(chart_id=self.chart_id, + series_names=data_dict["columns"], + category_names=data_dict["index"], + data_points=data_points) self._put_dto(dto) - class TableUpdaterWidget(UpdaterWidgetBase): """ Updates a Table in the Ooxml Automation service API at the the endpoint '/Table/TableUpdate/' - This class simplifies table updates, for tables residing in the Ooxml Automation Service, + This class simplifies table updates, for tables residing in the Ooxml Automation Service, allowing updates to ooxml object data and its underlying xml either via a list of lists or the `presalytics.client.presalytics_ooxml_automation.models.table_table_data_dto.TableTableDataDTO` - object. + object. Parameters ---------- @@ -852,10 +846,10 @@ class TableUpdaterWidget(UpdaterWidgetBase): A name for the widget. story_id : str - The the id of the story in the Presalytics API Story service. - + The the id of the story in the Presalytics API Story service. + table_id : str - The identifier of the Ooxml Automation Table service object. + The identifier of the Ooxml Automation Table service object. dto: presalytics.client.presalytics_ooxml_automation.models.table_table_data_dto.TableTableDataDTO, optional A an instance of the data transfer object model. The class of this object is defined by the @@ -867,18 +861,17 @@ class TableUpdaterWidget(UpdaterWidgetBase): """ __component_kind__ = "table-updater" - - def __init__(self, - name, - story_id: str, - table_id: str, - dto: 'TableTableDataDTO' = None, - data_table: typing.Sequence[typing.Sequence] = None, - **kwargs): + + def __init__(self, + name, + story_id: str, + table_id: str, + dto: 'TableTableDataDTO' = None, + data_table: typing.Sequence[typing.Sequence] = None, + **kwargs): super().__init__(name, story_id, table_id, OoxmlEndpointMap.table(), dto=dto, data_table=data_table, **kwargs) self.table_id = table_id - def _get_dto_class(self): return presalytics.client.presalytics_ooxml_automation.models.table_table_data_dto.TableTableDataDTO @@ -887,4 +880,3 @@ def _get_endpoint_path(self): def _get_dto_table_name(self): return "table_data" - \ No newline at end of file diff --git a/presalytics/lib/widgets/ooxml_editors.py b/presalytics/lib/widgets/ooxml_editors.py index abbf4e5..23a1bd9 100644 --- a/presalytics/lib/widgets/ooxml_editors.py +++ b/presalytics/lib/widgets/ooxml_editors.py @@ -20,8 +20,8 @@ class XmlTransformBase(abc.ABC): """ Base class for writing Open Office Xml tranform functions to be implemented by `presalytics.lib.widgets.ooxml_editors.OoxmlEditorWidget` - - *For more information about Open Office Xml Schema that underlies + + *For more information about Open Office Xml Schema that underlies .pptx and .xlsx files, see http://officeopenxml.com/ *For more information on how to lxml to write transforms, consult https://lxml.de/ @@ -44,11 +44,10 @@ def transform_function(self, lxml_element: lxml.etree.Element, params: typing.Di variables. Must be overridden in subclasses. """ pass - def execute(self, lxml_element: lxml.etree.Element): """ - Called by widget classes (e.g., `presalytics.lib.widgets.ooxml_editors.OoxmlEditorWidget`) to + Called by widget classes (e.g., `presalytics.lib.widgets.ooxml_editors.OoxmlEditorWidget`) to perform the updates prescribed in the `transform_function` """ return self.transform_function(lxml_element, self.function_params) @@ -57,7 +56,7 @@ def execute(self, lxml_element: lxml.etree.Element): class ChangeShapeColor(XmlTransformBase): """ Changes the color of a set of [Open Office Xml Shapes](http://officeopenxml.com/drwShape.php) - + Function Parameters Dictionary ---------- hex_color : str @@ -66,7 +65,7 @@ class ChangeShapeColor(XmlTransformBase): object_name : str, optional The object tree name of the target shape. If not supplied, all descendent shapes will - have their color changed. + have their color changed. """ __xml_transform_name__ = "ChangeShapeColor" @@ -109,7 +108,7 @@ def transform_function(self, lxml_element, params): params : dict See the `Function Parameters Dictionary` for required entries """ - + if re.match('{.*}sp', lxml_element.tag): shapes = [lxml_element] else: @@ -131,9 +130,10 @@ def transform_function(self, lxml_element, params): shape = ChangeShapeColor.replace_color_on_target_shape(shape, color) return lxml_element + class TextReplace(XmlTransformBase): """ - Replaces text in a template built into an Office Office Xml object that has + Replaces text in a template built into an Office Office Xml object that has been uploaded to the Presalytics Ooxml Automation service. Text to be should be in the format of a "template tag", which is a string enclosed in handlebars as such: '{{template_tag}}' @@ -144,19 +144,20 @@ class TextReplace(XmlTransformBase): replace_map: dict A dictionary that maps template tags to the new strings that will replace the tags in the rendered widget. The the dictionary keys should not be enclosed in handlebars. - + object_name: str, optional The name of a sub object will be the target of this function. If not includes, this will default to the parent object. Allows different replace map to be applied to multiple elements in - a widget + a widget """ __xml_transform_name__ = "TextReplace" class TextElementInfo(object): """ - Holds metadata for a list of `` tags from an Office Open Xml document. + Holds metadata for a list of `` tags from an Office Open Xml document. """ + def __init__(self, element: lxml.etree.Element, start_position: int): self.element = element self.text = "" if not element.text else element.text @@ -178,7 +179,7 @@ def append(self, text_element_info): self._list.append(text_element_info) else: raise presalytics.lib.exceptions.InvalidArgumentException(message="Argument must be an instance of class 'TextElementInfo'") - + def set_text(self, index, new_string): self._list[index].text = new_string self._list[index].element.text = new_string @@ -194,7 +195,7 @@ def get_plaintext_string(self): def get_position(self, match_strings): for potential_match in match_strings: - i = self.get_plaintext_string().find(potential_match) + i = self.get_plaintext_string().find(potential_match) if i >= 0: break return i, potential_match @@ -204,13 +205,13 @@ def get_list_index_of_position(self, position: int): if item.start_position <= position and item.end_position >= position: return self.get_index(item) raise presalytics.lib.exceptions.InvalidArgumentException(message="Position {} out of range".format(position)) - + def get_index(self, text_element_info: 'TextReplace.TextElementInfo'): for i in range(len(self._list)): if self._list[i] == text_element_info: return i raise presalytics.lib.exceptions.InvalidArgumentException(message="Supplied argument on in self._list") - + def set_text_to_empty_string(self, index): self._list[index].text = "" self._list[index].element.text = "" @@ -234,20 +235,18 @@ def plaintext_string_list(self): def reset(self): for i in range(0, len(self._list)): - start_position = 0 if i == 0 else self._list[i-1].end_position + 1 + start_position = 0 if i == 0 else self._list[i - 1].end_position + 1 self._list[i] = TextReplace.TextElementInfo(self._list[i].element, start_position) - - def replace_handlebars(self, info_list, params): """ - Method that finds template tags and replaces them + Method that finds template tags and replaces them TODO: Upgrade to Liquid Syntax (or similar) """ for key, val in params.items(): match_keys = ["{{" + key + "}}", "{{ " + key + " }}"] - match_start_position, match_key = info_list.get_position(match_keys) + match_start_position, match_key = info_list.get_position(match_keys) if match_start_position > -1: match_end_position = match_start_position + len(match_key) - 1 match_start_index = info_list.get_list_index_of_position(match_start_position) @@ -260,14 +259,13 @@ def replace_handlebars(self, info_list, params): info_list.reset() self.replace_handlebars(info_list, params) - def transform_function(self, lxml_element, params): """ Replaces template tags located {{inside_handlebars}} that match keys in the - `params` dict with values from the `params` dict. + `params` dict with values from the `params` dict. This method searches for match for plain text strings, so that if template - tags are split across xml `` elements, they are still identified and replaced. + tags are split across xml `` elements, they are still identified and replaced. Parameters ----------- @@ -286,7 +284,7 @@ def transform_function(self, lxml_element, params): if ele.get("name", None) == target_object_name: target_object = ele.getparent().getparent() break - + text_list = target_object.findall('.//{*}t') info_list = TextReplace.TextList() position = 0 @@ -304,8 +302,9 @@ class XmlTransformRegistry(presalytics.lib.registry.RegistryBase): and `presalytics.lib.widgets.ooxml_editors.OoxmlEditorWidget` so XmlTransformBase subclasses can be deserialized at run-time without a loaded instance in `locals()`. """ + def __init__(self): - include_paths = presalytics.COMPONENTS.autodiscover_paths # todo: Change this line -does not exist at timport time. + include_paths = presalytics.COMPONENTS.autodiscover_paths # todo: Change this line -does not exist at timport time. reserved_names = presalytics.COMPONENTS.reserved_names super(XmlTransformRegistry, self).__init__(autodiscover_paths=include_paths, reserved_names=reserved_names) @@ -314,21 +313,22 @@ def get_name(self, klass): def get_type(self, klass): return getattr(klass, "__xml_transform_kind__", None) - + def get_settings_object(self): return presalytics.settings.XML_TRANSFORMS XML_TRANSFORM_REGISTRY = None """ -Static instance of `presalytics.lib.widgets.ooxml_editors.XmlTransformRegistry`. +Static instance of `presalytics.lib.widgets.ooxml_editors.XmlTransformRegistry`. -Should not be used directly by consuming classes. Only initialized if a consuming +Should not be used directly by consuming classes. Only initialized if a consuming class or method calls the `presalytics.lib.widgets.ooxml_editors.get_transform_registry`. This is done -for performance reasons since there's no need to build this registry at import-time if its never +for performance reasons since there's no need to build this registry at import-time if its never used in a workspace. """ + def get_transform_registry(): """ Initalizes and retreives `presalytics.lib.widgets.ooxml_editors.XML_TRANSFORM_REGISTRY` @@ -339,37 +339,36 @@ def get_transform_registry(): return XML_TRANSFORM_REGISTRY - class MultiXmlTransform(XmlTransformBase): """ This class allow users to run mutiple transforms on multiple targets in a single widget. `MultiXmlTransform` wraps multiple subclasses of `presalytics.lib.widgets.ooxml_editors.XmlTransformBase`, creates instances of them, and feeds them into an `presalytics.lib.widgets.ooxml_editors.OoxmlEditorWidget` instance. To do this, - the `presalytics.lib.widgets.ooxml_editors.XmlTransformBase` subclasses must be loaded into the + the `presalytics.lib.widgets.ooxml_editors.XmlTransformBase` subclasses must be loaded into the `presalytics.lib.widgets.ooxml_editors.XML_TRANSFORM_REGISTRY` when called. - The function parameters for the + The function parameters for the Parameters ---------- fail_quietly: bool, optional Defaults to false. Indicates whether an exception should be raised when a subclass specified in the function parameters cannot not be found in the `presalytics.lib.widgets.ooxml_editors.XML_TRANSFORM_REGISTRY` - instance. - + instance. + Function Parameters Dictionary ---------- transforms_list: list of dict - A list of dictionaries, with each item in the list consiting of a dictionary of two entries. + A list of dictionaries, with each item in the list consiting of a dictionary of two entries. The entries are as follows: - + * name: [str] The name of the the subclass of `presalytics.lib.widgets.ooxml_editors.XmlTransformBase` that will be that will be initialized - + * function_params: [dict] - The `params` that will be the loaded into the instance's `transform_function` + The `params` that will be the loaded into the instance's `transform_function` """ transform_instances: typing.List[XmlTransformBase] @@ -381,7 +380,7 @@ def __init__(self, transforms: typing.Dict[str, typing.List[typing.Dict[str, typ self.fail_quietly = fail_quietly self.transform_instances = [] self.transform_registry = get_transform_registry() - for _transform in transforms_list: #type: ignore + for _transform in transforms_list: # type: ignore key = "XmlTransform." + _transform["name"] transform_class = self.transform_registry.get(key) if not transform_class: @@ -389,7 +388,7 @@ def __init__(self, transforms: typing.Dict[str, typing.List[typing.Dict[str, typ if self.fail_quietly: logging.info(message) else: - raise self.transform_registry.raise_error(message) #noqa + raise self.transform_registry.raise_error(message) # noqa else: inst = transform_class(_transform["function_params"]) self.transform_instances.append(inst) @@ -397,19 +396,19 @@ def __init__(self, transforms: typing.Dict[str, typing.List[typing.Dict[str, typ def transform_function(self, lxml_element, params): for inst in self.transform_instances: lxml_element = inst.execute(lxml_element) - return lxml_element + return lxml_element class OoxmlEditorWidget(presalytics.lib.widgets.ooxml.OoxmlWidgetBase): """ - Edits a `widget` from a Presentation or Spreadsheet document and renders + Edits a `widget` from a Presentation or Spreadsheet document and renders the edited widget. This class interacts with the Presalytics API to extract SVG objects from Presentation and spreadsheet documents, from Presaltytics Ooxml Automation service objects that have already been loaded into the API. This class requires - that users supply `transform_function` by subclassing - `presalytics.lib.widgets.ooxml_editors.XmlTransformBase`, and an optional set of + that users supply `transform_function` by subclassing + `presalytics.lib.widgets.ooxml_editors.XmlTransformBase`, and an optional set of parameters to act as variables in the transform function. Parameters @@ -419,15 +418,15 @@ class OoxmlEditorWidget(presalytics.lib.widgets.ooxml.OoxmlWidgetBase): the object to be rendered name : str - The widget name. If not provided, attribute will be set as the `object_name` + The widget name. If not provided, attribute will be set as the `object_name` or `filename` story_id : str - The the id of the story in the Presalytics API Story service. If not provided, - a new story will be created. Do not supply if this object has not yet been created. - + The the id of the story in the Presalytics API Story service. If not provided, + a new story will be created. Do not supply if this object has not yet been created. + object_ooxml_id : str - The identifier of the Ooxml Automation service object bound the Story. Do not supply if this + The identifier of the Ooxml Automation service object bound the Story. Do not supply if this object has not yet been created. endpoint_map : presalytics.lib.widgets.ooxml.OoxmlEndpointMap @@ -440,8 +439,8 @@ class OoxmlEditorWidget(presalytics.lib.widgets.ooxml.OoxmlWidgetBase): transform_params : dict, optional A dictionary of parameters that will be passed to the `transform_class`'s `transform_function` - as variables to modify the underlying OpenOfficeXml - + as variables to modify the underlying OpenOfficeXml + """ transform: XmlTransformBase diff --git a/presalytics/lib/widgets/url.py b/presalytics/lib/widgets/url.py index d8ae4b0..307a6f7 100644 --- a/presalytics/lib/widgets/url.py +++ b/presalytics/lib/widgets/url.py @@ -36,7 +36,7 @@ class UrlWidget(presalytics.story.components.WidgetBase): """ __component_kind__ = 'url' - def __init__(self, + def __init__(self, name: str, url: str, *args, @@ -44,10 +44,9 @@ def __init__(self, self.url = url super(UrlWidget, self).__init__(name, *args, **kwargs) - def to_html(self, data=None, **kwargs) -> str: """ - Renders url to a sandboxed iframe + Renders url to a sandboxed iframe """ return self.create_container() @@ -76,7 +75,7 @@ def create_container(self, **kwargs): def deserialize(cls, outline, **kwargs): return cls(outline.name, outline.data.get('url'), - **kwargs) + **kwargs) def serialize(self, **kwargs): data = { diff --git a/presalytics/story/__init__.py b/presalytics/story/__init__.py index f1732b8..8d53f17 100644 --- a/presalytics/story/__init__.py +++ b/presalytics/story/__init__.py @@ -1,3 +1,3 @@ """ Conains base objects for rendering, building, serializing, and view Story objects -""" \ No newline at end of file +""" diff --git a/presalytics/story/components.py b/presalytics/story/components.py index 6eed24e..2fb664d 100644 --- a/presalytics/story/components.py +++ b/presalytics/story/components.py @@ -64,7 +64,7 @@ class ComponentBase(abc.ABC): An identifier for this component class. Used for component registration. __plugins__ : list of dict - A list of dictionaries that reference `presalytics.story.outline.Plugin` configurations. When a + A list of dictionaries that reference `presalytics.story.outline.Plugin` configurations. When a `presaltytics.story.components.Renderer` is initialized, it will load these plugins into the rendered. This allows plugins to be statically configured on `presalytics.story.components` classes, in lieu dynamic configurations on `presalytics.story.outline.StoryOutline` instances. @@ -163,7 +163,6 @@ def render(self, **kwargs): logger.exception(ex) return self.to_html(**kwargs) - def cache_subdocument(self, subdocument: str) -> bool: subdocument_encoded = base64.b64encode(subdocument.encode('utf-8')).decode('utf-8') client = self.get_client() @@ -177,14 +176,14 @@ def cache_subdocument(self, subdocument: str) -> bool: } client.story.cache_post(payload) return True - return False - + return False + def create_subdocument(self, **kwargs) -> typing.Optional[str]: """ - Returns an html document that will be rendered by browser. If a `str` is returned, + Returns an html document that will be rendered by browser. If a `str` is returned, then the html subdocument is cached into the story API for retreival by the browser after - rendering. Widgets that render `