├── .github └── workflows │ └── linters.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── Makefile ├── make.bat └── source │ ├── conf.py │ └── index.rst ├── examples ├── buffer_events_for_bulk_sending.py ├── capture_exception.py ├── capture_message.py ├── capture_raw_event.py ├── change_api_provider_settings.py ├── do_soft_auth_check.py ├── integrations │ ├── flask_integration.py │ └── starlette_integration.py ├── manual_user_oauth.py ├── print_source_context.py └── print_transport.py ├── gatey_sdk ├── __init__.py ├── __version__.py ├── api.py ├── auth.py ├── buffer.py ├── client.py ├── consts.py ├── exceptions.py ├── integrations │ ├── __init__.py │ ├── django.py │ ├── flask.py │ └── starlette.py ├── internal │ ├── __init__.py │ ├── exc.py │ ├── source.py │ └── traceback.py ├── py.typed ├── response.py ├── transports │ ├── __init__.py │ ├── base.py │ ├── func.py │ ├── http.py │ ├── print.py │ └── void.py └── utils.py ├── pyproject.toml └── setup.py /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: Linters (Pylint, MyPy) 2 | 3 | on: 4 | pull_request: 5 | branches: ["main"] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | name: Linters 15 | strategy: 16 | matrix: 17 | python-version: ["3.9", "3.10"] 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install linters 24 | run: pip install --upgrade pip && pip install pylint==v3.0.0a3 mypy==v0.902 25 | working-directory: "gatey_sdk/" 26 | - name: PyLint lint. 27 | run: pylint --disable=import-error --disable=fixme --disable=too-few-public-methods --disable=duplicate-code --disable=line-too-long --disable=broad-except --disable=invalid-name --disable=too-many-arguments --disable=too-many-instance-attributes --disable=too-many-locals $(git ls-files '*.py') 28 | working-directory: "gatey_sdk/" 29 | - name: All PyLint warning. 30 | run: pylint $(git ls-files '*.py') || true 31 | working-directory: "gatey_sdk/" 32 | - name: MyPy type check. 33 | working-directory: "gatey_sdk/" 34 | run: mypy $(git ls-files '*.py') || true 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Florgon Solutions 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Florgon Gatey SDK for Python (Official). 2 | 3 | ## This is the official Python SDK for [Florgon Gatey](https://gatey.florgon.com) 4 | 5 | ## Getting Started 6 | 7 | ### Install 8 | 9 | ``` 10 | pip install --upgrade gatey-sdk 11 | ``` 12 | 13 | ### Configuration 14 | 15 | ```python 16 | import gatey_sdk 17 | client = gatey_sdk.Client( 18 | project_id=PROJECT_ID, 19 | server_secret=PROJECT_SERVER_SECRET, 20 | client_secret=PROJECT_CLIENT_SECRET, 21 | ) 22 | # Notice that you should only enter server or client secret, passing both have no effect as always server will be used. 23 | # (as client not preferred if server secret is passed). 24 | ``` 25 | 26 | ### Usage 27 | 28 | ```python 29 | import gatey_sdk 30 | client = gatey_sdk.Client( 31 | project_id=PROJECT_ID, 32 | server_secret=PROJECT_SERVER_SECRET, 33 | ) 34 | 35 | # Will send message (capture). 36 | client.capture_message("Hello Python Gatey SDK!", level="DEBUG") 37 | 38 | # Will capture exception. 39 | @client.catch() 40 | def f(): 41 | raise ValueError 42 | 43 | # Same as above. 44 | try: 45 | raise ValueError 46 | except Exception as e: 47 | client.capture_exception(e) 48 | 49 | # Will work by default also (see Client(handle_global_exceptions=True)) 50 | raise ValueError 51 | 52 | 53 | # (Notice that events by default being sent not immediatly!) 54 | ``` 55 | 56 | ## Examples 57 | 58 | [See examples directory...](/examples) 59 | 60 | ## Integrations 61 | 62 | [See integrations directory...](/gatey_sdk/integrations) \ 63 | [See examples directory...](/examples/integrations) 64 | 65 | - [Starlette (FastAPI) integration](/gatey_sdk/integrations/starlette.py) 66 | - [Flask integration](/gatey_sdk/integrations/flask.py) 67 | - [Django integration](/gatey_sdk/integrations/django.py) 68 | 69 | ## Documentation. 70 | 71 | [ReadTheDocs](https://gatey-sdk-py.readthedocs.io/) \ 72 | [Gatey Documentation](https://florgon.com/dev/gatey) 73 | 74 | ## License 75 | 76 | Licensed under the MIT license, see [`LICENSE`](LICENSE) 77 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | build -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = "Gatey Python SDK" 10 | copyright = "2022, Florgon Team and Contributors" 11 | author = "Florgon Team and Contributors" 12 | release = "0.0.8" 13 | 14 | # -- General configuration --------------------------------------------------- 15 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 16 | 17 | extensions = [] 18 | 19 | templates_path = ["_templates"] 20 | exclude_patterns = [] 21 | 22 | 23 | # -- Options for HTML output ------------------------------------------------- 24 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 25 | 26 | html_theme = "sphinx_rtd_theme" 27 | html_static_path = ["_static"] 28 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. gatey-sdk-py documentation master file, created by 2 | sphinx-quickstart on Wed Nov 23 22:00:43 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Gatey Python SDK documentation! 7 | ======================================== 8 | -------------------------------------------------------------------------------- /examples/buffer_events_for_bulk_sending.py: -------------------------------------------------------------------------------- 1 | import gatey_sdk 2 | 3 | 4 | client = gatey_sdk.Client( 5 | transport=gatey_sdk.PrintTransport(prepare_event=lambda e: e["message"]), 6 | handle_global_exceptions=False, 7 | buffer_events_for_bulk_sending=True, 8 | buffer_events_max_capacity=2, 9 | exceptions_capture_vars=False, 10 | ) 11 | 12 | # Will send requests on second call. 13 | client.capture_message("info", "hi!") 14 | client.capture_message("info", "hi!") 15 | 16 | # Will not capture any. 17 | client.events_buffer.max_capacity = 0 18 | client.capture_message("info", "hi!") 19 | client.capture_message("info", "hi!") 20 | client.events_buffer.clear_events() # Or `legacy` way as `client.force_drop_buffered_events()` 21 | client.events_buffer.send_all() # Or `legacy` way as `client.bulk_send_buffered_events()` 22 | client.events_buffer.max_capacity = 2 23 | 24 | # Will send requests with second (send) call. 25 | client.capture_message("info", "hi!") 26 | client.events_buffer.send_all() # Or `legacy` way as `client.bulk_send_buffered_events()` 27 | 28 | # Will send request after script end. 29 | client.capture_message("info", "hi!") 30 | -------------------------------------------------------------------------------- /examples/capture_exception.py: -------------------------------------------------------------------------------- 1 | import gatey_sdk 2 | 3 | client = gatey_sdk.Client(transport=gatey_sdk.PrintTransport) 4 | 5 | try: 6 | raise ValueError("Message text!") 7 | except Exception as e: 8 | client.capture_exception(e, level="error") 9 | 10 | try: 11 | raise ValueError("Message text for local getter!") 12 | except Exception: 13 | # Same as above but gets from current. 14 | client.capture_exception() 15 | 16 | 17 | @client.catch(reraise=False) 18 | def my_func(): 19 | raise ValueError("Message text from wrapped function") 20 | 21 | 22 | my_func() 23 | -------------------------------------------------------------------------------- /examples/capture_message.py: -------------------------------------------------------------------------------- 1 | import gatey_sdk 2 | 3 | client = gatey_sdk.Client(transport=gatey_sdk.PrintTransport) 4 | 5 | client.capture_message("My message", level="INFO") 6 | -------------------------------------------------------------------------------- /examples/capture_raw_event.py: -------------------------------------------------------------------------------- 1 | import gatey_sdk 2 | 3 | client = gatey_sdk.Client( 4 | transport=gatey_sdk.PrintTransport, 5 | buffer_events_for_bulk_sending=False, 6 | ) 7 | 8 | event_dict = {"message": "text", "some_injected_field": "injected"} 9 | 10 | client.capture_event(event_dict, level="required") 11 | 12 | print("---- ") 13 | 14 | event_dict.update({"level": "required"}) 15 | client.transport.send_event(event_dict) 16 | -------------------------------------------------------------------------------- /examples/change_api_provider_settings.py: -------------------------------------------------------------------------------- 1 | import gatey_sdk 2 | from gatey_sdk.consts import ( 3 | API_DEFAULT_SERVER_EXPECTED_VERSION, 4 | API_DEFAULT_SERVER_PROVIDER_URL, 5 | ) 6 | 7 | client = gatey_sdk.Client( 8 | project_id=-1, server_secret="secret", check_api_auth_on_init=False 9 | ) 10 | client.api.change_api_server_provider_url(API_DEFAULT_SERVER_PROVIDER_URL) 11 | client.api.change_api_server_expected_version(API_DEFAULT_SERVER_EXPECTED_VERSION) 12 | client.api.change_api_server_timeout(5) 13 | -------------------------------------------------------------------------------- /examples/do_soft_auth_check.py: -------------------------------------------------------------------------------- 1 | import gatey_sdk 2 | 3 | client = gatey_sdk.Client( 4 | project_id=-1, server_secret="secret", check_api_auth_on_init=False 5 | ) 6 | print( 7 | "Auth check:", client.api.do_auth_check() 8 | ) # Soft auth check (hard: do_hard_auth_check()) 9 | -------------------------------------------------------------------------------- /examples/integrations/flask_integration.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from gatey_sdk.integrations.flask import GateyFlaskMiddleware 3 | from gatey_sdk import Client, PrintTransport 4 | 5 | 6 | # Notice that hooks and transport print may differs because transport is not intended with middleware hooks. 7 | def pre_capture_hook(*_): 8 | print("[debug middleware] Pre capture hook...") 9 | 10 | 11 | def post_capture_hook(*_): 12 | print("[debug middleware] Post capture hook...") 13 | 14 | 15 | def on_request_hook(*_): 16 | print("[debug middleware] On request hook...") 17 | 18 | 19 | app = Flask(__name__) 20 | 21 | client = Client( 22 | transport=PrintTransport( 23 | prepare_event=lambda e: (e, e.get("exception", {}).pop("traceback", None))[0] 24 | ), 25 | include_platform_info=False, 26 | include_runtime_info=False, 27 | include_sdk_info=False, 28 | handle_global_exceptions=False, 29 | exceptions_capture_code_context=False, 30 | ) 31 | app.wsgi_app = GateyFlaskMiddleware( 32 | app.wsgi_app, 33 | client=client, 34 | capture_requests_info=True, 35 | client_getter=None, 36 | capture_exception_options=None, 37 | pre_capture_hook=pre_capture_hook, 38 | post_capture_hook=post_capture_hook, 39 | on_request_hook=on_request_hook, 40 | ) 41 | 42 | 43 | @app.get("/raise") 44 | def app_raise_route(): 45 | return 1 / 0 46 | 47 | 48 | if __name__ == "__main__": 49 | app.run("127.0.0.1", "8000", debug=True) 50 | -------------------------------------------------------------------------------- /examples/integrations/starlette_integration.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from gatey_sdk.integrations.starlette import GateyStarletteMiddleware 3 | from gatey_sdk import Client, PrintTransport 4 | 5 | 6 | # Notice that hooks and transport print may differs because transport is not intended with middleware hooks. 7 | async def pre_capture_hook(*_): 8 | print("[debug middleware] Pre capture hook...") 9 | 10 | 11 | async def post_capture_hook(*_): 12 | print("[debug middleware] Post capture hook...") 13 | 14 | 15 | async def on_request_hook(*_): 16 | print("[debug middleware] On request hook...") 17 | 18 | 19 | app = FastAPI() 20 | 21 | client = Client( 22 | transport=PrintTransport( 23 | prepare_event=lambda e: e.get("exception", {}).pop("traceback", None) 24 | ), 25 | include_platform_info=False, 26 | include_runtime_info=False, 27 | include_sdk_info=False, 28 | handle_global_exceptions=False, 29 | exceptions_capture_code_context=False, 30 | ) 31 | 32 | app.add_middleware( 33 | GateyStarletteMiddleware, 34 | client=client, 35 | capture_requests_info=True, 36 | client_getter=None, 37 | capture_exception_options=None, 38 | capture_reraise_after=False, # Only for testing! 39 | pre_capture_hook=pre_capture_hook, 40 | post_capture_hook=post_capture_hook, 41 | on_request_hook=on_request_hook, 42 | ) 43 | 44 | 45 | @app.get("/raise") 46 | def app_raise_route(): 47 | return 1 / 0 48 | 49 | 50 | if __name__ == "__main__": 51 | from uvicorn import run 52 | 53 | run(app) 54 | -------------------------------------------------------------------------------- /examples/manual_user_oauth.py: -------------------------------------------------------------------------------- 1 | import gatey_sdk 2 | 3 | client = gatey_sdk.Client(transport=gatey_sdk.VoidTransport) 4 | 5 | client.auth.request_oauth_from_stdin() 6 | -------------------------------------------------------------------------------- /examples/print_source_context.py: -------------------------------------------------------------------------------- 1 | import gatey_sdk 2 | 3 | 4 | def print_transport(event): 5 | """ 6 | No gatey_sdk.PrintTransport for formatted print. 7 | """ 8 | context = event["exception"]["traceback"][-1]["context"] 9 | print("---------------------") 10 | print("Code context sent to server: ") 11 | print("---------------------") 12 | print(*context["pre"], sep="\n") 13 | print(context["target"], "[TARGET_LINE]") 14 | print(*context["post"], sep="\n") 15 | print("---------------------") 16 | 17 | 18 | client = gatey_sdk.Client(transport=print_transport) 19 | 20 | try: 21 | raise ValueError("Message text!") 22 | except Exception as e: 23 | client.capture_exception(e, level="error") 24 | -------------------------------------------------------------------------------- /examples/print_transport.py: -------------------------------------------------------------------------------- 1 | import gatey_sdk 2 | 3 | client = gatey_sdk.Client( 4 | transport=gatey_sdk.PrintTransport, 5 | exceptions_capture_vars=False, 6 | handle_global_exceptions=True, 7 | global_handler_skip_internal_exceptions=True, 8 | ) 9 | 10 | raise ValueError("Message from exception!") 11 | -------------------------------------------------------------------------------- /gatey_sdk/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Florgon Gatey SDK. 3 | 4 | SDK Library for Florgon Gatey API. 5 | Provides interface for working with Florgon Gatey. 6 | 7 | Florgon Gatey is a error logging service. 8 | Library provides interface for working with exceptions, catching them, 9 | Working with message / events and all another analytics stuff, 10 | Also there is base API interface, 11 | allowing to work with native API, 12 | not base gateway end-user API. 13 | 14 | Author: Florgon Solutions. 15 | Gatey website: https://gatey.florgon.com/ 16 | Gatey API endpoint: https://api.florgon.com/gatey/ 17 | Gatey developer documentation: https://gatey-sdk-py.readthedocs.io/ 18 | 19 | If you have any questions please reach out us at: 20 | - support@florgon.com 21 | 22 | Main SDK maintainer: 23 | - Kirill Zhosul (@kirillzhosul) 24 | - kirillzhosul@florgon.com 25 | - https://github.com/kirillzhosul 26 | """ 27 | 28 | from gatey_sdk.transports import ( 29 | VoidTransport, 30 | PrintTransport, 31 | HttpTransport, 32 | FuncTransport, 33 | BaseTransport, 34 | ) 35 | from gatey_sdk.response import Response 36 | 37 | # Internal exceptions. 38 | from gatey_sdk.exceptions import GateyTransportError, GateyApiError 39 | 40 | # Base API. 41 | from gatey_sdk.client import Client 42 | 43 | # Additional API. 44 | from gatey_sdk.api import Api 45 | 46 | # Library specific information. 47 | from gatey_sdk.__version__ import ( 48 | __version__, 49 | __url__, 50 | __title__, 51 | __license__, 52 | __description__, 53 | __copyright__, 54 | __author_email__, 55 | __author__, 56 | ) 57 | 58 | __all__ = [ 59 | "Client", 60 | "Response", 61 | "Api", 62 | "BaseTransport", 63 | "HttpTransport", 64 | "FuncTransport", 65 | "VoidTransport", 66 | "PrintTransport", 67 | "GateyApiError", 68 | "GateyTransportError", 69 | ] 70 | -------------------------------------------------------------------------------- /gatey_sdk/__version__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Library specific information. 3 | """ 4 | 5 | __title__ = "gatey-sdk" 6 | __description__ = "Python client for Gatey (https://gatey.florgon.com)" 7 | __url__ = "https://github.com/florgon/gatey-sdk-py" 8 | __version__ = "0.0.10" 9 | __author__ = "Florgon Team and Contributors" 10 | __author_email__ = "support@florgon.com" 11 | __license__ = "MIT" 12 | __copyright__ = "Copyright 2022 Florgon Solutions" 13 | -------------------------------------------------------------------------------- /gatey_sdk/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | API class for working with API (HTTP). 3 | Sends HTTP requests, handles API methods. 4 | """ 5 | from typing import Optional, Dict, Any 6 | 7 | import requests 8 | 9 | from gatey_sdk.auth import Auth 10 | from gatey_sdk.response import Response 11 | from gatey_sdk.exceptions import GateyApiError, GateyApiAuthError, GateyApiResponseError 12 | from gatey_sdk.utils import remove_trailing_slash 13 | from gatey_sdk.consts import ( 14 | API_DEFAULT_SERVER_PROVIDER_URL, 15 | API_DEFAULT_SERVER_EXPECTED_VERSION, 16 | ) 17 | 18 | 19 | class Api: 20 | """ 21 | Wrapper for API methods, HTTP sender. 22 | """ 23 | 24 | # URL of the API. 25 | # Can be changed for Self-Hosted servers. 26 | _api_server_provider_url = API_DEFAULT_SERVER_PROVIDER_URL 27 | 28 | # Timeout for requests. 29 | _api_server_requests_timeout = 7 30 | 31 | # Version that expected from the API. 32 | _api_server_expected_version = API_DEFAULT_SERVER_EXPECTED_VERSION 33 | 34 | # `Auth` instance that provides authentication fields. 35 | _auth_provider: Auth = None 36 | 37 | # Kwargs for HTTP request call. 38 | _http_request_kwargs: Dict[str, Any] = dict() 39 | 40 | def __init__( 41 | self, 42 | auth: Optional[Auth] = None, 43 | *, 44 | http_request_kwargs: Optional[Dict[str, Any]] = None, 45 | ): 46 | """ 47 | :param auth: Auth provider as the `Auth` instance. 48 | :param http_request_kwargs: Kwargs that will be passed to the HTTP requests call. 49 | """ 50 | if auth and not isinstance(auth, Auth): 51 | raise TypeError( 52 | "Auth must be an instance of `Auth`! You may not pass auth as it will be initialise blank internally in `Api`." 53 | ) 54 | self._auth_provider = auth if auth else Auth() 55 | self._http_request_kwargs = http_request_kwargs 56 | 57 | def method( 58 | self, 59 | name: str, 60 | *, 61 | send_access_token: bool = False, 62 | send_project_auth: bool = False, 63 | **kwargs, 64 | ) -> Response: 65 | """ 66 | Executes API method with given name. 67 | And then return response from it. 68 | :param name: Name of the method to call. 69 | """ 70 | 71 | http_params = kwargs.copy() 72 | if send_access_token and self._auth_provider: 73 | if self._auth_provider.access_token: 74 | http_params.update({"access_token": self._auth_provider.access_token}) 75 | 76 | if send_project_auth and not send_access_token and self._auth_provider: 77 | if self._auth_provider.project_id: 78 | http_params.update({"project_id": self._auth_provider.project_id}) 79 | if self._auth_provider.server_secret: 80 | http_params.update({"server_secret": self._auth_provider.server_secret}) 81 | if ( 82 | self._auth_provider.client_secret 83 | and not self._auth_provider.server_secret 84 | ): 85 | http_params.update({"client_secret": self._auth_provider.client_secret}) 86 | 87 | # Build URL where API method is located. 88 | api_server_method_url = f"{self._api_server_provider_url}/{name}" 89 | 90 | # Send HTTP request. 91 | http_response = requests.get( 92 | url=api_server_method_url, 93 | params=http_params, 94 | timeout=self._api_server_requests_timeout, 95 | **self._http_request_kwargs, 96 | ) 97 | 98 | # Wrap HTTP response in to own Response object. 99 | try: 100 | response = Response(http_response=http_response) 101 | except requests.exceptions.JSONDecodeError: 102 | raise GateyApiResponseError( 103 | f"Failed to parse JSON response for response wrapper (Mostly due to server-side error!). Status code: {http_response.status_code}", 104 | raw_response=http_response, 105 | ) 106 | 107 | # Raise exception if there is any error returned with Api. 108 | self._process_error_and_raise( 109 | method_name=name, response=response, raw_response=http_response 110 | ) 111 | 112 | return response 113 | 114 | def change_api_server_provider_url(self, provider_url: str) -> None: 115 | """ 116 | Updates API server provider URL. 117 | Used for self-hosted servers. 118 | :param provider_url: URL of the server API provider. 119 | """ 120 | provider_url = remove_trailing_slash(provider_url) 121 | self._api_server_provider_url = provider_url 122 | 123 | def change_api_server_timeout(self, timeout: int) -> None: 124 | """ 125 | Updates API timeout for requests. 126 | :param timeout: New timeout 127 | """ 128 | self._api_server_requests_timeout = timeout 129 | 130 | def change_api_server_expected_version(self, version: str) -> None: 131 | """ 132 | Updates API version. 133 | :param version: Version of API. 134 | """ 135 | self._api_server_expected_version = version 136 | 137 | def do_auth_check(self) -> bool: 138 | """ 139 | Checks authentication with API. 140 | Returns is it successfully or no. 141 | """ 142 | try: 143 | self.do_hard_auth_check() 144 | except (GateyApiError, GateyApiAuthError): 145 | return False 146 | return True 147 | 148 | def do_hard_auth_check(self) -> bool: 149 | """ 150 | Checks authentication with API. 151 | Raises API error exception if unable to authenticate you. 152 | """ 153 | try: 154 | self.method( 155 | "project.checkAuthority", 156 | send_access_token=False, 157 | send_project_auth=True, 158 | ) 159 | except GateyApiError as api_error: 160 | if api_error.error_code == 7: 161 | if "please use server secret" in api_error.error_message.lower(): 162 | # TODO: Checkout backend rework for that case. 163 | raise GateyApiAuthError( 164 | "Project settings restricts using client secret, please use server secret instead of client, or ask to change project settings." 165 | ) from api_error 166 | raise GateyApiAuthError( 167 | "You are entered incorrect project secret (client or server)! Please review your SDK settings! (See previous exception to see more described information)" 168 | ) from api_error 169 | if api_error.error_code == 8: 170 | raise GateyApiAuthError( 171 | "You are entered not existing project id! Please review your SDK settings! (See previous exception to see more described information)" 172 | ) from api_error 173 | raise GateyApiAuthError( 174 | "There is unknown error while trying to check auth (do_auth)! (See previous exception to see more described information)" 175 | ) from api_error 176 | 177 | @staticmethod 178 | def _process_error_and_raise( 179 | method_name: str, response: Response, raw_response: requests.Response 180 | ) -> None: 181 | """ 182 | Processes error, and if there is any error, raise ApiError exception. 183 | """ 184 | error = response.raw_json().get("error") 185 | if error: 186 | # If there is an error. 187 | 188 | # Query error fields. 189 | error_message = error.get("message") 190 | error_code = error.get("code") 191 | error_status = error.get("status") 192 | 193 | # If invalid request by validation error, there will be additional error information in "exc" field of the error. 194 | if error_code == 3 and "exc" in error: 195 | error_message = f"{error_message} Additional exception information: {error.get('exc')}" 196 | 197 | # Raise ApiError exception. 198 | message = f"Failed to call API method {method_name}! Error code: {error_code}. Error message: {error_message}" 199 | raise GateyApiError( 200 | message=message, 201 | error_code=error_code, 202 | error_message=error_message, 203 | error_status=error_status, 204 | response=response, 205 | raw_response=raw_response, 206 | ) 207 | -------------------------------------------------------------------------------- /gatey_sdk/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | NOT REFACTORED. 3 | NOT USED. 4 | """ 5 | 6 | from typing import Optional 7 | 8 | # from urllib.parse import urlparse 9 | # from urllib.parse import parse_qs 10 | 11 | 12 | class Auth: 13 | """ 14 | Wrapper for authentication data (access token, project information for capturing (project id, client / server secret)) 15 | """ 16 | 17 | # Access token is used for user authorized calls. 18 | # Like editing project, or interacting with administration tools. 19 | access_token: Optional[str] = None 20 | 21 | # Project information for capturing events. 22 | # Secrets is used to verify calls to the project event capturer. 23 | project_id: Optional[int] = None 24 | server_secret: Optional[str] = None 25 | client_secret: Optional[str] = None 26 | 27 | def __init__( 28 | self, 29 | access_token: Optional[str] = None, 30 | project_id: Optional[int] = None, 31 | server_secret: Optional[str] = None, 32 | client_secret: Optional[str] = None, 33 | ): 34 | """ 35 | :param access_token: Access token of the your account with `gatey` scope. 36 | :param project_id: Project id to capture event for. 37 | :param server_secret: Secret of the project. 38 | :param client_secret: Secret of the project. 39 | """ 40 | self.access_token = access_token 41 | self.project_id = project_id 42 | self.server_secret = server_secret 43 | self.client_secret = client_secret 44 | 45 | def request_oauth_from_stdin(self) -> None: 46 | """ 47 | Get access token from stdin (IO, user). 48 | """ 49 | 50 | print( 51 | "\tOpen page in browser, signin and copy url here:", 52 | self.get_manual_oauth_user_login_url(), 53 | sep="\n\t\t", 54 | ) 55 | 56 | oauth_redirect_uri = input("\tRedirect URI: ") 57 | oauth_access_token = self.parse_access_token_from_redirect_uri( 58 | redirect_uri=oauth_redirect_uri 59 | ) 60 | 61 | print(f"\tSuccessfully grabbed access token: {oauth_access_token}") 62 | self.access_token = oauth_access_token 63 | 64 | @staticmethod 65 | def get_manual_oauth_user_login_url( 66 | client_id: int = 1, 67 | scope: str = "gatey", 68 | response_type: str = "token", 69 | redirect_uri: str = "https://florgon.com/oauth/blank", 70 | oauth_screen_url: str = "https://florgon.com/oauth/authorize", 71 | ) -> str: 72 | """ 73 | Returns url for OAuth user login manually. 74 | """ 75 | return f"{oauth_screen_url}?client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}&response_type={response_type}" 76 | 77 | @staticmethod 78 | def parse_access_token_from_redirect_uri(redirect_uri: str) -> Optional[str]: 79 | """ 80 | Returns token from redirect uri (OAuth) or None if not found there. 81 | """ 82 | url = redirect_uri.split("#token=") 83 | return url[1] if len(url) > 1 else None 84 | -------------------------------------------------------------------------------- /gatey_sdk/buffer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Events `buffer` class, that does storing events (as raw data) 3 | and handles them passing to the transport. 4 | 5 | Something like `proxy` that can just immediatly send event directly to the transport 6 | or can buffer them and wait for next requirement in their send right now. 7 | 8 | Includes handling exit signal to not loss any events, 9 | and flush thread if required (will refresh send every `N` time). 10 | """ 11 | import atexit 12 | from typing import Dict, List, Any, Optional, Callable 13 | from time import sleep 14 | from threading import Thread 15 | 16 | from gatey_sdk.transports.base import BaseTransport 17 | from gatey_sdk.consts import ( 18 | DEFAULT_EVENTS_BUFFER_FLUSH_EVERY, 19 | EVENTS_BUFFER_FLUSHER_THREAD_NAME, 20 | ) 21 | 22 | 23 | class _EventsBufferFlusher: 24 | """ 25 | Events `buffer` flusher class, does handling auto refresh (every time, at exit). 26 | 27 | Includes handling exit signal to not loss any events, 28 | and flush thread if required (will refresh send every `N` time). 29 | """ 30 | 31 | # Thread that is used for periodically flushing events to be passed to transport. 32 | flush_thread: Optional[Thread] = None 33 | 34 | # Settings. 35 | flush_every: float = DEFAULT_EVENTS_BUFFER_FLUSH_EVERY 36 | on_flush: Callable[[], Any] 37 | 38 | def __init__( 39 | self, 40 | on_flush: Callable[[], Any], 41 | *, 42 | flush_every: float = DEFAULT_EVENTS_BUFFER_FLUSH_EVERY 43 | ): 44 | """ 45 | :param on_flush: Callable that will be called on flush request. 46 | :param flush_every: Time in seconds for refreshing and flushing events (passing to the transport), (left 0 to disable) 47 | """ 48 | 49 | # Settings. 50 | self.on_flush = on_flush 51 | self.flush_every = float(flush_every) 52 | 53 | # Setup. 54 | self.ensure_running_thread() 55 | self.bind_system_exit_hook() 56 | 57 | def ensure_running_thread(self) -> Thread: 58 | """ 59 | Runs buffer flush thread if it is not running, and returns thread. 60 | Flush thread is used to send events buffer after some time, not causing to wait core application 61 | for terminate or new events that will trigger bulk sending (buffer flush). 62 | :returns Thread: Flush thread. 63 | """ 64 | 65 | if isinstance(self.flush_thread, Thread) and self.flush_thread.is_alive(): 66 | # If thread is currently alive - there is no need to create new thread by removing old reference. 67 | return self.flush_thread 68 | return self._spawn_new_thread() 69 | 70 | def flush_thread_target(self) -> None: 71 | """ 72 | Thread target for events buffer flusher. 73 | """ 74 | while True: 75 | sleep(self.flush_every) 76 | self.on_flush() 77 | 78 | def bind_system_exit_hook(self) -> None: 79 | """ 80 | Binds system hook for exit (`atexit`). 81 | Used for sending all buffered events at exit. 82 | """ 83 | atexit.register(self.on_flush) 84 | 85 | def _spawn_new_thread(self) -> Thread: 86 | """ 87 | Spawns new thread with removing reference to old one if exists. 88 | Please use `ensure_running_thread` for safe setup. 89 | :returns Thread: Flush thread. 90 | """ 91 | # Thread. 92 | self.flush_thread = Thread( 93 | target=self.flush_thread_target, 94 | args=(), 95 | name=EVENTS_BUFFER_FLUSHER_THREAD_NAME, 96 | ) 97 | 98 | # Mark thread as daemon (which is required for graceful main thread termination) and start. 99 | self.flush_thread.daemon = True 100 | self.flush_thread.start() 101 | return self.flush_thread 102 | 103 | 104 | class EventsBuffer: 105 | """ 106 | Events `buffer` class, that does storing events (as raw data) 107 | and handles them passing to the transport. 108 | 109 | Something like `proxy` that can just immediatly send event directly to the transport 110 | or can buffer them and wait for next requirement in their send right now. 111 | 112 | Includes handling exit signal to not loss any events, 113 | and flush thread if required (will refresh send every `N` time). 114 | (For that look into `_EventsBufferFlusher`) 115 | """ 116 | 117 | # Settings. 118 | skip_buffering: bool = True 119 | max_capacity: int = 0 120 | 121 | # Events data queue that waiting for being passed to the transport. 122 | # TODO: Research any LIFO structures. 123 | _events: List[Dict[str, Any]] = [] 124 | 125 | # Instances. 126 | _transport: BaseTransport 127 | _flusher: _EventsBufferFlusher 128 | 129 | def __init__( 130 | self, 131 | transport: BaseTransport, 132 | *, 133 | skip_buffering: bool = True, 134 | max_capacity: int = 0, 135 | flush_every: float = DEFAULT_EVENTS_BUFFER_FLUSH_EVERY 136 | ): 137 | """ 138 | :param transport: Configured transport instance to send events. 139 | :param skip_buffering: If true, will send (pass) events directly to the transport, without buffering. 140 | :param max_capacity: Cap for buffer, when that amount of buffered events is reached, will immediatly pass them (left 0 for no capacity) 141 | :param flush_every: Time in seconds for refreshing and flushing events (passing to the transport), (left 0 to disable) 142 | """ 143 | # Settings. 144 | self.skip_buffering = bool(skip_buffering) 145 | self.max_capacity = int(max_capacity) 146 | 147 | # Store transport instance. 148 | self._transport = transport 149 | if not isinstance(self._transport, BaseTransport): 150 | raise ValueError( 151 | "`EventsBuffer` expected `transport` to be instance of `BaseTransport`! Please instantiate by own!" 152 | ) 153 | 154 | # Flusher setup. 155 | self._flusher = _EventsBufferFlusher( 156 | on_flush=self.send_all, flush_every=flush_every 157 | ) 158 | 159 | def push_event(self, event_dict: Dict) -> bool: 160 | """ 161 | Collects events. 162 | Will send immediatly if configured, or just store to send later. 163 | 164 | :param event_dict: Event. 165 | :returns bool: Returns false if event failed to send or one of another buffered events failed to send. 166 | """ 167 | 168 | # Pass directly if should not buffer. 169 | if self.skip_buffering: 170 | return self._send_event(event_dict=event_dict, fail_fast=True) 171 | 172 | # Do buffer and send if required. 173 | self._store_event(event_dict=event_dict) 174 | if self.is_full(): 175 | return self.send_all() 176 | return True 177 | 178 | def clear_events(self) -> None: 179 | """ 180 | Drops (removes) all buffered events explicitly if any. 181 | WARNING: This will skip sending, use only if you know what this does! 182 | """ 183 | self._events = [] 184 | 185 | def is_empty(self) -> bool: 186 | """ 187 | Returns is buffer is empty or not. 188 | :returns bool: Is empty or not. 189 | """ 190 | return len(self._events) == 0 191 | 192 | def is_full(self) -> bool: 193 | """ 194 | Returns is events buffer is full and should send all to the transport. 195 | :returns bool: Is full. 196 | """ 197 | if self.max_capacity is None or self.max_capacity == 0: 198 | return False 199 | return len(self._events) >= self.max_capacity 200 | 201 | def send_all(self) -> bool: 202 | """ 203 | Sends all buffered events if any. 204 | :returns bool: Returns is all events was sent. 205 | """ 206 | events_to_send = self._events.copy() 207 | self.clear_events() 208 | 209 | # Build events based on non-sent events. 210 | self._events = [ 211 | event 212 | for event in events_to_send 213 | if not self._send_event(event_dict=event, fail_fast=False) 214 | ] 215 | 216 | return self.is_empty() 217 | 218 | def _store_event(self, event_dict: Dict) -> None: 219 | """ 220 | Stores event to events storage. 221 | :param event_dict: Event. 222 | """ 223 | self._events.append(event_dict) 224 | 225 | def _send_event(self, event_dict: Dict, *, fail_fast: bool = False) -> bool: 226 | """ 227 | Sends event with transport. 228 | :param event_dict: Event. 229 | :param fail_fast: If true, will raise exception rather and returning success / failure (TODO: Remove). 230 | """ 231 | # TODO: Remove `__fail_fast`, research better solution for decorating or remove that feature. 232 | return self._transport.send_event(event_dict=event_dict, __fail_fast=fail_fast) 233 | 234 | 235 | __all__ = ["EventsBuffer"] 236 | -------------------------------------------------------------------------------- /gatey_sdk/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main client for Gatey SDK. 3 | Provides root interface for working with Gatey. 4 | """ 5 | 6 | from typing import Callable, Union, Dict, List, Optional, Any 7 | 8 | # Utils. 9 | from gatey_sdk.utils import ( 10 | get_additional_event_tags, 11 | ) 12 | from gatey_sdk.consts import DEFAULT_EVENTS_BUFFER_FLUSH_EVERY 13 | from gatey_sdk.internal.exc import ( 14 | wrap_in_exception_handler, 15 | register_system_exception_hook, 16 | event_dict_from_exception, 17 | get_current_exception, 18 | ) 19 | 20 | # Components. 21 | from gatey_sdk.api import Api 22 | from gatey_sdk.auth import Auth 23 | from gatey_sdk.transports import build_transport_instance, BaseTransport 24 | from gatey_sdk.buffer import EventsBuffer 25 | 26 | 27 | class _Client: 28 | """ 29 | ## Gatey SDK client. 30 | Main interface for working with Gatey. 31 | Provides transport, auth, api interfaces. 32 | 33 | ### Example use: 34 | ```python 35 | import gatey_sdk 36 | client = gatey_sdk.Client(...) 37 | ``` 38 | """ 39 | 40 | # Instances. 41 | transport: BaseTransport 42 | auth: Auth 43 | api: Api 44 | events_buffer: EventsBuffer 45 | 46 | # Settings. 47 | kwargs_settings: Dict[str, Any] = dict() 48 | exceptions_capture_vars = True 49 | exceptions_capture_code_context = True 50 | include_runtime_info = True 51 | include_platform_info = True 52 | include_sdk_info = True 53 | default_tags_context = dict() 54 | 55 | def __init__( 56 | self, 57 | *, 58 | transport: Optional[Union[BaseTransport, Callable]] = None, 59 | # Settings. 60 | global_handler_skip_internal_exceptions: bool = True, 61 | buffer_events_for_bulk_sending: bool = False, 62 | buffer_events_max_capacity: int = 3, 63 | buffer_events_flush_every: float = DEFAULT_EVENTS_BUFFER_FLUSH_EVERY, 64 | handle_global_exceptions: bool = False, 65 | include_runtime_info: bool = True, 66 | include_platform_info: bool = True, 67 | include_sdk_info: bool = True, 68 | exceptions_capture_vars: bool = False, 69 | exceptions_capture_code_context: bool = True, 70 | # User auth settings. 71 | access_token: Optional[str] = None, 72 | # SDK auth settings. 73 | project_id: Optional[int] = None, 74 | server_secret: Optional[str] = None, 75 | client_secret: Optional[str] = None, 76 | check_api_auth_on_init: bool = True, 77 | # Other params. 78 | **kwargs_settings, 79 | ): 80 | """ 81 | :param transport: BaseTransport layer for sending event to the server / whatever else. 82 | :param global_handler_skip_internal_exceptions: 83 | :param buffer_events_for_bulk_sending: Will buffer all events (not send immediatly) and will do bulk send when this is required (at exit, or when reached buffer max cap) 84 | :param buffer_events_max_capacity: Maximal size of buffer to do bulk sending (left 0 for no cap). 85 | :param handle_global_exceptions: Will catch all exception (use system hook for that). 86 | :param include_runtime_info: If true, will send runtime information. 87 | :param include_platform_info: If true will send platform information. 88 | :param include_sdk_info: If true will send SDK information. 89 | :param exceptions_capture_vars: Will capture variable (globals, locals) for all exceptions. 90 | :param exceptions_capture_code_context: Will capture source code context (lines). 91 | :param access_token: User access token for calling API as authorized user (not for catching events). 92 | :param project_id: ID of the project from Gatey dashboard. 93 | :param server_secret: From Gatey dashboard. 94 | :param client_secret: From Gatey dashboard. 95 | :param check_api_auth_on_init: Will do hard auth check at init. 96 | """ 97 | 98 | # Components. 99 | self.auth = Auth( 100 | access_token=access_token, 101 | project_id=project_id, 102 | server_secret=server_secret, 103 | client_secret=client_secret, 104 | ) 105 | self.api = Api( 106 | auth=self.auth, 107 | http_request_kwargs=kwargs_settings.get("api_instance_kwargs", dict()), 108 | ) 109 | self.transport = build_transport_instance( 110 | transport_argument=transport, api=self.api, auth=self.auth 111 | ) 112 | self.events_buffer = EventsBuffer( 113 | transport=self.transport, 114 | skip_buffering=not buffer_events_for_bulk_sending, 115 | max_capacity=buffer_events_max_capacity, 116 | flush_every=buffer_events_flush_every, 117 | ) 118 | 119 | # Options. 120 | self.exceptions_capture_vars = exceptions_capture_vars 121 | self.exceptions_capture_code_context = exceptions_capture_code_context 122 | self.include_runtime_info = include_runtime_info 123 | self.include_platform_info = include_platform_info 124 | self.include_sdk_info = include_sdk_info 125 | self.kwargs_settings = kwargs_settings.copy() 126 | 127 | # Tags like platform, sdk, etc. 128 | self.default_tags_context = self._build_default_tags_context( 129 | foreign_tags=kwargs_settings.get("default_tags_context", dict()) 130 | ) 131 | 132 | # Check API auth if requested and should. 133 | # Notice that auth check is not done when you are using custom transports. 134 | # (even it is default transport) 135 | if check_api_auth_on_init is True and transport is None: 136 | self.api.do_hard_auth_check() 137 | 138 | # Register system hooks. 139 | if handle_global_exceptions is True: 140 | register_system_exception_hook( 141 | hook=self._on_catch_exception_hook, 142 | skip_internal_exceptions=global_handler_skip_internal_exceptions, 143 | ) 144 | 145 | def catch( 146 | self, 147 | *, 148 | reraise: bool = True, 149 | exception: Optional[BaseException] = None, 150 | ignored_exceptions: Optional[List[BaseException]] = None, 151 | skip_global_handler_on_ignore: bool = False, 152 | ): 153 | """ 154 | Decorator that catches the exception and captures it as Gatey exception. 155 | :param reraise: If False, will not raise the exception again, application will not fall (WARNING: USE THIS WISELY TO NOT GET UNEXPECTED BEHAVIOR) 156 | :param exception: Target exception type to capture. 157 | :param ignored_exceptions: List of exceptions that should not be captured. 158 | :param skip_global_handler_on_ignore: If true, will skip global exception handler if exception was ignored. 159 | """ 160 | return wrap_in_exception_handler( 161 | reraise=reraise, 162 | exception=exception, 163 | ignored_exceptions=ignored_exceptions, 164 | skip_global_handler_on_ignore=skip_global_handler_on_ignore, 165 | on_catch_exception=self._on_catch_exception_hook, 166 | ) 167 | 168 | def capture_event( 169 | self, 170 | event: Dict, 171 | level: str, 172 | tags: Optional[Dict[str, str]] = None, 173 | include_default_tags: bool = True, 174 | ) -> bool: 175 | """ 176 | Captures raw event data and passes it to the transport. 177 | You should not use this function directly, please use `capture_message` or `capture_exception`! 178 | This function is used as low-level call to capture all events. 179 | 180 | :param event: Raw event dictionary that will be updated with base event data (including tags). 181 | :param level: Level of the event. 182 | :param tags: Dictionary of the tags (string-string). 183 | :param include_default_tags: If false, will force to not pass default tags context of the client to the event. 184 | """ 185 | if tags is None or not isinstance(tags, Dict): 186 | tags = dict() 187 | 188 | if not isinstance(level, str): 189 | raise TypeError("Level of the event should be always string!") 190 | if not isinstance(event, Dict): 191 | raise TypeError("Event data should be Dict!") 192 | 193 | # Include default tags if requred. 194 | tags.update(self.default_tags_context if include_default_tags else {}) 195 | 196 | # Build event data. 197 | event_dict = event.copy() 198 | event_dict["tags"] = tags 199 | event_dict["level"] = level.lower() 200 | 201 | # Will buffer or immediatly send event. 202 | # return self._buffer_captured_event(event_dict=event_dict) 203 | self.events_buffer.push_event(event_dict=event_dict) 204 | 205 | def capture_message( 206 | self, 207 | message: str, 208 | level: str = "info", 209 | *, 210 | tags: Optional[Dict[str, str]] = None, 211 | include_default_tags: bool = True, 212 | ) -> bool: 213 | """ 214 | Captures message event. 215 | :param level: String of the level (INFO, DEBUG, etc) 216 | :param message: Message string. 217 | :param tags: Dictionary of the tags (string-string). 218 | :param include_default_tags: If false, will force to not pass default tags context of the client to the event. 219 | """ 220 | event_dict = {"message": message} 221 | return self.capture_event( 222 | event=event_dict, 223 | level=level, 224 | tags=tags, 225 | include_default_tags=include_default_tags, 226 | ) 227 | 228 | def capture_exception( 229 | self, 230 | exception: Optional[BaseException], 231 | *, 232 | level: str = "error", 233 | tags: Optional[Dict[str, str]] = None, 234 | include_default_tags: bool = True, 235 | ) -> bool: 236 | """ 237 | Captures exception event. 238 | :param exception: Raw exception. 239 | :param level: Level of the event that will be sent. 240 | :param tags: Dictionary of the tags (string-string). 241 | :param include_default_tags: If false, will force to not pass default tags context of the client to the event. 242 | """ 243 | if exception is None: 244 | # If exception is not passed, 245 | # get local current exception. 246 | exception = get_current_exception() 247 | 248 | if not isinstance(exception, BaseException): 249 | raise TypeError( 250 | "Expected `exception` to be an `BaseException`, please review your `capture_exception` call, or explicitly pass exception." 251 | ) 252 | 253 | exception_dict = event_dict_from_exception( 254 | exception=exception, 255 | skip_vars=not self.exceptions_capture_vars, 256 | include_code_context=self.exceptions_capture_code_context, 257 | ) 258 | event_dict = {"exception": exception_dict} 259 | if "description" in exception_dict: 260 | event_dict["message"] = exception_dict["description"] 261 | return self.capture_event( 262 | event=event_dict, 263 | level=level, 264 | tags=tags, 265 | include_default_tags=include_default_tags, 266 | ) 267 | 268 | def update_default_tag(self, tag_name: str, tag_value: str) -> None: 269 | """ 270 | Updates default value for tag. 271 | """ 272 | if not isinstance(tag_value, str) or not isinstance(tag_name, str): 273 | raise TypeError("Tag name and value should be strings!") 274 | self.default_tags_context[tag_name] = tag_value 275 | 276 | def _build_default_tags_context( 277 | self, foreign_tags: Dict[str, Any] 278 | ) -> Dict[str, Any]: 279 | """ 280 | Returns default tags dict (context). 281 | """ 282 | default_tags = dict() 283 | default_tags = get_additional_event_tags( 284 | include_runtime_info=self.include_runtime_info, 285 | include_platform_info=self.include_runtime_info, 286 | include_sdk_info=self.include_sdk_info, 287 | ) 288 | default_tags.update(foreign_tags) 289 | return default_tags 290 | 291 | def bulk_send_buffered_events(self) -> bool: 292 | """ 293 | Sends all buffered events. 294 | Returns is all events was sent. 295 | """ 296 | return self.events_buffer.send_all() 297 | 298 | def force_drop_buffered_events(self) -> None: 299 | """ 300 | Drops (removes) buffered events explicitly. 301 | """ 302 | return self.events_buffer.clear_events() 303 | 304 | def _on_catch_exception_hook(self, exception) -> bool: 305 | """ 306 | Hook that will be called when catched exception. 307 | (except via `capture_exception`) 308 | """ 309 | return self.capture_exception(exception=exception) 310 | 311 | 312 | Client = _Client 313 | -------------------------------------------------------------------------------- /gatey_sdk/consts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants for the SDK. 3 | """ 4 | 5 | from gatey_sdk.__version__ import __version__ as library_version 6 | 7 | # Default API server provider. 8 | # By default, this is API server provided from Florgon, 9 | # but user can override this with self-hosted provider. 10 | API_DEFAULT_SERVER_PROVIDER_URL = "https://api-gatey.florgon.com/v1" 11 | 12 | # Expected version from the API server. 13 | API_DEFAULT_SERVER_EXPECTED_VERSION = "0.0.0" 14 | 15 | # SDK fields. 16 | SDK_NAME = "gatey.python.official" 17 | SDK_VERSION = library_version 18 | SDK_INFORMATION_DICT = {"sdk.name": SDK_NAME, "sdk.ver": SDK_VERSION} 19 | 20 | # Exception attribute names. 21 | EXC_ATTR_SHOULD_SKIP_SYSTEM_HOOK = "gatey_should_skip_system_hook" 22 | EXC_ATTR_WAS_HANDLED = "gatey_was_handled" 23 | EXC_ATTR_IS_INTERNAL = "gatey_is_internal" 24 | 25 | # Runtime name for runtime event data. 26 | RUNTIME_NAME = "Python" 27 | 28 | # Events buffer defaults. 29 | DEFAULT_EVENTS_BUFFER_FLUSH_EVERY = 10.0 30 | EVENTS_BUFFER_FLUSHER_THREAD_NAME = "gatey_sdk.events_buffer.flusher" 31 | -------------------------------------------------------------------------------- /gatey_sdk/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom exceptions that may occur while working with SDK. 3 | """ 4 | from requests import Response as _HttpResponse 5 | from gatey_sdk.response import Response 6 | from gatey_sdk.consts import EXC_ATTR_IS_INTERNAL 7 | 8 | 9 | class GateyError(Exception): 10 | """ 11 | Super class for Gatey exceptions. 12 | """ 13 | 14 | 15 | class GateyHttpError(GateyError): 16 | """ 17 | Raised when there is any error with HTTP call. 18 | """ 19 | 20 | def __init__(self, message: str, raw_response: _HttpResponse): 21 | """ 22 | :param message: Message of the exception. 23 | :param raw_response: Raw HTTP response. 24 | """ 25 | super().__init__(message) 26 | self.raw_response = raw_response 27 | setattr(self, EXC_ATTR_IS_INTERNAL, True) 28 | 29 | 30 | class GateyApiError(GateyHttpError): 31 | """ 32 | Raised when there is any error with response. 33 | Means API return error (not success). 34 | """ 35 | 36 | def __init__( 37 | self, 38 | message: str, 39 | error_code: int, 40 | error_message: str, 41 | error_status: int, 42 | response: Response, 43 | raw_response: _HttpResponse, 44 | ): 45 | """ 46 | :param message: Message of the exception. 47 | :param error_code: API error code 48 | :param error_message: API error message. 49 | :param error_status: API error status (HTTP status, from the API `status` error field). 50 | :param response: API response. 51 | """ 52 | super().__init__(message, raw_response=raw_response) 53 | self.error_code = error_code 54 | self.error_message = error_message 55 | self.error_status = error_status 56 | self.response = response 57 | setattr(self, EXC_ATTR_IS_INTERNAL, True) 58 | 59 | 60 | class GateyApiResponseError(GateyHttpError): 61 | """ 62 | Raised when there is any error in the procesing response fro the API. 63 | """ 64 | 65 | def __init__(self, message: str, raw_response: _HttpResponse): 66 | """ 67 | :param message: Message of the exception. 68 | """ 69 | super().__init__(message, raw_response) 70 | setattr(self, EXC_ATTR_IS_INTERNAL, True) 71 | 72 | 73 | class GateyApiAuthError(GateyError): 74 | """ 75 | Raised when there is any error in the auth to the API. 76 | """ 77 | 78 | def __init__(self, message: str): 79 | """ 80 | :param message: Message of the exception. 81 | """ 82 | super().__init__(message) 83 | setattr(self, EXC_ATTR_IS_INTERNAL, True) 84 | 85 | 86 | class GateyTransportError(GateyError): 87 | """ 88 | Raised when there is any error in the transport. 89 | For example, raised when `FuncTransport` function raises any exceptions. 90 | """ 91 | 92 | def __init__(self, message: str): 93 | """ 94 | :param message: Message of the exception. 95 | """ 96 | super().__init__(message) 97 | setattr(self, EXC_ATTR_IS_INTERNAL, True) 98 | 99 | 100 | class GateyTransportImproperlyConfiguredError(GateyTransportError): 101 | """ 102 | Raised when there is any error in the transport configuration. 103 | For example, raised when no project id or client / server secret. 104 | """ 105 | 106 | def __init__(self, message: str): 107 | """ 108 | :param message: Message of the exception. 109 | """ 110 | super().__init__(message) 111 | setattr(self, EXC_ATTR_IS_INTERNAL, True) 112 | -------------------------------------------------------------------------------- /gatey_sdk/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integrations for Gatey SDK in different frameworks / tools. 3 | """ 4 | -------------------------------------------------------------------------------- /gatey_sdk/integrations/django.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=inconsistent-return-statements 2 | """ 3 | Django integration(s). 4 | """ 5 | 6 | from typing import Any, Callable, Dict 7 | 8 | from django.conf import settings 9 | from django.http import HttpRequest 10 | 11 | from gatey_sdk.client import Client 12 | 13 | # Type aliases for callables. 14 | HookCallable = Callable[["GateyDjangoMiddleware", HttpRequest, Callable], None] 15 | CaptureHookCallable = Callable[["GateyDjangoMiddleware", HttpRequest, BaseException], None] 16 | ClientGetterCallable = Callable[[], Client] 17 | 18 | 19 | class GateyDjangoMiddleware: 20 | """Gatey SDK Django middleware.""" 21 | 22 | # Requirements. 23 | get_response: Callable[[HttpRequest], Any] 24 | gatey_client: Client 25 | 26 | # Gatey options. 27 | capture_exception_options: Dict[str, Any] = {"include_default_tags": True} 28 | pre_capture_hook: CaptureHookCallable 29 | post_capture_hook: CaptureHookCallable 30 | on_request_hook: HookCallable 31 | client_getter: ClientGetterCallable 32 | capture_requests_info: bool = False 33 | capture_requests_info_additional_tags: Dict[str, str] = dict() 34 | 35 | def __init__(self, get_response: Callable[[HttpRequest], Any]) -> None: 36 | # Django middleware getter. 37 | self.get_response = get_response 38 | 39 | self.gatey_client = getattr(settings, "GATEY_CLIENT", None) # Redefined below by `client_getter`. 40 | 41 | self.capture_requests_info = getattr(settings, "GATEY_CAPTURE_REQUESTS_INFO", None) 42 | 43 | self.capture_exception_options = getattr( 44 | settings, "GATEY_CAPTURE_EXCEPTION_OPTIONS", self.capture_exception_options 45 | ) 46 | 47 | self.capture_requests_info_additional_tags = getattr( 48 | settings, "GATEY_CAPTURE_REQUESTS_INFO_ADDITIONAL_TAGS", dict() 49 | ) 50 | 51 | self.client_getter = getattr(settings, "GATEY_CLIENT_GETTER", self._default_client_getter) 52 | 53 | # Hooks. 54 | hooks = { 55 | "pre_capture_hook": getattr(settings, "GATEY_PRE_CAPTURE_HOOK", self._default_void_hook), 56 | "post_capture_hook": getattr(settings, "GATEY_POST_CAPTURE_HOOK", self._default_void_hook), 57 | "on_request_hook": getattr(settings, "GATEY_ON_REQUEST_HOOK", self._default_void_hook), 58 | } 59 | for name, hook in hooks.items(): 60 | setattr(self, name, hook) 61 | 62 | self.gatey_client = self.client_getter() 63 | if not isinstance(self.gatey_client, Client): 64 | raise ValueError("Gatey client is invalid! Please review `client` param or review your client getter!") 65 | 66 | def __call__(self, request: HttpRequest): 67 | """ 68 | Middleware itself (handle request). 69 | """ 70 | 71 | if self.on_request_hook: 72 | self.on_request_hook(self, request, self.get_response) 73 | 74 | if self.capture_requests_info: 75 | self._capture_request_info(request=request) 76 | 77 | return self.get_response(request) 78 | 79 | def process_exception(self, request: HttpRequest, exception: BaseException): 80 | """ 81 | Process exception by capturing it via Gatey Client. 82 | """ 83 | 84 | client = self.client_getter() 85 | if client and isinstance(client, Client): # type: ignore 86 | self.pre_capture_hook(self, request, exception) 87 | 88 | capture_options = self.capture_exception_options.copy() 89 | if "tags" not in capture_options: 90 | capture_options["tags"] = self._get_request_tags_from_request(request=request) 91 | 92 | client.capture_exception(exception, **capture_options) 93 | 94 | self.post_capture_hook(self, request, exception) 95 | return None 96 | 97 | @staticmethod 98 | def _get_request_tags_from_request(request: HttpRequest) -> Dict[str, str]: 99 | """ 100 | Returns tags for request from request. 101 | """ 102 | return { 103 | "gatey.sdk.integration_type": "Django", 104 | "method": request.method or "", 105 | "scheme": request.scheme, 106 | "port": request.get_port(), 107 | "path": request.path, 108 | "full_path": request.get_full_path(), 109 | "server_host": request.get_host(), 110 | **GateyDjangoMiddleware._unpack_request_meta_tags(request), 111 | } 112 | 113 | @staticmethod 114 | def _unpack_request_meta_tags(request: HttpRequest) -> Dict[str, str]: 115 | unpacked: Dict[str, str] = {} 116 | for k, v in request.META.values(): 117 | if not isinstance(v, (str, int)): 118 | continue 119 | unpacked[f"django.request.meta.{k}"] = str(v) 120 | return unpacked 121 | 122 | def _capture_request_info(self, request: HttpRequest) -> None: 123 | """ 124 | Captures request info as message to the client. 125 | """ 126 | client = self.client_getter() 127 | if not client: 128 | return 129 | 130 | tags = self._get_request_tags_from_request(request=request) 131 | message = f"{tags['method']} '{tags['path']}'" 132 | message = message if message != " ''" else "Request was handled." 133 | tags.update(self.capture_requests_info_additional_tags) 134 | 135 | client.capture_message( 136 | message, 137 | level="debug", 138 | tags=tags, 139 | ) 140 | 141 | def _default_client_getter(self) -> Client: 142 | """ 143 | Default getter for Gatey client if none is specified. 144 | """ 145 | return self.gatey_client 146 | 147 | @staticmethod 148 | def _default_void_hook(*_) -> None: 149 | """Default hook for pre/post capture, just does nothing.""" 150 | return 151 | -------------------------------------------------------------------------------- /gatey_sdk/integrations/flask.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=inconsistent-return-statements 2 | """ 3 | Flask integration(s). 4 | """ 5 | 6 | from typing import Optional, Dict, Callable, Any 7 | 8 | from werkzeug.wrappers import Request 9 | from gatey_sdk.client import Client 10 | from flask import abort 11 | 12 | # Type aliases for callables. 13 | HookCallable = Callable[["GateyFlaskMiddleware", Dict, Callable], None] 14 | ClientGetterCallable = Callable[[], Client] 15 | 16 | 17 | class GateyFlaskMiddleware: 18 | """Gatey SDK Flask middleware.""" 19 | 20 | # Requirements. 21 | flask_app: Callable[[Dict, Callable], Any] 22 | gatey_client: Client 23 | 24 | # Gatey options. 25 | capture_exception_options: Dict[str, Any] = {"include_default_tags": True} 26 | pre_capture_hook: HookCallable 27 | post_capture_hook: HookCallable 28 | on_request_hook: HookCallable 29 | client_getter: ClientGetterCallable 30 | capture_requests_info: bool = False 31 | capture_requests_info_additional_tags: Dict[str, str] = dict() 32 | 33 | def __init__( 34 | self, 35 | app, 36 | client: Optional[Client] = None, 37 | *, 38 | capture_requests_info: bool = False, 39 | client_getter: Optional[ClientGetterCallable] = None, 40 | capture_exception_options: Optional[Dict[str, Any]] = None, 41 | pre_capture_hook: Optional[HookCallable] = None, 42 | post_capture_hook: Optional[HookCallable] = None, 43 | on_request_hook: Optional[HookCallable] = None, 44 | capture_requests_info_additional_tags: Optional[Dict[str, str]] = None, 45 | ) -> None: 46 | self.flask_app = app 47 | self.gatey_client = client # Redefined below by `client_getter`. 48 | 49 | self.capture_requests_info = capture_requests_info 50 | self.capture_exception_options = ( 51 | capture_exception_options 52 | if capture_exception_options 53 | else self.capture_exception_options 54 | ) 55 | self.capture_requests_info_additional_tags = ( 56 | capture_requests_info_additional_tags 57 | if capture_requests_info_additional_tags 58 | else dict() 59 | ) 60 | self.client_getter = ( 61 | client_getter if client_getter else self._default_client_getter 62 | ) 63 | 64 | # Hooks. 65 | hooks = { 66 | "pre_capture_hook": pre_capture_hook, 67 | "post_capture_hook": post_capture_hook, 68 | "on_request_hook": on_request_hook, 69 | } 70 | for name, hook in hooks.items(): 71 | setattr(self, name, hook or self._default_void_hook) 72 | 73 | self.gatey_client = self.client_getter() 74 | if not isinstance(self.gatey_client, Client): 75 | raise ValueError( 76 | "Gatey client is invalid! Please review `client` param or review your client getter!" 77 | ) 78 | 79 | def __call__(self, environ: Dict, start_response: Callable) -> Any: 80 | """ 81 | Middleware itself (handle request). 82 | """ 83 | return self._execute_app_wrapped(environ, start_response) 84 | 85 | def _execute_app_wrapped(self, environ: Dict, start_response: Callable) -> Any: 86 | """ 87 | Executes app wrapped with middleware. 88 | """ 89 | app_args = [environ, start_response] 90 | 91 | if self.on_request_hook: 92 | self.on_request_hook(self, *app_args) 93 | 94 | if self.capture_requests_info: 95 | self._capture_request_info(environ=environ) 96 | 97 | try: 98 | return self.flask_app(*app_args) 99 | except Exception as _flask_app_exception: 100 | client = self.client_getter() 101 | if client and isinstance(client, Client): 102 | self.pre_capture_hook(self, *app_args) 103 | 104 | capture_options = self.capture_exception_options.copy() 105 | if "tags" not in capture_options: 106 | capture_options["tags"] = self._get_request_tags_from_environ( 107 | environ=environ 108 | ) 109 | 110 | client.capture_exception(_flask_app_exception, **capture_options) 111 | 112 | self.post_capture_hook(self, *app_args) 113 | abort(500) 114 | 115 | @staticmethod 116 | def _get_request_tags_from_environ(environ: Dict) -> Dict[str, str]: 117 | """ 118 | Returns tags for request from request environ. 119 | """ 120 | request = Request(environ) 121 | return { 122 | "query": request.query_string.decode("utf-8"), 123 | "path": request.root_path + request.path, 124 | "scheme": request.scheme, 125 | "method": request.method, 126 | "client_host": request.remote_addr, 127 | "server_host": ":".join(map(str, request.server)), 128 | "gatey.sdk.integration_type": "Flask", 129 | } 130 | 131 | def _capture_request_info(self, environ: Dict) -> None: 132 | """ 133 | Captures request info as message to the client. 134 | """ 135 | client = self.client_getter() 136 | if not client: 137 | return 138 | 139 | tags = self._get_request_tags_from_environ(environ=environ) 140 | message = f"{tags['method']} '{tags['path']}'" 141 | message = message if message != " ''" else "Request was handled." 142 | tags.update(self.capture_requests_info_additional_tags) 143 | 144 | client.capture_message( 145 | message, 146 | level="debug", 147 | tags=tags, 148 | ) 149 | 150 | def _default_client_getter(self) -> Client: 151 | """ 152 | Default getter for Gatey client if none is specified. 153 | """ 154 | return self.gatey_client 155 | 156 | @staticmethod 157 | def _default_void_hook(*_) -> None: 158 | """ 159 | Default hook for pre/post capture, just does nohing. 160 | """ 161 | return None 162 | -------------------------------------------------------------------------------- /gatey_sdk/integrations/starlette.py: -------------------------------------------------------------------------------- 1 | """ 2 | Starlette integration(s). 3 | """ 4 | 5 | from typing import Optional, Dict, Callable, Awaitable, Any 6 | 7 | from starlette.types import Send, Scope, Receive, ASGIApp 8 | from starlette.datastructures import Headers 9 | from gatey_sdk.client import Client 10 | 11 | # Type aliases for callables. 12 | HookCallable = Callable[ 13 | ["GateyStarletteMiddleware", Scope, Receive, Send], Awaitable[None] 14 | ] 15 | ClientGetterCallable = Callable[[], Client] 16 | 17 | 18 | class GateyStarletteMiddleware: 19 | """Gatey SDK Starlette middleware.""" 20 | 21 | # Requirements. 22 | starlette_app: ASGIApp 23 | gatey_client: Client 24 | 25 | # Gatey options. 26 | capture_exception_options: Dict[str, Any] = {"include_default_tags": True} 27 | pre_capture_hook: HookCallable 28 | post_capture_hook: HookCallable 29 | on_request_hook: HookCallable 30 | client_getter: ClientGetterCallable 31 | capture_reraise_after: bool = True 32 | capture_requests_info: bool = False 33 | capture_requests_info_additional_tags: Dict[str, str] = dict() 34 | 35 | def __init__( 36 | self, 37 | app: ASGIApp, 38 | client: Optional[Client] = None, 39 | *, 40 | capture_requests_info: bool = False, 41 | client_getter: Optional[ClientGetterCallable] = None, 42 | capture_exception_options: Optional[Dict[str, Any]] = None, 43 | capture_reraise_after: bool = True, 44 | pre_capture_hook: Optional[HookCallable] = None, 45 | post_capture_hook: Optional[HookCallable] = None, 46 | on_request_hook: Optional[HookCallable] = None, 47 | capture_requests_info_additional_tags: Optional[Dict[str, str]] = None, 48 | ) -> None: 49 | self.starlette_app = app 50 | self.gatey_client = client # Redefined below by `client_getter`. 51 | 52 | self.capture_reraise_after = capture_reraise_after 53 | self.capture_requests_info = capture_requests_info 54 | self.capture_exception_options = ( 55 | capture_exception_options 56 | if capture_exception_options 57 | else self.capture_exception_options 58 | ) 59 | self.capture_requests_info_additional_tags = ( 60 | capture_requests_info_additional_tags 61 | if capture_requests_info_additional_tags 62 | else dict() 63 | ) 64 | self.client_getter = ( 65 | client_getter if client_getter else self._default_client_getter 66 | ) 67 | 68 | # Hooks. 69 | hooks = { 70 | "pre_capture_hook": pre_capture_hook, 71 | "post_capture_hook": post_capture_hook, 72 | "on_request_hook": on_request_hook, 73 | } 74 | for name, hook in hooks.items(): 75 | setattr(self, name, hook or self._default_void_hook) 76 | 77 | self.gatey_client = self.client_getter() 78 | if not isinstance(self.gatey_client, Client): 79 | raise ValueError( 80 | "Gatey client is invalid! Please review `client` param or review your client getter!" 81 | ) 82 | 83 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 84 | """ 85 | Middleware itself (handle request). 86 | """ 87 | if scope["type"] != "http": 88 | # Skip non-requests (like, lifespan event). 89 | # Do not have there `simple_response`. 90 | await self.starlette_app(scope, receive, send) 91 | return 92 | await self._execute_app_wrapped(scope, receive, send) 93 | 94 | async def _execute_app_wrapped( 95 | self, scope: Scope, receive: Receive, send: Send 96 | ) -> None: 97 | """ 98 | Executes app wrapped with middleware. 99 | """ 100 | app_args = [scope, receive, send] 101 | 102 | if self.on_request_hook: 103 | await self.on_request_hook(self, *app_args) 104 | 105 | if self.capture_requests_info: 106 | await self._capture_request_info(*app_args) 107 | 108 | try: 109 | await self.starlette_app(*app_args) 110 | except Exception as _starlette_app_exception: 111 | client = self.client_getter() 112 | if client and isinstance(client, Client): 113 | await self.pre_capture_hook(self, *app_args) 114 | 115 | capture_options = self.capture_exception_options.copy() 116 | if "tags" not in capture_options: 117 | capture_options["tags"] = self._get_request_tags_from_scope( 118 | scope=scope 119 | ) 120 | 121 | client.capture_exception(_starlette_app_exception, **capture_options) 122 | 123 | await self.post_capture_hook(self, *app_args) 124 | 125 | if self.capture_reraise_after: 126 | raise _starlette_app_exception 127 | 128 | def _get_request_tags_from_scope(self, scope: Scope) -> Dict[str, str]: 129 | """ 130 | Returns tags for request from request scope. 131 | """ 132 | query, path, method = ( 133 | scope.get("query_string", b"").decode("UTF-8"), 134 | scope.get("path", ""), 135 | scope.get("method", "UNKNOWN"), 136 | ) 137 | return { 138 | "gatey.sdk.integration_type": "Starlette", 139 | "query": query, 140 | "path": path, 141 | "method": method, 142 | "client_host": self._get_client_host_from_scope(scope), 143 | "server_host": ":".join(map(str, scope["server"])), 144 | } 145 | 146 | async def _capture_request_info(self, scope: Scope, *_) -> None: 147 | """ 148 | Captures request info as message to the client. 149 | """ 150 | client = self.client_getter() 151 | if not client: 152 | return 153 | 154 | tags = self._get_request_tags_from_scope(scope=scope) 155 | message = f"{tags['method']} '{tags['path']}'" 156 | message = message if message != " ''" else "Request was handled." 157 | tags.update(self.capture_requests_info_additional_tags) 158 | 159 | client.capture_message( 160 | message, 161 | level="debug", 162 | tags=tags, 163 | ) 164 | 165 | def _default_client_getter(self) -> Client: 166 | """ 167 | Default getter for Gatey client if none is specified. 168 | """ 169 | return self.gatey_client 170 | 171 | @staticmethod 172 | async def _default_void_hook(*_) -> None: 173 | """ 174 | Default hook for pre/post capture, just does nohing. 175 | """ 176 | return None 177 | 178 | @staticmethod 179 | def _get_client_host_from_scope(scope: Scope) -> str: 180 | """Returns client host (IP) from passed scope, if it is forwarded, queries correct host.""" 181 | header_x_forwarded_for = Headers(scope=scope).get("X-Forwarded-For") 182 | if header_x_forwarded_for: 183 | return header_x_forwarded_for.split(",")[0] 184 | return scope["client"][0] 185 | -------------------------------------------------------------------------------- /gatey_sdk/internal/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Package with internal modules, used at core sdk level. 3 | 4 | Provides modules for working with traceback, source code fetching, exceptions at level of sdk (low-level). 5 | 6 | Should not be used by not sdk, as it is not designed to be used from elsewhere. 7 | """ 8 | 9 | from gatey_sdk.internal.traceback import ( 10 | get_trace_from_traceback, 11 | get_variables_from_traceback, 12 | ) 13 | from gatey_sdk.internal.source import get_context_lines_from_source_code 14 | from gatey_sdk.internal.exc import ( 15 | wrap_in_exception_handler, 16 | register_system_exception_hook, 17 | event_dict_from_exception, 18 | get_current_exception, 19 | ) 20 | 21 | __all__ = [ 22 | "get_trace_from_traceback", 23 | "get_variables_from_traceback", 24 | "get_context_lines_from_source_code", 25 | "wrap_in_exception_handler", 26 | "register_system_exception_hook", 27 | "event_dict_from_exception", 28 | "get_current_exception", 29 | ] 30 | -------------------------------------------------------------------------------- /gatey_sdk/internal/exc.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=inconsistent-return-statements 2 | """ 3 | Stuff for working with exceptions on SDK level. 4 | """ 5 | 6 | import sys 7 | from typing import Dict, List, Callable, Optional 8 | from types import TracebackType 9 | 10 | from gatey_sdk.exceptions import ( 11 | GateyApiError, 12 | GateyTransportError, 13 | GateyTransportImproperlyConfiguredError, 14 | ) 15 | from gatey_sdk.consts import ( 16 | EXC_ATTR_SHOULD_SKIP_SYSTEM_HOOK, 17 | EXC_ATTR_WAS_HANDLED, 18 | ) 19 | from gatey_sdk.internal.traceback import ( 20 | get_trace_from_traceback, 21 | get_variables_from_traceback, 22 | ) 23 | 24 | 25 | def wrap_in_exception_handler( 26 | *, 27 | reraise: bool = True, 28 | exception: Optional[BaseException] = None, 29 | ignored_exceptions: Optional[List[BaseException]] = None, 30 | on_catch_exception: Optional[Callable] = None, 31 | skip_global_handler_on_ignore: bool = False, 32 | ) -> Callable: 33 | """ 34 | Decorator that catches the exception and captures it as Gatey exception. 35 | :param reraise: If False, will not raise the exception again, application will not fall (WARNING: USE THIS WISELY TO NOT GET UNEXPECTED BEHAVIOR) 36 | :param exception: Target exception type to capture. 37 | :param ignored_exceptions: List of exceptions that should not be captured. 38 | :param on_catch_exception: Function that will be called when an exception is caught. 39 | :param skip_global_handler_on_ignore: If true, will skip global exception handler if exception was ignored. 40 | """ 41 | 42 | # Default target exception value. 43 | if exception is None: 44 | exception = BaseException 45 | 46 | # Default value for ignored exception list (Do not ignore any exceptions) 47 | if ignored_exceptions is None: 48 | ignored_exceptions = [] 49 | 50 | def decorator(function: Callable): 51 | def wrapper(*args, **kwargs): 52 | # pylint: disable=inconsistent-return-statements 53 | # Gets called when `decorated` function get called. 54 | try: 55 | # This will simply return function result if there is no exception. 56 | return function(*args, **kwargs) 57 | except exception as e: 58 | # There is any exception that we should handle occurred. 59 | 60 | # Typed. 61 | e: BaseException = e 62 | 63 | # Do not handle ignored exceptions. 64 | if _exception_is_ignored(e, ignored_exceptions): 65 | if skip_global_handler_on_ignore: 66 | # If we should skip global exception handler. 67 | setattr(e, EXC_ATTR_SHOULD_SKIP_SYSTEM_HOOK, True) 68 | raise e 69 | 70 | # Call catch event. 71 | if callable(on_catch_exception): 72 | on_catch_exception(e) 73 | # Mark as handled. 74 | setattr(e, EXC_ATTR_WAS_HANDLED, True) 75 | 76 | # Raise exception again if we expected that. 77 | if reraise is True: 78 | raise e 79 | return 80 | 81 | return wrapper 82 | 83 | return decorator 84 | 85 | 86 | def register_system_exception_hook( 87 | hook: Callable, skip_internal_exceptions: bool = True 88 | ): 89 | """ 90 | Register exception hook for system. 91 | :param hook: Will be called when exception triggered. 92 | """ 93 | 94 | def _system_exception_hook_handler( 95 | exception_type: type[BaseException], 96 | exception: BaseException, 97 | traceback: TracebackType, 98 | ): 99 | # System exception hook handler. 100 | 101 | was_handled = hasattr(exception, EXC_ATTR_WAS_HANDLED) 102 | if not was_handled and not hasattr(exception, EXC_ATTR_SHOULD_SKIP_SYSTEM_HOOK): 103 | # If marked as skipped for system hook. 104 | try: 105 | # Try to handle this exception with hook. 106 | hook(exception=exception) 107 | except ( 108 | GateyApiError, 109 | GateyTransportError, 110 | GateyTransportImproperlyConfiguredError, 111 | ) as e: 112 | # If there is any error while processing global exception handler. 113 | if not skip_internal_exceptions: 114 | raise e 115 | 116 | # Default system hook. 117 | sys.__excepthook__(exception_type, exception, traceback) 118 | 119 | # Register system exception hook. 120 | sys.excepthook = _system_exception_hook_handler 121 | 122 | 123 | def event_dict_from_exception( 124 | exception: BaseException, skip_vars: bool = True, include_code_context: bool = True 125 | ) -> Dict: 126 | """ 127 | Returns event dictionary of the event (field) from the raw exception. 128 | Fetches all required information about system, exception. 129 | """ 130 | 131 | # Get raw exception traceback information. 132 | exception_traceback = getattr(exception, "__traceback__", None) 133 | 134 | # Query traceback information. 135 | traceback_vars = get_variables_from_traceback( 136 | traceback=exception_traceback, _always_skip=skip_vars 137 | ) 138 | traceback_trace = get_trace_from_traceback( 139 | exception_traceback, include_code_context=include_code_context 140 | ) 141 | 142 | # Get exception type ("BaseException", "ValueError"). 143 | exception_type = _get_exception_type_name(exception) 144 | exception_description = str(exception) 145 | event_dict = { 146 | "class": exception_type, 147 | "description": exception_description, 148 | "vars": traceback_vars, # Will be migrated to the traceback context later. 149 | "traceback": traceback_trace, 150 | } 151 | return event_dict 152 | 153 | 154 | def get_current_exception() -> BaseException: 155 | """ 156 | Returns current local exception from scope. 157 | :returns BaseException: local exception info. 158 | """ 159 | local_exception_info = sys.exc_info() 160 | return local_exception_info[1] 161 | 162 | 163 | def _get_exception_type_name(exception: BaseException) -> str: 164 | """ 165 | Returns exception type ("BaseException", "ValueError"). 166 | """ 167 | return getattr(type(exception), "__name__", "NoneException") 168 | 169 | 170 | def _exception_is_ignored( 171 | exception: BaseException, ignored_exceptions: List[BaseException] 172 | ) -> bool: 173 | """ 174 | Returns True if exception should be ignored based on `ignored_exceptions` list. 175 | """ 176 | exception_type = type(exception) 177 | for ignored_exception_type in ignored_exceptions: 178 | if exception_type == ignored_exception_type: 179 | return True 180 | return False 181 | -------------------------------------------------------------------------------- /gatey_sdk/internal/source.py: -------------------------------------------------------------------------------- 1 | """ 2 | Works with source code reading. 3 | """ 4 | 5 | import tokenize 6 | from typing import Dict, List, Union 7 | 8 | 9 | def get_context_lines_from_source_code( 10 | filename: str, line_number: int, context_lines_count: int = 5 11 | ) -> Dict[str, Union[str, None, List[str]]]: 12 | """ 13 | Returns context lines from source code file. 14 | """ 15 | source_code_lines = _get_lines_from_source_code(filename=filename) 16 | bounds_start = max(0, line_number - context_lines_count - 1) 17 | bounds_end = min(line_number + 1 + context_lines_count, len(source_code_lines)) 18 | 19 | strip_line = lambda line: line.strip("\r\n").replace(" ", "\t") 20 | context_pre, context_target, context_post = [], None, [] 21 | try: 22 | context_pre = [ 23 | strip_line(line) 24 | for line in source_code_lines[bounds_start : line_number - 1] 25 | ] 26 | context_target = strip_line(source_code_lines[line_number - 1]) 27 | context_post = [ 28 | strip_line(line) for line in source_code_lines[line_number:bounds_end] 29 | ] 30 | except IndexError: 31 | # File was changed? 32 | pass 33 | return {"pre": context_pre, "target": context_target, "post": context_post} 34 | 35 | 36 | def _get_lines_from_source_code(filename: str) -> List[str]: 37 | """ 38 | Returns lines of the code from the source code filename. 39 | """ 40 | try: 41 | with tokenize.open(filename=filename) as source_file: 42 | return source_file.readlines() 43 | except (OSError, IOError): 44 | return [] 45 | -------------------------------------------------------------------------------- /gatey_sdk/internal/traceback.py: -------------------------------------------------------------------------------- 1 | """ 2 | Stuff to work with tracebacks. 3 | """ 4 | 5 | from typing import List, Dict 6 | from types import TracebackType, FrameType 7 | 8 | from gatey_sdk.internal.source import get_context_lines_from_source_code 9 | 10 | 11 | def get_trace_from_traceback( 12 | traceback: TracebackType, 13 | include_code_context: bool = True, 14 | code_context_lines_count: int = 5, 15 | code_context_only_for_tail: bool = True, 16 | ) -> List[Dict]: 17 | """ 18 | Returns trace from the given traceback. 19 | """ 20 | 21 | trace = [] 22 | 23 | while traceback is not None: 24 | # Iterating over traceback with `tb_next` 25 | frame = traceback.tb_frame 26 | frame_code = getattr(frame, "f_code", None) 27 | filename, function = None, None 28 | if frame_code: 29 | filename = frame.f_code.co_filename 30 | function = frame.f_code.co_name 31 | 32 | line_number = traceback.tb_lineno 33 | trace_element = { 34 | "filename": filename, 35 | "name": function or "", 36 | "line": line_number, 37 | "module": frame.f_globals.get("__name__", None), 38 | } 39 | 40 | if include_code_context and not code_context_only_for_tail: 41 | trace_element |= { 42 | "context": get_context_lines_from_source_code( 43 | filename=filename, 44 | line_number=line_number, 45 | context_lines_count=code_context_lines_count, 46 | ) 47 | } 48 | 49 | trace.append(trace_element) 50 | traceback = traceback.tb_next 51 | 52 | if include_code_context and code_context_only_for_tail: 53 | tail_trace = trace[-1] 54 | tail_trace["context"] = get_context_lines_from_source_code( 55 | filename=tail_trace["filename"], 56 | line_number=tail_trace["line"], 57 | context_lines_count=5, 58 | ) 59 | 60 | return trace 61 | 62 | 63 | def get_variables_from_traceback( 64 | traceback: TracebackType, *, _always_skip: bool = False 65 | ) -> Dict: 66 | """ 67 | Returns local and global variables from the given traceback. 68 | """ 69 | 70 | traceback_variables_locals = {} 71 | traceback_variables_globals = {} 72 | 73 | if traceback and not _always_skip: 74 | last_frame = _traceback_query_tail_frame(traceback) 75 | traceback_variables_locals = last_frame.f_locals 76 | traceback_variables_globals = last_frame.f_globals 77 | 78 | # Stringify variable values. 79 | traceback_variables_locals = { 80 | key: str(value) for key, value in traceback_variables_locals.items() 81 | } 82 | traceback_variables_globals = { 83 | key: str(value) for key, value in traceback_variables_globals.items() 84 | } 85 | 86 | return { 87 | "locals": traceback_variables_locals, 88 | "globals": traceback_variables_globals, 89 | } 90 | 91 | 92 | def _traceback_query_tail_frame(traceback: TracebackType) -> FrameType: 93 | """ 94 | Returns last frame of the frame (tail). 95 | """ 96 | tail_frame = traceback.tb_frame 97 | while traceback is not None: 98 | tail_frame = traceback.tb_frame 99 | traceback = traceback.tb_next 100 | return tail_frame 101 | -------------------------------------------------------------------------------- /gatey_sdk/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florgon/gatey-sdk-py/aa1bd0b44839116adad1156fd1a8c49749201451/gatey_sdk/py.typed -------------------------------------------------------------------------------- /gatey_sdk/response.py: -------------------------------------------------------------------------------- 1 | """ 2 | Response class. 3 | Result of all API methods. 4 | Implements Gatey API response structure (For success). 5 | 6 | If API request will raise error, there will be `gatey_sdk.exceptions.GateyApiError` 7 | """ 8 | 9 | from typing import Dict, Any, Optional 10 | from requests import Response as _HttpResponse 11 | 12 | 13 | class Response: 14 | """ 15 | Gatey API response structure. 16 | """ 17 | 18 | # Raw response fields. 19 | _raw_json: Optional[Dict] = None 20 | _raw_response: Optional[_HttpResponse] = None 21 | 22 | # API response fields. 23 | _response_version: Optional[str] = None 24 | _response_object: Optional[Dict] = None # `success` response field. 25 | 26 | def __init__(self, http_response: _HttpResponse): 27 | """ 28 | :param http_response: Response object (HTTP). 29 | """ 30 | 31 | # Store raw response to work later. 32 | self._raw_response = http_response 33 | 34 | # Parse raw response once for working later. 35 | self._raw_json = self._raw_response.json() 36 | self._response_object = self._raw_json.get("success", dict()) 37 | self._response_version = self._raw_json.get("v", "-") 38 | 39 | def get(self, key: str, default: Any = None): 40 | """ 41 | Allows to access Response fields by `response.get(field, default)`. 42 | """ 43 | try: 44 | return self[key] 45 | except KeyError: 46 | return default 47 | 48 | def __getitem__(self, key: str) -> Any: 49 | """ 50 | Allows to access Response fields by `response[field]`. 51 | Notice that this will fall with `KeyError` if field was not found in the response. 52 | """ 53 | if key not in self._response_object: 54 | raise KeyError(f"{key} does not exist in the response!") 55 | field_value = self._response_object.get(key) 56 | return field_value 57 | 58 | def __getattr__(self, attribute_name: str) -> Any: 59 | """ 60 | Allows to access Response fields by `response.my_response_var`. 61 | Notice that this will fall with `AttributeError` if field was not found in the response. 62 | """ 63 | if attribute_name not in self._response_object: 64 | raise AttributeError(f"{attribute_name} does not exist in the response!") 65 | attribute_value = self._response_object.get(attribute_name) 66 | return attribute_value 67 | 68 | def get_version(self) -> str: 69 | """ 70 | Returns response API version. 71 | """ 72 | return self._response_version 73 | 74 | def get_response_object(self) -> Dict: 75 | """ 76 | Returns response object. 77 | """ 78 | return self._response_object 79 | 80 | def raw_json(self) -> Dict: 81 | """ 82 | Returns raw JSON from the response. 83 | WARNING: Do not use this method. 84 | """ 85 | return self._raw_json 86 | 87 | def raw_response(self) -> _HttpResponse: 88 | """ 89 | Returns raw response object. 90 | WARNING: Do not use this method. 91 | """ 92 | return self._raw_response 93 | -------------------------------------------------------------------------------- /gatey_sdk/transports/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Transports for Client. 3 | """ 4 | 5 | from typing import Any, Union, Optional 6 | 7 | from gatey_sdk.transports.base import BaseTransport 8 | from gatey_sdk.transports.http import HttpTransport 9 | from gatey_sdk.transports.func import FuncTransport 10 | from gatey_sdk.transports.void import VoidTransport 11 | from gatey_sdk.transports.print import PrintTransport 12 | 13 | from gatey_sdk.exceptions import ( 14 | GateyTransportImproperlyConfiguredError, 15 | ) 16 | from gatey_sdk.api import Api 17 | from gatey_sdk.auth import Auth 18 | 19 | 20 | def build_transport_instance( 21 | transport_argument: Any = None, 22 | api: Optional[Api] = None, 23 | auth: Optional[Auth] = None, 24 | ) -> Union[BaseTransport, None]: 25 | """ 26 | Builds transport instance by transport argument. 27 | """ 28 | transport_class = None 29 | 30 | if transport_argument is None: 31 | # If nothing is passed, should be default http transport type. 32 | return HttpTransport(api=api, auth=auth) 33 | 34 | if isinstance(transport_argument, type) and issubclass( 35 | transport_argument, BaseTransport 36 | ): 37 | # Passed subclass (type) of BaseTransport as transport. 38 | # Should be instantiated as cls. 39 | transport_class = transport_argument 40 | if transport_class in (VoidTransport, PrintTransport): 41 | return transport_class() 42 | try: 43 | return transport_class(api=api, auth=auth) 44 | except TypeError as _transport_params_error: 45 | raise GateyTransportImproperlyConfiguredError( 46 | "Failed to build transport instance. Please instantiate before or except your transport to handle `api`, `auth` params in constructor!" 47 | ) from _transport_params_error 48 | 49 | if isinstance(transport_argument, BaseTransport): 50 | # Passed already constructed transport, should do nothing. 51 | return transport_argument 52 | 53 | if callable(transport_argument): 54 | # Passed callable (function) as transport. 55 | # Should be Function transport, as it handles raw function call. 56 | return FuncTransport(func=transport_argument) 57 | 58 | # Unable to instantiate transport instance. 59 | raise GateyTransportImproperlyConfiguredError( 60 | "Failed to build transport instance. Please pass valid transport argument!" 61 | ) 62 | 63 | 64 | # Base transport should be used as typing 65 | # (polymorphism for expecting any implementation of abstract class as interface) 66 | # You are not supposed to implement own class-transports (as there is always no need for that) 67 | # use FuncTransport with your function, to make work done with native library implementation. 68 | __all__ = [ 69 | # Typing. 70 | "BaseTransport", 71 | # Default. 72 | "HttpTransport", 73 | # Debug. 74 | "FuncTransport", 75 | "PrintTransport", 76 | "VoidTransport", 77 | ] 78 | -------------------------------------------------------------------------------- /gatey_sdk/transports/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base abstract class for all transports. 3 | """ 4 | 5 | from typing import Dict, Callable, Any 6 | from gatey_sdk.exceptions import GateyError 7 | 8 | # There is need in typing.ParamSpec which is 3.10 feature, 9 | # that is not supported in <=3.9, need better way to type hint decorated method. 10 | 11 | 12 | # Above public field because of requirements below. 13 | def _transport_base_sender_wrapper( 14 | func: Callable[[Dict], Any] 15 | ) -> Callable[[Dict], bool]: 16 | """ 17 | Wrapper for transport send event method that converts result to success state (boolean). 18 | """ 19 | 20 | def wrapper(*args, **kwargs) -> bool: 21 | fail_fast = kwargs.pop("__fail_fast", False) 22 | try: 23 | func(*args, **kwargs) 24 | except GateyError as internal_exception: 25 | if fail_fast: 26 | raise internal_exception 27 | return False 28 | else: 29 | return True 30 | 31 | return wrapper 32 | 33 | 34 | class BaseTransport: 35 | """ 36 | Base transport class. Cannot be used as transport. 37 | Abstract class for implementing transport classes. 38 | """ 39 | 40 | def __init__(self): 41 | pass 42 | 43 | # -> BaseTransport.transport_base_sender_wrapper 44 | # For inherited. 45 | @_transport_base_sender_wrapper 46 | def send_event(self, event_dict: Dict) -> None: 47 | """ 48 | Handles transport event callback (handle event sending). 49 | Should be inherited from BaseTransport and implemented in transports. 50 | """ 51 | raise NotImplementedError() 52 | 53 | @staticmethod 54 | def transport_base_sender_wrapper( 55 | func: Callable[[Dict], Any] 56 | ) -> Callable[[Dict], bool]: 57 | """ 58 | Wrapper for transports send event methods that converts result to success state. 59 | """ 60 | return _transport_base_sender_wrapper(func) 61 | 62 | 63 | __all__ = ["BaseTransport"] 64 | -------------------------------------------------------------------------------- /gatey_sdk/transports/func.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=arguments-differ 2 | # pylint: disable=raise-missing-from 3 | """ 4 | Function transport. Calls your function when event sends. 5 | """ 6 | 7 | from typing import Callable, Dict, Any 8 | from gatey_sdk.transports.base import BaseTransport 9 | from gatey_sdk.exceptions import GateyTransportError 10 | 11 | 12 | class FuncTransport(BaseTransport): 13 | """ 14 | Function transport. Calls your function when event sends. 15 | """ 16 | 17 | skip_to_internal_exception: bool = False 18 | _function: Callable[..., Any] 19 | 20 | def __init__(self, func: Callable, *, skip_to_internal_exception: bool = False): 21 | """ 22 | :param function: Function to call when event sends. 23 | """ 24 | BaseTransport.__init__(self) 25 | self.skip_to_internal_exception = skip_to_internal_exception 26 | self._function = func 27 | 28 | @BaseTransport.transport_base_sender_wrapper 29 | def send_event(self, event_dict: Dict) -> None: 30 | """ 31 | Handles transport event callback (handle event sending). 32 | Function transport just takes event and passed it raw to function call. 33 | """ 34 | if self.skip_to_internal_exception: 35 | try: 36 | self._function(event_dict) 37 | except Exception as _: 38 | raise GateyTransportError( 39 | "Unable to handle event send with Function transport (FuncTransport)." 40 | ) 41 | return 42 | self._function(event_dict) 43 | -------------------------------------------------------------------------------- /gatey_sdk/transports/http.py: -------------------------------------------------------------------------------- 1 | """ 2 | HTTP Transport. Sends event to the Gatey Server when event sends. 3 | """ 4 | import json 5 | from typing import Optional, Dict 6 | from gatey_sdk.transports.base import BaseTransport 7 | from gatey_sdk.api import Api 8 | from gatey_sdk.auth import Auth 9 | from gatey_sdk.exceptions import ( 10 | GateyTransportImproperlyConfiguredError, 11 | ) 12 | 13 | 14 | class HttpTransport(BaseTransport): 15 | """ 16 | HTTP Transport. Sends event to the Gatey Server when event sends. 17 | """ 18 | 19 | # Allowed aggreation / composition.. 20 | _api_provider: Api = None 21 | _auth_provider: Optional[Auth] = None 22 | 23 | def __init__(self, api: Optional[Api] = None, auth: Optional[Auth] = None): 24 | """ 25 | :param api: Api provider. 26 | :param auth: Authentication provider. 27 | """ 28 | 29 | BaseTransport.__init__(self) 30 | self._auth_provider = auth if auth else Auth() 31 | self._api_provider = api if api else Api(self._auth_provider) 32 | self._check_improperly_configured() 33 | 34 | @BaseTransport.transport_base_sender_wrapper 35 | def send_event(self, event_dict: Dict) -> None: 36 | """ 37 | Sends event to the Gatey API server. 38 | """ 39 | api_params = self._api_params_from_event_dict(event_dict=event_dict) 40 | self._api_provider.method("event.capture", send_project_auth=True, **api_params) 41 | 42 | @staticmethod 43 | def _api_params_from_event_dict(event_dict: Dict[str, str]) -> Dict[str, str]: 44 | """ 45 | Converts event dict to ready for sending API params dict. 46 | """ 47 | api_params = { 48 | "level": event_dict["level"], 49 | } 50 | 51 | event_params = ["exception", "message", "tags"] 52 | 53 | for param in event_params: 54 | if param in event_dict: 55 | api_params[param] = json.dumps(event_dict[param]) 56 | 57 | return api_params 58 | 59 | def _check_improperly_configured(self): 60 | """ 61 | Raises error if auth provider improperly configured for sending event. 62 | """ 63 | if self._auth_provider.project_id is None: 64 | raise GateyTransportImproperlyConfiguredError( 65 | "HttpTransport improperly configured! No project id found in auth provider!" 66 | ) 67 | if ( 68 | self._auth_provider.server_secret is None 69 | and self._auth_provider.client_secret is None 70 | ): 71 | raise GateyTransportImproperlyConfiguredError( 72 | "HttpTransport improperly configured! No client / server secret found in auth provider!" 73 | ) 74 | -------------------------------------------------------------------------------- /gatey_sdk/transports/print.py: -------------------------------------------------------------------------------- 1 | """ 2 | Print transport. Prints event data, used ONLY as test environment. 3 | """ 4 | 5 | import json 6 | from typing import Callable, Any, Dict, Optional, Union 7 | from gatey_sdk.transports.base import BaseTransport 8 | 9 | 10 | class PrintTransport(BaseTransport): 11 | """ 12 | Print transport. Prints event data, used ONLY as test environment. 13 | """ 14 | 15 | def __init__( 16 | self, 17 | indent: Optional[Union[int, str]] = 2, 18 | prepare_event: Optional[Callable[[Dict], Dict]] = None, 19 | print_function: Optional[Callable[[Dict], Any]] = None, 20 | ): 21 | """ 22 | :param indent: Indent for json convertion 23 | :param prepare_event: Function that will be called with event and should return event (can be used for clearing unused data to print) 24 | :param print_function: Function to pass prepared event data to. 25 | """ 26 | BaseTransport.__init__(self) 27 | self._indent = indent 28 | self._prepare_event = prepare_event if prepare_event else lambda e: e 29 | self._print_function = print_function if print_function else print 30 | 31 | @BaseTransport.transport_base_sender_wrapper 32 | def send_event(self, event_dict: Dict) -> None: 33 | """ 34 | Handles transport event callback (handle event sending). 35 | Print event data. 36 | """ 37 | print( 38 | json.dumps( 39 | self._prepare_event(event_dict), 40 | indent=self._indent, 41 | sort_keys=True, 42 | ) 43 | ) 44 | -------------------------------------------------------------------------------- /gatey_sdk/transports/void.py: -------------------------------------------------------------------------------- 1 | """ 2 | Void transport. Does nothing, used as test environment. 3 | """ 4 | 5 | from gatey_sdk.transports.base import BaseTransport 6 | 7 | 8 | class VoidTransport(BaseTransport): 9 | """ 10 | Void transport. Does nothing, used as test environment. 11 | """ 12 | 13 | @BaseTransport.transport_base_sender_wrapper 14 | def send_event(self, *args, **kwargs) -> None: 15 | """ 16 | Handles transport event callback (handle event sending). 17 | Does nothing. 18 | """ 19 | -------------------------------------------------------------------------------- /gatey_sdk/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utils stuff. 3 | """ 4 | 5 | import sys 6 | import platform 7 | 8 | from typing import Any, Dict 9 | 10 | from gatey_sdk.consts import SDK_INFORMATION_DICT 11 | from gatey_sdk.consts import ( 12 | RUNTIME_NAME, 13 | ) 14 | 15 | 16 | def remove_trailing_slash(url: str) -> str: 17 | """ 18 | Removes trailing slash from a URL. 19 | Example: `http://example.com/` will become `http://example.com` (No trailing slash) 20 | 21 | :param url: The URL to remove trailing slash. 22 | """ 23 | if not isinstance(url, str): 24 | raise TypeError("URL must be a string!") 25 | 26 | if url.endswith("/"): 27 | url = url[:-1] 28 | 29 | return url 30 | 31 | 32 | def get_additional_event_tags( 33 | include_platform_info: bool = True, 34 | include_runtime_info: bool = True, 35 | include_sdk_info: bool = True, 36 | ) -> Dict[str, Any]: 37 | """ 38 | Returns additional event dictionary for tags with event information such as SDK information, platform information etc. 39 | """ 40 | additional_event_tags = dict() 41 | if include_sdk_info: 42 | additional_event_tags.update(SDK_INFORMATION_DICT) 43 | if include_platform_info: 44 | additional_event_tags.update(get_platform_event_tags()) 45 | if include_runtime_info: 46 | additional_event_tags.update(get_runtime_event_tags()) 47 | return additional_event_tags 48 | 49 | 50 | def get_platform_event_tags() -> Dict[str, Any]: 51 | """ 52 | Returns platform information for event data tags. 53 | """ 54 | 55 | platform_os = platform.system() 56 | platform_network_name = platform.node() 57 | platform_event_data_tags = { 58 | "os": platform_os, 59 | "os.node": platform_network_name, 60 | "os.version": platform.version(), # For major there is `platform.release()` 61 | "os.bits": platform.architecture()[0], 62 | } 63 | 64 | return platform_event_data_tags 65 | 66 | 67 | def get_runtime_event_tags() -> Dict: 68 | """ 69 | Returns runtime information event data tags. 70 | """ 71 | runtime_name = RUNTIME_NAME 72 | runtime_version = sys.version_info 73 | runtime_version = f"{runtime_version[0]}.{runtime_version[1]}.{runtime_version[2]}-{runtime_version[3]}-{runtime_version[4]}" 74 | return { 75 | "runtime.name": runtime_name, 76 | "runtime.ver": runtime_version, 77 | "runtime.impl": platform.python_implementation(), 78 | } 79 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "gatey-sdk" 3 | version = "0.0.10" 4 | description = "Python client for Gatey (https://gatey.florgon.com)" 5 | authors = ["Florgon Team and Contributors"] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.7" 10 | requests = "^2.28.1" 11 | 12 | [tool.poetry.dev-dependencies] 13 | 14 | [build-system] 15 | requires = ["poetry-core>=1.0.0"] 16 | build-backend = "poetry.core.masonry.api" 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Florgon Gatey SDK. 3 | Setup tools script. 4 | """ 5 | 6 | import os 7 | from setuptools import setup, find_packages 8 | 9 | 10 | # Read and pass all data from version file (module.) 11 | version_file = {} 12 | with open( 13 | os.path.join( 14 | os.path.abspath(os.path.dirname(__file__)), "gatey_sdk", "__version__.py" 15 | ), 16 | "r", 17 | "utf-8", 18 | ) as f: 19 | exec(f.read(), version_file) 20 | 21 | # Read whole readme file. 22 | with open("README.md", "r", "utf-8") as f: 23 | readme = f.read() 24 | 25 | classifiers = [ 26 | "Development Status :: 3 - Alpha", 27 | "Environment :: Web Environment", 28 | "Intended Audience :: Developers", 29 | "License :: OSI Approved :: MIT License", 30 | "Natural Language :: English", 31 | "Operating System :: OS Independent", 32 | "Programming Language :: Python", 33 | "Programming Language :: Python :: 3", 34 | "Programming Language :: Python :: 3.7", 35 | "Programming Language :: Python :: 3.8", 36 | "Programming Language :: Python :: 3.9", 37 | "Programming Language :: Python :: 3.10", 38 | "Programming Language :: Python :: 3.11", 39 | "Programming Language :: Python :: 3 :: Only", 40 | "Programming Language :: Python :: Implementation :: CPython", 41 | "Programming Language :: Python :: Implementation :: PyPy", 42 | "Topic :: Software Development :: Libraries :: Python Modules", 43 | ] 44 | project_urls = { 45 | "Documentation": "https://github.com/florgon/gatey-sdk-py", 46 | "Source": "https://github.com/florgon/gatey-sdk-py", 47 | } 48 | setup( 49 | name=version_file["__title__"], 50 | version=version_file["__version__"], 51 | description=version_file["__description__"], 52 | long_description=readme, 53 | long_description_content_type="text/markdown", 54 | author=version_file["__author__"], 55 | author_email=version_file["__author_email__"], 56 | url=version_file["__url__"], 57 | packages=find_packages(), 58 | package_data={"": ["LICENSE"], "gatey_sdk": ["py.typed"]}, 59 | package_dir={"gatey_sdk": "gatey_sdk"}, 60 | include_package_data=True, 61 | license=version_file["__license__"], 62 | python_requires=">=3.7", 63 | install_requires=["requests^2.28.1"], 64 | classifiers=classifiers, 65 | project_urls=project_urls, 66 | zip_safe=False, 67 | ) 68 | --------------------------------------------------------------------------------