├── .coveragerc ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── feature_request.md │ └── question.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ └── pre-commit.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── api │ ├── auth.md │ ├── clients.md │ ├── consumer.md │ ├── converters.md │ ├── decorators.md │ ├── index.md │ └── types.md ├── index.md ├── plugins │ └── main.py └── user │ ├── auth.md │ ├── clients.md │ ├── install.md │ ├── introduction.md │ ├── quickstart.md │ ├── serialization.md │ └── tips.md ├── examples ├── README.md ├── async-requests │ ├── README.md │ ├── asyncio_example.py │ ├── github.py │ └── twisted_example.py ├── github-api │ ├── README.md │ ├── Server.py │ ├── Tests.py │ └── keys.sh ├── handler_callbacks │ ├── README.md │ ├── Server.py │ └── handlers_example.py └── marshmallow │ ├── README.md │ ├── github.py │ ├── main.py │ └── schemas.py ├── mkdocs.yml ├── pyproject.toml ├── rtd_requirements.txt ├── ruff.toml ├── tests ├── __init__.py ├── conftest.py ├── integration │ ├── __init__.py │ ├── conftest.py │ ├── test_basic.py │ ├── test_extend.py │ ├── test_form_url_encoded.py │ ├── test_handlers.py │ ├── test_handlers_aiohttp.py │ ├── test_multipart.py │ ├── test_ratelimit.py │ ├── test_retry.py │ ├── test_retry_aiohttp.py │ └── test_returns.py └── unit │ ├── __init__.py │ ├── conftest.py │ ├── test__extras.py │ ├── test_aiohttp_client.py │ ├── test_arguments.py │ ├── test_auth.py │ ├── test_builder.py │ ├── test_clients.py │ ├── test_commands.py │ ├── test_converters.py │ ├── test_decorators.py │ ├── test_helpers.py │ ├── test_hooks.py │ ├── test_io.py │ ├── test_models.py │ ├── test_retry.py │ ├── test_returns.py │ ├── test_session.py │ └── test_utils.py ├── uplink ├── __about__.py ├── __init__.py ├── _extras.py ├── arguments.py ├── auth.py ├── builder.py ├── clients │ ├── __init__.py │ ├── aiohttp_.py │ ├── exceptions.py │ ├── interfaces.py │ ├── io │ │ ├── __init__.py │ │ ├── asyncio_strategy.py │ │ ├── blocking_strategy.py │ │ ├── execution.py │ │ ├── interfaces.py │ │ ├── state.py │ │ ├── templates.py │ │ ├── transitions.py │ │ └── twisted_strategy.py │ ├── register.py │ ├── requests_.py │ └── twisted_.py ├── commands.py ├── compat.py ├── converters │ ├── __init__.py │ ├── interfaces.py │ ├── keys.py │ ├── marshmallow_.py │ ├── pydantic_.py │ ├── pydantic_v1.py │ ├── pydantic_v2.py │ ├── register.py │ ├── standard.py │ └── typing_.py ├── decorators.py ├── exceptions.py ├── helpers.py ├── hooks.py ├── interfaces.py ├── models.py ├── ratelimit.py ├── retry │ ├── __init__.py │ ├── _helpers.py │ ├── backoff.py │ ├── retry.py │ ├── stop.py │ └── when.py ├── returns.py ├── session.py ├── types.py └── utils.py ├── uv.lock └── verify_tag.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # Configuration file for coverage.py, used by pytest-cov (see tox.ini) 2 | 3 | [run] 4 | # Whether to measure branch coverage in addition to statement coverage. 5 | branch = True 6 | 7 | # The source to measure during execution. 8 | source = uplink 9 | 10 | # A list of file name patterns, specifying files not to measure. 11 | omit = 12 | uplink/__about__.py 13 | uplink/interfaces.py 14 | uplink/clients/interfaces.py 15 | uplink/clients/io/interfaces.py 16 | uplink/converters/interfaces.py 17 | 18 | [report] 19 | # When running a summary report, show missing lines. 20 | show_missing = True 21 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503 3 | max-line-length = 80 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Report a problem 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 13 | **Expected behavior** 14 | A clear and concise description of what you expected to happen. 15 | 16 | **Additional context** 17 | Add any other context about the problem here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Additional context** 14 | Add any other context or screenshots about the feature request here. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Pose an inquiry 4 | 5 | --- 6 | 7 | -- 8 | **Important:** Before creating an issue, please consider posting your question to our [GitHub Discussions](https://github.com/prkumar/uplink/discussions) page first, especially if you suspect that the underlying problem is not a bug nor a feature request. 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Fixes # . 2 | 3 | Changes proposed in this pull request: 4 | - 5 | - 6 | - 7 | 8 | Attention: @prkumar -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - "**" 9 | pull_request: {} 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["310", "311", "312", "313"] 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Install uv 24 | uses: astral-sh/setup-uv@v5 25 | 26 | - name: Run tests 27 | run: uv tool run --with=tox-uv tox r -e py${{ matrix.python-version }} 28 | 29 | publish: 30 | needs: test 31 | runs-on: ubuntu-latest 32 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 33 | environment: release 34 | permissions: 35 | # IMPORTANT: This is required to publish to PyPI using Trusted Publishing. 36 | # See: https://docs.pypi.org/trusted-publishers/using-a-publisher/ 37 | id-token: write 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | - name: Install uv 44 | uses: astral-sh/setup-uv@v5 45 | 46 | - name: Build package 47 | run: uv build 48 | 49 | - name: Publish to PyPI 50 | run: uv publish --trusted-publishing always 51 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master] 7 | 8 | env: 9 | SKIP: no-commit-to-branch 10 | 11 | jobs: 12 | pre-commit: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-python@v5 17 | - uses: pre-commit/action@v3.0.1 18 | 19 | -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | pip-wheel-metadata/ 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # PyBuilder 65 | target/ 66 | 67 | # Jupyter Notebook 68 | .ipynb_checkpoints 69 | 70 | # pyenv 71 | .python-version 72 | 73 | # celery beat schedule file 74 | celerybeat-schedule 75 | 76 | # SageMath parsed files 77 | *.sage.py 78 | 79 | # Environments 80 | .env 81 | .venv 82 | env/ 83 | venv/ 84 | ENV/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | .spyproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | # mkdocs documentation 94 | /docs/changelog.md 95 | /site 96 | /site.zip 97 | 98 | # mypy 99 | .mypy_cache/ 100 | 101 | # Jetbrains 102 | .idea/ 103 | 104 | # pytest 105 | .pytest_cache 106 | 107 | # Pipfile & Pipfile.lock 108 | # 109 | # When developing libraries, it is generally recommended to exclude 110 | # Pipfile and Pipfile.lock from version control [1]. Notably, including 111 | # Pipfile in the repo is fine when it is used to specify development 112 | # dependencies (e.g., tox), as long as the file does not repeat abstract 113 | # dependencies that are already defined within setup.py [2]. 114 | # 115 | # [1]: https://github.com/pypa/pipenv/issues/1911#issuecomment-379422008 116 | # [2]: https://docs.pipenv.org/advanced/#pipfile-vs-setup-py 117 | # 118 | Pipfile.lock 119 | 120 | # macOS 121 | .DS_Store 122 | 123 | # VS Code 124 | .vscode/ 125 | .envrc 126 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: pretty-format-json 6 | args: [--autofix] 7 | - id: check-added-large-files 8 | args: ['--maxkb=5120'] 9 | - id: no-commit-to-branch 10 | args: [-p master] 11 | - repo: https://github.com/charliermarsh/ruff-pre-commit 12 | rev: 'v0.9.8' 13 | hooks: 14 | - id: ruff 15 | args: [--fix] 16 | - id: ruff-format 17 | 18 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at raj.pritvi.kumar@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 P. Raj Kumar 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 | -------------------------------------------------------------------------------- /docs/api/auth.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | The `auth` parameter of the `Consumer` constructor offers a way to 4 | define an auth method to use for all requests. 5 | 6 | ``` python 7 | auth_method = SomeAuthMethod(...) 8 | github = GitHub(BASE_URL, auth=auth_method) 9 | ``` 10 | 11 | ::: uplink.auth.BasicAuth 12 | options: 13 | show_bases: false 14 | members: false 15 | inherited_members: false 16 | 17 | ::: uplink.auth.ProxyAuth 18 | options: 19 | show_bases: false 20 | members: false 21 | inherited_members: false 22 | 23 | ::: uplink.auth.BearerToken 24 | options: 25 | show_bases: false 26 | members: false 27 | inherited_members: false 28 | 29 | ::: uplink.auth.MultiAuth 30 | options: 31 | show_bases: false 32 | members: false 33 | inherited_members: false 34 | 35 | ::: uplink.auth.ApiTokenParam 36 | options: 37 | show_bases: false 38 | members: false 39 | inherited_members: false 40 | -------------------------------------------------------------------------------- /docs/api/clients.md: -------------------------------------------------------------------------------- 1 | # HTTP Clients 2 | 3 | The `client` parameter of the `Consumer` constructor offers a way 4 | to swap out Requests with another HTTP client, including those listed here: 5 | 6 | ```python 7 | github = GitHub(BASE_URL, client=...) 8 | ``` 9 | 10 | ## Requests 11 | 12 | ::: uplink.RequestsClient 13 | options: 14 | members: false 15 | inherited_members: false 16 | 17 | ## Aiohttp 18 | 19 | ::: uplink.AiohttpClient 20 | options: 21 | members: false 22 | inherited_members: false 23 | 24 | ## Twisted 25 | 26 | ::: uplink.TwistedClient 27 | options: 28 | members: false 29 | inherited_members: false 30 | -------------------------------------------------------------------------------- /docs/api/consumer.md: -------------------------------------------------------------------------------- 1 | # The Base Consumer Class 2 | 3 | ## Consumer 4 | 5 | ::: uplink.Consumer 6 | options: 7 | members: 8 | - exceptions 9 | - session 10 | inherited_members: false 11 | 12 | ## Session 13 | 14 | ::: uplink.session.Session 15 | options: 16 | members: 17 | - auth 18 | - base_url 19 | - context 20 | - headers 21 | - inject 22 | - params 23 | -------------------------------------------------------------------------------- /docs/api/converters.md: -------------------------------------------------------------------------------- 1 | # Converters 2 | 3 | The `converter` parameter of the `uplink.Consumer` constructor accepts a custom adapter class that handles serialization of HTTP request properties and deserialization of HTTP response objects: 4 | 5 | ```python 6 | github = GitHub(BASE_URL, converter=...) 7 | ``` 8 | 9 | Starting with version v0.5, some out-of-the-box converters are included automatically and don't need to be explicitly provided through the `converter` parameter. These implementations are detailed below. 10 | 11 | ## Marshmallow 12 | 13 | Uplink comes with optional support for `marshmallow`. 14 | 15 | ::: uplink.converters.MarshmallowConverter 16 | options: 17 | members: false 18 | inherited_members: false 19 | 20 | !!! note 21 | Starting with version v0.5, this converter factory is automatically included if you have `marshmallow` installed, so you don't need to provide it when constructing your consumer instances. 22 | 23 | ## Pydantic 24 | 25 | !!! info "New in version v0.9.2" 26 | Uplink comes with optional support for `pydantic`. 27 | 28 | ::: uplink.converters.PydanticConverter 29 | options: 30 | members: false 31 | inherited_members: false 32 | 33 | !!! note 34 | Starting with version v0.9.2, this converter factory is automatically included if you have `pydantic` installed, so you don't need to provide it when constructing your consumer instances. 35 | 36 | ## Converting Collections 37 | 38 | !!! info "New in version v0.5.0" 39 | Uplink can convert collections of a type, such as deserializing a response body into a list of users. If you have `typing` installed (the module is part of the standard library starting Python 3.5), you can use type hints (see [PEP 484](https://www.python.org/dev/peps/pep-0484/)) to specify such conversions. You can also leverage this feature without `typing` by using one of the proxy types defined in `uplink.types`. 40 | 41 | The following converter factory implements this feature and is automatically included, so you don't need to provide it when constructing your consumer instance: 42 | 43 | ::: uplink.converters.TypingConverter 44 | options: 45 | members: false 46 | inherited_members: false 47 | 48 | Here are the collection types defined in `uplink.types`. You can use these or the corresponding type hints from `typing` to leverage this feature: 49 | 50 | ::: uplink.types.List 51 | options: 52 | show_bases: false 53 | members: false 54 | inherited_members: false 55 | 56 | ::: uplink.types.Dict 57 | options: 58 | show_bases: false 59 | members: false 60 | inherited_members: false 61 | 62 | ## Writing Custom JSON Converters 63 | 64 | As a shorthand, you can define custom JSON converters using the `@loads.from_json` (deserialization) and `@dumps.to_json` (serialization) decorators. 65 | 66 | These classes can be used as decorators to create converters of a class and its subclasses: 67 | 68 | ```python 69 | # Creates a converter that can deserialize the given `json` in to an 70 | # instance of a `Model` subtype. 71 | @loads.from_json(Model) 72 | def load_model_from_json(model_type, json): 73 | ... 74 | ``` 75 | 76 | !!! note 77 | Unlike consumer methods, these functions should be defined outside of a class scope. 78 | 79 | To use the converter, provide the generated converter object when instantiating a `uplink.Consumer` subclass, through the `converter` constructor parameter: 80 | 81 | ```python 82 | github = GitHub(BASE_URL, converter=load_model_from_json) 83 | ``` 84 | 85 | Alternatively, you can add the `@install` decorator to register the converter function as a default converter, meaning the converter will be included automatically with any consumer instance and doesn't need to be explicitly provided through the `converter` parameter: 86 | 87 | ```python 88 | from uplink import install, loads 89 | 90 | # Register the function as a default loader for the given model class. 91 | @install 92 | @loads.from_json(Model) 93 | def load_model_from_json(model_type, json): 94 | ... 95 | ``` 96 | 97 | ::: uplink.loads 98 | options: 99 | members: ["from_json"] 100 | 101 | ::: uplink.dumps 102 | options: 103 | ffmembers: ["to_json"] 104 | -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | This guide details the classes and methods in Uplink's public API. 4 | -------------------------------------------------------------------------------- /docs/api/types.md: -------------------------------------------------------------------------------- 1 | # Function Annotations 2 | 3 | For programming in general, function parameters drive a function's 4 | dynamic behavior; a function's output depends normally on its inputs. 5 | With `uplink`, function arguments parametrize an HTTP request, and you 6 | indicate the dynamic parts of the request by appropriately annotating 7 | those arguments with the classes detailed in this section. 8 | 9 | ::: uplink.Path 10 | options: 11 | show_bases: false 12 | members: false 13 | inherited_members: false 14 | 15 | ::: uplink.Query 16 | options: 17 | show_bases: false 18 | members: false 19 | inherited_members: false 20 | 21 | ::: uplink.QueryMap 22 | options: 23 | show_bases: false 24 | members: false 25 | inherited_members: false 26 | 27 | ::: uplink.Header 28 | options: 29 | show_bases: false 30 | members: false 31 | inherited_members: false 32 | 33 | ::: uplink.HeaderMap 34 | options: 35 | show_bases: false 36 | members: false 37 | inherited_members: false 38 | 39 | ::: uplink.Field 40 | options: 41 | show_bases: false 42 | members: false 43 | inherited_members: false 44 | 45 | ::: uplink.FieldMap 46 | options: 47 | show_bases: false 48 | members: false 49 | inherited_members: false 50 | 51 | ::: uplink.Part 52 | options: 53 | show_bases: false 54 | members: false 55 | inherited_members: false 56 | 57 | ::: uplink.PartMap 58 | options: 59 | show_bases: false 60 | members: false 61 | inherited_members: false 62 | 63 | ::: uplink.Body 64 | options: 65 | show_bases: false 66 | members: false 67 | inherited_members: false 68 | 69 | ::: uplink.Url 70 | options: 71 | show_bases: false 72 | members: false 73 | inherited_members: false 74 | 75 | ::: uplink.Timeout 76 | options: 77 | show_bases: false 78 | members: false 79 | inherited_members: false 80 | 81 | ::: uplink.Context 82 | options: 83 | show_bases: false 84 | members: false 85 | inherited_members: false 86 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Uplink 📡 2 | 3 | A Declarative HTTP Client for Python. Inspired by 4 | [Retrofit](http://square.github.io/retrofit/). 5 | 6 | [![Release](https://img.shields.io/github/release/prkumar/uplink/all.svg)](https://github.com/prkumar/uplink) 7 | [![Python 8 | Version](https://img.shields.io/pypi/pyversions/uplink.svg)](https://pypi.python.org/pypi/uplink) 9 | [![License](https://img.shields.io/github/license/prkumar/uplink.svg)](https://github.com/prkumar/uplink/blob/master/LICENSE) 10 | [![Codecov](https://img.shields.io/codecov/c/github/prkumar/uplink.svg)](https://codecov.io/gh/prkumar/uplink) 11 | [![GitHub 12 | Discussions](https://img.shields.io/github/discussions/prkumar/uplink.png)](https://github.com/prkumar/uplink/discussions) 13 | 14 | !!! note 15 | Uplink is in beta development. The public API is still evolving, but we 16 | expect most changes to be backwards compatible at this point. 17 | 18 | Uplink turns your HTTP API into a Python class. 19 | 20 | ``` python 21 | from uplink import Consumer, get, Path, Query 22 | 23 | 24 | class GitHub(Consumer): 25 | """A Python Client for the GitHub API.""" 26 | 27 | @get("users/{user}/repos") 28 | def get_repos(self, user: Path, sort_by: Query("sort")): 29 | """Get user's public repositories.""" 30 | ``` 31 | 32 | Build an instance to interact with the webservice. 33 | 34 | ``` python 35 | github = GitHub(base_url="https://api.github.com/") 36 | repos = github.get_repos("prkumar", sort_by="created") 37 | ``` 38 | 39 | Then, executing an HTTP request is as simply as invoking a method. 40 | 41 | ``` python 42 | print(repos.json()) 43 | # Output: [{'id': 64778136, 'name': 'linguist', ... 44 | ``` 45 | 46 | For sending non-blocking requests, Uplink comes with support for 47 | `aiohttp` and `twisted` 48 | ([example](https://github.com/prkumar/uplink/tree/master/examples/async-requests)). 49 | 50 | ## Features 51 | 52 | - **Quickly Define Structured API Clients** 53 | - Use decorators and type hints to describe each HTTP request 54 | - JSON, URL-encoded, and multipart request body and file upload 55 | - URL parameter replacement, request headers, and query parameter 56 | support 57 | - **Bring Your Own HTTP Library** 58 | - [Non-blocking I/O 59 | support](https://github.com/prkumar/uplink/tree/master/examples/async-requests) 60 | for Aiohttp and Twisted 61 | - `Supply your own session ` (e.g., 62 | `requests.Session`) for greater control 63 | - **Easy and Transparent Deserialization/Serialization** 64 | - Define `custom converters ` for 65 | your own objects 66 | - Support for 67 | [`marshmallow`](https://github.com/prkumar/uplink/tree/master/examples/marshmallow) 68 | schemas and `handling collections ` 69 | (e.g., list of Users) 70 | - Support for pydantic models and 71 | `handling collections ` (e.g., list of 72 | Repos) 73 | - **Extendable** 74 | - Install optional plugins for additional features (e.g., 75 | [protobuf support](https://github.com/prkumar/uplink-protobuf)) 76 | - Compose 77 | `custom response and error handling ` 78 | functions as middleware 79 | - **Authentication** 80 | - Built-in support for 81 | `Basic Authentication ` 82 | - Use existing auth libraries for supported clients (e.g., 83 | [`requests-oauthlib`](https://github.com/requests/requests-oauthlib)) 84 | 85 | Uplink officially supports Python 3.10+. 86 | 87 | ## User Testimonials 88 | 89 | **Michael Kennedy** ([@mkennedy](https://twitter.com/mkennedy)), host of 90 | [Talk Python](https://twitter.com/TalkPython) and [Python 91 | Bytes](https://twitter.com/pythonbytes) podcasts- 92 | 93 | > Of course our first reaction when consuming HTTP resources in Python 94 | > is to reach for Requests. But for *structured* APIs, we often want 95 | > more than ad-hoc calls to Requests. We want a client-side API for our 96 | > apps. Uplink is the quickest and simplest way to build just that 97 | > client-side API. Highly recommended. 98 | 99 | **Or Carmi** ([@liiight](https://github.com/liiight)), 100 | [notifiers](https://github.com/notifiers/notifiers) maintainer- 101 | 102 | > Uplink's intelligent usage of decorators and typing leverages the most 103 | > pythonic features in an elegant and dynamic way. If you need to create 104 | > an API abstraction layer, there is really no reason to look elsewhere. 105 | 106 | ## Where to go from here 107 | 108 | - [Quickstart](user/quickstart.md) 109 | - [Installation](user/install.md) 110 | - [Introduction](user/introduction.md) 111 | -------------------------------------------------------------------------------- /docs/plugins/main.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | 4 | from mkdocs.config import Config 5 | 6 | THIS_DIR = Path(__file__).parent 7 | DOCS_DIR = THIS_DIR.parent 8 | PROJECT_ROOT = DOCS_DIR.parent 9 | 10 | 11 | def on_pre_build(config: Config): 12 | """Before the build starts.""" 13 | _add_changelog() 14 | 15 | 16 | def _add_changelog() -> None: 17 | changelog = (PROJECT_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") 18 | changelog = re.sub( 19 | r"(\s)@([\w\-]+)", r"\1[@\2](https://github.com/\2)", changelog, flags=re.I 20 | ) 21 | changelog = re.sub( 22 | r"\[GitHub release]\(", r"[:simple-github: GitHub release](", changelog 23 | ) 24 | changelog = re.sub("@@", "@", changelog) 25 | new_file = DOCS_DIR / "changelog.md" 26 | 27 | # avoid writing file unless the content has changed to avoid infinite build loop 28 | if not new_file.is_file() or new_file.read_text(encoding="utf-8") != changelog: 29 | new_file.write_text(changelog, encoding="utf-8") 30 | -------------------------------------------------------------------------------- /docs/user/auth.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | This section covers how to do authentication with Uplink. 4 | 5 | In v0.4, we added the `auth` parameter to the `uplink.Consumer` 6 | constructor which allowed for sending HTTP Basic Authentication with all 7 | requests. 8 | 9 | In v0.9, we added more auth methods which can be used in the `auth` 10 | parameter of the `uplink.Consumer` constructor. If you are using an 11 | uplink-based API library, the library might extend these methods with 12 | additional API-specific auth methods. 13 | 14 | Some common auth methods are described below, but for a complete list of 15 | auth methods provided with Uplink, see the `auth_methods` reference. 16 | 17 | ## Basic Authentication 18 | 19 | It's simple to construct a consumer that uses HTTP Basic Authentication 20 | with all requests: 21 | 22 | ``` python 23 | github = GitHub(BASE_URL, auth=("user", "pass")) 24 | ``` 25 | 26 | ## Proxy Authentication 27 | 28 | If you need to supply credentials for an intermediate proxy in addition 29 | to the API's HTTP Basic Authentication, use `uplink.auth.MultiAuth` with 30 | `uplink.auth.ProxyAuth` and `uplink.auth.BasicAuth`. 31 | 32 | ``` python 33 | from uplink.auth import BasicAuth, MultiAuth, ProxyAuth 34 | 35 | auth_methods = MultiAuth( 36 | ProxyAuth("proxy_user", "proxy_pass"), 37 | BasicAuth("user", "pass") 38 | ) 39 | github = GitHub(BASE_URL, auth=auth_methods) 40 | ``` 41 | 42 | ## Other Authentication 43 | 44 | Often, APIs accept credentials as header values (e.g., Bearer tokens) or 45 | query parameters. Your request method can handle these types of 46 | authentication by simply accepting the user's credentials as an 47 | argument: 48 | 49 | ``` python 50 | @post("/user") 51 | def update_user(self, access_token: Query, **info: Body): 52 | """Update the user associated to the given access token.""" 53 | ``` 54 | 55 | If several request methods require authentication, you can persist the 56 | token through the consumer's `session ` 57 | property: 58 | 59 | ``` python 60 | class GitHub(Consumer): 61 | 62 | def __init__(self, base_url, access_token): 63 | super(GitHub, self).__init__(base_url=base_url) 64 | self.session.params["access_token"] = access_token 65 | ... 66 | ``` 67 | 68 | As of v0.9, you can also supply these tokens via the `auth` parameter of 69 | the `uplink.Consumer` constructor. This is like adding the token to the 70 | session (above) so that the token is sent as part of every request. 71 | 72 | ``` python 73 | from uplink.auth import ApiTokenParam, ApiTokenHeader, BearerToken 74 | 75 | # Passing an auth token as a query parameter 76 | token_auth = ApiTokenParam("access_token", access_token) 77 | github = GitHub(BASE_URL, auth=token_auth) 78 | 79 | # Passing the token as a header value 80 | token_auth = ApiTokenHeader("Access-Token", access_token) 81 | github = GitHub(BASE_URL, auth=token_auth) 82 | 83 | # Passing a Bearer auth token 84 | bearer_auth = BearerToken(access_token) 85 | github = GitHub(BASE_URL, auth=bearer_auth) 86 | ``` 87 | 88 | ## Using Auth Support for Requests and aiohttp 89 | 90 | As we work towards Uplink's v1.0 release, improving built-in support for 91 | other types of authentication is a continuing goal. 92 | 93 | With that said, if Uplink currently doesn't offer a solution for you 94 | authentication needs, you can always leverage the available auth support 95 | for the underlying HTTP client. 96 | 97 | For instance, `requests` offers out-of-the-box support for making 98 | requests with HTTP Digest Authentication, which you can leverage like 99 | so: 100 | 101 | ``` python 102 | from requests.auth import HTTPDigestAuth 103 | 104 | client = uplink.RequestsClient(cred=HTTPDigestAuth("user", "pass")) 105 | api = MyApi(BASE_URL, client=client) 106 | ``` 107 | 108 | You can also use other third-party libraries that extend auth support 109 | for the underlying client. For instance, you can use 110 | [requests-oauthlib](https://github.com/requests/requests-oauthlib) for 111 | doing OAuth with Requests: 112 | 113 | ``` python 114 | from requests_oauthlib import OAuth2Session 115 | 116 | session = OAuth2Session(...) 117 | api = MyApi(BASE_URL, client=session) 118 | ``` 119 | -------------------------------------------------------------------------------- /docs/user/clients.md: -------------------------------------------------------------------------------- 1 | # Clients 2 | 3 | To use a common English metaphor: Uplink stands on the shoulders of 4 | giants. 5 | 6 | Uplink doesn't implement any code to handle HTTP protocol stuff 7 | directly; for that, the library delegates to an actual HTTP client, such 8 | as Requests or Aiohttp. Whatever backing client you choose, when a 9 | request method on a `uplink.Consumer` subclass is invoked, Uplink 10 | ultimately interacts with the backing library's interface, at minimum to 11 | submit requests and read responses. 12 | 13 | This section covers the interaction between Uplink and the backing HTTP 14 | client library of your choosing, including how to specify your 15 | selection. 16 | 17 | ## Swapping Out the Default HTTP Session 18 | 19 | By default, Uplink sends requests using the Requests library. You can 20 | configure the backing HTTP client object using the `client` parameter of 21 | the `uplink.Consumer` constructor: 22 | 23 | ``` python 24 | github = GitHub(BASE_URL, client=...) 25 | ``` 26 | 27 | For example, you can use the `client` parameter to pass in your own 28 | [Requests 29 | session](http://docs.python-requests.org/en/master/user/advanced/#session-objects) 30 | object: 31 | 32 | ``` python 33 | session = requests.Session() 34 | session.verify = False 35 | github = GitHub(BASE_URL, client=session) 36 | ``` 37 | 38 | Further, this also applies for session objects from other HTTP client 39 | libraries that Uplink supports, such as `aiohttp` (i.e., a custom 40 | `aiohttp.ClientSession` works here, as well). 41 | 42 | Following the above example, the `client` parameter also accepts an 43 | instance of any `requests.Session` subclass. This makes it easy to 44 | leverage functionality from third-party Requests extensions, such as 45 | [requests-oauthlib](https://github.com/requests/requests-oauthlib), with 46 | minimal integration overhead: 47 | 48 | ``` python 49 | from requests_oauthlib import OAuth2Session 50 | 51 | session = OAuth2Session(...) 52 | api = MyApi(BASE_URL, client=session) 53 | ``` 54 | 55 | ## Synchronous vs. Asynchronous 56 | 57 | Notably, Requests blocks while waiting for a response from the server. 58 | For non-blocking requests, Uplink comes with built-in (but optional) 59 | support for `aiohttp` and `twisted`. 60 | 61 | For instance, you can provide the `uplink.AiohttpClient` when 62 | constructing a `uplink.Consumer` instance: 63 | 64 | ``` python 65 | from uplink import AiohttpClient 66 | 67 | github = GitHub(BASE_URL, client=AiohttpClient()) 68 | ``` 69 | 70 | Checkout [this example on 71 | GitHub](https://github.com/prkumar/uplink/tree/master/examples/async-requests) 72 | for more. 73 | 74 | ## Handling Exceptions From the Underlying HTTP Client Library 75 | 76 | Each `uplink.Consumer` instance has an `exceptions 77 | ` property that exposes an enum of standard 78 | HTTP client exceptions that can be handled: 79 | 80 | ``` python 81 | try: 82 | repo = github.create_repo(name="myproject", auto_init=True) 83 | except github.exceptions.ConnectionError: 84 | # Handle client socket error: 85 | ... 86 | ``` 87 | 88 | This approach to handling exceptions decouples your code from the 89 | backing HTTP client, improving code reuse and testability. 90 | 91 | Here are the HTTP client exceptions that are exposed through this property: 92 | 93 | : - `BaseClientException`: Base exception for client connection 94 | errors. 95 | - `ConnectionError`: A client socket error occurred. 96 | - `ConnectionTimeout`: The request timed out while trying to 97 | connect to the remote server. 98 | - `ServerTimeout`: The server did not send any data in the 99 | allotted amount of time. 100 | - `SSLError`: An SSL error occurred. 101 | - `InvalidURL`: URL used for fetching is malformed. 102 | 103 | Of course, you can also explicitly catch a particular client error from 104 | the backing client (e.g., `requests.FileModeWarning`). This may be 105 | useful for handling exceptions that are not exposed through the 106 | `Consumer.exceptions ` property, for 107 | example: 108 | 109 | ``` python 110 | try: 111 | repo = github.create_repo(name="myproject", auto_init=True) 112 | except aiohttp.ContentTypeError: 113 | ... 114 | ``` 115 | 116 | ### Handling Client Exceptions within an `@error_handler` 117 | 118 | The `@error_handler ` decorator registers a 119 | callback to deal with exceptions thrown by the backing HTTP client. 120 | 121 | To provide the decorated callback a reference to the `Consumer` instance 122 | at runtime, set the decorator's optional argument `requires_consumer` to 123 | `True`. This enables the error handler to leverage the consumer's 124 | `exceptions 125 | ` property: 126 | 127 | ``` python 128 | @error_handler(requires_consumer=True) 129 | def raise_api_error(consumer, exc_type, exc_val, exc_tb): 130 | """Wraps client error with custom API error""" 131 | if isinstance(exc_val, consumer.exceptions.ServerTimeout): 132 | # Handle the server timeout specifically: 133 | ... 134 | 135 | class GitHub(Consumer): 136 | @raise_api_error 137 | @post("user/repo") 138 | def create_repo(self, name: Field): 139 | """Create a new repository.""" 140 | ``` 141 | -------------------------------------------------------------------------------- /docs/user/install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Using pip 4 | 5 | With `pip` (or `uv`), you can install Uplink simply by typing: 6 | 7 | $ pip install -U uplink 8 | 9 | ## Download the Source Code 10 | 11 | Uplink's source code is in a [public repository hosted on 12 | GitHub](https://github.com/prkumar/uplink). 13 | 14 | As an alternative to installing with `pip`, you could clone the 15 | repository, 16 | 17 | $ git clone https://github.com/prkumar/uplink.git 18 | 19 | then, install; e.g., with `setup.py`: 20 | 21 | $ cd uplink 22 | $ python setup.py install 23 | 24 | ## Extras 25 | 26 | These are optional integrations and features that extend the library's 27 | core functionality and typically require an additional dependency. 28 | 29 | When installing Uplink with `pip`, you can specify any of the following 30 | extras, to add their respective dependencies to your installation: 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 46 | 47 | 48 | 49 | 54 | 55 | 56 | 57 | 61 | 62 | 63 | 64 | 69 | 70 | 71 |
ExtraDescription
aiohttpEnables uplink.AiohttpClient, for sending 45 | non-blocking requests and receiving awaitable responses.
marshmallowEnables uplink.MarshmallowConverter, for converting 52 | JSON responses directly into Python objects using marshmallow.Schema.
pydanticEnables uplink.PydanticConverter, for converting JSON 59 | responses directly into Python objects using pydantic.BaseModel.
twistedEnables uplink.TwistedClient, for sending 67 | non-blocking requests and receiving ~twisted.internet.defer.Deferred responses.
72 | 73 | To download all available features, run 74 | 75 | $ pip install -U uplink[aiohttp, marshmallow, pydantic, twisted] 76 | -------------------------------------------------------------------------------- /docs/user/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Uplink delivers reusable and self-sufficient objects for accessing HTTP 4 | webservices, with minimal code and user pain. Simply define your 5 | consumers using decorators and function annotations, and we’ll handle 6 | the rest for you... pun intended, obviously 😎 7 | 8 | ## Static Request Handling 9 | 10 | Method decorators describe request properties that are relevant to all 11 | invocations of a consumer method. 12 | 13 | For instance, consider the following GitHub API consumer: 14 | 15 | ``` python 16 | class GitHub(uplink.Consumer): 17 | @uplink.timeout(60) 18 | @uplink.get("/repositories") 19 | def get_repos(self): 20 | """Dump every public repository.""" 21 | ``` 22 | 23 | Annotated with `timeout`, the method `get_repos` will build HTTP 24 | requests that wait an allotted number of seconds -- 60, in this case 25 | --for the server to respond before giving up. 26 | 27 | As method annotations are simply decorators, you can stack one on top of 28 | another for chaining: 29 | 30 | ``` python 31 | class GitHub(uplink.Consumer): 32 | @uplink.headers({"Accept": "application/vnd.github.v3.full+json"}) 33 | @uplink.timeout(60) 34 | @uplink.get("/repositories") 35 | def get_repos(self): 36 | """Dump every public repository.""" 37 | ``` 38 | 39 | ## Dynamic Request Handling 40 | 41 | For programming in general, function parameters drive a function's 42 | dynamic behavior; a function's output depends normally on its inputs. 43 | With `uplink`, function arguments parametrize an HTTP request, and you 44 | indicate the dynamic parts of the request by appropriately annotating 45 | those arguments. 46 | 47 | To illustrate, for the method `get_user` in the following snippet, we 48 | have flagged the argument `username` as a URI placeholder replacement 49 | using the `uplink.Path` annotation: 50 | 51 | ``` python 52 | class GitHub(uplink.Consumer): 53 | @uplink.get("users/{username}") 54 | def get_user(self, username: uplink.Path("username")): pass 55 | ``` 56 | 57 | Invoking this method on a consumer instance, like so: 58 | 59 | ``` python 60 | github.get_user(username="prkumar") 61 | ``` 62 | 63 | Builds an HTTP request that has a URL ending with `users/prkumar`. 64 | 65 | !!! note 66 | As you probably took away from the above example: when parsing the 67 | method's signature for argument annotations, `uplink` skips the instance 68 | reference argument, which is the leading method parameter and usually 69 | named `self`. 70 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This directory contains a few usage examples for Uplink. If you notice a need for a particular example, 4 | feel free to [open an Issue](https://github.com/prkumar/uplink/issues/new). Or, to contribute your own example, 5 | [open a Pull Request](https://github.com/prkumar/uplink/compare) -- please include a README detailing the example's 6 | purpose and add a short description to the "Table of Contents" section below. 7 | 8 | ## Table of Contents 9 | 10 | - **[async-requests](async-requests)**: Use [`aiohttp`](https://github.com/aio-libs/aiohttp) or 11 | [`twisted`](https://github.com/twisted/twisted) for non-blocking I/O 12 | - **[github-api](github-api)**: Consume the GitHub API to serve a downstream HTTP service with Flask. 13 | - **[handler_callbacks](handler_callbacks)**: Handle client errors and responses using callbacks. 14 | - **[marshmallow](marshmallow)**: Create clients that automatically deserialize responses with the 15 | help of [`marshmallow`](https://marshmallow.readthedocs.io/en/latest/). 16 | -------------------------------------------------------------------------------- /examples/async-requests/README.md: -------------------------------------------------------------------------------- 1 | # Non-Blocking Requests with Uplink 2 | 3 | This example details how you can use the same Uplink consumer with different 4 | HTTP clients, with an emphasis on performing non-blocking HTTP requests. 5 | 6 | ## Requirements 7 | 8 | Support for `twisted` and `aiohttp` are optional features. To enable these 9 | extras, you can declare them when installing Uplink with ``pip``: 10 | 11 | ``` 12 | # Install both clients (requires Python 3.4+) 13 | $ pip install -U uplink[twisted, aiohttp] 14 | 15 | # Or, install support for twisted only: 16 | $ pip install -U uplink[twisted] 17 | 18 | # Or, install support for aiohttp only: 19 | $ pip install -U uplink[aiohttp] 20 | ``` 21 | 22 | Notably, while `twisted` features should work on all versions of Python that 23 | Uplink supports, the `aiohttp` library requires Python 3.4 or above. 24 | 25 | ## Overview 26 | 27 | The example includes three Python scripts: 28 | 29 | - `github.py`: Defines a `GitHub` API with two methods: 30 | - `GitHub.get_repos`: Gets all public repositories 31 | - `GitHub.get_contributors`: Lists contributors for the specified repository. 32 | 33 | The other two scripts are functionally identical. They each use the `GitHub` 34 | consumer to fetch contributors for 10 public repositories, concurrently. The 35 | only difference between the scripts is the HTTP client used: 36 | 37 | - `asyncio_example.py`: Uses `aiohttp` for awaitable responses to be run with 38 | an `asyncio` event loop. 39 | - `twisted_example.py`: Uses `requests` with `twisted` (inspired by 40 | [`requests-threads`](https://github.com/requests/requests-threads)) 41 | to create `Deferred` responses. 42 | 43 | -------------------------------------------------------------------------------- /examples/async-requests/asyncio_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of using Uplink with aiohttp for non-blocking HTTP requests. 3 | This should work on Python 3.7 and above. 4 | """ 5 | 6 | import asyncio 7 | 8 | # Local imports 9 | from github import BASE_URL, GitHub 10 | 11 | import uplink 12 | 13 | 14 | async def get_contributors(full_name): 15 | print(f"Getting GitHub repository `{full_name}`") 16 | response = await gh_async.get_contributors(*full_name.split("/")) 17 | json = await response.json() 18 | print(f"response for {full_name}: {json}") 19 | return json 20 | 21 | 22 | if __name__ == "__main__": 23 | # This consumer instance uses Requests to make blocking requests. 24 | gh_sync = GitHub(base_url=BASE_URL) 25 | 26 | # This uses aiohttp, an HTTP client for asyncio. 27 | gh_async = GitHub(base_url=BASE_URL, client=uplink.AiohttpClient()) 28 | 29 | # First, let's fetch a list of all public repositories. 30 | repos = gh_sync.get_repos().json() 31 | 32 | # Use only the first 10 results to avoid hitting the rate limit. 33 | repos = repos[:10] 34 | 35 | # Concurrently fetch the contributors for those repositories. 36 | futures = [get_contributors(repo["full_name"]) for repo in repos] 37 | loop = asyncio.get_event_loop() 38 | loop.run_until_complete(asyncio.gather(*futures)) 39 | -------------------------------------------------------------------------------- /examples/async-requests/github.py: -------------------------------------------------------------------------------- 1 | import uplink 2 | 3 | # Constants 4 | BASE_URL = "https://api.github.com/" 5 | 6 | 7 | @uplink.headers({"Accept": "application/vnd.github.v3.full+json"}) 8 | class GitHub(uplink.Consumer): 9 | @uplink.get("/repositories") 10 | def get_repos(self): 11 | """Gets all public repositories.""" 12 | 13 | @uplink.get("/repos/{owner}/{repo}/contributors") 14 | def get_contributors(self, owner, repo): 15 | """Lists contributors for the specified repository.""" 16 | 17 | 18 | if __name__ == "__main__": 19 | # Executes the code above 20 | # The for loop is used for readability, you can just call print(response.json()) 21 | 22 | # Gets all public repositories 23 | github = GitHub(BASE_URL) 24 | response = github.get_repos() 25 | for repos in response.json(): 26 | print(repos) 27 | 28 | # Lists contributors for a repository 29 | github = GitHub(BASE_URL) 30 | response = github.get_contributors("prkumar", "uplink") 31 | for contributors in response.json(): 32 | print(contributors) 33 | -------------------------------------------------------------------------------- /examples/async-requests/twisted_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of using Uplink with Twisted for non-blocking HTTP requests. 3 | """ 4 | 5 | # Local imports 6 | from github import BASE_URL, GitHub 7 | from twisted.internet import defer, reactor 8 | 9 | import uplink 10 | 11 | 12 | @defer.inlineCallbacks 13 | def get_contributors(full_name): 14 | print(f"Getting GitHub repository `{full_name}`") 15 | response = yield gh_async.get_contributors(*full_name.split("/")) 16 | json = response.json() 17 | print(f"response for {full_name}: {json}") 18 | 19 | 20 | if __name__ == "__main__": 21 | # This consumer instance uses Requests to make blocking requests. 22 | gh_sync = GitHub(base_url=BASE_URL) 23 | 24 | # This uses Twisted with Requests, inspired by `requests-threads`. 25 | gh_async = GitHub(base_url=BASE_URL, client=uplink.TwistedClient()) 26 | 27 | # First, let's fetch a list of all public repositories. 28 | repos = gh_sync.get_repos().json() 29 | 30 | # Use only the first 10 results to avoid hitting the rate limit. 31 | repos = repos[:10] 32 | 33 | # Asynchronously fetch the contributors for those 10 repositories. 34 | deferred = [get_contributors(repo["full_name"]) for repo in repos] 35 | reactor.callLater(2, reactor.stop) # Stop the reactor after 2 secs 36 | reactor.run() 37 | -------------------------------------------------------------------------------- /examples/github-api/README.md: -------------------------------------------------------------------------------- 1 | # GitHub API Example 2 | By: Kareem Moussa ([@itstehkman](https://github.com/itstehkman)) 3 | 4 | --- 5 | 6 | This is an example of using the uplink client. This is an API written in flask that gets the top 7 | users who have commited in repositories matching a given keyword (in the name, readme, or description 8 | in the last month. 9 | 10 | To try this out, first fill out keys.sh with your github api client id and client secret so that 11 | you can use the API. 12 | 13 | Then run 14 | ``` 15 | source keys.sh 16 | python3 Server.py 17 | ``` 18 | 19 | These are the endpoints I've written: 20 | ``` 21 | Get a list of users who have committed in repos matching the keyword since oldest-age weeks ago 22 | /users?keyword=[?oldest-age=] 23 | 24 | Get a list of users who have committed in the given repo since oldest-age weeks ago 25 | /users//repo/[?oldest-age=] 26 | 27 | Get a list of repos matching the keyword 28 | /repos?keyword= 29 | ``` 30 | 31 | I've written a quick test script to try out all the endpoints: 32 | ``` 33 | python3 Tests.py 34 | ``` 35 | -------------------------------------------------------------------------------- /examples/github-api/Server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | # add uplink directory to path 4 | sys.path.insert(0, "../../") 5 | import asyncio 6 | import os 7 | from datetime import datetime, timedelta 8 | 9 | from flask import Flask, jsonify, request 10 | 11 | import uplink 12 | from uplink import Consumer, Query, get, headers 13 | 14 | app = Flask(__name__) 15 | 16 | BASE_URL = "https://api.github.com" 17 | CLIENT_ID = os.environ["CLIENT_ID"] 18 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 19 | 20 | 21 | @headers({"Accept": "application/vnd.github.v3+json"}) 22 | class Github(Consumer): 23 | @get("/search/repositories?q={keyword} in:name,description,readme") 24 | def repos_for_keyword( 25 | self, 26 | keyword, 27 | client_id: Query = CLIENT_ID, 28 | client_secret: Query = CLIENT_SECRET, 29 | ): 30 | """Get a list of repositories which have a given keyword in the name, description or readme""" 31 | pass 32 | 33 | @get("/repos/{user}/{repo_name}/commits") 34 | def commits_for_repo( 35 | self, 36 | user, 37 | repo_name, 38 | since: Query, 39 | client_id: Query = CLIENT_ID, 40 | client_secret: Query = CLIENT_SECRET, 41 | ): 42 | """Get a list of commits in a repo since some start date""" 43 | pass 44 | 45 | 46 | github = Github(BASE_URL, client=uplink.AiohttpClient()) 47 | loop = asyncio.get_event_loop() 48 | 49 | # Helpers 50 | 51 | 52 | async def _repos_for_keyword(keyword): 53 | """Get repos which match the keyword search""" 54 | r = await github.repos_for_keyword(keyword) 55 | r_json = await r.json() 56 | return [item["full_name"] for item in r_json["items"]] 57 | 58 | 59 | async def _users_for_repo(user, repo_name, oldest_age=55): 60 | """Returns users that have commited in a repo in the last N weeks""" 61 | 62 | since = (datetime.now() - timedelta(weeks=oldest_age)).isoformat() 63 | r = await github.commits_for_repo(user, repo_name, since=since) 64 | r_json = await r.json() 65 | users = set() 66 | for commit in r_json: 67 | if "author" in commit and commit["author"] is not None: 68 | user = ( 69 | commit["author"]["login"], 70 | commit["commit"]["author"]["email"], 71 | commit["commit"]["author"]["name"], 72 | ) 73 | users.add(user) 74 | return list(users) 75 | 76 | 77 | # Flask routes 78 | 79 | 80 | @app.route("/repos", methods=["GET"]) 81 | def repos_for_keyword(): 82 | """ 83 | /repos?keyword= 84 | 85 | Finds all repos which contain the given keyword in the name, readme, or description""" 86 | if "keyword" not in request.args: 87 | return "", 400 88 | 89 | keyword = request.args["keyword"] 90 | future = _repos_for_keyword(keyword) 91 | repos = loop.run_until_complete(future) 92 | return jsonify(repos) 93 | 94 | 95 | @app.route("/users//repo/", methods=["GET"]) 96 | def users_for_repo(user, repo_name): 97 | """ 98 | /users//repo/[?oldest-age=] 99 | 100 | Returns list of users who have commited in the resource user/repo in the last given amount of 101 | weeks""" 102 | 103 | oldest_age = 55 if "oldest-age" not in request.args else request.args["oldest-age"] 104 | future = _users_for_repo(user, repo_name, oldest_age=oldest_age) 105 | users = loop.run_until_complete(future) 106 | return jsonify(users) 107 | 108 | 109 | @app.route("/users", methods=["GET"]) 110 | def users_for_keyword(): 111 | """ 112 | /users?keyword=[?oldest-age=] 113 | 114 | Find the top users who have commited in repositories matching the keyword in the last month""" 115 | if "keyword" not in request.args: 116 | return "", 400 117 | 118 | keyword = request.args["keyword"] 119 | oldest_age = 55 if "oldest-age" not in request.args else request.args["oldest-age"] 120 | 121 | repos_future = _repos_for_keyword(keyword) 122 | repos = loop.run_until_complete(repos_future) 123 | 124 | # gather futures for getting users from each repo 125 | users_futures = [] 126 | users = set() 127 | for repo in repos: 128 | user, repo_name = repo.split("/") 129 | users_futures.append(_users_for_repo(user, repo_name, oldest_age=oldest_age)) 130 | 131 | # barrier on all the users futures 132 | users_results = loop.run_until_complete(asyncio.wait(users_futures)) 133 | 134 | # gather the results 135 | for users_result in users_results: 136 | for task in users_result: 137 | if task.result(): 138 | users.update(set(task.result())) 139 | 140 | return jsonify(list(users)) 141 | 142 | 143 | app.run("0.0.0.0") 144 | -------------------------------------------------------------------------------- /examples/github-api/Tests.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | 3 | import requests 4 | 5 | 6 | def test_repo(): 7 | r = requests.get("http://localhost:5000/repos?keyword=natural+language+processing") 8 | pprint.pprint(r.json()) 9 | 10 | 11 | def test_users_for_repos(): 12 | r = requests.get( 13 | "http://localhost:5000/users/JustFollowUs/repo/Natural-Language-Processing" 14 | ) 15 | pprint.pprint(r.json()) 16 | 17 | 18 | def test_users_for_keyword(): 19 | r = requests.get("http://localhost:5000/users?keyword=lstm") 20 | pprint.pprint(r.json()) 21 | 22 | 23 | test_repo() 24 | test_users_for_repos() 25 | test_users_for_keyword() 26 | -------------------------------------------------------------------------------- /examples/github-api/keys.sh: -------------------------------------------------------------------------------- 1 | export CLIENT_ID= 2 | export CLIENT_SECRET= 3 | -------------------------------------------------------------------------------- /examples/handler_callbacks/README.md: -------------------------------------------------------------------------------- 1 | # Response and Error Handling Example 2 | 3 | --- 4 | 5 | This example shows how to handle errors and responses via callbacks. These callbacks allow you 6 | to define error handlers and response handlers that can perform processing of the response 7 | from the remote server. 8 | 9 | Included are two examples: 10 | 11 | 1. (response_handler) Get the google home page and print the response status code 12 | ``` 13 | google = Google(base_url=BASE_URL) 14 | google.homepage() 15 | Prints: Google response status: 200 16 | ``` 17 | 18 | 2. (error_handler) Get an invalid url and prints the exception type 19 | ``` 20 | google = Google(base_url="NON_EXISTENT_URL") 21 | google.bad_page() 22 | Prints: Error Encountered. Exception will be raised... 23 | ``` -------------------------------------------------------------------------------- /examples/handler_callbacks/Server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | # add uplink directory to path 4 | sys.path.insert(0, "../../") 5 | from uplink import Consumer, error_handler, get, headers, response_handler 6 | 7 | BASE_URL = "https://www.google.com" 8 | 9 | 10 | def print_status(response): 11 | print(f"Google response status:{response.status_code}") 12 | return response 13 | 14 | 15 | def handle_error(exc_type, exc_val, exc_tb): 16 | print(f"Error encountered. Exception will be raised. Exception Type:{exc_type}") 17 | 18 | 19 | @headers({"Accept": "text/html"}) 20 | class Google(Consumer): 21 | @response_handler(print_status) 22 | @get("/") 23 | def homepage(self): 24 | print("google home page") 25 | pass 26 | 27 | @error_handler(handle_error) 28 | @get("/bad_page.html") 29 | def bad_page(selfs): 30 | pass 31 | -------------------------------------------------------------------------------- /examples/handler_callbacks/handlers_example.py: -------------------------------------------------------------------------------- 1 | from Server import BASE_URL, Google 2 | 3 | 4 | def test_homepage(): 5 | google = Google(base_url=BASE_URL) 6 | google.homepage() 7 | 8 | 9 | def test_error(): 10 | google = Google(base_url="NON_EXISTENT_URL") 11 | google.bad_page() 12 | 13 | 14 | test_homepage() 15 | test_error() 16 | -------------------------------------------------------------------------------- /examples/marshmallow/README.md: -------------------------------------------------------------------------------- 1 | # Response Deserialization with Marshmallow 2 | 3 | Many modern Web APIs deliver content as JSON objects. To abstract away 4 | the data source, we often want to convert that JSON into a regular 5 | Python object, so that other parts of our code can interact with the 6 | resource using well-defined methods and attributes. 7 | 8 | This example illustrates how to use Uplink with 9 | [`marshmallow`](https://marshmallow.readthedocs.io/en/latest/) to 10 | have your JSON API return Python objects. 11 | 12 | 13 | ## Requirements 14 | 15 | Uplink's integration with `marshmallow` is an optional feature. You 16 | can either install `marshmallow` separately or declare the extra when 17 | installing Uplink with ``pip``: 18 | 19 | ``` 20 | $ pip install -U uplink[marshmallow] 21 | ``` 22 | 23 | ## Overview 24 | 25 | This example includes three files: 26 | 27 | - `schemas.py`: Defines our schemas for repositories and contributors. 28 | - `github.py`: Defines a `GitHub` API with two methods: 29 | - `GitHub.get_repos`: Gets all public repositories. Uses the 30 | repository schema to return `Repo` objects. 31 | - `GitHub.get_contributors`: Lists contributors for the specified repository. 32 | Uses the contributors schema to return `Contributor` objects. 33 | - `main.py`: Connects all the pieces with an example that prints to the 34 | console the contributors for the first 10 public repositories. 35 | 36 | ## Challenge 37 | Using the [`async-requests`](../async-requests/) example 38 | as a guide, rewrite `main.py` to make non-blocking requests using 39 | either `aiohttp` or `twisted`. 40 | 41 | -------------------------------------------------------------------------------- /examples/marshmallow/github.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from schemas import ContributorSchema, RepoSchema 3 | 4 | from uplink import Consumer, get, headers 5 | 6 | 7 | @headers({"Accept": "application/vnd.github.v3.full+json"}) 8 | class GitHub(Consumer): 9 | @get("/repositories") 10 | def get_repos(self) -> RepoSchema(many=True): 11 | """Lists all public repositories.""" 12 | 13 | @get("/repos/{owner}/{repo}/contributors") 14 | def get_contributors(self, owner, repo) -> ContributorSchema(many=True): 15 | """Lists contributors for the specified repository.""" 16 | -------------------------------------------------------------------------------- /examples/marshmallow/main.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | from pprint import pformat 3 | 4 | # Local imports 5 | import github 6 | 7 | BASE_URL = "https://api.github.com/" 8 | 9 | 10 | if __name__ == "__main__": 11 | # Create a GitHub API client 12 | gh = github.GitHub(base_url=BASE_URL) 13 | 14 | # Get all public repositories 15 | repos = gh.get_repos() 16 | 17 | # Shorten to first 10 results to avoid hitting the rate limit. 18 | repos = repos[:10] 19 | 20 | # Print contributors for those repositories 21 | for repo in repos: 22 | contributors = gh.get_contributors(repo.owner, repo.name) 23 | print(f"Contributors for {repo}:\n{pformat(contributors, indent=4)}\n") 24 | -------------------------------------------------------------------------------- /examples/marshmallow/schemas.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | import marshmallow 4 | 5 | # == Models == # 6 | Contributor = collections.namedtuple( 7 | "Contributor", field_names=["username", "contributions"] 8 | ) 9 | 10 | Repo = collections.namedtuple("Repo", field_names=["owner", "name"]) 11 | 12 | 13 | class SchemaBase(marshmallow.Schema): 14 | class Meta: 15 | # Pass EXCLUDE as Meta option to keep marshmallow 2 behavior 16 | # ref: https://marshmallow.readthedocs.io/en/3.0/upgrading.html 17 | unknown = getattr(marshmallow, "EXCLUDE", None) 18 | 19 | 20 | # == Schemas == # 21 | class ContributorSchema(SchemaBase): 22 | login = marshmallow.fields.Str(attribute="username") 23 | contributions = marshmallow.fields.Int() 24 | 25 | @marshmallow.post_load 26 | def make_contributor(self, data): 27 | return Contributor(**data) 28 | 29 | 30 | class RepoSchema(SchemaBase): 31 | full_name = marshmallow.fields.Str() 32 | 33 | @marshmallow.post_load 34 | def make_repo(self, data): 35 | return Repo(*data["full_name"].split("/")) 36 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Uplink 2 | site_description: A Declarative HTTP Client for Python 3 | repo_url: https://github.com/prkumar/uplink 4 | repo_name: prkumar/uplink 5 | 6 | hooks: 7 | - docs/plugins/main.py 8 | 9 | theme: 10 | name: material 11 | palette: 12 | # Light mode 13 | - media: "(prefers-color-scheme: light)" 14 | scheme: default 15 | primary: indigo 16 | accent: indigo 17 | toggle: 18 | icon: material/brightness-7 19 | name: Switch to dark mode 20 | # Dark mode 21 | - media: "(prefers-color-scheme: dark)" 22 | scheme: slate 23 | primary: indigo 24 | accent: indigo 25 | toggle: 26 | icon: material/brightness-4 27 | name: Switch to light mode 28 | icon: 29 | repo: fontawesome/brands/github 30 | features: 31 | - navigation.instant 32 | - navigation.tracking 33 | - navigation.expand 34 | - navigation.indexes 35 | - navigation.tabs 36 | - content.code.copy 37 | 38 | plugins: 39 | - search 40 | - mkdocstrings: 41 | handlers: 42 | python: 43 | paths: [uplink] 44 | options: 45 | docstring_style: google 46 | show_root_heading: true 47 | show_if_no_docstring: true 48 | inherited_members: true 49 | members_order: source 50 | separate_signature: true 51 | unwrap_annotated: true 52 | # preload_modules: 53 | # - requests 54 | # - aiohttp 55 | # - pydantic 56 | # - twisted 57 | filters: 58 | - "!^_" 59 | merge_init_into_class: true 60 | docstring_section_style: spacy 61 | signature_crossrefs: true 62 | show_symbol_type_heading: true 63 | show_symbol_type_toc: true 64 | 65 | markdown_extensions: 66 | - pymdownx.highlight: 67 | anchor_linenums: true 68 | - pymdownx.superfences 69 | - pymdownx.inlinehilite 70 | - pymdownx.snippets 71 | - admonition 72 | - pymdownx.details 73 | - pymdownx.tabbed: 74 | alternate_style: true 75 | - tables 76 | - footnotes 77 | 78 | nav: 79 | - Home: index.md 80 | - User Guide: 81 | - Introduction: user/introduction.md 82 | - Installation: user/install.md 83 | - Quickstart: user/quickstart.md 84 | - Authentication: user/auth.md 85 | - Clients: user/clients.md 86 | - Serialization: user/serialization.md 87 | - Tips & Tricks: user/tips.md 88 | - API Reference: 89 | - Overview: api/index.md 90 | - The Base Consumer Class: api/consumer.md 91 | - Decorators: api/decorators.md 92 | - Function Annotations: api/types.md 93 | - HTTP Clients: api/clients.md 94 | - Converters: api/converters.md 95 | - Authentication: api/auth.md 96 | - Changelog: changelog.md 97 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = 'uplink' 3 | version = '0.10.0a1' 4 | description = 'A Declarative HTTP Client for Python.' 5 | readme = 'README.md' 6 | maintainers = [{ name = 'P. Raj Kumar', email = 'raj.pritvi.kumar@gmail.com' }] 7 | authors = [ 8 | { name = 'Kareem Moussa' }, 9 | { name = 'Brandon Milton' }, 10 | { name = 'Fabio Rosado' }, 11 | { name = 'Niko Eckerskorn' }, 12 | { name = 'Or Carmi' }, 13 | { name = 'George Kontridze' }, 14 | { name = 'Sean Chambers' }, 15 | { name = 'Nils Philippsen' }, 16 | { name = 'Alexander Duryagin' }, 17 | { name = 'Sakorn Waungwiwatsin' }, 18 | { name = 'Jacob Floyd' }, 19 | { name = 'Guilherme Crocetti' }, 20 | { name = 'Alexander Shadchin' }, 21 | { name = 'Ernesto Avilés Vázquez' }, 22 | { name = 'Leiser Fernández Gallo', email = 'leiserfg@gmail.com' }, 23 | ] 24 | license = { file = 'LICENSE' } 25 | keywords = ['http', 'api', 'rest', 'client', 'retrofit'] 26 | urls = { Repository = 'https://github.com/prkumar/uplink' } 27 | classifiers = [ 28 | 'Development Status :: 4 - Beta', 29 | 'Intended Audience :: Developers', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Operating System :: OS Independent', 32 | 'Programming Language :: Python :: 3.10', 33 | 'Programming Language :: Python :: 3.11', 34 | 'Programming Language :: Python :: 3.12', 35 | 'Programming Language :: Python :: 3.13', 36 | 'Programming Language :: Python :: 3 :: Only', 37 | 'Programming Language :: Python :: Implementation :: CPython', 38 | 'Programming Language :: Python :: Implementation :: PyPy', 39 | 'Topic :: Software Development :: Libraries', 40 | ] 41 | requires-python = '>=3.10' 42 | dependencies = ['requests>=2.18.0', 'six>=1.13.0', 'uritemplate>=3.0.0'] 43 | 44 | [project.optional-dependencies] 45 | marshmallow = ['marshmallow>=2.15.0'] 46 | pydantic = ['pydantic>=2.0.0'] 47 | aiohttp = ['aiohttp>=3.8.1'] 48 | twisted = ['twisted>=21.7.0'] 49 | 50 | [dependency-groups] 51 | dev = [ 52 | 'pytest', 53 | 'pytest-mock', 54 | 'pytest-cov', 55 | 'pytest-twisted', 56 | 'pytest-asyncio', 57 | ] 58 | docs = [ 59 | 'mkdocs', 60 | 'mkdocs-material', 61 | 'mkdocstrings[python]', 62 | ] 63 | 64 | [build-system] 65 | requires = ['hatchling'] 66 | build-backend = 'hatchling.build' 67 | 68 | [tool.pytest] 69 | twisted=1 70 | 71 | 72 | [tool.tox] 73 | env_list = ["py310", "py311", "py312", "py313"] 74 | 75 | [tool.tox.testenv] 76 | runner = "uv-venv-lock-runner" 77 | deps = [ 78 | "test", 79 | "marshmallow", 80 | "aiohttp", 81 | "twisted", 82 | "pydantic", 83 | ] 84 | commands = [ 85 | "pytest tests --cov-config .coveragerc --cov=uplink {posargs}", 86 | ] 87 | -------------------------------------------------------------------------------- /rtd_requirements.txt: -------------------------------------------------------------------------------- 1 | # This requirements file is used to build the documentation at 2 | # https://uplink.readthedocs.io/. This file will become unnecessary after RTD 3 | # adds support for Pipfiles: https://github.com/rtfd/readthedocs.org/issues/3181 4 | 5 | aiohttp 6 | marshmallow 7 | requests 8 | twisted 9 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 88 2 | 3 | target-version = "py310" 4 | 5 | [lint] 6 | extend-select = [ 7 | "E", # pycodestyle errors 8 | "W", # pycodestyle warnings 9 | "F", # pyflakes 10 | "I", # isort 11 | "C", # flake8-comprehensions 12 | "B", # flake8-bugbear 13 | "UP", # pyupgrade 14 | "PT", # flake8-pytest-style 15 | "TCH", # flake8-type-checking 16 | "RET", # flake8-return 17 | "RUF", # ruff 18 | ] 19 | 20 | ignore = [ 21 | "E501", 22 | "C901", # too complex" 23 | "RUF012", 24 | ] 25 | 26 | exclude = [ 27 | ".direnv", 28 | ".git", 29 | ".mypy_cache", 30 | ".ruff_cache", 31 | ".venv", 32 | "__pypackages__", 33 | "_build", 34 | "buck-out", 35 | "build", 36 | "dist", 37 | "node_modules", 38 | "venv", 39 | ] 40 | per-file-ignores = {} 41 | 42 | 43 | # Allow unused variables when underscore-prefixed. 44 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import sys 3 | 4 | # Third-party import 5 | import pytest 6 | 7 | requires_python34 = pytest.mark.skipif( 8 | sys.version_info < (3, 4), reason="Requires Python 3.4 or above." 9 | ) 10 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | collect_ignore = [] 4 | if sys.version_info.major < 3: 5 | collect_ignore.extend( 6 | [ 7 | "unit/test_aiohttp_client.py", 8 | "integration/test_handlers_aiohttp.py", 9 | "integration/test_retry_aiohttp.py", 10 | ] 11 | ) 12 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink import clients, utils 3 | from uplink.clients import exceptions as client_exceptions 4 | from uplink.clients import io 5 | 6 | 7 | class MockClient(clients.interfaces.HttpClientAdapter): 8 | def __init__(self, mock_client): 9 | self._mock_client = mock_client 10 | self._exceptions = client_exceptions.Exceptions() 11 | self._history = [] 12 | self._io = io.BlockingStrategy() 13 | 14 | def with_response(self, response): 15 | self._mock_client.send.return_value = response 16 | return self 17 | 18 | def with_side_effect(self, side_effect): 19 | self._mock_client.send.side_effect = side_effect 20 | return self 21 | 22 | @property 23 | def exceptions(self): 24 | return self._exceptions 25 | 26 | @property 27 | def history(self): 28 | return self._history 29 | 30 | def with_io(self, io_): 31 | self._io = io_ 32 | return self 33 | 34 | def io(self): 35 | return self._io 36 | 37 | def apply_callback(self, callback, response): 38 | return callback(response) 39 | 40 | def send(self, request): 41 | method, url, extras = request 42 | self._history.append(RequestInvocation(method, url, extras)) 43 | return self._mock_client.send(method, url, extras) 44 | 45 | 46 | class MockResponse: 47 | def __init__(self, response): 48 | self._response = response 49 | 50 | def with_json(self, json): 51 | self._response.json.return_value = json 52 | return self 53 | 54 | def __getattr__(self, item): 55 | return self._response.__getattr__(item) 56 | 57 | 58 | class RequestInvocation: 59 | def __init__(self, method, url, extras): 60 | self._method = method 61 | self._url = url 62 | self._extras = extras 63 | 64 | @staticmethod 65 | def _get_endpoint(url): 66 | parsed_url = utils.urlparse.urlparse(url) 67 | return parsed_url.path 68 | 69 | @staticmethod 70 | def _get_base_url(url): 71 | parsed_url = utils.urlparse.urlparse(url) 72 | return utils.urlparse.urlunsplit( 73 | [parsed_url.scheme, parsed_url.netloc, "", "", ""] 74 | ) 75 | 76 | @property 77 | def base_url(self): 78 | return self._get_base_url(self._url) 79 | 80 | @property 81 | def endpoint(self): 82 | return self._get_endpoint(self._url) 83 | 84 | def has_base_url(self, base_url): 85 | return self.base_url == self._get_base_url(base_url) 86 | 87 | def has_endpoint(self, endpoint): 88 | return self.endpoint == self._get_endpoint(endpoint) 89 | 90 | @property 91 | def url(self): 92 | return self._url 93 | 94 | @property 95 | def method(self): 96 | return self._method 97 | 98 | @property 99 | def params(self): 100 | return self._extras.get("params", None) 101 | 102 | @property 103 | def headers(self): 104 | return self._extras.get("headers", None) 105 | 106 | @property 107 | def data(self): 108 | return self._extras.get("data", None) 109 | 110 | @property 111 | def files(self): 112 | return self._extras.get("files", None) 113 | 114 | @property 115 | def json(self): 116 | return self._extras.get("json", None) 117 | 118 | def __getattr__(self, item): 119 | try: 120 | return self._extras[item] 121 | except KeyError as e: 122 | raise AttributeError(item) from e 123 | -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | # Third-party imports 2 | import pytest 3 | import requests 4 | 5 | from tests.integration import MockClient, MockResponse 6 | 7 | # Local imports 8 | from uplink import clients 9 | 10 | 11 | @pytest.fixture 12 | def mock_client(mocker): 13 | client = mocker.Mock(spec=clients.interfaces.HttpClientAdapter) 14 | return MockClient(client) 15 | 16 | 17 | @pytest.fixture 18 | def mock_response(mocker): 19 | response = mocker.Mock(spec=requests.Response) 20 | return MockResponse(response) 21 | -------------------------------------------------------------------------------- /tests/integration/test_basic.py: -------------------------------------------------------------------------------- 1 | # Third-party imports 2 | import pytest 3 | 4 | # Local imports. 5 | import uplink 6 | 7 | # Constants 8 | BASE_URL = "https://api.github.com/" 9 | 10 | 11 | @uplink.timeout(10) 12 | @uplink.headers({"Accept": "application/vnd.github.v3.full+json"}) 13 | class GitHubService(uplink.Consumer): 14 | @uplink.timeout(15) 15 | @uplink.get("/users/{user}/repos") 16 | def list_repos(self, user): 17 | """List all public repositories for a specific user.""" 18 | 19 | @uplink.returns.json 20 | @uplink.get( 21 | "/users/{user}/repos/{repo}", 22 | args={"token": uplink.Header("Authorization")}, 23 | ) 24 | def get_repo(self, user, repo, token): 25 | pass 26 | 27 | @uplink.get(args={"url": uplink.Url}) 28 | def forward(self, url): 29 | pass 30 | 31 | 32 | def test_list_repo_wrapper(mock_client): 33 | """Ensures that the consumer method looks like the original func.""" 34 | github = GitHubService(base_url=BASE_URL, client=mock_client) 35 | assert ( 36 | github.list_repos.__doc__ 37 | == GitHubService.list_repos.__doc__ 38 | == "List all public repositories for a specific user." 39 | ) 40 | assert ( 41 | github.list_repos.__name__ == GitHubService.list_repos.__name__ == "list_repos" 42 | ) 43 | 44 | 45 | def test_list_repo(mock_client): 46 | github = GitHubService(base_url=BASE_URL, client=mock_client) 47 | github.list_repos("prkumar") 48 | request = mock_client.history[0] 49 | assert request.method == "GET" 50 | assert request.has_base_url(BASE_URL) 51 | assert request.has_endpoint("/users/prkumar/repos") 52 | assert request.headers == {"Accept": "application/vnd.github.v3.full+json"} 53 | assert request.timeout == 15 54 | 55 | 56 | def test_get_repo(mock_client, mock_response): 57 | """ 58 | This integration test ensures that the returns.json returns the 59 | Json body when a model is not provided. 60 | """ 61 | # Setup: return a mock response 62 | expected_json = {"key": "value"} 63 | mock_response.with_json(expected_json) 64 | mock_client.with_response(mock_response) 65 | github = GitHubService(base_url=BASE_URL, client=mock_client) 66 | 67 | # Run 68 | actual_json = github.get_repo("prkumar", "uplink", "Bearer token") 69 | 70 | # Verify 71 | assert expected_json == actual_json 72 | assert mock_client.history[0].headers["Authorization"] == "Bearer token" 73 | 74 | 75 | def test_forward(mock_client): 76 | github = GitHubService(base_url=BASE_URL, client=mock_client) 77 | github.forward("/users/prkumar/repos") 78 | request = mock_client.history[0] 79 | assert request.method == "GET" 80 | assert request.has_base_url(BASE_URL) 81 | assert request.has_endpoint("/users/prkumar/repos") 82 | 83 | 84 | def test_handle_client_exceptions(mock_client): 85 | # Setup: mock client exceptions 86 | 87 | class MockBaseClientException(Exception): 88 | pass 89 | 90 | class MockInvalidURL(MockBaseClientException): 91 | pass 92 | 93 | mock_client.exceptions.BaseClientException = MockBaseClientException 94 | mock_client.exceptions.InvalidURL = MockInvalidURL 95 | 96 | # Setup: instantiate service 97 | service = GitHubService(base_url=BASE_URL, client=mock_client) 98 | 99 | # Run: Catch base exception 100 | mock_client.with_side_effect(MockBaseClientException) 101 | 102 | with pytest.raises(service.exceptions.BaseClientException): 103 | service.list_repos("prkumar") 104 | 105 | # Run: Catch leaf exception 106 | mock_client.with_side_effect(MockInvalidURL) 107 | 108 | with pytest.raises(service.exceptions.InvalidURL): 109 | service.list_repos("prkumar") 110 | 111 | # Run: Try polymorphism 112 | mock_client.with_side_effect(MockInvalidURL) 113 | 114 | with pytest.raises(service.exceptions.BaseClientException): 115 | service.list_repos("prkumar") 116 | -------------------------------------------------------------------------------- /tests/integration/test_extend.py: -------------------------------------------------------------------------------- 1 | # Third-party imports 2 | import pytest 3 | 4 | # Local imports 5 | import uplink 6 | 7 | # Constants 8 | BASE_URL = "https://api.github.com" 9 | 10 | 11 | class GitHubError(Exception): 12 | pass 13 | 14 | 15 | @uplink.response_handler 16 | def github_error(response): 17 | if "errors" in response.json(): 18 | raise GitHubError() 19 | return response 20 | 21 | 22 | @uplink.timeout(10) 23 | class GitHubService(uplink.Consumer): 24 | @github_error 25 | @uplink.json 26 | @uplink.post("graphql", args=(uplink.Body,)) 27 | def graphql(self, **body): 28 | pass 29 | 30 | @uplink.returns.json(member=("data", "repository")) 31 | @uplink.args(body=uplink.Body) 32 | @graphql 33 | def get_repository(self, **body): 34 | pass 35 | 36 | @uplink.returns.json(member=("data", "repository")) 37 | @graphql.extend("graphql2", args=(uplink.Body,)) 38 | def get_repository2(self, **body): 39 | pass 40 | 41 | 42 | def test_get_repository(mock_client, mock_response): 43 | data = { 44 | "query": """\ 45 | query { 46 | repository(owner: "prkumar", name: "uplink") { 47 | nameWithOwner 48 | } 49 | }""" 50 | } 51 | result = {"data": {"repository": {"nameWithOwner": "prkumar/uplink"}}} 52 | mock_response.with_json(result) 53 | mock_client.with_response(mock_response) 54 | github = GitHubService(base_url=BASE_URL, client=mock_client) 55 | response = github.get_repository(**data) 56 | request = mock_client.history[0] 57 | assert request.method == "POST" 58 | assert request.base_url == BASE_URL 59 | assert request.endpoint == "/graphql" 60 | assert request.timeout == 10 61 | assert request.json == data 62 | assert response == result["data"]["repository"] 63 | 64 | 65 | def test_get_repository2_failure(mock_client, mock_response): 66 | data = { 67 | "query": """\ 68 | query { 69 | repository(owner: "prkumar", name: "uplink") { 70 | nameWithOwner 71 | } 72 | }""" 73 | } 74 | result = { 75 | "data": {"repository": None}, 76 | "errors": [ 77 | { 78 | "type": "NOT_FOUND", 79 | "path": ["repository"], 80 | "locations": [{"line": 7, "column": 3}], 81 | "message": "Could not resolve to a User with the username 'prkussmar'.", 82 | } 83 | ], 84 | } 85 | mock_response.with_json(result) 86 | mock_client.with_response(mock_response) 87 | github = GitHubService(base_url=BASE_URL, client=mock_client) 88 | with pytest.raises(GitHubError): 89 | github.get_repository2(**data) 90 | request = mock_client.history[0] 91 | assert request.method == "POST" 92 | assert request.base_url == BASE_URL 93 | assert request.endpoint == "/graphql2" 94 | assert request.timeout == 10 95 | -------------------------------------------------------------------------------- /tests/integration/test_form_url_encoded.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink import Consumer, FieldMap, form_url_encoded, put 3 | 4 | # Constants 5 | BASE_URL = "https://example.com/" 6 | 7 | 8 | def test_without_converter(mock_response, mock_client): 9 | class Calendar(Consumer): 10 | @form_url_encoded 11 | @put("/user/repos", args={"event_data": FieldMap}) 12 | def add_event(self, **event_data): 13 | pass 14 | 15 | mock_client.with_response(mock_response) 16 | calendar = Calendar(base_url=BASE_URL, client=mock_client) 17 | 18 | # Run 19 | calendar.add_event(name="Weekly Stand-up", public=True) 20 | 21 | # Assertions: should not convert if converter is None 22 | request = mock_client.history[0] 23 | assert request.data == {"name": "Weekly Stand-up", "public": True} 24 | -------------------------------------------------------------------------------- /tests/integration/test_handlers.py: -------------------------------------------------------------------------------- 1 | # Local imports. 2 | import pytest 3 | 4 | import uplink 5 | 6 | # Constants 7 | BASE_URL = "https://example.com/" 8 | 9 | 10 | @uplink.response_handler(requires_consumer=True) 11 | def handle_response_with_consumer(consumer, response): 12 | consumer.flagged = True 13 | return response 14 | 15 | 16 | @uplink.response_handler 17 | def handle_response(response): 18 | response.flagged = True 19 | return response 20 | 21 | 22 | class WrappedException(Exception): 23 | def __init__(self, exception): 24 | self.exception = exception 25 | 26 | 27 | @uplink.error_handler(requires_consumer=True) 28 | def handle_error_with_consumer(consumer, exc_type, exc_value, exc_tb): 29 | consumer.flagged = True 30 | raise WrappedException(exc_value) 31 | 32 | 33 | @uplink.error_handler 34 | def handle_error(exc_type, exc_value, exc_tb): 35 | raise WrappedException(exc_value) 36 | 37 | 38 | class Calendar(uplink.Consumer): 39 | @handle_response_with_consumer 40 | @uplink.get("todos/{todo_id}") 41 | def get_todo(self, todo_id): 42 | pass 43 | 44 | @handle_response 45 | @uplink.get("months/{name}/todos") 46 | def get_month(self, name): 47 | pass 48 | 49 | @handle_response_with_consumer 50 | @handle_response 51 | @uplink.get("months/{month}/days/{day}/todos") 52 | def get_day(self, month, day): 53 | pass 54 | 55 | @handle_error_with_consumer 56 | @uplink.get("users/{user_id}") 57 | def get_user(self, user_id): 58 | pass 59 | 60 | @handle_error 61 | @uplink.get("events/{event_id}") 62 | def get_event(self, event_id): 63 | pass 64 | 65 | 66 | @handle_response 67 | class CalendarV2(uplink.Consumer): 68 | @uplink.get("todos/{todo_id}") 69 | def get_todo(self, todo_id): 70 | pass 71 | 72 | 73 | def test_class_response_handler(mock_client): 74 | calendar = CalendarV2(base_url=BASE_URL, client=mock_client) 75 | calendar.flagged = False 76 | 77 | # Run 78 | response = calendar.get_todo(todo_id=1) 79 | 80 | # Verify 81 | assert response.flagged is True 82 | 83 | 84 | def test_response_handler_with_consumer(mock_client): 85 | calendar = Calendar(base_url=BASE_URL, client=mock_client) 86 | calendar.flagged = False 87 | 88 | # Run 89 | calendar.get_todo(todo_id=1) 90 | 91 | # Verify 92 | assert calendar.flagged is True 93 | 94 | 95 | def test_response_handler(mock_client): 96 | calendar = Calendar(base_url=BASE_URL, client=mock_client) 97 | 98 | # Run 99 | response = calendar.get_month("September") 100 | 101 | # Verify 102 | assert response.flagged is True 103 | 104 | 105 | def test_multiple_response_handlers(mock_client): 106 | calendar = Calendar(base_url=BASE_URL, client=mock_client) 107 | 108 | # Run 109 | response = calendar.get_day("September", 2) 110 | 111 | # Verify 112 | assert response.flagged 113 | assert calendar.flagged 114 | 115 | 116 | class CustomError(OSError): ... 117 | 118 | 119 | def test_error_handler_with_consumer(mock_client): 120 | # Setup: raise specific exception 121 | expected_error = CustomError() 122 | mock_client.with_side_effect(expected_error) 123 | 124 | calendar = Calendar(base_url=BASE_URL, client=mock_client) 125 | calendar.flagged = False 126 | 127 | # Run 128 | with pytest.raises(WrappedException) as exc_info: 129 | calendar.get_user(user_id=1) 130 | 131 | # Verify 132 | assert exc_info.value.exception == expected_error 133 | assert calendar.flagged is True 134 | 135 | 136 | def test_error_handler(mock_client): 137 | # Setup: raise specific exception 138 | expected_error = CustomError() 139 | mock_client.with_side_effect(expected_error) 140 | 141 | calendar = Calendar(base_url=BASE_URL, client=mock_client) 142 | 143 | # Run 144 | with pytest.raises(WrappedException) as exc_info: 145 | calendar.get_event(event_id=1) 146 | 147 | # Verify 148 | assert exc_info.value.exception == expected_error 149 | -------------------------------------------------------------------------------- /tests/integration/test_handlers_aiohttp.py: -------------------------------------------------------------------------------- 1 | # Local imports. 2 | # Third-party imports 3 | import aiohttp 4 | import pytest 5 | 6 | import uplink 7 | from uplink.clients.aiohttp_ import AiohttpClient 8 | 9 | # Constants 10 | BASE_URL = "https://example.com/" 11 | SIMPLE_RESPONSE = "simple response" 12 | 13 | 14 | @pytest.fixture 15 | def mock_aiohttp_session(mocker): 16 | return mocker.Mock(spec=aiohttp.ClientSession) 17 | 18 | 19 | @uplink.response_handler 20 | async def simple_async_handler(response): 21 | return SIMPLE_RESPONSE 22 | 23 | 24 | class Calendar(uplink.Consumer): 25 | @simple_async_handler 26 | @uplink.get("todos/{todo_id}") 27 | def get_todo(self, todo_id): 28 | pass 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_simple_async_handler(mock_aiohttp_session, mock_response): 33 | mock_response.status = 200 34 | 35 | async def request(*args, **kwargs): 36 | return mock_response 37 | 38 | mock_aiohttp_session.request = request 39 | 40 | calendar = Calendar(base_url=BASE_URL, client=AiohttpClient(mock_aiohttp_session)) 41 | 42 | # Run 43 | response = await calendar.get_todo(todo_id=1) 44 | 45 | # Verify 46 | assert response == SIMPLE_RESPONSE 47 | -------------------------------------------------------------------------------- /tests/integration/test_multipart.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink import Consumer, PartMap, multipart, post 3 | 4 | # Constants 5 | BASE_URL = "https://example.com/" 6 | 7 | 8 | def test_without_converter(mock_response, mock_client): 9 | class Calendar(Consumer): 10 | @multipart 11 | @post("/attachments", args={"files": PartMap}) 12 | def upload_attachments(self, **files): 13 | pass 14 | 15 | mock_client.with_response(mock_response) 16 | calendar = Calendar(base_url=BASE_URL, client=mock_client) 17 | file = object() 18 | 19 | # Run 20 | calendar.upload_attachments(file=file) 21 | 22 | # Assertion: should not convert if converter is None 23 | request = mock_client.history[0] 24 | assert request.files == {"file": file} 25 | -------------------------------------------------------------------------------- /tests/integration/test_ratelimit.py: -------------------------------------------------------------------------------- 1 | # Third-party imports. 2 | import pytest 3 | 4 | # Local imports. 5 | import uplink 6 | from uplink.ratelimit import RateLimitExceeded, now 7 | 8 | # Constants 9 | BASE_URL = "https://api.github.com/" 10 | DIFFERENT_BASE_URL = "https://hostedgithub.com/" 11 | 12 | 13 | class CustomRateLimitException(RuntimeError): 14 | pass 15 | 16 | 17 | class GitHub(uplink.Consumer): 18 | @uplink.ratelimit( 19 | calls=1, period=10, raise_on_limit=True, group_by=None 20 | ) # 1 call every 10 seconds 21 | @uplink.get("users/{user}") 22 | def get_user(self, user): 23 | pass 24 | 25 | @uplink.ratelimit(calls=1, period=1, raise_on_limit=False) 26 | @uplink.get("repos/{user}/{repo}") 27 | def get_repo(self, user, repo): 28 | pass 29 | 30 | @uplink.ratelimit(calls=1, period=10, raise_on_limit=CustomRateLimitException) 31 | @uplink.get("repos/{user}/{repo}/comments/{comment}") 32 | def get_comment(self, user, repo, comment): 33 | pass 34 | 35 | @uplink.ratelimit(calls=1, period=10, raise_on_limit=True, group_by=None) 36 | @uplink.get("repos/{user}/{repo}/issues") 37 | def get_issues(self, user, repo): 38 | pass 39 | 40 | @uplink.ratelimit(calls=1, period=10, raise_on_limit=True) 41 | @uplink.get("repos/{user}/{repo}/issues/{issue}") 42 | def get_issue(self, user, repo, issue): 43 | pass 44 | 45 | 46 | # Tests 47 | 48 | 49 | def test_limit_exceeded_by_1(mock_client): 50 | # Setup 51 | github = GitHub(base_url=BASE_URL, client=mock_client) 52 | 53 | # Run 54 | github.get_user("prkumar") 55 | 56 | with pytest.raises(RateLimitExceeded): 57 | github.get_user("prkumar") 58 | 59 | 60 | def test_limit_exceeded_by_1_with_custom_exception(mock_client): 61 | # Setup 62 | github = GitHub(base_url=BASE_URL, client=mock_client) 63 | 64 | # Run 65 | github.get_comment("prkumar", "uplink", "1") 66 | 67 | with pytest.raises(CustomRateLimitException): 68 | github.get_comment("prkumar", "uplink", "1") 69 | 70 | 71 | def test_exceeded_limit_wait(mock_client): 72 | # Setup 73 | github = GitHub(base_url=BASE_URL, client=mock_client) 74 | 75 | # Run 76 | start = now() 77 | github.get_repo("prkumar", "uplink") 78 | github.get_repo("prkumar", "uplink") 79 | elapsed = now() - start 80 | 81 | assert elapsed >= 1 82 | 83 | 84 | def test_limit_with_group_by_None(mock_client): 85 | # Setup: consumers pointing to separate hosts 86 | github1 = GitHub(base_url=BASE_URL, client=mock_client) 87 | github2 = GitHub(base_url=DIFFERENT_BASE_URL, client=mock_client) 88 | 89 | # Run 90 | github1.get_issues("prkumar", "uplink") 91 | 92 | # Verify: the rate limit should be applied globally for all instances 93 | with pytest.raises(RateLimitExceeded): 94 | github2.get_issues("prkumar", "uplink") 95 | 96 | 97 | def test_limit_with_group_by_host_and_port(mock_client): 98 | # Setup: consumers pointing to separate hosts 99 | github1 = GitHub(base_url=BASE_URL, client=mock_client) 100 | github2 = GitHub(base_url=DIFFERENT_BASE_URL, client=mock_client) 101 | 102 | # Run 103 | github1.get_issue("prkumar", "uplink", "issue1") 104 | 105 | # Verify: the rate limit should be applied separately by host-port, 106 | # so this request should be fine. 107 | github2.get_issue("prkumar", "uplink", "issue2") 108 | -------------------------------------------------------------------------------- /tests/integration/test_retry.py: -------------------------------------------------------------------------------- 1 | # Third-party imports 2 | import pytest 3 | import pytest_twisted 4 | 5 | # Local imports. 6 | from uplink import Consumer, get, retry 7 | from uplink.clients import io 8 | 9 | # Constants 10 | BASE_URL = "https://api.github.com/" 11 | 12 | 13 | def backoff_once(): 14 | yield 0.1 15 | 16 | 17 | backoff_default = retry.backoff.exponential(multiplier=0.1, minimum=0.1) 18 | 19 | 20 | class CustomException(Exception): ... 21 | 22 | 23 | class GitHub(Consumer): 24 | @retry(max_attempts=2, backoff=backoff_default) 25 | @get("/users/{user}") 26 | def get_user(self, user): 27 | pass 28 | 29 | @retry(max_attempts=3, backoff=backoff_once) 30 | @get("repos/{user}/{repo}/issues/{issue}") 31 | def get_issue(self, user, repo, issue): 32 | pass 33 | 34 | @retry(stop=retry.stop.after_attempt(3), on_exception=retry.CONNECTION_TIMEOUT) 35 | @get("repos/{user}/{repo}/project/{project}") 36 | def get_project(self, user, repo, project): 37 | pass 38 | 39 | @retry(when=retry.when.status_5xx(), backoff=backoff_default) 40 | @get("repos/{user}/{repo}/issues") 41 | def get_issues(self, user, repo): 42 | pass 43 | 44 | 45 | # Tests 46 | 47 | 48 | def test_retry(mock_client, mock_response): 49 | # Setup 50 | mock_response.with_json({"id": 123, "name": "prkumar"}) 51 | mock_client.with_side_effect([CustomException, mock_response]) 52 | github = GitHub(base_url=BASE_URL, client=mock_client) 53 | 54 | # Run 55 | response = github.get_user("prkumar") 56 | 57 | # Verify 58 | assert len(mock_client.history) == 2 59 | assert response.json() == {"id": 123, "name": "prkumar"} 60 | 61 | 62 | def test_retry_fail(mock_client, mock_response): 63 | # Setup 64 | mock_response.with_json({"id": 123, "name": "prkumar"}) 65 | mock_client.with_side_effect([CustomException, CustomException, mock_response]) 66 | github = GitHub(base_url=BASE_URL, client=mock_client) 67 | 68 | # Run 69 | with pytest.raises(CustomException): 70 | github.get_issue("prkumar", "uplink", "#1") 71 | 72 | # Verify 73 | assert len(mock_client.history) == 2 74 | 75 | 76 | def test_retry_fail_with_client_exception(mock_client, mock_response): 77 | # Setup 78 | mock_response.with_json({"id": 123, "name": "prkumar"}) 79 | mock_client.exceptions.ConnectionTimeout = type( 80 | "ConnectionTimeout", (CustomException,), {} 81 | ) 82 | mock_client.with_side_effect( 83 | [ 84 | mock_client.exceptions.ConnectionTimeout, 85 | CustomException, 86 | mock_response, 87 | ] 88 | ) 89 | github = GitHub(base_url=BASE_URL, client=mock_client) 90 | 91 | # Run 92 | with pytest.raises(CustomException): 93 | github.get_project("prkumar", "uplink", "1") 94 | 95 | # Verify 96 | assert len(mock_client.history) == 2 97 | 98 | 99 | def test_retry_fail_because_of_wait(mock_client, mock_response): 100 | # Setup 101 | mock_response.with_json({"id": 123, "name": "prkumar"}) 102 | mock_client.with_side_effect([CustomException, CustomException, mock_response]) 103 | github = GitHub(base_url=BASE_URL, client=mock_client) 104 | 105 | # Run 106 | with pytest.raises(CustomException): 107 | github.get_issue("prkumar", "uplink", "#1") 108 | 109 | # Verify 110 | assert len(mock_client.history) == 2 111 | 112 | 113 | def test_retry_with_status_501(mock_client, mock_response): 114 | # Setup 115 | mock_response.status_code = 501 116 | mock_client.with_side_effect([mock_response, CustomException]) 117 | github = GitHub(base_url=BASE_URL, client=mock_client) 118 | 119 | # Run 120 | with pytest.raises(CustomException): 121 | github.get_issues("prkumar", "uplink") 122 | 123 | # Verify 124 | assert len(mock_client.history) == 2 125 | 126 | 127 | @pytest_twisted.inlineCallbacks 128 | def test_retry_with_twisted(mock_client, mock_response): 129 | from twisted.internet import defer 130 | 131 | @defer.inlineCallbacks 132 | def return_response(): 133 | yield 134 | defer.returnValue(mock_response) 135 | 136 | # Setup 137 | mock_response.with_json({"id": 123, "name": "prkumar"}) 138 | mock_client.with_side_effect([CustomException, return_response()]) 139 | mock_client.with_io(io.TwistedStrategy()) 140 | github = GitHub(base_url=BASE_URL, client=mock_client) 141 | 142 | # Run 143 | response = yield github.get_user("prkumar") 144 | 145 | assert len(mock_client.history) == 2 146 | assert response.json() == {"id": 123, "name": "prkumar"} 147 | 148 | 149 | @pytest_twisted.inlineCallbacks 150 | def test_retry_fail_with_twisted(mock_client, mock_response): 151 | from twisted.internet import defer 152 | 153 | @defer.inlineCallbacks 154 | def return_response(): 155 | yield 156 | defer.returnValue(mock_response) 157 | 158 | # Setup 159 | mock_response.with_json({"id": 123, "name": "prkumar"}) 160 | mock_client.with_side_effect([CustomException, CustomException, return_response()]) 161 | mock_client.with_io(io.TwistedStrategy()) 162 | github = GitHub(base_url=BASE_URL, client=mock_client) 163 | 164 | # Run 165 | with pytest.raises(CustomException): 166 | yield github.get_user("prkumar") 167 | 168 | assert len(mock_client.history) == 2 169 | -------------------------------------------------------------------------------- /tests/integration/test_retry_aiohttp.py: -------------------------------------------------------------------------------- 1 | # Third-party imports 2 | import pytest 3 | 4 | # Local imports. 5 | from uplink.clients import io 6 | 7 | from . import test_retry 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_retry_with_asyncio(mock_client, mock_response): 12 | async def coroutine(): 13 | return mock_response 14 | 15 | # Setup 16 | mock_response.with_json({"id": 123, "name": "prkumar"}) 17 | mock_client.with_side_effect([Exception, coroutine()]) 18 | mock_client.with_io(io.AsyncioStrategy()) 19 | github = test_retry.GitHub(base_url=test_retry.BASE_URL, client=mock_client) 20 | 21 | # Run 22 | response = await github.get_user("prkumar") 23 | 24 | # Verify 25 | assert len(mock_client.history) == 2 26 | assert response.json() == {"id": 123, "name": "prkumar"} 27 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prkumar/uplink/0f6aa9b24fe6a7a0621177fa1edf73adf185a24f/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import collections 3 | 4 | # Third-party imports 5 | import pytest 6 | 7 | # Local imports 8 | from uplink import clients, converters, helpers, hooks, interfaces 9 | from uplink.clients.exceptions import Exceptions 10 | 11 | 12 | @pytest.fixture 13 | def http_client_mock(mocker): 14 | return mocker.Mock(spec=clients.interfaces.HttpClientAdapter) 15 | 16 | 17 | @pytest.fixture 18 | def transaction_hook_mock(mocker): 19 | return mocker.Mock(spec=hooks.TransactionHook) 20 | 21 | 22 | @pytest.fixture 23 | def converter_mock(mocker): 24 | return mocker.Mock(spec=converters.interfaces.Converter) 25 | 26 | 27 | @pytest.fixture 28 | def converter_factory_mock(mocker): 29 | return mocker.Mock(spec=converters.interfaces.Factory) 30 | 31 | 32 | @pytest.fixture 33 | def annotation_mock(mocker): 34 | return mocker.Mock(spec=interfaces.Annotation) 35 | 36 | 37 | @pytest.fixture 38 | def annotation_handler_builder_mock(mocker): 39 | return mocker.Mock(spec=interfaces.AnnotationHandlerBuilder) 40 | 41 | 42 | @pytest.fixture 43 | def annotation_handler_mock(mocker): 44 | return mocker.Mock(spec=interfaces.AnnotationHandler) 45 | 46 | 47 | @pytest.fixture 48 | def request_definition_builder(mocker): 49 | return mocker.Mock(spec=interfaces.RequestDefinitionBuilder) 50 | 51 | 52 | @pytest.fixture 53 | def request_definition(mocker): 54 | return mocker.Mock(spec=interfaces.RequestDefinition) 55 | 56 | 57 | @pytest.fixture 58 | def uplink_builder_mock(mocker): 59 | return mocker.Mock(spec=interfaces.CallBuilder) 60 | 61 | 62 | @pytest.fixture 63 | def request_builder(mocker): 64 | builder = mocker.MagicMock(spec=helpers.RequestBuilder) 65 | builder.info = collections.defaultdict(dict) 66 | builder.context = {} 67 | builder.get_converter.return_value = lambda x: x 68 | builder.client.exceptions = Exceptions() 69 | return builder 70 | -------------------------------------------------------------------------------- /tests/unit/test__extras.py: -------------------------------------------------------------------------------- 1 | # Third-party imports 2 | import pytest 3 | 4 | # Local imports 5 | from uplink import _extras 6 | 7 | 8 | def test_load_entry_points(mocker): 9 | # Setup 10 | func = mocker.stub() 11 | iter_entry_points = mocker.stub() 12 | entry_point = mocker.Mock() 13 | entry_point.name = "plugin-name" 14 | entry_point.load.return_value = "plugin-value" 15 | iter_entry_points.return_value = [entry_point] 16 | entry_points = {"entry-point": func} 17 | 18 | # Run 19 | _extras.load_entry_points( 20 | _entry_points=entry_points, _iter_entry_points=iter_entry_points 21 | ) 22 | 23 | # Verify 24 | func.assert_called_with("plugin-value") 25 | 26 | 27 | def test_install(mocker): 28 | # Setup 29 | func = mocker.stub() 30 | installers = {list: func} 31 | obj = [] 32 | 33 | # Run: Success 34 | _extras.install(obj, _installers=installers) 35 | 36 | # Verify 37 | func.assert_called_with(obj) 38 | 39 | # Run & Verify Failure 40 | with pytest.raises(TypeError): 41 | _extras.install({}, _installers=installers) 42 | -------------------------------------------------------------------------------- /tests/unit/test_helpers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | # Local imports 4 | from uplink import helpers 5 | 6 | 7 | def test_get_api_definitions(request_definition_builder): 8 | class Service: 9 | builder = request_definition_builder 10 | 11 | assert dict(helpers.get_api_definitions(Service)) == { 12 | "builder": request_definition_builder 13 | } 14 | 15 | 16 | def test_get_api_definitions_from_parent(request_definition_builder): 17 | class Parent: 18 | builder = request_definition_builder 19 | 20 | class Child(Parent): 21 | other_builder = request_definition_builder 22 | 23 | assert dict(helpers.get_api_definitions(Child)) == { 24 | "builder": request_definition_builder, 25 | "other_builder": request_definition_builder, 26 | } 27 | 28 | 29 | class TestRequestBuilder: 30 | def test_return_type(self): 31 | # Setup 32 | builder = helpers.RequestBuilder(None, {}, "base_url") 33 | 34 | # Run 35 | builder.return_type = str 36 | 37 | # Verify 38 | assert builder.return_type is str 39 | 40 | def test_add_transaction_hook(self, transaction_hook_mock): 41 | # Setup 42 | builder = helpers.RequestBuilder(None, {}, "base_url") 43 | 44 | # Run 45 | builder.add_transaction_hook(transaction_hook_mock) 46 | 47 | # Verify 48 | assert list(builder.transaction_hooks) == [transaction_hook_mock] 49 | 50 | def test_context(self): 51 | # Setup 52 | builder = helpers.RequestBuilder(None, {}, "base_url") 53 | 54 | # Run 55 | builder.context["key"] = "value" 56 | 57 | # Verify 58 | assert builder.context["key"] == "value" 59 | 60 | def test_relative_url_template(self): 61 | # Setup 62 | builder = helpers.RequestBuilder(None, {}, "base_url") 63 | 64 | # Run 65 | builder.relative_url = "/v1/api/users/{username}/repos" 66 | builder.set_url_variable({"username": "cognifloyd"}) 67 | 68 | # Verify 69 | assert builder.relative_url == "/v1/api/users/cognifloyd/repos" 70 | 71 | def test_relative_url_template_type_error(self): 72 | # Setup 73 | builder = helpers.RequestBuilder(None, {}, "base_url") 74 | 75 | # Run 76 | with pytest.raises(TypeError): 77 | builder.relative_url = 1 78 | -------------------------------------------------------------------------------- /tests/unit/test_hooks.py: -------------------------------------------------------------------------------- 1 | # Third-party imports 2 | import pytest 3 | 4 | # Local imports 5 | from uplink import hooks 6 | 7 | 8 | class TestResponseHandler: 9 | def test_handle_response(self, mocker): 10 | converter = mocker.Mock() 11 | input_value = "converted" 12 | converter.return_value = input_value 13 | rc = hooks.ResponseHandler(converter) 14 | assert rc.handle_response(None) is input_value 15 | 16 | 17 | class TestRequestAuditor: 18 | def test_audit_request(self, mocker): 19 | auditor = mocker.Mock() 20 | ra = hooks.RequestAuditor(auditor) 21 | ra.audit_request("consumer", "request") 22 | auditor.assert_called_with("request") 23 | 24 | 25 | class TestExceptionHandler: 26 | def test_handle_exception_masked(self, mocker): 27 | handler = mocker.Mock() 28 | eh = hooks.ExceptionHandler(handler) 29 | eh.handle_exception("consumer", "exc_type", "exc_val", "exc_tb") 30 | handler.assert_called_with("exc_type", "exc_val", "exc_tb") 31 | 32 | 33 | class TestTransactionHookChain: 34 | def test_delegate_audit_request(self, transaction_hook_mock): 35 | chain = hooks.TransactionHookChain(transaction_hook_mock) 36 | chain.audit_request("consumer", "request") 37 | transaction_hook_mock.audit_request.assert_called_with("consumer", "request") 38 | 39 | def test_delegate_handle_response(self, transaction_hook_mock): 40 | chain = hooks.TransactionHookChain(transaction_hook_mock) 41 | chain.handle_response("consumer", {}) 42 | transaction_hook_mock.handle_response.assert_called_with("consumer", {}) 43 | 44 | def test_delegate_handle_response_multiple(self, mocker): 45 | # Include one hook that can't handle responses 46 | mock_response_handler = mocker.Mock() 47 | mock_request_auditor = mocker.Mock() 48 | 49 | chain = hooks.TransactionHookChain( 50 | hooks.RequestAuditor(mock_request_auditor), 51 | hooks.ResponseHandler(mock_response_handler), 52 | hooks.ResponseHandler(mock_response_handler), 53 | ) 54 | chain.handle_response("consumer", {}) 55 | assert mock_response_handler.call_count == 2 56 | assert mock_request_auditor.call_count == 0 57 | 58 | def test_delegate_handle_exception(self, transaction_hook_mock): 59 | class CustomException(Exception): 60 | pass 61 | 62 | err = CustomException() 63 | chain = hooks.TransactionHookChain(transaction_hook_mock) 64 | 65 | with pytest.raises(CustomException): 66 | chain.handle_exception(None, CustomException, err, None) 67 | 68 | transaction_hook_mock.handle_exception.assert_called_with( 69 | None, CustomException, err, None 70 | ) 71 | -------------------------------------------------------------------------------- /tests/unit/test_io.py: -------------------------------------------------------------------------------- 1 | # Third-party imports 2 | import pytest 3 | 4 | # Local imports 5 | from uplink.clients.io import interfaces, state, transitions 6 | 7 | 8 | @pytest.fixture 9 | def request_execution_mock(mocker): 10 | return mocker.Mock(spec=interfaces.RequestExecution) 11 | 12 | 13 | @pytest.fixture 14 | def request_state_mock(mocker): 15 | return mocker.Mock(spec=interfaces.RequestState) 16 | 17 | 18 | class BasicStateTest: 19 | def create_state(self, request): 20 | raise NotImplementedError 21 | 22 | @staticmethod 23 | def _create_request_mock(): 24 | return object() 25 | 26 | def test_prepare(self): 27 | request = self._create_request_mock() 28 | target = self.create_state(request) 29 | output = target.prepare(request) 30 | assert output == state.BeforeRequest(request) 31 | 32 | def test_send(self): 33 | request = self._create_request_mock() 34 | target = self.create_state(request) 35 | output = target.send(request) 36 | assert output == state.SendRequest(request) 37 | 38 | def test_fail(self): 39 | request = self._create_request_mock() 40 | target = self.create_state(request) 41 | error = Exception() 42 | output = target.fail(Exception, error, None) 43 | assert output == state.Fail(request, Exception, error, None) 44 | 45 | def test_finish(self): 46 | request = self._create_request_mock() 47 | response = object() 48 | target = self.create_state(request) 49 | output = target.finish(response) 50 | assert output == state.Finish(request, response) 51 | 52 | def test_sleep(self): 53 | request = self._create_request_mock() 54 | target = self.create_state(request) 55 | output = target.sleep(10) 56 | assert output == state.Sleep(request, 10) 57 | 58 | def test_request_property(self): 59 | request = self._create_request_mock() 60 | target = self.create_state(request) 61 | assert target.request == request 62 | 63 | 64 | class TestBeforeRequest(BasicStateTest): 65 | def create_state(self, request): 66 | return state.BeforeRequest(request) 67 | 68 | 69 | class TestAfterResponse(BasicStateTest): 70 | def create_state(self, request): 71 | return state.AfterResponse(request, object()) 72 | 73 | 74 | class TestAfterException(BasicStateTest): 75 | def create_state(self, request): 76 | return state.AfterException(request, Exception, Exception(), None) 77 | 78 | 79 | class TestSleep: 80 | def test_execute(self, request_execution_mock): 81 | request = object() 82 | sleep = state.Sleep(request, 10) 83 | sleep.execute(request_execution_mock) 84 | assert request_execution_mock.sleep.called 85 | 86 | args, _ = request_execution_mock.sleep.call_args 87 | callback = args[1] 88 | assert isinstance(callback, interfaces.SleepCallback) 89 | 90 | callback.on_success() 91 | assert request_execution_mock.state == state.BeforeRequest(request) 92 | 93 | error = Exception() 94 | callback.on_failure(Exception, error, None) 95 | assert request_execution_mock.state == state.AfterException( 96 | request, Exception, error, None 97 | ) 98 | 99 | 100 | class TestSendRequest: 101 | def test_execute(self, request_execution_mock): 102 | request = object() 103 | send_request = state.SendRequest(request) 104 | send_request.execute(request_execution_mock) 105 | assert request_execution_mock.send.called 106 | 107 | args, _ = request_execution_mock.send.call_args 108 | callback = args[1] 109 | assert isinstance(callback, interfaces.InvokeCallback) 110 | 111 | response = object() 112 | callback.on_success(response) 113 | assert request_execution_mock.state == state.AfterResponse(request, response) 114 | 115 | error = Exception() 116 | callback.on_failure(Exception, error, None) 117 | assert request_execution_mock.state == state.AfterException( 118 | request, Exception, error, None 119 | ) 120 | 121 | 122 | class TestFail: 123 | def test_execute(self, request_execution_mock): 124 | request, error = object(), Exception() 125 | fail = state.Fail(request, type(error), error, None) 126 | fail.execute(request_execution_mock) 127 | request_execution_mock.fail.assert_called_with(Exception, error, None) 128 | 129 | 130 | class TestFinish: 131 | def test_execute(self, request_execution_mock): 132 | request, response = object(), object() 133 | finish = state.Finish(request, response) 134 | finish.execute(request_execution_mock) 135 | request_execution_mock.finish.assert_called_with(response) 136 | 137 | 138 | def test_sleep_transition(request_state_mock): 139 | transitions.sleep(10)(request_state_mock) 140 | request_state_mock.sleep.assert_called_with(10) 141 | 142 | 143 | def test_send_transition(request_state_mock): 144 | request = object() 145 | transitions.send(request)(request_state_mock) 146 | request_state_mock.send.assert_called_with(request) 147 | 148 | 149 | def test_finish_transition(request_state_mock): 150 | response = object() 151 | transitions.finish(response)(request_state_mock) 152 | request_state_mock.finish.assert_called_with(response) 153 | 154 | 155 | def test_fail_transition(request_state_mock): 156 | error = Exception() 157 | transitions.fail(Exception, error, None)(request_state_mock) 158 | request_state_mock.fail.assert_called_with(Exception, error, None) 159 | 160 | 161 | def test_prepare_transition(request_state_mock): 162 | request = object() 163 | transitions.prepare(request)(request_state_mock) 164 | request_state_mock.prepare.assert_called_with(request) 165 | -------------------------------------------------------------------------------- /tests/unit/test_models.py: -------------------------------------------------------------------------------- 1 | # Third-party imports 2 | import pytest 3 | 4 | # Local imports 5 | from uplink import returns 6 | from uplink.decorators import json 7 | from uplink.models import dumps, loads 8 | 9 | 10 | @pytest.mark.parametrize( 11 | ("cls", "method"), 12 | [ 13 | (loads, "create_response_body_converter"), 14 | (dumps, "create_request_body_converter"), 15 | ], 16 | ) 17 | def test_models(mocker, cls, method, request_definition): 18 | # Setup 19 | func = mocker.stub() 20 | func.return_value = 1 21 | 22 | # Verify: Returns obj that wraps func 23 | obj = cls(object, (object,)) 24 | factory = obj(func) 25 | assert callable(factory) 26 | assert factory(1) == 1 27 | 28 | # Verify: not relevant 29 | request_definition.argument_annotations = () 30 | request_definition.method_annotations = () 31 | value = getattr(factory, method)(None, request_definition) 32 | assert value is None 33 | 34 | # Verify: relevant 35 | request_definition.argument_annotations = (object(),) 36 | value = getattr(factory, method)(object, request_definition) 37 | assert callable(value) 38 | 39 | 40 | @pytest.mark.parametrize( 41 | ("cls", "method", "decorator"), 42 | [ 43 | (loads.from_json, "create_response_body_converter", returns.json()), 44 | (dumps.to_json, "create_request_body_converter", json()), 45 | ], 46 | ) 47 | def test_json_builders(mocker, cls, method, decorator, request_definition): 48 | # Setup 49 | func = mocker.stub() 50 | func.return_value = 1 51 | obj = cls(object) 52 | factory = obj.using(func) 53 | 54 | # Verify: not relevant 55 | request_definition.argument_annotations = () 56 | request_definition.method_annotations = () 57 | value = getattr(factory, method)(object, request_definition) 58 | assert value is None 59 | 60 | # Verify relevant 61 | request_definition.method_annotations = (decorator,) 62 | value = getattr(factory, method)(object, request_definition) 63 | assert callable(value) 64 | assert value(1) == 1 65 | -------------------------------------------------------------------------------- /tests/unit/test_returns.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink import returns 3 | 4 | 5 | def test_returns(request_builder): 6 | custom = returns(str) 7 | request_builder.get_converter.return_value = str 8 | request_builder.return_type = returns.ReturnType.with_decorator(None, custom) 9 | custom.modify_request(request_builder) 10 | assert request_builder.return_type(2) == "2" 11 | 12 | 13 | def test_returns_with_multiple_decorators(request_builder, mocker): 14 | decorator1 = returns(str) 15 | decorator2 = returns.json() 16 | request_builder.get_converter.return_value = str 17 | first_type = returns.ReturnType.with_decorator(None, decorator1) 18 | second_type = request_builder.return_type = returns.ReturnType.with_decorator( 19 | first_type, decorator2 20 | ) 21 | 22 | # Verify that the return type doesn't change after being handled by first decorator 23 | decorator1.modify_request(request_builder) 24 | assert request_builder.return_type is second_type 25 | 26 | # Verify that the second decorator does handle the return type 27 | mock_response = mocker.Mock() 28 | mock_response.json.return_value = {"key": "value"} 29 | decorator2.modify_request(request_builder) 30 | assert request_builder.return_type(mock_response) == str(mock_response.json()) 31 | 32 | 33 | def test_returns_json(request_builder, mocker): 34 | mock_response = mocker.Mock() 35 | mock_response.json.return_value = {"key": "value"} 36 | request_builder.get_converter.return_value = str 37 | returns_json = returns.json(str, ()) 38 | request_builder.return_type = returns.ReturnType.with_decorator(None, returns_json) 39 | returns_json.modify_request(request_builder) 40 | assert isinstance(request_builder.return_type, returns.ReturnType) 41 | assert callable(request_builder.return_type) 42 | assert request_builder.return_type(mock_response) == str(mock_response.json()) 43 | 44 | # Verify: Idempotent 45 | returns_json.modify_request(request_builder) 46 | assert isinstance(request_builder.return_type, returns.ReturnType) 47 | assert callable(request_builder.return_type) 48 | assert request_builder.return_type(mock_response) == str(mock_response.json()) 49 | 50 | # Verify: Returns JSON when type cannot be converted 51 | request_builder.get_converter.return_value = None 52 | returns_json = returns.json(None, ()) 53 | request_builder.return_type = returns.ReturnType.with_decorator(None, returns_json) 54 | returns_json.modify_request(request_builder) 55 | assert callable(request_builder.return_type) 56 | assert request_builder.return_type(mock_response) == mock_response.json() 57 | 58 | 59 | def test_returns_json_builtin_type(request_builder, mocker): 60 | mock_response = mocker.Mock() 61 | mock_response.json.return_value = {"key": "1"} 62 | request_builder.get_converter.return_value = None 63 | returns_json = returns.json(type=int, key="key") 64 | request_builder.return_type = returns.ReturnType.with_decorator(None, returns_json) 65 | returns_json.modify_request(request_builder) 66 | print(request_builder.return_type) 67 | assert callable(request_builder.return_type) 68 | assert request_builder.return_type(mock_response) == 1 69 | 70 | 71 | class TestReturnsJsonCast: 72 | default_value = {"key": "1"} 73 | 74 | @staticmethod 75 | def prepare_test(request_builder, mocker, value=default_value, **kwargs): 76 | mock_response = mocker.Mock() 77 | mock_response.json.return_value = value 78 | request_builder.get_converter.return_value = None 79 | returns_json = returns.json(**kwargs) 80 | request_builder.return_type = returns.ReturnType.with_decorator( 81 | None, returns_json 82 | ) 83 | returns_json.modify_request(request_builder) 84 | return mock_response 85 | 86 | def test_without_type(self, request_builder, mocker): 87 | mock_response = self.prepare_test(request_builder, mocker, key="key") 88 | assert request_builder.return_type(mock_response) == "1" 89 | 90 | def test_with_callable_type(self, request_builder, mocker): 91 | mock_response = self.prepare_test( 92 | request_builder, 93 | mocker, 94 | type=lambda _: "test", 95 | ) 96 | assert request_builder.return_type(mock_response) == "test" 97 | 98 | def test_with_builtin_type(self, request_builder, mocker): 99 | mock_response = self.prepare_test(request_builder, mocker, type=str) 100 | assert request_builder.return_type(mock_response) == str(self.default_value) 101 | 102 | def test_with_builtin_type_and_key(self, request_builder, mocker): 103 | mock_response = self.prepare_test(request_builder, mocker, key="key", type=int) 104 | assert request_builder.return_type(mock_response) == 1 105 | 106 | def test_with_not_callable_cast(self, request_builder, mocker): 107 | mock_response = self.prepare_test(request_builder, mocker, type=1) 108 | assert request_builder.return_type(mock_response) == self.default_value 109 | 110 | 111 | def test_returns_JsonStrategy(mocker): 112 | response = mocker.Mock(spec=["json"]) 113 | response.json.return_value = {"hello": "world"} 114 | converter = returns.JsonStrategy(lambda x: x, "hello") 115 | assert converter(response) == "world" 116 | 117 | converter = returns.JsonStrategy(lambda y: y + "!", "hello") 118 | assert converter(response) == "world!" 119 | -------------------------------------------------------------------------------- /tests/unit/test_session.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink import session 3 | 4 | 5 | def test_base_url(uplink_builder_mock): 6 | # Setup 7 | uplink_builder_mock.base_url = "https://api.github.com" 8 | sess = session.Session(uplink_builder_mock) 9 | 10 | # Run & Verify 11 | assert uplink_builder_mock.base_url == sess.base_url 12 | 13 | 14 | def test_headers(uplink_builder_mock): 15 | # Setup 16 | sess = session.Session(uplink_builder_mock) 17 | 18 | # Run 19 | sess.headers["key"] = "value" 20 | 21 | # Verify 22 | assert uplink_builder_mock.add_hook.called 23 | assert sess.headers == {"key": "value"} 24 | 25 | 26 | def test_params(uplink_builder_mock): 27 | # Setup 28 | sess = session.Session(uplink_builder_mock) 29 | 30 | # Run 31 | sess.params["key"] = "value" 32 | 33 | # Verify 34 | assert uplink_builder_mock.add_hook.called 35 | assert sess.params == {"key": "value"} 36 | 37 | 38 | def test_auth(uplink_builder_mock): 39 | # Setup 40 | uplink_builder_mock.auth = ("username", "password") 41 | sess = session.Session(uplink_builder_mock) 42 | 43 | # Run & Verify 44 | assert uplink_builder_mock.auth == sess.auth 45 | 46 | 47 | def test_auth_set(uplink_builder_mock): 48 | # Setup 49 | sess = session.Session(uplink_builder_mock) 50 | 51 | # Run 52 | sess.auth = ("username", "password") 53 | 54 | # Verify 55 | assert ("username", "password") == uplink_builder_mock.auth 56 | 57 | 58 | def test_context(uplink_builder_mock): 59 | # Setup 60 | sess = session.Session(uplink_builder_mock) 61 | 62 | # Run 63 | sess.context["key"] = "value" 64 | 65 | # Verify 66 | assert uplink_builder_mock.add_hook.called 67 | assert sess.context == {"key": "value"} 68 | -------------------------------------------------------------------------------- /tests/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | 3 | # Local imports 4 | from uplink import utils 5 | 6 | 7 | def test_get_arg_spec(): 8 | def func(pos1, *args: 2, **kwargs: 3) -> 4: 9 | pass 10 | 11 | signature = utils.get_arg_spec(func) 12 | assert isinstance(signature, utils.Signature) 13 | assert signature.args == ["pos1", "args", "kwargs"] 14 | assert signature.annotations == {"args": 2, "kwargs": 3} 15 | assert signature.return_annotation == 4 16 | 17 | 18 | def test_call_args(): 19 | def func(pos1, *args, **kwargs): 20 | pass 21 | 22 | call_args = utils.get_call_args(func, 1, 2, named=3) 23 | assert call_args == {"pos1": 1, "args": (2,), "kwargs": {"named": 3}} 24 | 25 | 26 | class TestURIBuilder: 27 | def test_variables_not_string(self): 28 | assert utils.URIBuilder.variables(None) == set() 29 | 30 | def test_set_variable(self): 31 | builder = utils.URIBuilder("/path/to/{variable}") 32 | assert builder.build() == "/path/to/" 33 | builder.set_variable(variable="resource") 34 | assert builder.build() == "/path/to/resource" 35 | 36 | def test_remaining_variables(self): 37 | builder = utils.URIBuilder("{variable}") 38 | assert builder.remaining_variables() == {"variable"} 39 | builder.set_variable(variable="resource") 40 | assert len(builder.remaining_variables()) == 0 41 | -------------------------------------------------------------------------------- /uplink/__about__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is the single source of truth for any package metadata 3 | that is used both in distribution (i.e., setup.py) and within the 4 | codebase. 5 | """ 6 | 7 | import importlib.metadata 8 | 9 | __version__ = importlib.metadata.version("uplink") 10 | -------------------------------------------------------------------------------- /uplink/__init__.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink import returns, types 3 | from uplink.__about__ import __version__ 4 | from uplink._extras import install 5 | from uplink._extras import load_entry_points as _load_entry_points 6 | from uplink.arguments import ( 7 | Body, 8 | Context, 9 | Field, 10 | FieldMap, 11 | Header, 12 | HeaderMap, 13 | Part, 14 | PartMap, 15 | Path, 16 | Query, 17 | QueryMap, 18 | Timeout, 19 | Url, 20 | ) 21 | from uplink.builder import Consumer, build 22 | from uplink.clients import AiohttpClient, RequestsClient, TwistedClient 23 | from uplink.commands import delete, get, head, patch, post, put 24 | 25 | # todo: remove this in v1.0.0 26 | from uplink.converters import MarshmallowConverter 27 | from uplink.decorators import ( 28 | args, 29 | error_handler, 30 | form_url_encoded, 31 | headers, 32 | inject, 33 | json, 34 | multipart, 35 | params, 36 | response_handler, 37 | timeout, 38 | ) 39 | from uplink.exceptions import ( 40 | AnnotationError, 41 | Error, 42 | InvalidRequestDefinition, 43 | UplinkBuilderError, 44 | ) 45 | from uplink.models import dumps, loads 46 | from uplink.ratelimit import ratelimit 47 | from uplink.retry import retry 48 | 49 | __all__ = [ 50 | "AiohttpClient", 51 | "AnnotationError", 52 | "Body", 53 | "Consumer", 54 | "Context", 55 | "Error", 56 | "Field", 57 | "FieldMap", 58 | "Header", 59 | "HeaderMap", 60 | "InvalidRequestDefinition", 61 | "MarshmallowConverter", 62 | "Part", 63 | "PartMap", 64 | "Path", 65 | "Query", 66 | "QueryMap", 67 | "RequestsClient", 68 | "Timeout", 69 | "TwistedClient", 70 | "UplinkBuilderError", 71 | "Url", 72 | "__version__", 73 | "args", 74 | "build", 75 | "delete", 76 | "dumps", 77 | "error_handler", 78 | "form_url_encoded", 79 | "get", 80 | "head", 81 | "headers", 82 | "inject", 83 | "install", 84 | "json", 85 | "loads", 86 | "multipart", 87 | "params", 88 | "patch", 89 | "post", 90 | "put", 91 | "ratelimit", 92 | "response_handler", 93 | "retry", 94 | "returns", 95 | "timeout", 96 | "types", 97 | ] 98 | 99 | _load_entry_points() 100 | -------------------------------------------------------------------------------- /uplink/_extras.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import collections 3 | import inspect 4 | from importlib.metadata import entry_points 5 | 6 | _INSTALLERS = collections.OrderedDict() 7 | _ENTRY_POINTS = collections.OrderedDict() 8 | 9 | 10 | class plugin: 11 | _BASE_ENTRY_POINT_NAME = "uplink.plugins." 12 | 13 | def __init__(self, name, _entry_points=_ENTRY_POINTS): 14 | self._name = self._BASE_ENTRY_POINT_NAME + name 15 | self._entry_points = _entry_points 16 | 17 | def __call__(self, func): 18 | self._entry_points[self._name] = func 19 | return func 20 | 21 | 22 | class installer: 23 | def __init__(self, base_cls, _installers=_INSTALLERS): 24 | self._base_cls = base_cls 25 | self._installers = _installers 26 | 27 | def __call__(self, func): 28 | self._installers[self._base_cls] = func 29 | return func 30 | 31 | 32 | def load_entry_points( 33 | _entry_points=_ENTRY_POINTS, 34 | _iter_entry_points=entry_points, 35 | ): 36 | for name in _entry_points: 37 | plugins = { 38 | entry_point.name: entry_point.load() 39 | for entry_point in _iter_entry_points(name=name) 40 | } 41 | func = _entry_points[name] 42 | for value in plugins.values(): 43 | func(value) 44 | 45 | 46 | def install(installable, _installers=_INSTALLERS): 47 | cls = installable if inspect.isclass(installable) else type(installable) 48 | for base_cls in _installers: 49 | if issubclass(cls, base_cls): 50 | _installers[base_cls](installable) 51 | break 52 | else: 53 | raise TypeError(f"Failed to install: '{installable!s}'") 54 | 55 | return installable 56 | -------------------------------------------------------------------------------- /uplink/clients/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package defines an adapter layer for writing wrappers of existing 3 | HTTP clients (Requests, aiohttp, etc.) so they can handle requests built by 4 | Uplink's high-level, declarative API. 5 | 6 | We refer to this layer as the backend, as these adapters handle the 7 | actual HTTP client logic (i.e., making a request to a server). 8 | 9 | Todo: 10 | At some point, we may want to expose this layer to the user, so 11 | they can create custom adapters. 12 | """ 13 | 14 | # Local imports 15 | from uplink import utils 16 | from uplink.clients import interfaces, register 17 | from uplink.clients.register import DEFAULT_CLIENT, get_client 18 | from uplink.clients.requests_ import RequestsClient 19 | from uplink.clients.twisted_ import TwistedClient 20 | 21 | 22 | @register.handler 23 | def _client_class_handler(key): 24 | if utils.is_subclass(key, interfaces.HttpClientAdapter): 25 | return key() 26 | return None 27 | 28 | 29 | try: 30 | from uplink.clients.aiohttp_ import AiohttpClient 31 | except (ImportError, SyntaxError): # pragma: no cover 32 | 33 | class AiohttpClient(interfaces.HttpClientAdapter): 34 | def __init__(self, *args, **kwargs): 35 | raise NotImplementedError( 36 | "Failed to load `aiohttp` client: you may be using a version " 37 | "of Python below 3.3. `aiohttp` requires Python 3.4+." 38 | ) 39 | 40 | 41 | __all__ = [ 42 | "DEFAULT_CLIENT", 43 | "AiohttpClient", 44 | "RequestsClient", 45 | "TwistedClient", 46 | "get_client", 47 | ] 48 | 49 | register.set_default_client(RequestsClient) 50 | -------------------------------------------------------------------------------- /uplink/clients/exceptions.py: -------------------------------------------------------------------------------- 1 | class _UnmappedClientException(BaseException): 2 | pass 3 | 4 | 5 | class Exceptions: 6 | """Enum of standard HTTP client exceptions.""" 7 | 8 | BaseClientException = _UnmappedClientException 9 | """Base class for client errors.""" 10 | 11 | ConnectionError = _UnmappedClientException 12 | """A connection error occurred.""" 13 | 14 | ConnectionTimeout = _UnmappedClientException 15 | """The request timed out while trying to connect to the remote server. 16 | 17 | Requests that produce this error are typically safe to retry. 18 | """ 19 | 20 | ServerTimeout = _UnmappedClientException 21 | """The server did not send any data in the allotted amount of time.""" 22 | 23 | SSLError = _UnmappedClientException 24 | """An SSL error occurred.""" 25 | 26 | InvalidURL = _UnmappedClientException 27 | """The URL provided was somehow invalid.""" 28 | -------------------------------------------------------------------------------- /uplink/clients/interfaces.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink.clients import exceptions, io 3 | 4 | 5 | class HttpClientAdapter(io.Client): 6 | """An adapter of an HTTP client library.""" 7 | 8 | __exceptions = exceptions.Exceptions() 9 | 10 | def io(self): 11 | """Returns the execution strategy for this client.""" 12 | raise NotImplementedError 13 | 14 | @property 15 | def exceptions(self): 16 | """ 17 | uplink.clients.exceptions.Exceptions: An enum of standard HTTP 18 | client errors that have been mapped to client specific 19 | exceptions. 20 | """ 21 | return self.__exceptions 22 | 23 | def send(self, request): 24 | raise NotImplementedError 25 | 26 | def apply_callback(self, callback, response): 27 | raise NotImplementedError 28 | -------------------------------------------------------------------------------- /uplink/clients/io/__init__.py: -------------------------------------------------------------------------------- 1 | from uplink.clients.io.blocking_strategy import BlockingStrategy 2 | from uplink.clients.io.execution import RequestExecutionBuilder 3 | from uplink.clients.io.interfaces import ( 4 | Client, 5 | Executable, 6 | IOStrategy, 7 | RequestTemplate, 8 | ) 9 | from uplink.clients.io.templates import CompositeRequestTemplate 10 | 11 | __all__ = [ 12 | "AsyncioStrategy", 13 | "BlockingStrategy", 14 | "Client", 15 | "CompositeRequestTemplate", 16 | "Executable", 17 | "IOStrategy", 18 | "RequestExecutionBuilder", 19 | "RequestTemplate", 20 | "TwistedStrategy", 21 | ] 22 | 23 | try: 24 | from uplink.clients.io.asyncio_strategy import AsyncioStrategy 25 | except (ImportError, SyntaxError): # pragma: no cover 26 | 27 | class AsyncioStrategy(IOStrategy): 28 | def __init__(self, *args, **kwargs): 29 | raise NotImplementedError( 30 | "Failed to load `asyncio` execution strategy: you may be using a version " 31 | "of Python below 3.3. `aiohttp` requires Python 3.4+." 32 | ) 33 | 34 | 35 | try: 36 | from uplink.clients.io.twisted_strategy import TwistedStrategy 37 | except (ImportError, SyntaxError): # pragma: no cover 38 | 39 | class TwistedStrategy(IOStrategy): 40 | def __init__(self, *args, **kwargs): 41 | raise NotImplementedError( 42 | "Failed to load `twisted` execution strategy: you may be not have " 43 | "the twisted library installed." 44 | ) 45 | -------------------------------------------------------------------------------- /uplink/clients/io/asyncio_strategy.py: -------------------------------------------------------------------------------- 1 | # Third-party imports 2 | import asyncio 3 | import sys 4 | 5 | # Local models 6 | from uplink.clients.io import interfaces 7 | 8 | __all__ = ["AsyncioStrategy"] 9 | 10 | 11 | class AsyncioStrategy(interfaces.IOStrategy): 12 | """A non-blocking execution strategy using asyncio.""" 13 | 14 | async def invoke(self, func, args, kwargs, callback): 15 | try: 16 | response = await func(*args, **kwargs) 17 | except Exception as error: 18 | tb = sys.exc_info()[2] 19 | response = await callback.on_failure(type(error), error, tb) 20 | else: 21 | response = await callback.on_success(response) 22 | return response 23 | 24 | async def sleep(self, duration, callback): 25 | await asyncio.sleep(duration) 26 | return await callback.on_success() 27 | 28 | async def finish(self, response): 29 | return response 30 | 31 | async def execute(self, executable): 32 | return await executable.execute() 33 | -------------------------------------------------------------------------------- /uplink/clients/io/blocking_strategy.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import sys 3 | import time 4 | 5 | # Local imports 6 | from uplink.clients.io import interfaces 7 | 8 | __all__ = ["BlockingStrategy"] 9 | 10 | 11 | class BlockingStrategy(interfaces.IOStrategy): 12 | """A blocking execution strategy.""" 13 | 14 | def invoke(self, func, arg, kwargs, callback): 15 | try: 16 | response = func(*arg, **kwargs) 17 | except Exception as error: 18 | tb = sys.exc_info()[2] 19 | return callback.on_failure(type(error), error, tb) 20 | else: 21 | return callback.on_success(response) 22 | 23 | def sleep(self, duration, callback): 24 | time.sleep(duration) 25 | return callback.on_success() 26 | 27 | def finish(self, response): 28 | return response 29 | 30 | def execute(self, executable): 31 | return executable.execute() 32 | -------------------------------------------------------------------------------- /uplink/clients/io/execution.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink.clients.io import interfaces, state 3 | 4 | __all__ = ["RequestExecutionBuilder"] 5 | 6 | 7 | class RequestExecutionBuilder: 8 | def __init__(self): 9 | self._client = None 10 | self._template = None 11 | self._io = None 12 | self._callbacks = [] 13 | self._errbacks = [] 14 | 15 | def with_client(self, client): 16 | self._client = client 17 | return self 18 | 19 | def with_template(self, template): 20 | self._template = template 21 | return self 22 | 23 | def with_io(self, io): 24 | self._io = io 25 | return self 26 | 27 | def with_callbacks(self, *callbacks): 28 | self._callbacks.extend(callbacks) 29 | return self 30 | 31 | def with_errbacks(self, *errbacks): 32 | self._errbacks.extend(errbacks) 33 | return self 34 | 35 | def build(self): 36 | client, io = self._client, self._io 37 | for callback in self._callbacks: 38 | io = CallbackDecorator(io, client, callback) 39 | for errback in self._errbacks: 40 | io = ErrbackDecorator(io, errback) 41 | return DefaultRequestExecution(client, io, self._template) 42 | 43 | 44 | class DefaultRequestExecution(interfaces.RequestExecution): 45 | def __init__(self, client, io, template): 46 | self._client = client 47 | self._template = template 48 | self._io = io 49 | self._state = None 50 | 51 | def before_request(self, request): 52 | action = self._template.before_request(request) 53 | next_state = action(self._state) 54 | self._state = next_state 55 | return self.execute() 56 | 57 | def after_response(self, request, response): 58 | action = self._template.after_response(request, response) 59 | next_state = action(self._state) 60 | self._state = next_state 61 | return self.execute() 62 | 63 | def after_exception(self, request, exc_type, exc_val, exc_tb): 64 | action = self._template.after_exception(request, exc_type, exc_val, exc_tb) 65 | next_state = action(self._state) 66 | self._state = next_state 67 | return self.execute() 68 | 69 | def send(self, request, callback): 70 | return self._io.invoke(self._client.send, (request,), {}, callback) 71 | 72 | def sleep(self, duration, callback): 73 | return self._io.sleep(duration, callback) 74 | 75 | def finish(self, response): 76 | return self._io.finish(response) 77 | 78 | def fail(self, exc_type, exc_val, exc_tb): 79 | return self._io.fail(exc_type, exc_val, exc_tb) 80 | 81 | @property 82 | def state(self): 83 | return self._state 84 | 85 | @state.setter 86 | def state(self, new_state): 87 | self._state = new_state 88 | 89 | def execute(self): 90 | return self.state.execute(self) 91 | 92 | def start(self, request): 93 | self._state = state.BeforeRequest(request) # Start state 94 | return self._io.execute(self) 95 | 96 | 97 | class FinishingCallback(interfaces.InvokeCallback): 98 | def __init__(self, io): 99 | self._io = io 100 | 101 | def on_success(self, result): 102 | return self._io.finish(result) 103 | 104 | def on_failure(self, exc_type, exc_val, exc_tb): 105 | return self._io.fail(exc_type, exc_val, exc_tb) 106 | 107 | 108 | class IOStrategyDecorator(interfaces.IOStrategy): 109 | def __init__(self, io): 110 | self._io = io 111 | 112 | def invoke(self, func, args, kwargs, callback): 113 | return self._io.invoke(func, args, kwargs, callback) 114 | 115 | def sleep(self, duration, callback): 116 | return self._io.sleep(duration, callback) 117 | 118 | def execute(self, executable): 119 | return self._io.execute(executable) 120 | 121 | def finish(self, response): 122 | return self._io.finish(response) 123 | 124 | def fail(self, exc_type, exc_val, exc_tb): # pragma: no cover 125 | return self._io.fail(exc_type, exc_val, exc_tb) 126 | 127 | 128 | class FinishingDecorator(IOStrategyDecorator): 129 | def _invoke(self, func, *args, **kwargs): 130 | return self._io.invoke(func, args, kwargs, FinishingCallback(self._io)) 131 | 132 | 133 | class CallbackDecorator(FinishingDecorator): 134 | def __init__(self, io, client, callback): 135 | super().__init__(io) 136 | self._client = client 137 | self._callback = callback 138 | 139 | def finish(self, response): 140 | return self._invoke(self._client.apply_callback, self._callback, response) 141 | 142 | 143 | class ErrbackDecorator(FinishingDecorator): 144 | def __init__(self, io, errback): 145 | super().__init__(io) 146 | self._errback = errback 147 | 148 | def fail(self, exc_type, exc_val, exc_tb): 149 | return self._invoke(self._errback, exc_type, exc_val, exc_tb) 150 | -------------------------------------------------------------------------------- /uplink/clients/io/templates.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import operator 3 | 4 | # Local imports 5 | from uplink.clients.io import RequestTemplate, transitions 6 | 7 | 8 | class DefaultRequestTemplate(RequestTemplate): 9 | """The fallback behaviors for all hooks.""" 10 | 11 | def before_request(self, request): 12 | return transitions.send(request) 13 | 14 | def after_response(self, request, response): 15 | return transitions.finish(response) 16 | 17 | def after_exception(self, request, exc_type, exc_val, exc_tb): 18 | return transitions.fail(exc_type, exc_val, exc_tb) 19 | 20 | 21 | class CompositeRequestTemplate(RequestTemplate): 22 | """A chain of many templates with fallback behaviors.""" 23 | 24 | __FALLBACK = DefaultRequestTemplate() 25 | 26 | def _get_transition(self, method, *args, **kwargs): 27 | caller = operator.methodcaller(method, *args, **kwargs) 28 | for template in self._templates: 29 | transition = caller(template) 30 | if transition is not None: 31 | return transition 32 | else: 33 | return caller(self._fallback) 34 | 35 | def __init__(self, templates, fallback=__FALLBACK): 36 | self._templates = list(templates) 37 | self._fallback = fallback 38 | 39 | def before_request(self, request): 40 | return self._get_transition(RequestTemplate.before_request.__name__, request) 41 | 42 | def after_response(self, request, response): 43 | return self._get_transition( 44 | RequestTemplate.after_response.__name__, request, response 45 | ) 46 | 47 | def after_exception(self, request, exc_type, exc_val, exc_tb): 48 | return self._get_transition( 49 | RequestTemplate.after_exception.__name__, 50 | request, 51 | exc_type, 52 | exc_val, 53 | exc_tb, 54 | ) 55 | -------------------------------------------------------------------------------- /uplink/clients/io/transitions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions for transitioning between request execution states. 3 | """ 4 | 5 | __all__ = ["fail", "finish", "prepare", "send", "sleep"] 6 | 7 | 8 | def sleep(seconds): 9 | """ 10 | Transitions the execution to pause for the allotted duration. 11 | 12 | Args: 13 | seconds: The number of seconds to delay execution. 14 | """ 15 | 16 | def action(state): 17 | return state.sleep(seconds) 18 | 19 | return action 20 | 21 | 22 | def send(request): 23 | """ 24 | Transitions the execution to send the given request. 25 | 26 | Args: 27 | request: The intended request data to be sent. 28 | """ 29 | 30 | def action(state): 31 | return state.send(request) 32 | 33 | return action 34 | 35 | 36 | def finish(response): 37 | """ 38 | Transitions the execution to completion. 39 | 40 | Args: 41 | response: The object to return to the execution's invoker. 42 | """ 43 | 44 | def action(state): 45 | return state.finish(response) 46 | 47 | return action 48 | 49 | 50 | def fail(exc_type, exc_val, exc_tb): 51 | """ 52 | Transitions the execution to fail with a specific error. 53 | 54 | This will prompt the execution of any RequestTemplate.after_exception hooks. 55 | 56 | Args: 57 | exc_type: The exception class. 58 | exc_val: The exception object. 59 | exc_tb: The exception's stacktrace. 60 | """ 61 | 62 | def action(state): 63 | return state.fail(exc_type, exc_val, exc_tb) 64 | 65 | return action 66 | 67 | 68 | def prepare(request): 69 | """ 70 | Transitions the execution to prepare the given request. 71 | 72 | This will prompt the execution of any RequestTemplate.before_request. 73 | 74 | Args: 75 | request: The intended request data to be sent. 76 | """ 77 | 78 | def action(state): 79 | return state.prepare(request) 80 | 81 | return action 82 | -------------------------------------------------------------------------------- /uplink/clients/io/twisted_strategy.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import sys 3 | 4 | # Third-party imports 5 | from twisted.internet import defer, reactor, task 6 | 7 | # Local imports 8 | from uplink.clients.io import interfaces 9 | 10 | __all__ = ["TwistedStrategy"] 11 | 12 | 13 | class TwistedStrategy(interfaces.IOStrategy): 14 | """A non-blocking execution strategy using asyncio.""" 15 | 16 | _deferred = None 17 | 18 | @defer.inlineCallbacks 19 | def invoke(self, func, args, kwargs, callback): 20 | try: 21 | response = yield func(*args, **kwargs) 22 | except Exception as error: 23 | tb = sys.exc_info()[2] 24 | response = yield callback.on_failure(type(error), error, tb) 25 | else: 26 | response = yield callback.on_success(response) 27 | defer.returnValue(response) 28 | 29 | @defer.inlineCallbacks 30 | def sleep(self, duration, callback): 31 | yield task.deferLater(reactor, duration, lambda: None) 32 | response = yield callback.on_success() 33 | defer.returnValue(response) 34 | 35 | @defer.inlineCallbacks 36 | def finish(self, response): 37 | yield 38 | defer.returnValue(response) 39 | 40 | @defer.inlineCallbacks 41 | def fail(self, exc_type, exc_val, exc_tb): 42 | yield 43 | super().fail(exc_type, exc_val, exc_tb) 44 | 45 | @defer.inlineCallbacks 46 | def execute(self, executable): 47 | response = yield executable.execute() 48 | defer.returnValue(response) 49 | -------------------------------------------------------------------------------- /uplink/clients/register.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink.clients import interfaces 3 | 4 | #: Provide this flag to :py:func:`get_client` to create an instance of 5 | #: the default HTTP client adapter. 6 | DEFAULT_CLIENT = None 7 | 8 | # (default client, handlers) 9 | _registrar = [None, []] 10 | 11 | 12 | def handler(func): 13 | """Registers :py:obj:`func` as a handler.""" 14 | # TODO: support handler prioritization? 15 | _registrar[1].append(func) 16 | 17 | 18 | def handle_client_key(key): 19 | # Try handlers 20 | for func in _registrar[1]: 21 | client = func(key) 22 | if isinstance(client, interfaces.HttpClientAdapter): 23 | return client 24 | return None 25 | 26 | 27 | def set_default_client(client): 28 | _registrar[0] = client 29 | 30 | 31 | def get_default_client(): 32 | default_client = _registrar[0] 33 | if callable(default_client): 34 | return default_client() 35 | return default_client 36 | 37 | 38 | def get_client(client=DEFAULT_CLIENT): 39 | if client is DEFAULT_CLIENT: 40 | client = get_default_client() 41 | 42 | if isinstance(client, interfaces.HttpClientAdapter): 43 | return client 44 | # Try handlers 45 | return handle_client_key(client) 46 | -------------------------------------------------------------------------------- /uplink/clients/requests_.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | 3 | # Third party imports 4 | import requests 5 | 6 | # Local imports 7 | from uplink.clients import exceptions, interfaces, io, register 8 | 9 | 10 | class RequestsClient(interfaces.HttpClientAdapter): 11 | """ 12 | A `requests` client that returns 13 | `requests.Response` responses. 14 | 15 | Args: 16 | session: The session that should handle sending requests. If this argument is 17 | omitted or set to `None`, a new session will be created. 18 | """ 19 | 20 | exceptions = exceptions.Exceptions() 21 | 22 | def __init__(self, session=None, **kwargs): 23 | self.__auto_created_session = False 24 | if session is None: 25 | session = self._create_session(**kwargs) 26 | self.__auto_created_session = True 27 | self.__session = session 28 | 29 | def __del__(self): 30 | if self.__auto_created_session: 31 | self.__session.close() 32 | 33 | @staticmethod 34 | @register.handler 35 | def with_session(session, *args, **kwargs): 36 | if isinstance(session, requests.Session): 37 | return RequestsClient(session, *args, **kwargs) 38 | return None 39 | 40 | @staticmethod 41 | def _create_session(**kwargs): 42 | session = requests.Session() 43 | for key in kwargs: 44 | setattr(session, key, kwargs[key]) 45 | return session 46 | 47 | def send(self, request): 48 | method, url, extras = request 49 | return self.__session.request(method=method, url=url, **extras) 50 | 51 | def apply_callback(self, callback, response): 52 | return callback(response) 53 | 54 | @staticmethod 55 | def io(): 56 | return io.BlockingStrategy() 57 | 58 | 59 | # === Register client exceptions === # 60 | RequestsClient.exceptions.BaseClientException = requests.RequestException 61 | RequestsClient.exceptions.ConnectionError = requests.ConnectionError 62 | RequestsClient.exceptions.ConnectionTimeout = requests.ConnectTimeout 63 | RequestsClient.exceptions.ServerTimeout = requests.ReadTimeout 64 | RequestsClient.exceptions.SSLError = requests.exceptions.SSLError 65 | RequestsClient.exceptions.InvalidURL = requests.exceptions.InvalidURL 66 | -------------------------------------------------------------------------------- /uplink/clients/twisted_.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines an :py:class:`aiohttp.ClientSession` adapter that 3 | returns :py:class:`twisted.internet.defer.Deferred` responses. 4 | """ 5 | 6 | # Third party imports 7 | try: 8 | from twisted.internet import threads 9 | except ImportError: # pragma: no cover 10 | threads = None 11 | 12 | # Local imports 13 | from uplink.clients import interfaces, io, register 14 | 15 | 16 | class TwistedClient(interfaces.HttpClientAdapter): 17 | """ 18 | Client that returns [`twisted.internet.defer.Deferred`][twisted.internet.defer.Deferred] 19 | responses. 20 | 21 | Note: 22 | This client is an optional feature and requires the [`twisted`][twisted] 23 | package. For example, here's how to install this extra using pip: 24 | 25 | ```bash 26 | $ pip install uplink[twisted] 27 | ``` 28 | 29 | Args: 30 | session ([`requests.Session`][requests.Session], optional): The session 31 | that should handle sending requests. If this argument is 32 | omitted or set to `None`, a new session will be 33 | created. 34 | """ 35 | 36 | def __init__(self, session=None): 37 | if threads is None: 38 | raise NotImplementedError("twisted is not installed.") 39 | self._proxy = register.get_client(session) 40 | 41 | @property 42 | def exceptions(self): 43 | return self._proxy.exceptions 44 | 45 | @staticmethod 46 | def io(): 47 | return io.TwistedStrategy() 48 | 49 | def apply_callback(self, callback, response): 50 | return threads.deferToThread(self._proxy.apply_callback, callback, response) 51 | 52 | def send(self, request): 53 | return threads.deferToThread(self._proxy.send, request) 54 | -------------------------------------------------------------------------------- /uplink/compat.py: -------------------------------------------------------------------------------- 1 | # Third-party imports 2 | import six 3 | 4 | __all__ = ["abc", "reraise"] 5 | 6 | abc = six.moves.collections_abc 7 | reraise = six.reraise 8 | wraps = six.wraps 9 | -------------------------------------------------------------------------------- /uplink/converters/interfaces.py: -------------------------------------------------------------------------------- 1 | class Converter: 2 | def convert(self, value): 3 | raise NotImplementedError 4 | 5 | def __call__(self, *args, **kwargs): 6 | return self.convert(*args, **kwargs) 7 | 8 | def set_chain(self, chain): 9 | pass 10 | 11 | 12 | class Factory: 13 | """ 14 | An adapter that handles serialization of HTTP request properties 15 | (e.g., headers, query parameters, request body) and deserialization 16 | of HTTP response bodies. 17 | 18 | Each concrete implementation of this abstract class typically 19 | encapsulates a specific encoding/decoding strategy 20 | (e.g., Protocol Buffers or JSON). 21 | 22 | !!! note 23 | Overriding all inherited methods is unnecessary; the default 24 | implementation is to return `None`, which tells the 25 | converter layer to move on to the next factory. Hence, 26 | you only should implement the methods you intend to support. 27 | """ 28 | 29 | def create_response_body_converter(self, cls, request_definition): 30 | """ 31 | Returns a callable that can convert a response body into the 32 | specified `cls`. 33 | 34 | The returned callable should expect a single positional 35 | argument: the response body. 36 | 37 | If this factory can't produce such a callable, it should return 38 | `None`, so another factory can have a chance to handle 39 | the type. 40 | 41 | Args: 42 | cls (type): The target class for conversion. 43 | request_definition: Metadata for the outgoing request. 44 | This object exposes two properties: the 45 | `method_annotations` (e.g., `~uplink.headers`) and 46 | `argument_annotations` (e.g., `~uplink.Body`) bound 47 | to the underlying consumer method 48 | """ 49 | 50 | def create_request_body_converter(self, cls, request_definition): 51 | """ 52 | Returns a callable that can convert `cls` into an acceptable 53 | request body. 54 | 55 | The returned callable should expect a single positional 56 | argument: an instance of given type, `cls`. 57 | 58 | If this factory can't produce such a callable, it should return 59 | `None`, so another factory can have a chance to handle 60 | the type. 61 | 62 | Args: 63 | cls (type): The target class for conversion. 64 | request_definition: Metadata for the outgoing request. 65 | This object exposes two properties: the 66 | `method_annotations` (e.g., `~uplink.headers`) and 67 | `argument_annotations` (e.g., `~uplink.Body`) bound 68 | to the underlying consumer method 69 | """ 70 | 71 | def create_string_converter(self, cls, request_definition): 72 | """ 73 | Returns a callable that can convert `cls` into a 74 | `str`. 75 | 76 | The returned callable should expect a single positional 77 | argument: an instance of given type, `cls`. 78 | 79 | If this factory can't produce such a callable, it should return 80 | `None`, so another factory can have a chance to handle 81 | the type. 82 | 83 | Args: 84 | cls (type): The target class for conversion. 85 | request_definition: Metadata for the outgoing request. 86 | This object exposes two properties: the 87 | `method_annotations` (e.g., `~uplink.headers`) and 88 | `argument_annotations` (e.g., `~uplink.Body`) bound 89 | to the underlying consumer method 90 | """ 91 | 92 | 93 | class ConverterFactory(Factory): 94 | # TODO: Remove this in v1.0.0 -- use Factory instead. 95 | 96 | def create_response_body_converter(self, cls, request_definition): 97 | return self.make_response_body_converter( 98 | cls, 99 | request_definition.argument_annotations, 100 | request_definition.method_annotations, 101 | ) 102 | 103 | def create_request_body_converter(self, cls, request_definition): 104 | return self.make_request_body_converter( 105 | cls, 106 | request_definition.argument_annotations, 107 | request_definition.method_annotations, 108 | ) 109 | 110 | def create_string_converter(self, cls, request_definition): 111 | return self.make_string_converter( 112 | cls, 113 | request_definition.argument_annotations, 114 | request_definition.method_annotations, 115 | ) 116 | 117 | def make_response_body_converter( 118 | self, type, argument_annotations, method_annotations 119 | ): 120 | pass 121 | 122 | def make_request_body_converter( 123 | self, type, argument_annotations, method_annotations 124 | ): 125 | pass 126 | 127 | def make_string_converter(self, type, argument_annotations, method_annotations): 128 | pass 129 | -------------------------------------------------------------------------------- /uplink/converters/keys.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines common converter keys, used by consumers of the 3 | converter layer to identify the desired conversion type when querying a 4 | :py:class:`uplink.converters.ConverterFactoryRegistry`. 5 | """ 6 | 7 | # Standard library imports 8 | import functools 9 | 10 | # Local imports 11 | 12 | __all__ = [ 13 | "CONVERT_FROM_RESPONSE_BODY", 14 | "CONVERT_TO_REQUEST_BODY", 15 | "CONVERT_TO_STRING", 16 | "Map", 17 | "Sequence", 18 | ] 19 | 20 | #: Object to string conversion. 21 | CONVERT_TO_STRING = 0 22 | 23 | #: Object to request body conversion. 24 | CONVERT_TO_REQUEST_BODY = 1 25 | 26 | # Response body to object conversion. 27 | CONVERT_FROM_RESPONSE_BODY = 2 28 | 29 | 30 | class CompositeKey: 31 | """ 32 | A utility class for defining composable converter keys. 33 | 34 | Arguments: 35 | converter_key: The enveloped converter key. 36 | """ 37 | 38 | def __init__(self, converter_key): 39 | self._converter_key = converter_key 40 | 41 | def __eq__(self, other): 42 | if isinstance(other, CompositeKey) and type(other) is type(self): 43 | return other._converter_key == self._converter_key 44 | return False 45 | 46 | def convert(self, converter, value): # pragma: no cover 47 | raise NotImplementedError 48 | 49 | def __call__(self, converter_registry): 50 | factory = converter_registry[self._converter_key] 51 | 52 | def factory_wrapper(*args, **kwargs): 53 | converter = factory(*args, **kwargs) 54 | if not converter: 55 | return None 56 | return functools.partial(self.convert, converter) 57 | 58 | return factory_wrapper 59 | 60 | 61 | class Map(CompositeKey): 62 | """ 63 | Object to mapping conversion. 64 | 65 | The constructor argument `converter_key` details the type 66 | for the values in the mapping. For instance: 67 | 68 | ```python 69 | # Key for conversion of an object to a mapping of strings. 70 | Map(CONVERT_TO_STRING) 71 | ``` 72 | """ 73 | 74 | def convert(self, converter, value): 75 | return {k: converter(value[k]) for k in value} 76 | 77 | 78 | class Sequence(CompositeKey): 79 | """ 80 | Object to sequence conversion. 81 | 82 | The constructor argument `converter_key` details the type 83 | for the elements in the sequence. For instance: 84 | 85 | ```python 86 | # Key for conversion of an object to a sequence of strings. 87 | Sequence(CONVERT_TO_STRING) 88 | ``` 89 | """ 90 | 91 | def convert(self, converter, value): 92 | if isinstance(value, list | tuple): 93 | return list(map(converter, value)) 94 | return converter(value) 95 | 96 | 97 | class Identity: 98 | """ 99 | Identity conversion - pass value as is 100 | """ 101 | 102 | def __call__(self, converter_registry): 103 | return self._identity_factory 104 | 105 | def __eq__(self, other): 106 | return type(other) is type(self) 107 | 108 | def _identity_factory(self, *args, **kwargs): 109 | return self._identity 110 | 111 | def _identity(self, value): 112 | return value 113 | -------------------------------------------------------------------------------- /uplink/converters/marshmallow_.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines a converter that uses :py:mod:`marshmallow` schemas 3 | to deserialize and serialize values. 4 | """ 5 | 6 | # Local imports 7 | import importlib.metadata 8 | 9 | from uplink import utils 10 | from uplink.converters import interfaces, register_default_converter_factory 11 | 12 | 13 | class MarshmallowConverter(interfaces.Factory): 14 | """ 15 | A converter that serializes and deserializes values using 16 | `marshmallow` schemas. 17 | 18 | To deserialize JSON responses into Python objects with this 19 | converter, define a `marshmallow.Schema` subclass and set 20 | it as the return annotation of a consumer method: 21 | 22 | ```python 23 | @get("/users") 24 | def get_users(self, username) -> UserSchema(): 25 | '''Fetch a single user''' 26 | ``` 27 | 28 | !!! note 29 | This converter is an optional feature and requires the 30 | `marshmallow` package. For example, here's how to 31 | install this feature using pip: 32 | 33 | ``` 34 | $ pip install uplink[marshmallow] 35 | ``` 36 | """ 37 | 38 | try: 39 | import marshmallow 40 | except ImportError: # pragma: no cover 41 | marshmallow = None 42 | is_marshmallow_3 = None 43 | else: 44 | is_marshmallow_3 = importlib.metadata.version("marshmallow") >= "3.0" 45 | 46 | def __init__(self): 47 | if self.marshmallow is None: 48 | raise ImportError("No module named 'marshmallow'") 49 | 50 | class ResponseBodyConverter(interfaces.Converter): 51 | def __init__(self, extract_data, schema): 52 | self._extract_data = extract_data 53 | self._schema = schema 54 | 55 | def convert(self, response): 56 | try: 57 | json = response.json() 58 | except AttributeError: 59 | # Assume that the response is already json 60 | json = response 61 | 62 | return self._extract_data(self._schema.load(json)) 63 | 64 | class RequestBodyConverter(interfaces.Converter): 65 | def __init__(self, extract_data, schema): 66 | self._extract_data = extract_data 67 | self._schema = schema 68 | 69 | def convert(self, value): 70 | return self._extract_data(self._schema.dump(value)) 71 | 72 | @classmethod 73 | def _get_schema(cls, type_): 74 | if utils.is_subclass(type_, cls.marshmallow.Schema): 75 | return type_() 76 | if isinstance(type_, cls.marshmallow.Schema): 77 | return type_ 78 | raise ValueError("Expected marshmallow.Scheme subclass or instance.") 79 | 80 | def _extract_data(self, m): 81 | # After marshmallow 3.0, Schema.load() and Schema.dump() don't 82 | # return a (data, errors) tuple any more. Only `data` is returned. 83 | return m if self.is_marshmallow_3 else m.data 84 | 85 | def _make_converter(self, converter_cls, type_): 86 | try: 87 | # Try to generate schema instance from the given type. 88 | schema = self._get_schema(type_) 89 | except ValueError: 90 | # Failure: the given type is not a `marshmallow.Schema`. 91 | return None 92 | else: 93 | return converter_cls(self._extract_data, schema) 94 | 95 | def create_request_body_converter(self, type_, *args, **kwargs): 96 | return self._make_converter(self.RequestBodyConverter, type_) 97 | 98 | def create_response_body_converter(self, type_, *args, **kwargs): 99 | return self._make_converter(self.ResponseBodyConverter, type_) 100 | 101 | @classmethod 102 | def register_if_necessary(cls, register_func): 103 | if cls.marshmallow is not None: 104 | register_func(cls) 105 | 106 | 107 | MarshmallowConverter.register_if_necessary(register_default_converter_factory) 108 | -------------------------------------------------------------------------------- /uplink/converters/pydantic_.py: -------------------------------------------------------------------------------- 1 | from uplink.converters import register_default_converter_factory 2 | from uplink.converters.interfaces import Factory 3 | from uplink.utils import is_subclass 4 | 5 | from .pydantic_v1 import _PydanticV1RequestBody, _PydanticV1ResponseBody 6 | from .pydantic_v2 import _PydanticV2RequestBody, _PydanticV2ResponseBody 7 | 8 | 9 | class PydanticConverter(Factory): 10 | """ 11 | A converter that serializes and deserializes values using 12 | `pydantic.v1` and `pydantic` models. 13 | 14 | To deserialize JSON responses into Python objects with this 15 | converter, define a `pydantic.v1.BaseModel` or `pydantic.BaseModel` subclass and set 16 | it as the return annotation of a consumer method: 17 | 18 | ```python 19 | @returns.json() 20 | @get("/users") 21 | def get_users(self, username) -> List[UserModel]: 22 | '''Fetch multiple users''' 23 | ``` 24 | 25 | !!! note 26 | This converter is an optional feature and requires the 27 | `pydantic` package. For example, here's how to 28 | install this feature using pip: 29 | 30 | ```bash 31 | $ pip install uplink[pydantic] 32 | ``` 33 | """ 34 | 35 | try: 36 | import pydantic 37 | import pydantic.v1 as pydantic_v1 38 | except ImportError: # pragma: no cover 39 | pydantic = None 40 | pydantic_v1 = None 41 | 42 | def __init__(self): 43 | """ 44 | Validates if :py:mod:`pydantic` is installed 45 | """ 46 | if (self.pydantic or self.pydantic_v1) is None: 47 | raise ImportError("No module named 'pydantic'") 48 | 49 | def _get_model(self, type_): 50 | if is_subclass(type_, (self.pydantic_v1.BaseModel, self.pydantic.BaseModel)): 51 | return type_ 52 | raise ValueError( 53 | "Expected pydantic.BaseModel or pydantic.v1.BaseModel subclass or instance" 54 | ) 55 | 56 | def _make_converter(self, converter, type_): 57 | try: 58 | model = self._get_model(type_) 59 | except ValueError: 60 | return None 61 | 62 | return converter(model) 63 | 64 | def create_request_body_converter(self, type_, *args, **kwargs): 65 | if is_subclass(type_, self.pydantic.BaseModel): 66 | return self._make_converter(_PydanticV2RequestBody, type_) 67 | return self._make_converter(_PydanticV1RequestBody, type_) 68 | 69 | def create_response_body_converter(self, type_, *args, **kwargs): 70 | if is_subclass(type_, self.pydantic.BaseModel): 71 | return self._make_converter(_PydanticV2ResponseBody, type_) 72 | return self._make_converter(_PydanticV1ResponseBody, type_) 73 | 74 | @classmethod 75 | def register_if_necessary(cls, register_func): 76 | if cls.pydantic is not None: 77 | register_func(cls) 78 | 79 | 80 | PydanticConverter.register_if_necessary(register_default_converter_factory) 81 | -------------------------------------------------------------------------------- /uplink/converters/pydantic_v1.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines a converter that uses :py:mod:`pydantic.v1` models 3 | to deserialize and serialize values. 4 | """ 5 | 6 | from typing import Any 7 | 8 | from uplink.converters.interfaces import Converter 9 | 10 | 11 | def _encode_pydantic_v1(obj: Any) -> Any: 12 | from pydantic.v1.json import pydantic_encoder 13 | 14 | # json atoms 15 | if isinstance(obj, str | int | float | bool) or obj is None: 16 | return obj 17 | 18 | # json containers 19 | if isinstance(obj, dict): 20 | return {_encode_pydantic_v1(k): _encode_pydantic_v1(v) for k, v in obj.items()} 21 | if isinstance(obj, list | tuple): 22 | return [_encode_pydantic_v1(i) for i in obj] 23 | 24 | # pydantic v1 types 25 | return _encode_pydantic_v1(pydantic_encoder(obj)) 26 | 27 | 28 | class _PydanticV1RequestBody(Converter): 29 | def __init__(self, model): 30 | self._model = model 31 | 32 | def convert(self, value): 33 | if isinstance(value, self._model): 34 | return _encode_pydantic_v1(value) 35 | return _encode_pydantic_v1(self._model.parse_obj(value)) 36 | 37 | 38 | class _PydanticV1ResponseBody(Converter): 39 | def __init__(self, model): 40 | self._model = model 41 | 42 | def convert(self, response): 43 | try: 44 | data = response.json() 45 | except AttributeError: 46 | data = response 47 | 48 | return self._model.parse_obj(data) 49 | -------------------------------------------------------------------------------- /uplink/converters/pydantic_v2.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines a converter that uses :py:mod:`pydantic` models 3 | to deserialize and serialize values. 4 | """ 5 | 6 | from typing import TYPE_CHECKING, Any 7 | 8 | from uplink.converters.interfaces import Converter 9 | 10 | if TYPE_CHECKING: 11 | # To allow using string anotations, as we wanna lazy-import 12 | import pydantic 13 | 14 | 15 | def _encode_pydantic_v2(model: "pydantic.BaseModel") -> dict[str, Any]: 16 | return model.model_dump(mode="json") 17 | 18 | 19 | class _PydanticV2RequestBody(Converter): 20 | def __init__(self, model): 21 | self._model = model 22 | 23 | def convert(self, value): 24 | if isinstance(value, self._model): 25 | return _encode_pydantic_v2(value) 26 | return _encode_pydantic_v2(self._model.model_validate(value)) 27 | 28 | 29 | class _PydanticV2ResponseBody(Converter): 30 | def __init__(self, model): 31 | self._model = model 32 | 33 | def convert(self, response): 34 | try: 35 | data = response.json() 36 | except AttributeError: 37 | data = response 38 | 39 | return self._model.parse_obj(data) 40 | -------------------------------------------------------------------------------- /uplink/converters/register.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import collections 3 | import inspect 4 | 5 | # Local imports 6 | from uplink.converters import interfaces 7 | 8 | 9 | class Register: 10 | def __init__(self): 11 | self._register = collections.deque() 12 | 13 | def register_converter_factory(self, proxy): 14 | factory = proxy() if inspect.isclass(proxy) else proxy 15 | if not isinstance(factory, interfaces.Factory): 16 | raise TypeError( 17 | f"Failed to register '{factory}' as a converter factory: it is not an " 18 | f"instance of '{interfaces.Factory}'." 19 | ) 20 | self._register.appendleft(factory) 21 | return proxy 22 | 23 | def get_converter_factories(self): 24 | return tuple(self._register) 25 | 26 | 27 | _registry = Register() 28 | 29 | register_default_converter_factory = _registry.register_converter_factory 30 | get_default_converter_factories = _registry.get_converter_factories 31 | -------------------------------------------------------------------------------- /uplink/converters/standard.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink.converters import interfaces, register_default_converter_factory 3 | 4 | 5 | class StringConverter(interfaces.Converter): 6 | def convert(self, value): 7 | return str(value) 8 | 9 | 10 | @register_default_converter_factory 11 | class StandardConverter(interfaces.Factory): 12 | """ 13 | The default converter, this class seeks to provide sane alternatives 14 | for (de)serialization when all else fails -- e.g., no other 15 | converters could handle a particular type. 16 | """ 17 | 18 | def create_request_body_converter(self, cls, *args, **kwargs): 19 | if isinstance(cls, interfaces.Converter): 20 | return cls 21 | return None 22 | 23 | def create_response_body_converter(self, cls, *args, **kwargs): 24 | if isinstance(cls, interfaces.Converter): 25 | return cls 26 | return None 27 | 28 | def create_string_converter(self, cls, *args, **kwargs): 29 | if isinstance(cls, interfaces.Converter): 30 | return cls 31 | return StringConverter() 32 | -------------------------------------------------------------------------------- /uplink/converters/typing_.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import collections 3 | import functools 4 | 5 | # Local imports 6 | from uplink.compat import abc 7 | from uplink.converters import interfaces, register_default_converter_factory 8 | 9 | __all__ = ["DictConverter", "ListConverter", "TypingConverter"] 10 | 11 | 12 | class BaseTypeConverter: 13 | Builder = collections.namedtuple("Builder", "build") 14 | 15 | @classmethod 16 | def freeze(cls, *args, **kwargs): 17 | return cls.Builder(functools.partial(cls, *args, **kwargs)) 18 | 19 | 20 | class ListConverter(BaseTypeConverter, interfaces.Converter): 21 | def __init__(self, elem_type): 22 | self._elem_type = elem_type 23 | self._elem_converter = None 24 | 25 | def set_chain(self, chain): 26 | self._elem_converter = chain(self._elem_type) or self._elem_type 27 | 28 | def convert(self, value): 29 | if isinstance(value, abc.Sequence): 30 | return list(map(self._elem_converter, value)) 31 | # TODO: Handle the case where the value is not an sequence. 32 | return [self._elem_converter(value)] 33 | 34 | 35 | class DictConverter(BaseTypeConverter, interfaces.Converter): 36 | def __init__(self, key_type, value_type): 37 | self._key_type = key_type 38 | self._value_type = value_type 39 | self._key_converter = None 40 | self._value_converter = None 41 | 42 | def set_chain(self, chain): 43 | self._key_converter = chain(self._key_type) or self._key_type 44 | self._value_converter = chain(self._value_type) or self._value_type 45 | 46 | def convert(self, value): 47 | if isinstance(value, abc.Mapping): 48 | key_c, val_c = self._key_converter, self._value_converter 49 | return {key_c(k): val_c(value[k]) for k in value} 50 | # TODO: Handle the case where the value is not a mapping. 51 | return self._value_converter(value) 52 | 53 | 54 | class _TypeProxy: 55 | def __init__(self, func): 56 | self._func = func 57 | 58 | def __getitem__(self, item): 59 | items = item if isinstance(item, tuple) else (item,) 60 | return self._func(*items) 61 | 62 | 63 | def _get_types(try_typing=True): 64 | if TypingConverter.typing and try_typing: 65 | return TypingConverter.typing.List, TypingConverter.typing.Dict 66 | return ( 67 | _TypeProxy(ListConverter.freeze), 68 | _TypeProxy(DictConverter.freeze), 69 | ) 70 | 71 | 72 | @register_default_converter_factory 73 | class TypingConverter(interfaces.Factory): 74 | """ 75 | Added in v0.5.0 76 | 77 | An adapter that serializes and deserializes collection types from 78 | the `typing` module, such as `typing.List`. 79 | 80 | Inner types of a collection are recursively resolved, using other 81 | available converters if necessary. For instance, when resolving the 82 | type hint `typing.Sequence[UserSchema]`, where 83 | `UserSchema` is a custom `marshmallow.Schema` 84 | subclass, the converter will resolve the inner type using 85 | `uplink.converters.MarshmallowConverter`. 86 | 87 | ```python 88 | @get("/users") 89 | def get_users(self) -> typing.Sequence[UserSchema]: 90 | '''Fetch all users.''' 91 | ``` 92 | 93 | Note: 94 | The `typing` module is available in the standard library 95 | starting from Python 3.5. For earlier versions of Python, there 96 | is a port of the module available on PyPI. 97 | 98 | However, you can utilize this converter without the 99 | `typing` module by using one of the proxies defined by 100 | `uplink.returns` (e.g., `uplink.types.List`). 101 | """ 102 | 103 | try: 104 | import typing 105 | except ImportError: # pragma: no cover 106 | typing = None 107 | 108 | def _check_typing(self, t): 109 | has_origin = hasattr(t, "__origin__") 110 | has_args = hasattr(t, "__args__") 111 | return self.typing and has_origin and has_args 112 | 113 | def _base_converter(self, type_): 114 | if isinstance(type_, BaseTypeConverter.Builder): 115 | return type_.build() 116 | if self._check_typing(type_): 117 | if issubclass(type_.__origin__, self.typing.Sequence): 118 | return ListConverter(*type_.__args__) 119 | if issubclass(type_.__origin__, self.typing.Mapping): 120 | return DictConverter(*type_.__args__) 121 | return None 122 | return None 123 | 124 | def create_response_body_converter(self, type_, *args, **kwargs): 125 | return self._base_converter(type_) 126 | 127 | def create_request_body_converter(self, type_, *args, **kwargs): 128 | return self._base_converter(type_) 129 | 130 | 131 | TypingConverter.List, TypingConverter.Dict = _get_types() 132 | -------------------------------------------------------------------------------- /uplink/exceptions.py: -------------------------------------------------------------------------------- 1 | class Error(Exception): 2 | """Base exception for this package""" 3 | 4 | message = None 5 | 6 | def __str__(self): 7 | return str(self.message) 8 | 9 | 10 | class UplinkBuilderError(Error): 11 | """Something went wrong while building a service.""" 12 | 13 | message = "`%s`: %s" 14 | 15 | def __init__(self, class_name, definition_name, error): 16 | fullname = class_name + "." + definition_name 17 | self.message = self.message % (fullname, error) 18 | self.error = error 19 | 20 | 21 | class InvalidRequestDefinition(Error): 22 | """Something went wrong when building the request definition.""" 23 | 24 | 25 | class AnnotationError(Error): 26 | """Something went wrong with an annotation.""" 27 | -------------------------------------------------------------------------------- /uplink/helpers.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import collections 3 | 4 | # Local imports 5 | from uplink import interfaces, utils 6 | from uplink.clients import io 7 | 8 | 9 | def get_api_definitions(service): 10 | """ 11 | Returns all attributes with type 12 | `uplink.interfaces.RequestDefinitionBuilder` defined on the given 13 | class. 14 | 15 | Note: 16 | All attributes are considered, not only defined directly on the class. 17 | 18 | Args: 19 | service: A class object. 20 | 21 | Returns: 22 | A list of tuples containing the name and value of each request definition. 23 | """ 24 | # In Python 3.3, `inspect.getmembers` doesn't respect the descriptor 25 | # protocol when the first argument is a class. In other words, the 26 | # function includes any descriptors bound to `service` as is rather 27 | # than calling the descriptor's __get__ method. This is seemingly 28 | # fixed in Python 2.7 and 3.4+ (TODO: locate corresponding bug 29 | # report in Python issue tracker). Directly invoking `getattr` to 30 | # force Python's attribute lookup protocol is a decent workaround to 31 | # ensure parity: 32 | class_attributes = ((k, getattr(service, k)) for k in dir(service)) 33 | 34 | is_definition = interfaces.RequestDefinitionBuilder.__instancecheck__ 35 | return [(k, v) for k, v in class_attributes if is_definition(v)] 36 | 37 | 38 | def set_api_definition(service, name, definition): 39 | setattr(service, name, definition) 40 | 41 | 42 | class RequestBuilder: 43 | def __init__(self, client, converter_registry, base_url): 44 | self._method = None 45 | self._relative_url_template = utils.URIBuilder("") 46 | self._return_type = None 47 | self._client = client 48 | self._base_url = base_url 49 | 50 | # TODO: Pass this in as constructor parameter 51 | # TODO: Delegate instantiations to uplink.HTTPClientAdapter 52 | self._info = collections.defaultdict(dict) 53 | self._context = {} 54 | 55 | self._converter_registry = converter_registry 56 | self._transaction_hooks = [] 57 | self._request_templates = [] 58 | 59 | @property 60 | def client(self): 61 | return self._client 62 | 63 | @property 64 | def method(self): 65 | return self._method 66 | 67 | @method.setter 68 | def method(self, method): 69 | self._method = method 70 | 71 | @property 72 | def base_url(self): 73 | return self._base_url 74 | 75 | def set_url_variable(self, variables): 76 | self._relative_url_template.set_variable(variables) 77 | 78 | @property 79 | def relative_url(self): 80 | return self._relative_url_template.build() 81 | 82 | @relative_url.setter 83 | def relative_url(self, url): 84 | self._relative_url_template = utils.URIBuilder(url) 85 | 86 | @property 87 | def info(self): 88 | return self._info 89 | 90 | @property 91 | def context(self): 92 | return self._context 93 | 94 | @property 95 | def transaction_hooks(self): 96 | return iter(self._transaction_hooks) 97 | 98 | def get_converter(self, converter_key, *args, **kwargs): 99 | return self._converter_registry[converter_key](*args, **kwargs) 100 | 101 | @property 102 | def return_type(self): 103 | return self._return_type 104 | 105 | @return_type.setter 106 | def return_type(self, return_type): 107 | self._return_type = return_type 108 | 109 | @property 110 | def request_template(self): 111 | return io.CompositeRequestTemplate(self._request_templates) 112 | 113 | @property 114 | def url(self): 115 | return utils.urlparse.urljoin(self.base_url, self.relative_url) 116 | 117 | def add_transaction_hook(self, hook): 118 | self._transaction_hooks.append(hook) 119 | 120 | def add_request_template(self, template): 121 | self._request_templates.append(template) 122 | -------------------------------------------------------------------------------- /uplink/hooks.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides a class for defining custom handling for specific 3 | points of an HTTP transaction. 4 | """ 5 | 6 | # Local imports 7 | from uplink import compat 8 | 9 | __all__ = ["RequestAuditor", "ResponseHandler", "TransactionHook"] 10 | 11 | 12 | def _wrap_if_necessary(hook, requires_consumer): 13 | if not requires_consumer: 14 | return _wrap_to_ignore_consumer(hook) 15 | return hook 16 | 17 | 18 | def _wrap_to_ignore_consumer(hook): 19 | @compat.wraps(hook) 20 | def wrapper(_, *args, **kwargs): 21 | # Expects that consumer is the first argument 22 | return hook(*args, **kwargs) 23 | 24 | return wrapper 25 | 26 | 27 | class TransactionHook: 28 | """ 29 | A utility class providing methods that define hooks for specific 30 | points of an HTTP transaction. 31 | """ 32 | 33 | def audit_request(self, consumer, request_builder): # pragma: no cover 34 | """Inspects details of a request before it is sent.""" 35 | pass 36 | 37 | handle_response = None 38 | """ 39 | Handles a response object from the server. 40 | 41 | This method can be undefined (i.e., None), indicating that this hook 42 | does not handle responses. 43 | 44 | Args: 45 | response: The received HTTP response. 46 | """ 47 | 48 | def handle_exception(self, consumer, exc_type, exc_val, exc_tb): # pragma: no cover 49 | """ 50 | Handles an exception thrown while waiting for a response from 51 | the server. 52 | 53 | Args: 54 | consumer: The consumer that spawned the failing request. 55 | exc_type: The type of the exception. 56 | exc_val: The exception instance raised. 57 | exc_tb: A traceback instance. 58 | """ 59 | pass 60 | 61 | 62 | class TransactionHookChain(TransactionHook): 63 | """ 64 | A chain that conjoins several transaction hooks into a single 65 | object. 66 | 67 | A method call on this composite object invokes the corresponding 68 | method on all hooks in the chain. 69 | """ 70 | 71 | def __init__(self, *hooks): 72 | self._hooks = hooks 73 | self._response_handlers = [] 74 | 75 | # TODO: If more than one callback exists on the chain, the chain 76 | # expects it can execute each synchronously. Instead, we should 77 | # be smart about this and produces a chained coroutine when all 78 | # callbacks are coroutines, so that the client can execute the 79 | # chain asynchronously. Further, when provided both synchronous 80 | # and asynchronous callbacks, we should raise an exception when 81 | # the order is mixed and split into two chains (one async and 82 | # the other sync) when the order permits separation. 83 | 84 | # Adding a synchronous callback to an async request forces the 85 | # request to execute synchronously while running this chain. To 86 | # avoid unnecessarily executing this chain when no callbacks 87 | # exists, we can set the `handle_response` method to null, 88 | # indicating that this hook doesn't handle responses. 89 | response_handlers = [h for h in hooks if h.handle_response is not None] 90 | if not response_handlers: 91 | self.handle_response = None 92 | elif len(response_handlers) == 1: 93 | self.handle_response = response_handlers[0].handle_response 94 | 95 | self._response_handlers = response_handlers 96 | 97 | def audit_request(self, consumer, request_handler): 98 | for hook in self._hooks: 99 | hook.audit_request(consumer, request_handler) 100 | 101 | def handle_response(self, consumer, response): 102 | for hook in self._response_handlers: 103 | response = hook.handle_response(consumer, response) 104 | return response 105 | 106 | def handle_exception(self, consumer, exc_type, exc_val, exc_tb): 107 | for hook in self._hooks: 108 | hook.handle_exception(consumer, exc_type, exc_val, exc_tb) 109 | compat.reraise(exc_type, exc_val, exc_tb) 110 | 111 | 112 | class RequestAuditor(TransactionHook): 113 | """ 114 | Transaction hook that inspects requests using a function provided at 115 | time of instantiation. 116 | """ 117 | 118 | def __init__(self, auditor, requires_consumer=False): 119 | self.audit_request = _wrap_if_necessary(auditor, requires_consumer) 120 | 121 | 122 | class ResponseHandler(TransactionHook): 123 | """ 124 | Transaction hook that handles responses using a function provided at 125 | time of instantiation. 126 | """ 127 | 128 | def __init__(self, handler, requires_consumer=False): 129 | self.handle_response = _wrap_if_necessary(handler, requires_consumer) 130 | 131 | 132 | class ExceptionHandler(TransactionHook): 133 | """ 134 | Transaction hook that handles an exception thrown while waiting for 135 | a response, using the provided function. 136 | """ 137 | 138 | def __init__(self, exception_handler, requires_consumer=False): 139 | self.handle_exception = _wrap_if_necessary(exception_handler, requires_consumer) 140 | -------------------------------------------------------------------------------- /uplink/interfaces.py: -------------------------------------------------------------------------------- 1 | class AnnotationMeta(type): 2 | def __call__(cls, *args, **kwargs): 3 | if cls._can_be_static and cls._is_static_call(*args, **kwargs): 4 | self = super().__call__() 5 | self(args[0]) 6 | return args[0] 7 | return super().__call__(*args, **kwargs) 8 | 9 | 10 | class _Annotation: 11 | _can_be_static = False 12 | 13 | def modify_request_definition(self, request_definition_builder): 14 | pass 15 | 16 | @classmethod 17 | def _is_static_call(cls, *args, **kwargs): 18 | try: 19 | is_builder = isinstance(args[0], RequestDefinitionBuilder) 20 | except IndexError: 21 | return False 22 | else: 23 | return is_builder and not (kwargs or args[1:]) 24 | 25 | 26 | Annotation = AnnotationMeta("Annotation", (_Annotation,), {}) 27 | 28 | 29 | class AnnotationHandlerBuilder: 30 | __listener = None 31 | 32 | @property 33 | def listener(self): 34 | return self.__listener 35 | 36 | @listener.setter 37 | def listener(self, listener): 38 | self.__listener = listener 39 | 40 | def add_annotation(self, annotation, *args, **kwargs): 41 | if self.__listener is not None: 42 | self.__listener(annotation) 43 | 44 | def is_done(self): 45 | return True 46 | 47 | def build(self): 48 | raise NotImplementedError 49 | 50 | 51 | class AnnotationHandler: 52 | @property 53 | def annotations(self): 54 | raise NotImplementedError 55 | 56 | 57 | class UriDefinitionBuilder: 58 | @property 59 | def is_static(self): 60 | raise NotImplementedError 61 | 62 | @property 63 | def is_dynamic(self): 64 | raise NotImplementedError 65 | 66 | @is_dynamic.setter 67 | def is_dynamic(self, is_dynamic): 68 | raise NotImplementedError 69 | 70 | def add_variable(self, name): 71 | raise NotImplementedError 72 | 73 | @property 74 | def remaining_variables(self): 75 | raise NotImplementedError 76 | 77 | def build(self): 78 | raise NotImplementedError 79 | 80 | 81 | class RequestDefinitionBuilder: 82 | @property 83 | def method(self): 84 | raise NotImplementedError 85 | 86 | @property 87 | def uri(self): 88 | raise NotImplementedError 89 | 90 | @property 91 | def argument_handler_builder(self): 92 | raise NotImplementedError 93 | 94 | @property 95 | def method_handler_builder(self): 96 | raise NotImplementedError 97 | 98 | def update_wrapper(self, wrapper): 99 | raise NotImplementedError 100 | 101 | def build(self): 102 | raise NotImplementedError 103 | 104 | def copy(self): 105 | raise NotImplementedError 106 | 107 | 108 | class RequestDefinition: 109 | def make_converter_registry(self, converters): 110 | raise NotImplementedError 111 | 112 | def define_request(self, request_builder, func_args, func_kwargs): 113 | raise NotImplementedError 114 | 115 | 116 | class CallBuilder: 117 | @property 118 | def client(self): 119 | raise NotImplementedError 120 | 121 | @property 122 | def base_url(self): 123 | raise NotImplementedError 124 | 125 | @property 126 | def converters(self): 127 | raise NotImplementedError 128 | 129 | @property 130 | def hooks(self): 131 | raise NotImplementedError 132 | 133 | def add_hook(self, hook, *more_hooks): 134 | raise NotImplementedError 135 | 136 | @property 137 | def auth(self): 138 | raise NotImplementedError 139 | 140 | def build(self, definition): 141 | raise NotImplementedError 142 | 143 | 144 | class Auth: 145 | def __call__(self, request_builder): 146 | raise NotImplementedError 147 | 148 | 149 | class Consumer: 150 | @property 151 | def session(self): 152 | raise NotImplementedError 153 | -------------------------------------------------------------------------------- /uplink/ratelimit.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import contextlib 3 | import math 4 | import sys 5 | import threading 6 | import time 7 | 8 | # Local imports 9 | from uplink import decorators, utils 10 | from uplink.clients.io import RequestTemplate, transitions 11 | 12 | __all__ = ["RateLimitExceeded", "ratelimit"] 13 | 14 | # Use monotonic time if available, otherwise fall back to the system clock. 15 | now = time.monotonic if hasattr(time, "monotonic") else time.time 16 | 17 | 18 | def _get_host_and_port(base_url): 19 | parsed_url = utils.urlparse.urlparse(base_url) 20 | return parsed_url.hostname, parsed_url.port 21 | 22 | 23 | class RateLimitExceeded(RuntimeError): 24 | """A request failed because it exceeded the client-side rate limit.""" 25 | 26 | def __init__(self, calls, period): 27 | super().__init__( 28 | f"Exceeded rate limit of [{calls}] calls every [{period}] seconds." 29 | ) 30 | 31 | 32 | class Limiter: 33 | _last_reset = _num_calls = None 34 | 35 | def __init__(self, max_calls, period, clock): 36 | self._max_calls = max_calls 37 | self._period = period 38 | self._clock = clock 39 | self._lock = threading.RLock() 40 | self._reset() 41 | 42 | @property 43 | def period_remaining(self): 44 | return self._period - (self._clock() - self._last_reset) 45 | 46 | @contextlib.contextmanager 47 | def check(self): 48 | with self._lock: 49 | if self.period_remaining <= 0: 50 | self._reset() 51 | yield self._max_calls > self._num_calls 52 | self._num_calls += 1 53 | 54 | def _reset(self): 55 | self._num_calls = 0 56 | self._last_reset = self._clock() 57 | 58 | 59 | class RateLimiterTemplate(RequestTemplate): 60 | def __init__(self, limiter, create_limit_reached_exception): 61 | self._limiter = limiter 62 | self._create_limit_reached_exception = create_limit_reached_exception 63 | 64 | def before_request(self, request): 65 | with self._limiter.check() as ok: 66 | if ok: 67 | return None # Fallback to default behavior 68 | if self._create_limit_reached_exception is not None: 69 | raise self._create_limit_reached_exception() 70 | return transitions.sleep(self._limiter.period_remaining) 71 | 72 | 73 | # noinspection PyPep8Naming 74 | class ratelimit(decorators.MethodAnnotation): 75 | """ 76 | A decorator that constrains a consumer method or an entire 77 | consumer to making a specified maximum number of requests within a 78 | defined time period (e.g., 15 calls every 15 minutes). 79 | 80 | !!! note 81 | The rate limit is enforced separately for each host-port 82 | combination. Logically, requests are grouped by host and port, 83 | and the number of requests within a time period are counted and 84 | capped separately for each group. 85 | 86 | By default, when the limit is reached, the client will wait until 87 | the current period is over before executing any subsequent 88 | requests. If you'd prefer the client to raise an exception when the 89 | limit is exceeded, set the `raise_on_limit` argument. 90 | 91 | Args: 92 | calls: The maximum number of allowed calls that the 93 | consumer can make within the time period. 94 | period: The duration of each time period in seconds. 95 | raise_on_limit: Either an exception to raise when the client 96 | exceeds the rate limit or a boolean. If `True`, a 97 | [`RateLimitExceeded`][uplink.ratelimit.RateLimitExceeded] exception is 98 | raised. 99 | """ 100 | 101 | BY_HOST_AND_PORT = _get_host_and_port 102 | 103 | def __init__( 104 | self, 105 | calls=15, 106 | period=900, 107 | raise_on_limit=False, 108 | group_by=BY_HOST_AND_PORT, 109 | clock=now, 110 | ): 111 | self._max_calls = max(1, min(sys.maxsize, math.floor(calls))) 112 | self._period = period 113 | self._clock = clock 114 | self._limiter_cache = {} 115 | self._group_by = utils.no_op if group_by is None else group_by 116 | 117 | if utils.is_subclass(raise_on_limit, Exception) or isinstance( 118 | raise_on_limit, Exception 119 | ): 120 | self._create_limit_reached_exception = raise_on_limit 121 | elif raise_on_limit: 122 | self._create_limit_reached_exception = self._create_rate_limit_exceeded 123 | else: 124 | self._create_limit_reached_exception = None 125 | 126 | def _get_limiter_for_request(self, request_builder): 127 | key = self._group_by(request_builder.base_url) 128 | try: 129 | return self._limiter_cache[key] 130 | except KeyError: 131 | return self._limiter_cache.setdefault( 132 | key, Limiter(self._max_calls, self._period, self._clock) 133 | ) 134 | 135 | def modify_request(self, request_builder): 136 | limiter = self._get_limiter_for_request(request_builder) 137 | request_builder.add_request_template( 138 | RateLimiterTemplate(limiter, self._create_limit_reached_exception) 139 | ) 140 | 141 | def _create_rate_limit_exceeded(self): 142 | return RateLimitExceeded(self._max_calls, self._period) 143 | -------------------------------------------------------------------------------- /uplink/retry/__init__.py: -------------------------------------------------------------------------------- 1 | from uplink.retry.backoff import RetryBackoff 2 | from uplink.retry.retry import retry 3 | from uplink.retry.when import RetryPredicate 4 | 5 | __all__ = ["RetryBackoff", "RetryPredicate", "retry"] 6 | -------------------------------------------------------------------------------- /uplink/retry/_helpers.py: -------------------------------------------------------------------------------- 1 | class ClientExceptionProxy: 2 | def __init__(self, getter): 3 | self._getter = getter 4 | 5 | @classmethod 6 | def wrap_proxy_if_necessary(cls, exc): 7 | return exc if isinstance(exc, cls) else (lambda exceptions: exc) 8 | 9 | def __call__(self, exceptions): 10 | return self._getter(exceptions) 11 | -------------------------------------------------------------------------------- /uplink/retry/backoff.py: -------------------------------------------------------------------------------- 1 | # Standard imports 2 | import random 3 | import sys 4 | 5 | # Constants 6 | MAX_VALUE = sys.maxsize / 2 7 | 8 | __all__ = ["exponential", "fixed", "jittered"] 9 | 10 | 11 | def from_iterable(iterable): 12 | """Creates a retry strategy from an iterable of timeouts""" 13 | 14 | class IterableRetryBackoff(_IterableBackoff): 15 | def __iter__(self): 16 | return iter(iterable) 17 | 18 | return IterableRetryBackoff() 19 | 20 | 21 | def from_iterable_factory(iterable_factory): 22 | """ 23 | Creates a retry strategy from a function that returns an iterable 24 | of timeouts. 25 | """ 26 | 27 | class IterableRetryBackoff(_IterableBackoff): 28 | def __iter__(self): 29 | return iter(iterable_factory()) 30 | 31 | return IterableRetryBackoff() 32 | 33 | 34 | class RetryBackoff: 35 | """ 36 | Base class for a strategy that calculates the timeout between 37 | retry attempts. 38 | 39 | You can compose two `RetryBackoff` instances by using the `|` 40 | operator: 41 | 42 | ```python 43 | CustomBackoffA() | CustomBackoffB() 44 | ``` 45 | 46 | The resulting backoff strategy will first compute the timeout using 47 | the left-hand instance. If that timeout is `None`, the strategy 48 | will try to compute a fallback using the right-hand instance. If 49 | both instances return `None`, the resulting strategy will also 50 | return `None`. 51 | """ 52 | 53 | def get_timeout_after_response(self, request, response): 54 | """ 55 | Returns the number of seconds to wait before retrying the 56 | request, or `None` to indicate that the given response should 57 | be returned. 58 | """ 59 | raise NotImplementedError # pragma: no cover 60 | 61 | def get_timeout_after_exception(self, request, exc_type, exc_val, exc_tb): 62 | """ 63 | Returns the number of seconds to wait before retrying the 64 | request, or ``None`` to indicate that the given exception 65 | should be raised. 66 | """ 67 | raise NotImplementedError # pragma: no cover 68 | 69 | def handle_after_final_retry(self): 70 | """ 71 | Handles any clean-up necessary following the final retry 72 | attempt. 73 | """ 74 | pass # pragma: no cover 75 | 76 | def __or__(self, other): 77 | """Composes the current strategy with another.""" 78 | assert isinstance(other, RetryBackoff), "Both objects should be backoffs." 79 | return _Or(self, other) 80 | 81 | 82 | class _Or(RetryBackoff): 83 | def __init__(self, left, right): 84 | self._left = left 85 | self._right = right 86 | 87 | def get_timeout_after_response(self, request, response): 88 | timeout = self._left.get_timeout_after_response(request, response) 89 | if timeout is None: 90 | return self._right.get_timeout_after_response(request, response) 91 | return timeout 92 | 93 | def get_timeout_after_exception(self, request, exc_type, exc_val, exc_tb): 94 | timeout = self._left.get_timeout_after_exception( 95 | request, exc_type, exc_val, exc_tb 96 | ) 97 | if timeout is None: 98 | return self._right.get_timeout_after_exception( 99 | request, exc_type, exc_val, exc_tb 100 | ) 101 | return timeout 102 | 103 | def handle_after_final_retry(self): 104 | self._left.handle_after_final_retry() 105 | self._right.handle_after_final_retry() 106 | 107 | 108 | class _IterableBackoff(RetryBackoff): 109 | __iterator = None 110 | 111 | def __iter__(self): 112 | raise NotImplementedError # pragma: no cover 113 | 114 | def __call__(self): 115 | return iter(self) 116 | 117 | def __next(self): 118 | if self.__iterator is None: 119 | self.__iterator = iter(self) 120 | 121 | try: 122 | return next(self.__iterator) 123 | except StopIteration: 124 | return None 125 | 126 | def get_timeout_after_response(self, request, response): 127 | return self.__next() 128 | 129 | def get_timeout_after_exception(self, request, exc_type, exc_val, exc_tb): 130 | return self.__next() 131 | 132 | def handle_after_final_retry(self): 133 | self.__iterator = None 134 | 135 | 136 | class jittered(_IterableBackoff): 137 | """ 138 | Waits using capped exponential backoff and full jitter. 139 | 140 | The implementation is discussed in [this AWS Architecture Blog 141 | post](https://amzn.to/2xc2nK2), which recommends this approach 142 | for any remote clients, as it minimizes the total completion 143 | time of competing clients in a distributed system experiencing 144 | high contention. 145 | """ 146 | 147 | def __init__(self, base=2, multiplier=1, minimum=0, maximum=MAX_VALUE): 148 | self._exp_backoff = exponential(base, multiplier, minimum, maximum) 149 | 150 | def __iter__(self): 151 | for delay in self._exp_backoff(): # pragma: no branch 152 | yield random.uniform(0, 1) * delay 153 | 154 | 155 | class exponential(_IterableBackoff): 156 | """ 157 | Waits using capped exponential backoff, meaning that the delay 158 | is multiplied by a constant `base` after each attempt, up to 159 | an optional `maximum` value. 160 | """ 161 | 162 | def __init__(self, base=2, multiplier=1, minimum=0, maximum=MAX_VALUE): 163 | self._base = base 164 | self._multiplier = multiplier 165 | self._minimum = minimum 166 | self._maximum = maximum 167 | 168 | def __iter__(self): 169 | delay = self._multiplier 170 | while self._minimum > delay: 171 | delay *= self._base 172 | while True: 173 | yield min(delay, self._maximum) 174 | delay *= self._base 175 | 176 | 177 | class fixed(_IterableBackoff): 178 | """Waits for a fixed number of `seconds` before each retry.""" 179 | 180 | def __init__(self, seconds): 181 | self._seconds = seconds 182 | 183 | def __iter__(self): 184 | while True: 185 | yield self._seconds 186 | -------------------------------------------------------------------------------- /uplink/retry/retry.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink import decorators 3 | from uplink.clients.io import RequestTemplate, transitions 4 | from uplink.retry import ( 5 | backoff as backoff_mod, 6 | ) 7 | from uplink.retry import ( 8 | stop as stop_mod, 9 | ) 10 | from uplink.retry import ( 11 | when as when_mod, 12 | ) 13 | from uplink.retry._helpers import ClientExceptionProxy 14 | 15 | __all__ = ["retry"] 16 | 17 | 18 | # noinspection PyPep8Naming 19 | class retry(decorators.MethodAnnotation): 20 | """ 21 | A decorator that adds retry support to a consumer method or to an 22 | entire consumer. 23 | 24 | Unless you specify the `when` or `on_exception` argument, all 25 | failed requests that raise an exception are retried. 26 | 27 | Unless you specify the `max_attempts` or `stop` argument, this 28 | decorator continues retrying until the server returns a response. 29 | 30 | Unless you specify the `backoff` argument, this decorator uses 31 | [capped exponential backoff and jitter](https://amzn.to/2xc2nK2), 32 | which should benefit performance with remote services under high 33 | contention. 34 | 35 | !!! note 36 | Response and error handlers (see [custom response handler](../../user-guide/response-and-error-handling.md)) 37 | are invoked after the retry condition breaks or all 38 | retry attempts are exhausted, whatever comes first. These 39 | handlers will receive the first response/exception that triggers 40 | the retry's `stop` condition or doesn't match its `when` 41 | filter. 42 | 43 | In other words, responses or exceptions that match 44 | the retry condition (e.g., retry when status code is 5xx) are 45 | not subject to response or error handlers as long as the request 46 | doesn't break the retry's stop condition (e.g., stop retrying 47 | after 5 attempts). 48 | 49 | Args: 50 | when: A predicate that determines when a retry 51 | should be attempted. 52 | max_attempts: The number of retries to attempt. 53 | If not specified, requests are retried continuously until 54 | a response is rendered. 55 | on_exception: The exception type 56 | that should prompt a retry attempt. 57 | stop: A function that creates 58 | predicates that decide when to stop retrying a request. 59 | backoff: A backoff strategy or a function that creates an iterator 60 | over the ordered sequence of timeouts between retries. If 61 | not specified, exponential backoff is used. 62 | """ 63 | 64 | _DEFAULT_PREDICATE = when_mod.raises(Exception) 65 | 66 | stop = stop_mod 67 | backoff = backoff_mod 68 | when = when_mod 69 | 70 | def __init__( 71 | self, 72 | when=None, 73 | max_attempts=None, 74 | on_exception=None, 75 | stop=None, 76 | backoff=None, 77 | ): 78 | if stop is None: 79 | if max_attempts is not None: 80 | stop = stop_mod.after_attempt(max_attempts) 81 | else: 82 | stop = stop_mod.NEVER 83 | 84 | if on_exception is not None: 85 | when = when_mod.raises(on_exception) | when 86 | 87 | if when is None: 88 | when = self._DEFAULT_PREDICATE 89 | 90 | if backoff is None: 91 | backoff = backoff_mod.jittered() 92 | 93 | if not isinstance(backoff, backoff_mod.RetryBackoff): 94 | backoff = backoff_mod.from_iterable_factory(backoff) 95 | 96 | self._when = when 97 | self._backoff = backoff 98 | self._stop = stop 99 | 100 | BASE_CLIENT_EXCEPTION = ClientExceptionProxy(lambda ex: ex.BaseClientException) 101 | CONNECTION_ERROR = ClientExceptionProxy(lambda ex: ex.ConnectionError) 102 | CONNECTION_TIMEOUT = ClientExceptionProxy(lambda ex: ex.ConnectionTimeout) 103 | SERVER_TIMEOUT = ClientExceptionProxy(lambda ex: ex.ServerTimeout) 104 | SSL_ERROR = ClientExceptionProxy(lambda ex: ex.SSLError) 105 | 106 | def modify_request(self, request_builder): 107 | request_builder.add_request_template( 108 | _RetryTemplate( 109 | self._when(request_builder), 110 | self._backoff, 111 | self._stop, 112 | ) 113 | ) 114 | 115 | 116 | class _RetryTemplate(RequestTemplate): 117 | def __init__(self, condition, backoff, stop): 118 | self._condition = condition 119 | self._backoff = backoff 120 | self._stop = stop 121 | self._stop_iter = self._stop() 122 | 123 | def _process_timeout(self, timeout): 124 | next(self._stop_iter) 125 | if timeout is None or self._stop_iter.send(timeout): 126 | self._backoff.handle_after_final_retry() 127 | self._stop_iter = self._stop() 128 | return None 129 | return transitions.sleep(timeout) 130 | 131 | def after_response(self, request, response): 132 | if not self._condition.should_retry_after_response(response): 133 | return self._process_timeout(None) 134 | return self._process_timeout( 135 | self._backoff.get_timeout_after_response(request, response) 136 | ) 137 | 138 | def after_exception(self, request, exc_type, exc_val, exc_tb): 139 | if not self._condition.should_retry_after_exception(exc_type, exc_val, exc_tb): 140 | return self._process_timeout(None) 141 | return self._process_timeout( 142 | self._backoff.get_timeout_after_exception( 143 | request, exc_type, exc_val, exc_tb 144 | ) 145 | ) 146 | -------------------------------------------------------------------------------- /uplink/retry/stop.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines stop conditions for retry operations. 3 | 4 | This module provides classes and functions to control when retry operations should stop. 5 | """ 6 | 7 | __all__ = ["after_attempt", "after_delay"] 8 | 9 | 10 | class RetryBreaker: 11 | """ 12 | Base class for defining retry stop conditions. 13 | 14 | You can compose two `RetryBreaker` instances by using the `|` operator: 15 | 16 | ```python 17 | CustomBreakerA() | CustomBreakerB() 18 | ``` 19 | 20 | The resulting breaker will stop retrying if either of the composed breakers 21 | indicates to stop. 22 | """ 23 | 24 | def __or__(self, other): 25 | if other is not None: 26 | assert isinstance(other, RetryBreaker), ( 27 | "Both objects should be retry breakers." 28 | ) 29 | return _Or(self, other) 30 | return self 31 | 32 | def __call__(self): # pragma: no cover 33 | raise NotImplementedError 34 | 35 | 36 | class _Or(RetryBreaker): 37 | def __init__(self, left, right): 38 | self._left = left 39 | self._right = right 40 | 41 | def __call__(self): 42 | left = self._left() 43 | right = self._right() 44 | while True: 45 | delay = yield 46 | next(left) 47 | next(right) 48 | stop_left = left.send(delay) 49 | stop_right = right.send(delay) 50 | yield stop_left or stop_right 51 | 52 | 53 | # noinspection PyPep8Naming 54 | class after_attempt(RetryBreaker): 55 | """ 56 | Stops retrying after the specified number of attempts. 57 | 58 | Args: 59 | attempt: The maximum number of retry attempts before stopping. 60 | """ 61 | 62 | def __init__(self, attempt): 63 | self._max_attempt = attempt 64 | self._attempt = 0 65 | 66 | def __call__(self): 67 | attempt = 0 68 | while True: 69 | yield 70 | attempt += 1 71 | yield self._max_attempt <= attempt 72 | 73 | 74 | # noinspection PyPep8Naming 75 | class after_delay(RetryBreaker): 76 | """ 77 | Stops retrying after the backoff exceeds the specified delay in seconds. 78 | 79 | Args: 80 | delay: The maximum delay in seconds before stopping retry attempts. 81 | """ 82 | 83 | def __init__(self, delay): 84 | self._max_delay = delay 85 | 86 | def __call__(self): 87 | while True: 88 | delay = yield 89 | yield self._max_delay < delay 90 | 91 | 92 | class _NeverStop(RetryBreaker): 93 | """Never stops retrying.""" 94 | 95 | def __call__(self): 96 | while True: 97 | yield 98 | yield False 99 | 100 | 101 | #: Continuously retry until the server returns a successful response 102 | NEVER = _NeverStop() 103 | """ 104 | Continuously retry until the server returns a successful response. 105 | """ 106 | 107 | # Keep for backwards compatibility with v0.8.0 108 | # TODO: Remove in v1.0.0 109 | DISABLE = NEVER 110 | -------------------------------------------------------------------------------- /uplink/retry/when.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines predicates for determining when to retry a request. 3 | 4 | This module provides classes and functions to control when retry operations should be triggered. 5 | """ 6 | 7 | # Local imports 8 | from uplink.retry._helpers import ClientExceptionProxy 9 | 10 | __all__ = ["RetryPredicate", "raises", "status", "status_5xx"] 11 | 12 | 13 | class RetryPredicate: 14 | """ 15 | Base class for defining retry conditions. 16 | 17 | You can compose two `RetryPredicate` instances by using the `|` operator: 18 | 19 | ```python 20 | CustomPredicateA() | CustomPredicateB() 21 | ``` 22 | 23 | The resulting predicate will retry if either of the composed predicates 24 | indicates to retry. 25 | """ 26 | 27 | def should_retry_after_response(self, response): 28 | """ 29 | Determines whether to retry after receiving a response. 30 | 31 | Args: 32 | response: The HTTP response. 33 | 34 | Returns: 35 | bool: True if the request should be retried, False otherwise. 36 | """ 37 | return False 38 | 39 | def should_retry_after_exception(self, exc_type, exc_val, exc_tb): 40 | """ 41 | Determines whether to retry after an exception occurs. 42 | 43 | Args: 44 | exc_type: The exception type. 45 | exc_val: The exception value. 46 | exc_tb: The exception traceback. 47 | 48 | Returns: 49 | bool: True if the request should be retried, False otherwise. 50 | """ 51 | return False 52 | 53 | def __call__(self, request_builder): # Pragma: no cover 54 | """ 55 | Adapts the predicate to the request builder. 56 | 57 | Args: 58 | request_builder: The request builder. 59 | 60 | Returns: 61 | RetryPredicate: A retry predicate adapted to the request builder. 62 | """ 63 | return self 64 | 65 | def __or__(self, other): 66 | """ 67 | Composes the current predicate with another. 68 | 69 | Args: 70 | other: Another RetryPredicate instance. 71 | 72 | Returns: 73 | RetryPredicate: A composite predicate. 74 | """ 75 | if other is not None: 76 | assert isinstance(other, RetryPredicate), ( 77 | "Both objects should be retry conditions." 78 | ) 79 | return _Or(self, other) 80 | return self 81 | 82 | 83 | class _Or(RetryPredicate): 84 | def __init__(self, left, right): 85 | self._left = left 86 | self._right = right 87 | 88 | def should_retry_after_response(self, *args, **kwargs): 89 | return self._left.should_retry_after_response( 90 | *args, **kwargs 91 | ) or self._right.should_retry_after_response(*args, **kwargs) 92 | 93 | def should_retry_after_exception(self, *args, **kwargs): 94 | return self._left.should_retry_after_exception( 95 | *args, **kwargs 96 | ) or self._right.should_retry_after_exception(*args, **kwargs) 97 | 98 | def __call__(self, request_builder): 99 | left = self._left(request_builder) 100 | right = self._right(request_builder) 101 | return type(self)(left, right) 102 | 103 | 104 | # noinspection PyPep8Naming 105 | class raises(RetryPredicate): 106 | """ 107 | Retry when a specific exception type is raised. 108 | 109 | Args: 110 | expected_exception: The exception type that should trigger a retry. 111 | """ 112 | 113 | def __init__(self, expected_exception): 114 | self._expected_exception = expected_exception 115 | 116 | def __call__(self, request_builder): 117 | proxy = ClientExceptionProxy.wrap_proxy_if_necessary(self._expected_exception) 118 | type_ = proxy(request_builder.client.exceptions) 119 | return raises(type_) 120 | 121 | def should_retry_after_exception(self, exc_type, exc_val, exc_tb): 122 | return isinstance(exc_val, self._expected_exception) 123 | 124 | 125 | # noinspection PyPep8Naming 126 | class status(RetryPredicate): 127 | """ 128 | Retry on specific HTTP response status codes. 129 | 130 | Args: 131 | *status_codes: The status codes that should trigger a retry. 132 | """ 133 | 134 | def __init__(self, *status_codes): 135 | self._status_codes = status_codes 136 | 137 | def should_retry_after_response(self, response): 138 | return response.status_code in self._status_codes 139 | 140 | 141 | # noinspection PyPep8Naming 142 | class status_5xx(RetryPredicate): 143 | """Retry after receiving a 5xx (server error) response.""" 144 | 145 | def should_retry_after_response(self, response): 146 | return 500 <= response.status_code < 600 147 | -------------------------------------------------------------------------------- /uplink/session.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink import arguments 3 | 4 | 5 | class Session: 6 | """ 7 | The session of a [`Consumer`][uplink.Consumer] instance. 8 | 9 | Exposes the configuration of a [`Consumer`][uplink.Consumer] instance and 10 | allows the persistence of certain properties across all requests sent 11 | from that instance. 12 | """ 13 | 14 | def __init__(self, builder): 15 | self.__builder = builder 16 | self.__params = None 17 | self.__headers = None 18 | self.__context = None 19 | 20 | def create(self, consumer, definition): 21 | return self.__builder.build(definition, consumer) 22 | 23 | @property 24 | def base_url(self): 25 | """ 26 | The base URL for any requests sent from this consumer instance. 27 | """ 28 | return self.__builder.base_url 29 | 30 | @property 31 | def headers(self): 32 | """ 33 | A dictionary of headers to be sent on each request from this 34 | consumer instance. 35 | """ 36 | if self.__headers is None: 37 | self.__headers = {} 38 | self.inject(arguments.HeaderMap().with_value(self.__headers)) 39 | return self.__headers 40 | 41 | @property 42 | def params(self): 43 | """ 44 | A dictionary of querystring data to attach to each request from 45 | this consumer instance. 46 | """ 47 | if self.__params is None: 48 | self.__params = {} 49 | self.inject(arguments.QueryMap().with_value(self.__params)) 50 | return self.__params 51 | 52 | @property 53 | def context(self): 54 | """ 55 | A dictionary of name-value pairs that are made available to 56 | request middleware. 57 | """ 58 | if self.__context is None: 59 | self.__context = {} 60 | self.inject(arguments.ContextMap().with_value(self.__context)) 61 | return self.__context 62 | 63 | @property 64 | def auth(self): 65 | """The authentication object for this consumer instance.""" 66 | return self.__builder.auth 67 | 68 | @auth.setter 69 | def auth(self, auth): 70 | self.__builder.auth = auth 71 | 72 | def inject(self, hook, *more_hooks): 73 | """ 74 | Add hooks (e.g., functions decorated with either 75 | [`uplink.response_handler`][uplink.response_handler] or 76 | [`uplink.error_handler`][uplink.error_handler]) to the session. 77 | """ 78 | self.__builder.add_hook(hook, *more_hooks) 79 | -------------------------------------------------------------------------------- /uplink/types.py: -------------------------------------------------------------------------------- 1 | # Local imports 2 | from uplink import converters 3 | 4 | __all__ = ["Dict", "List"] 5 | 6 | List = converters.TypingConverter.List 7 | """ 8 | A proxy for [`typing.List`](typing.List) that is safe to use in type 9 | hints with Python 3.4 and below. 10 | 11 | ```python 12 | @get("/users") 13 | def get_users(self) -> types.List[str]: 14 | \"""Fetches all users\""" 15 | ``` 16 | """ 17 | 18 | Dict = converters.TypingConverter.Dict 19 | """ 20 | A proxy for [`typing.Dict`](typing.Dict) that is safe to use in type 21 | hints with Python 3.4 and below. 22 | 23 | ```python 24 | @returns.from_json 25 | @get("/users") 26 | def get_users(self) -> types.Dict[str, str]: 27 | \"""Fetches all users\""" 28 | ``` 29 | """ 30 | -------------------------------------------------------------------------------- /uplink/utils.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import collections 3 | import inspect 4 | 5 | try: 6 | # Python 3.2+ 7 | from inspect import signature 8 | except ImportError: # pragma: no cover 9 | # Python 2.7 10 | from inspect import getargspec as _getargspec 11 | from inspect import getcallargs as get_call_args 12 | 13 | def signature(_): 14 | raise ImportError 15 | 16 | def get_arg_spec(f): 17 | arg_spec = _getargspec(f) 18 | args = arg_spec.args 19 | if arg_spec.varargs is not None: 20 | args.append(arg_spec.varargs) 21 | if arg_spec.keywords is not None: 22 | args.append(arg_spec.keywords) 23 | return Signature(args, {}, None) 24 | 25 | 26 | else: # pragma: no cover 27 | 28 | def get_call_args(f, *args, **kwargs): 29 | sig = signature(f) 30 | arguments = sig.bind(*args, **kwargs).arguments 31 | # apply defaults: 32 | new_arguments = [] 33 | for name, param in sig.parameters.items(): 34 | try: 35 | new_arguments.append((name, arguments[name])) 36 | except KeyError: 37 | if param.default is not param.empty: 38 | val = param.default 39 | elif param.kind is param.VAR_POSITIONAL: 40 | val = () 41 | elif param.kind is param.VAR_KEYWORD: 42 | val = {} 43 | else: 44 | continue 45 | new_arguments.append((name, val)) 46 | return collections.OrderedDict(new_arguments) 47 | 48 | def get_arg_spec(f): 49 | sig = signature(f) 50 | parameters = sig.parameters 51 | args = [] 52 | annotations = collections.OrderedDict() 53 | has_return_type = sig.return_annotation is not sig.empty 54 | return_type = sig.return_annotation if has_return_type else None 55 | for p in parameters: 56 | if parameters[p].annotation is not sig.empty: 57 | annotations[p] = parameters[p].annotation 58 | args.append(p) 59 | return Signature(args, annotations, return_type) 60 | 61 | 62 | try: 63 | import urllib.parse as _urlparse 64 | except ImportError: 65 | import urlparse as _urlparse 66 | 67 | 68 | # Third-party imports 69 | import uritemplate 70 | 71 | urlparse = _urlparse 72 | 73 | Signature = collections.namedtuple("Signature", "args annotations return_annotation") 74 | 75 | Request = collections.namedtuple("Request", "method uri info return_type") 76 | 77 | 78 | def is_subclass(cls, class_info): 79 | return inspect.isclass(cls) and issubclass(cls, class_info) 80 | 81 | 82 | def no_op(*_, **__): 83 | pass 84 | 85 | 86 | class URIBuilder: 87 | @staticmethod 88 | def variables(uri): 89 | try: 90 | return uritemplate.URITemplate(uri).variable_names 91 | except TypeError: 92 | return set() 93 | 94 | def __init__(self, uri): 95 | self._uri = uritemplate.URITemplate(uri or "") 96 | 97 | def set_variable(self, var_dict=None, **kwargs): 98 | self._uri = self._uri.partial(var_dict, **kwargs) 99 | 100 | def remaining_variables(self): 101 | return self._uri.variable_names 102 | 103 | def build(self): 104 | return self._uri.expand() 105 | -------------------------------------------------------------------------------- /verify_tag.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import argparse 3 | import os 4 | import re 5 | 6 | 7 | def is_appropriate_tag(version, tag): 8 | # Make sure the tag version and version in __about__.py match 9 | return ( 10 | re.match( 11 | r"^" + tag + r"(\.post(0|[1-9]\d*))?(\.dev(0|[1-9]\d*))?$", "v" + version 12 | ) 13 | is not None 14 | ) 15 | 16 | 17 | def is_canonical(version): 18 | return ( 19 | re.match( 20 | r"^([1-9]\d*!)?(0|[1-9]\d*)(\.(0|[1-9]\d*))*((a|b|rc)(0|[1-9]\d*))?(\.post(0|[1-9]\d*))?(\.dev(0|[1-9]\d*))?$", 21 | version, 22 | ) 23 | is not None 24 | ) 25 | 26 | 27 | def _get_current_version(): 28 | about = {} 29 | with open(os.path.join("uplink", "__about__.py")) as fp: 30 | exec(fp.read(), about) 31 | return about.get("__version__", None) 32 | 33 | 34 | def verify_version(tag): 35 | # Get version defined in package 36 | version = _get_current_version() 37 | assert version is not None, "The version is not defined in uplink/__about__.py." 38 | assert tag is not None, "The tag is not defined." 39 | assert is_canonical(version), "The version string [%s] violates PEP-440" 40 | assert is_appropriate_tag(version, tag), ( 41 | f"The tag [{tag}] does not match the current version in uplink/__about__.py [{version}]" 42 | ) 43 | return version 44 | 45 | 46 | def main(): 47 | parser = argparse.ArgumentParser() 48 | parser.add_argument("--tag", required=True) 49 | args = parser.parse_args() 50 | return verify_version(args.tag) 51 | 52 | 53 | if __name__ == "__main__": 54 | print(main()) 55 | --------------------------------------------------------------------------------