├── .flake8 ├── .github ├── actions │ └── test │ │ └── action.yml └── workflows │ └── flowzone.yml ├── .gitignore ├── .pydocstyle ├── .versionbot └── CHANGELOG.yml ├── CHANGELOG.md ├── DOCUMENTATION.md ├── LICENSE ├── README.md ├── __init__.py ├── balena ├── __init__.py ├── auth.py ├── balena_auth.py ├── builder.py ├── dependent_resource.py ├── exceptions.py ├── hup.py ├── logs.py ├── models │ ├── __init__.py │ ├── api_key.py │ ├── application.py │ ├── billing.py │ ├── config.py │ ├── credit_bundle.py │ ├── device.py │ ├── device_type.py │ ├── history.py │ ├── image.py │ ├── key.py │ ├── organization.py │ ├── os.py │ ├── release.py │ └── service.py ├── pine.py ├── resources.py ├── settings.py ├── twofactor_auth.py ├── types │ ├── __init__.py │ └── models.py └── utils.py ├── docs ├── __init__.py └── doc2md.py ├── docs_generator.py ├── package-lock.json ├── pyproject.toml ├── tests ├── __init__.py ├── functional │ ├── __init__.py │ ├── models │ │ ├── __init__.py │ │ ├── test-data │ │ │ └── balena-python-sdk-test-logo.png │ │ ├── test_api_key.py │ │ ├── test_application.py │ │ ├── test_device.py │ │ ├── test_device_os.py │ │ ├── test_device_type.py │ │ ├── test_environment_variables.py │ │ ├── test_history.py │ │ ├── test_image.py │ │ ├── test_key.py │ │ ├── test_organization.py │ │ ├── test_release.py │ │ ├── test_service.py │ │ └── test_tag.py │ ├── test_auth.py │ └── test_logs.py ├── helper.py ├── pep8-git-pr-checker └── skip.py └── versionist.conf.js /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore=E131 -------------------------------------------------------------------------------- /.github/actions/test/action.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/actions/creating-actions/creating-a-composite-action 2 | name: "Test custom" 3 | description: "Custom test step to run during a pull request" 4 | # this inputs are always provided by flowzone, so they must always be defined on the composite action 5 | inputs: 6 | json: 7 | description: "JSON stringified object containing all the inputs from the calling workflow" 8 | required: true 9 | secrets: 10 | description: "JSON stringified object containing all the secrets from the calling workflow" 11 | required: true 12 | runs: 13 | using: "composite" 14 | steps: 15 | - name: Set up Python 3.10 16 | if: steps.python_poetry.outputs.enabled == 'true' 17 | uses: actions/setup-python@d27e3f3d7c64b4bbf8e4abfb9b63b83e846e0435 # v4 18 | with: 19 | python-version: "3.10" 20 | 21 | - name: Install Poetry 22 | shell: bash 23 | run: | 24 | pipx install poetry 25 | 26 | - name: Install dependencies 27 | shell: bash 28 | run: | 29 | poetry install 30 | 31 | - name: Run custom node tests 32 | shell: bash 33 | run: | 34 | export TEST_ENV_EMAIL=${{ fromJSON(inputs.secrets).TEST_ENV_EMAIL }} 35 | export TEST_ENV_USER_ID=${{ fromJSON(inputs.secrets).TEST_ENV_USER_ID }} 36 | export TEST_ENV_PASSWORD=${{ fromJSON(inputs.secrets).TEST_ENV_PASSWORD }} 37 | 38 | poetry run python -m unittest discover tests -v 39 | -------------------------------------------------------------------------------- /.github/workflows/flowzone.yml: -------------------------------------------------------------------------------- 1 | name: Flowzone 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, closed] 6 | branches: [main, master] 7 | # allow external contributions to use secrets within trusted code 8 | pull_request_target: 9 | types: [opened, synchronize, closed] 10 | branches: [main, master] 11 | 12 | jobs: 13 | flowzone: 14 | name: Flowzone 15 | uses: product-os/flowzone/.github/workflows/flowzone.yml@master 16 | # prevent duplicate workflows and only allow one `pull_request` or `pull_request_target` for 17 | # internal or external contributions respectively 18 | if: | 19 | (github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request') || 20 | (github.event.pull_request.head.repo.full_name != github.repository && github.event_name == 'pull_request_target') 21 | secrets: inherit 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | test.py 3 | *.py.bak 4 | .env* 5 | build/* 6 | .vscode/ 7 | dist/ 8 | poetry.lock 9 | .coverage 10 | -------------------------------------------------------------------------------- /.pydocstyle: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | select= 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Balena SDK 2 | --------- 3 | 4 | The official [balena](https://balena.io/) SDK for python. 5 | 6 | Role 7 | ---- 8 | 9 | The intention of this module is to provide developers a nice API to integrate their python applications with balena. 10 | 11 | Installation 12 | ------------ 13 | 14 | Install the balena SDK: 15 | 16 | From Source: 17 | ``` 18 | https://github.com/balena-io/balena-sdk-python 19 | ``` 20 | 21 | From git: 22 | ``` 23 | pip install git+https://github.com/balena-io/balena-sdk-python.git 24 | ``` 25 | 26 | Example of installing on a Debian container: 27 | ``` 28 | FROM balenalib/amd64-debian:stretch 29 | 30 | # Install python 3 and balena SDK dependencies. 31 | RUN install_packages build-essential python3 python3-pip python3-setuptools \ 32 | python3-dev libffi-dev libssl-dev 33 | 34 | # Install balena python SDK in python 3. 35 | RUN pip3 install balena-sdk 36 | ``` 37 | 38 | Example of installing on a Alpine Linux container: 39 | ``` 40 | FROM balenalib/amd64-alpine:3.9 41 | 42 | # Install python 3 and balena SDK dependencies. 43 | RUN install_packages build-base python3 py3-setuptools python3-dev libffi-dev openssl-dev 44 | 45 | # Install balena python SDK in python 3. 46 | RUN pip3 install balena-sdk 47 | ``` 48 | 49 | Platforms 50 | --------- 51 | 52 | We also support [NodeJS SDK](https://github.com/balena-io/balena-sdk). 53 | 54 | Basic Usage 55 | ----------- 56 | 57 | ```python 58 | >>> from balena import Balena 59 | >>> balena = Balena() 60 | >>> credentials = {'username':, 'password':} 61 | >>> balena.auth.login(**credentials) 62 | ... 63 | ``` 64 | 65 | Documentation 66 | ------------- 67 | 68 | We generate markdown documentation in [DOCUMENTATION.md](https://github.com/balena-io/balena-sdk-python/blob/master/DOCUMENTATION.md). 69 | 70 | To generate the documentation run: 71 | ```bash 72 | python docs_generator.py > DOCUMENTATION.md 73 | ``` 74 | 75 | Deprecation policy 76 | ------------------ 77 | 78 | The balena SDK for Python uses [semver versioning](https://semver.org/), with the concepts of major, minor and patch version releases. 79 | 80 | The latest release of the previous major version of the balena SDK will remain compatible with the balenaCloud backend services for one year from the date when the next major version is released. 81 | For example, balena SDK v8.1.1, as the latest v8 release, would remain compatible with the balenaCloud backend for one year from the date when v9.0.0 is released. 82 | 83 | At the end of this period, the older major version is considered deprecated and some of the functionality that depends on balenaCloud services may stop working at any time. 84 | Users are encouraged to regularly update the balena SDK to the latest version. 85 | 86 | Developing locally 87 | ----- 88 | This project uses [poetry](https://python-poetry.org/) for dependency management. In order to install all the needed dependencies please run `poetry install`. 89 | 90 | Linting and Formatting 91 | ----- 92 | This project uses [black](https://pypi.org/project/black/) for code formatting and [flake8](https://flake8.pycqa.org/en/latest/) for linting. 93 | 94 | To format this project please use `poetry run black . -l 120` 95 | To verify linting you can run `poetry run flake8 --max-line-length=120` 96 | 97 | Tests 98 | ----- 99 | 100 | To run the tests, first create a `.env` file with your test user configuration, like below. **Do not** use credentials for a user with active fleets, as the tests may be destructive. 101 | 102 | ``` 103 | [Credentials] 104 | email=my_test_user@balena.io 105 | user_id=my_test_user 106 | password=123456my_password 107 | ``` 108 | 109 | You can optionally change the target API endpoint too, e.g. `api_endpoint=https://api.balena-cloud.com`. 110 | 111 | Then run `poetry run python -m unittest discover tests -v`. Use of poetry to run tests ensures use of its virtual environment. 112 | 113 | Support 114 | ------- 115 | 116 | If you're having any problem, please [raise an issue](https://github.com/balena-io/balena-sdk-python/issues/new) on GitHub and the balena team will be happy to help. 117 | 118 | Contribute 119 | ---------- 120 | 121 | - Issue Tracker: [github.com/balena-io/balena-sdk-python/issues](https://github.com/balena-io/balena-sdk-python/issues) 122 | - Source Code: [github.com/balena-io/balena-sdk-python](https://github.com/balena-io/balena-sdk-python) 123 | 124 | License 125 | ------- 126 | 127 | The project is licensed under the MIT license. 128 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/balena-sdk-python/7f92c104904383f36086359268f291b414420591/__init__.py -------------------------------------------------------------------------------- /balena/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Welcome to the balena python SDK documentation. 3 | This document aims to describe all the functions supported by the SDK, as well as 4 | showing examples of their expected usage. 5 | 6 | Install the Balena SDK: 7 | 8 | From Pip: 9 | ``` 10 | pip install balena-sdk 11 | ``` 12 | 13 | From Source (In case, you want to test a development branch): 14 | ``` 15 | https://github.com/balena-io/balena-sdk-python 16 | ``` 17 | 18 | Getting started: 19 | 20 | ```python 21 | >>> from balena import Balena 22 | >>> balena = Balena() 23 | >>> credentials = {'username':, 'password':} 24 | >>> balena.auth.login(**credentials) 25 | ... 26 | ``` 27 | 28 | The Balena object can be configured with a dict of type Settings 29 | 30 | ```python 31 | balena = Balena({ 32 | "balena_host": "balena-cloud.com", 33 | "api_version": "v7", 34 | "device_actions_endpoint_version": "v1", 35 | "data_directory": "/home/example/.balena", 36 | "image_cache_time": str(1 * 1000 * 60 * 60 * 24 * 7), # 1 week 37 | "token_refresh_interval": str(1 * 1000 * 60 * 60), # 1 hour 38 | "timeout": str(30 * 1000), # request timeout, 30s 39 | "request_limit": str(300), # the number of requests per request_limit_interval that the SDK should respect, defaults to unlimited. 40 | "request_limit_interval": str(60), # the timespan that the request_limit should apply to in seconds, defaults to 60s (1 minute). 41 | "retry_rate_limited_request": False, # awaits and retry once a request is rate limited (429) 42 | }) 43 | ``` 44 | 45 | Notice that if you want to change for the staging environment, you could simply do: 46 | balena = Balena({"balena_host": "balena-staging.com"}) 47 | 48 | However, this will overwrite your balena-cloud settings (stored api keys etc). So we recommend using 49 | a different data_directory for each balena-sdk instance, e.g: 50 | 51 | ```python 52 | balena_prod = Balena() 53 | balena_staging = Balena({ 54 | "balena_host": "balena-staging.com", 55 | "data_directory": "/home/balena-staging-sdk/.balena", 56 | }) 57 | ``` 58 | 59 | In adition, you can also run balena-python-sdk completely in memory, without writing anything to the file system like: 60 | 61 | ```python 62 | balena_prod = Balena({"data_directory": False}) 63 | balena_staging = Balena({ 64 | "balena_host": "balena-staging.com", 65 | "data_directory": False 66 | }) 67 | ``` 68 | 69 | By default the SDK will throw once a request is Rate limited by the API (with a 429 status code). 70 | A 429 request will contain a header called "retry-after" which informs how long the client should wait before trying a new request. 71 | If you would like the SDK to use this header and wait and automatically retry the request, just do: 72 | 73 | ```python 74 | balena = Balena({"retry_rate_limited_request": True}) 75 | ``` 76 | 77 | If you feel something is missing, not clear or could be improved, [please don't 78 | hesitate to open an issue in GitHub](https://github.com/balena-io/balena-sdk-python/issues), we'll be happy to help. 79 | """ # noqa: E501 80 | 81 | from typing import Optional 82 | from .auth import Auth 83 | from .logs import Logs 84 | from .models import Models 85 | from .pine import PineClient 86 | from .settings import SettingsConfig, Settings 87 | 88 | __version__ = "15.1.4" 89 | 90 | 91 | class Balena: 92 | """ 93 | This class implements all functions supported by the python SDK. 94 | Attributes: 95 | settings (Settings): configuration settings for balena python SDK. 96 | logs (Logs): logs from devices working on Balena. 97 | auth (Auth): authentication handling. 98 | models (Models): all models in balena python SDK. 99 | 100 | """ 101 | 102 | def __init__(self, settings: Optional[SettingsConfig] = None): 103 | self.settings = Settings(settings) 104 | self.pine = PineClient(self.settings, __version__) 105 | self.logs = Logs(self.pine, self.settings) 106 | self.auth = Auth(self.pine, self.settings) 107 | self.models = Models(self.pine, self.settings) 108 | -------------------------------------------------------------------------------- /balena/auth.py: -------------------------------------------------------------------------------- 1 | from . import exceptions 2 | from .balena_auth import request 3 | from .settings import Settings 4 | from typing import TypedDict, Optional, Literal, Union, cast 5 | from typing_extensions import Unpack 6 | from .pine import PineClient 7 | from .twofactor_auth import TwoFactorAuth 8 | 9 | 10 | TOKEN_KEY = "token" 11 | 12 | 13 | class CredentialsType(TypedDict): 14 | username: str 15 | password: str 16 | 17 | 18 | class UserKeyWhoAmIResponse(TypedDict): 19 | id: int 20 | actorType: Literal["user"] 21 | actorTypeId: int 22 | username: str 23 | email: Optional[str] 24 | 25 | 26 | class ApplicationKeyWhoAmIResponse(TypedDict): 27 | id: int 28 | actorType: Literal["application"] 29 | actorTypeId: int 30 | slug: str 31 | 32 | 33 | class DeviceKeyWhoAmIResponse(TypedDict): 34 | id: int 35 | actorType: Literal["device"] 36 | actorTypeId: int 37 | uuid: str 38 | 39 | 40 | WhoamiResult = Union[UserKeyWhoAmIResponse, ApplicationKeyWhoAmIResponse, DeviceKeyWhoAmIResponse] 41 | 42 | 43 | class UserInfo(TypedDict): 44 | id: int 45 | actor: int 46 | username: str 47 | email: Optional[str] 48 | 49 | 50 | class Auth: 51 | """ 52 | This class implements all authentication functions for balena python SDK. 53 | 54 | """ 55 | 56 | _actor_details_cache: Optional[WhoamiResult] = None 57 | _user_actor_id_cache: Optional[int] = None 58 | 59 | def __init__(self, pine: PineClient, settings: Settings): 60 | self.two_factor = TwoFactorAuth(settings) 61 | self.__pine = pine 62 | self.__settings = settings 63 | 64 | def __get_actor_details(self, no_cache: bool = False) -> WhoamiResult: 65 | """ 66 | Get user details from token. 67 | 68 | Returns: 69 | Optional[WhoamiResult]: user details. 70 | """ 71 | if not self._actor_details_cache or no_cache: 72 | whoami = request(method="GET", settings=self.__settings, path="/actor/v1/whoami") 73 | if isinstance(whoami, dict) and set(["id", "actorType"]).issubset(set(whoami.keys())): 74 | self._actor_details_cache = cast(WhoamiResult, whoami) 75 | else: 76 | raise exceptions.NotLoggedIn() 77 | 78 | return self._actor_details_cache 79 | 80 | def whoami(self) -> Optional[WhoamiResult]: 81 | """ 82 | Return current logged in username. 83 | 84 | Returns: 85 | Optional[WhoamiResult]: current logged in information 86 | 87 | Examples: 88 | >>> balena.auth.whoami() 89 | """ 90 | return self.__get_actor_details() 91 | 92 | def authenticate(self, **credentials: Unpack[CredentialsType]) -> str: 93 | """ 94 | This function authenticates provided credentials information. 95 | You should use Auth.login when possible, as it takes care of saving the Auth Token and username as well. 96 | 97 | Args: 98 | **credentials: credentials keyword arguments. 99 | username (str): Balena username. 100 | password (str): Password. 101 | 102 | Returns: 103 | str: Auth Token, 104 | 105 | Examples: 106 | >>> balena.auth.authenticate(username='', password='') 107 | """ 108 | req = request( 109 | method="POST", settings=self.__settings, path="login_", body=credentials, send_token=False, return_raw=True 110 | ) 111 | 112 | if not req.ok: 113 | if req.status_code == 401: 114 | raise exceptions.LoginFailed() 115 | elif req.status_code == 429: 116 | raise exceptions.TooManyRequests() 117 | 118 | return req.content.decode() 119 | 120 | def login(self, **credentials: Unpack[CredentialsType]) -> None: 121 | """ 122 | This function is used for logging into balena using email and password. 123 | 124 | Args: 125 | **credentials: credentials keyword arguments. 126 | username (str): Balena email. 127 | password (str): Password. 128 | 129 | Examples: 130 | >>> from balena import Balena 131 | ... balena = Balena() 132 | ... credentials = {'username': '', 'password': ''} 133 | ... balena.auth.login(**credentials) 134 | ... # or 135 | ... balena.auth.login(username='', password='') 136 | """ 137 | token = self.authenticate(**credentials) 138 | self._actor_details_cache = None 139 | self._user_actor_id_cache = None 140 | self.__settings.set(TOKEN_KEY, token) 141 | 142 | def login_with_token(self, token: str) -> None: 143 | """ 144 | This function is used for logging into balena using Auth Token. 145 | Auth Token can be found in Preferences section on balena Dashboard. 146 | 147 | Args: 148 | token (str): Auth Token. 149 | 150 | Returns: 151 | This functions saves Auth Token to Settings and returns nothing. 152 | 153 | Examples: 154 | >>> from balena import Balena 155 | >>> balena = Balena() 156 | >>> auth_token = 157 | >>> balena.auth.login_with_token(auth_token) 158 | 159 | """ 160 | self._actor_details_cache = None 161 | self._user_actor_id_cache = None 162 | self.__settings.set(TOKEN_KEY, token) 163 | 164 | def is_logged_in(self) -> bool: 165 | """ 166 | This function checks if you're logged in 167 | 168 | Returns: 169 | bool: True if logged in, False otherwise. 170 | 171 | Examples: 172 | # Check if user logged in. 173 | >>> if balena.auth.is_logged_in(): 174 | ... print('You are logged in!') 175 | ... else: 176 | ... print('You are not logged in!') 177 | """ 178 | try: 179 | self.__get_actor_details(True) 180 | return True 181 | except ( 182 | exceptions.RequestError, 183 | exceptions.Unauthorized, 184 | exceptions.NotLoggedIn, 185 | ): 186 | return False 187 | 188 | def get_token(self) -> Optional[str]: 189 | """ 190 | This function retrieves Auth Token. 191 | 192 | Returns: 193 | str: Auth Token. 194 | 195 | Examples: 196 | >>> balena.auth.get_token() 197 | """ 198 | try: 199 | return cast(str, self.__settings.get(TOKEN_KEY)) 200 | except exceptions.InvalidOption: 201 | return None 202 | 203 | def get_user_info(self) -> UserInfo: 204 | """ 205 | Get current logged in user's info 206 | 207 | Returns: 208 | UserInfo: user info. 209 | 210 | Examples: 211 | # If you are logged in as a user. 212 | >>> balena.auth.get_user_info() 213 | """ 214 | actor = self.__get_actor_details() 215 | if actor and actor["actorType"] != "user": 216 | raise Exception("The authentication credentials in use are not of a user") 217 | 218 | actor = cast(UserKeyWhoAmIResponse, actor) 219 | return { 220 | "id": actor["actorTypeId"], 221 | "actor": actor["id"], 222 | "email": actor["email"], 223 | "username": actor["username"], 224 | } 225 | 226 | def get_actor_id(self) -> int: 227 | """ 228 | Get current logged in actor id. 229 | 230 | Returns: 231 | int: actor id 232 | 233 | Examples: 234 | # If you are logged in. 235 | >>> balena.auth.get_actor_id() 236 | """ 237 | return self.__get_actor_details()["id"] 238 | 239 | def logout(self) -> None: 240 | """ 241 | This function is used for logging out from balena. 242 | 243 | Examples: 244 | # If you are logged in. 245 | >>> balena.auth.logout() 246 | """ 247 | self._actor_details_cache = None 248 | self._user_actor_id_cache = None 249 | self.__settings.remove(TOKEN_KEY) 250 | 251 | def register(self, **creentials: Unpack[CredentialsType]) -> str: 252 | """ 253 | This function is used for registering to balena. 254 | 255 | Args: 256 | **credentials: credentials keyword arguments. 257 | email (str): email to register. 258 | password (str): Password. 259 | 260 | Returns: 261 | str: Auth Token for new account. 262 | 263 | Examples: 264 | >>> credentials = {'email': '', 'password': ''} 265 | >>> balena.auth.register(**credentials) 266 | """ 267 | return request( 268 | method="POST", 269 | settings=self.__settings, 270 | path="/user/register", 271 | body=creentials, 272 | send_token=False, 273 | ) 274 | -------------------------------------------------------------------------------- /balena/balena_auth.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from typing import Any, Optional 4 | from urllib.parse import urljoin 5 | 6 | import jwt 7 | import requests 8 | 9 | from . import exceptions 10 | from .settings import Settings 11 | import balena 12 | 13 | 14 | def __request_new_token(settings: Settings) -> str: 15 | headers = {"Authorization": f"Bearer {settings.get('token')}"} 16 | url = urljoin(settings.get("api_endpoint"), "whoami") 17 | response = requests.get(url, headers=headers) 18 | 19 | if not response.ok: 20 | # If it fails to get a new token on the default expiry time 21 | # let it continue trying with the current token 22 | # as not all roles have the refresh_token permission 23 | return None 24 | 25 | return response.content.decode() 26 | 27 | 28 | def __should_update_token(token: str, interval: str) -> bool: 29 | try: 30 | # Auth token 31 | token_data = jwt.decode(token, algorithms=["HS256"], options={"verify_signature": False}) 32 | # dt will be the same as Date.now() in Javascript but converted to 33 | # milliseconds for consistency with js/sc sdk 34 | dt = (datetime.utcnow() - datetime.utcfromtimestamp(0)).total_seconds() 35 | dt = dt * 1000 36 | age = dt - (int(token_data["iat"]) * 1000) 37 | return int(age) >= int(interval) 38 | except jwt.InvalidTokenError: 39 | return False 40 | 41 | 42 | def get_token(settings: Settings) -> Optional[str]: 43 | if settings.has("token"): 44 | token = settings.get("token") 45 | interval = settings.get("token_refresh_interval") 46 | if __should_update_token(token, interval): 47 | new_token = __request_new_token(settings) 48 | if new_token is not None: 49 | settings.set("token", new_token) 50 | api_key = settings.get("token") 51 | 52 | else: 53 | api_key = os.environ.get("BALENA_API_KEY") or os.environ.get("RESIN_API_KEY") 54 | 55 | return api_key 56 | 57 | 58 | def request( 59 | method: str, 60 | path: str, 61 | settings: Settings, 62 | body: Optional[Any] = None, 63 | endpoint: Optional[str] = None, 64 | token: Optional[str] = None, 65 | qs: Optional[Any] = {}, 66 | return_raw: bool = False, 67 | stream: bool = False, 68 | send_token: bool = True, 69 | ) -> Any: 70 | if endpoint is None: 71 | endpoint = settings.get("api_endpoint") 72 | 73 | if token is None and send_token: 74 | token = get_token(settings) 75 | 76 | url = urljoin(endpoint, path) 77 | 78 | if token is None and send_token: 79 | raise exceptions.NotLoggedIn() 80 | try: 81 | headers = {"X-Balena-Client": f"balena-python-sdk/{balena.__version__}"} 82 | if send_token: 83 | headers["Authorization"] = f"Bearer {token}" 84 | 85 | req = requests.request(method=method, url=url, params=qs, json=body, headers=headers, stream=stream) 86 | 87 | if return_raw: 88 | return req 89 | 90 | try: 91 | return req.json() 92 | except Exception: 93 | return req.content.decode() 94 | 95 | except Exception as e: 96 | if not send_token: 97 | raise e 98 | raise exceptions.NotLoggedIn() 99 | -------------------------------------------------------------------------------- /balena/builder.py: -------------------------------------------------------------------------------- 1 | from .balena_auth import request 2 | from .exceptions import BuilderRequestError 3 | from .settings import Settings 4 | 5 | 6 | def build_from_url(owner: str, app_name: str, url: str, flatten_tarball: bool, settings: Settings) -> int: 7 | res = request( 8 | method="post", 9 | path=f"/v3/buildFromUrl?headless=true&owner={owner}&app={app_name}", 10 | settings=settings, 11 | endpoint=settings.get("builder_url"), 12 | body={"url": url, "shouldFlatten": flatten_tarball}, 13 | ) 14 | if not res.get("started"): 15 | raise BuilderRequestError(res.get("message")) 16 | 17 | return res["releaseId"] 18 | -------------------------------------------------------------------------------- /balena/dependent_resource.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Generic, List, Optional, TypeVar 2 | 3 | from .pine import PineClient 4 | from .types import AnyObject 5 | from .utils import is_id, merge 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | class DependentResource(Generic[T]): 11 | def __init__( 12 | self, 13 | resource_name: str, 14 | resource_key_field: str, 15 | parent_resource_name: str, 16 | get_resource_id: Callable[[Any], int], 17 | pine: PineClient, 18 | ): 19 | self.resource_name = resource_name 20 | self.resource_key_field = resource_key_field 21 | self.parent_resource_name = parent_resource_name 22 | self.get_resource_id = get_resource_id 23 | self.__pine = pine 24 | 25 | def _get_all(self, options: AnyObject = {}) -> List[T]: 26 | default_orderby = {"$orderby": {self.resource_key_field: "asc"}} 27 | 28 | return self.__pine.get( 29 | { 30 | "resource": self.resource_name, 31 | "options": merge(default_orderby, options), 32 | } 33 | ) 34 | 35 | def _get_all_by_parent(self, parent_param: Any, options: AnyObject = {}) -> List[T]: 36 | parent_id = parent_param if is_id(parent_param) else self.get_resource_id(parent_param) 37 | 38 | get_options = { 39 | "$filter": {self.parent_resource_name: parent_id}, 40 | "$orderby": f"{self.resource_key_field} asc", 41 | } 42 | 43 | return self._get_all(merge(get_options, options)) 44 | 45 | def _get(self, parent_param: Any, key: str) -> Optional[str]: 46 | parent_id = parent_param if is_id(parent_param) else self.get_resource_id(parent_param) 47 | 48 | dollar_filter = {self.parent_resource_name: parent_id, self.resource_key_field: key} 49 | 50 | result = self.__pine.get( 51 | { 52 | "resource": self.resource_name, 53 | "options": {"$select": "value", "$filter": dollar_filter}, 54 | } 55 | ) 56 | 57 | if len(result) >= 1: 58 | return result[0].get("value") 59 | 60 | def _set(self, parent_param: Any, key: str, value: str) -> None: 61 | parent_id = parent_param if is_id(parent_param) else self.get_resource_id(parent_param) 62 | 63 | upsert_id = {self.parent_resource_name: parent_id, self.resource_key_field: key} 64 | 65 | self.__pine.upsert( 66 | { 67 | "resource": self.resource_name, 68 | "id": upsert_id, 69 | "body": {"value": value}, 70 | } 71 | ) 72 | 73 | def _remove(self, parent_param: Any, key: str) -> None: 74 | parent_id = parent_param if is_id(parent_param) else self.get_resource_id(parent_param) 75 | 76 | dollar_filter = {self.parent_resource_name: parent_id, self.resource_key_field: key} 77 | 78 | self.__pine.delete({"resource": self.resource_name, "options": {"$filter": dollar_filter}}) 79 | -------------------------------------------------------------------------------- /balena/hup.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Optional 2 | 3 | from semver.version import Version 4 | 5 | from . import exceptions 6 | 7 | MIN_TARGET_VERSION = "2.2.0+rev1" 8 | 9 | 10 | def __get_variant(ver: Version) -> Optional[Literal["dev", "prod"]]: 11 | if "dev" in (ver.build or "") or "dev" in (ver.prerelease or ""): 12 | return "dev" 13 | if "prod" in (ver.build or "") or "prod" in (ver.prerelease or ""): 14 | return "prod" 15 | return None 16 | 17 | 18 | def get_hup_action_type(device_type: str, current_version: Optional[str], target_version: str): 19 | """ 20 | getHUPActionType in Python 21 | ref: https://github.com/balena-io-modules/balena-hup-action-utils/blob/master/lib/index.ts#L67 22 | """ 23 | 24 | try: 25 | parsed_current_ver = Version.parse(current_version) # type: ignore 26 | except Exception: 27 | raise exceptions.OsUpdateError("Invalid current balenaOS version") 28 | 29 | try: 30 | parsed_target_ver = Version.parse(target_version) 31 | except Exception: 32 | raise exceptions.OsUpdateError("Invalid target balenaOS version") 33 | 34 | if parsed_current_ver.prerelease or parsed_target_ver.prerelease: 35 | raise exceptions.OsUpdateError("Updates cannot be performed on pre-release balenaOS versions") 36 | 37 | cur_variant = __get_variant(parsed_current_ver) 38 | target_variant = __get_variant(parsed_target_ver) 39 | 40 | if target_variant is not None and ((cur_variant == "dev") != (target_variant == "dev")): 41 | raise exceptions.OsUpdateError( 42 | "Updates cannot be performed between development and production balenaOS variants" 43 | ) 44 | 45 | if Version.parse(target_version).compare(current_version) < 0: 46 | raise exceptions.OsUpdateError("OS downgrades are not allowed") 47 | 48 | # For 1.x -> 2.x or 2.x to 2.x only 49 | if parsed_target_ver.major > 1 and Version.parse(target_version).compare(MIN_TARGET_VERSION) < 0: 50 | raise exceptions.OsUpdateError("Target balenaOS version must be greater than {0}".format(MIN_TARGET_VERSION)) 51 | 52 | return "resinhup{from_v}{to_v}".format(from_v=parsed_current_ver.major, to_v=parsed_target_ver.major) 53 | -------------------------------------------------------------------------------- /balena/logs.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import defaultdict 3 | from threading import Thread 4 | from urllib.parse import urljoin 5 | from typing import Union, Optional, Literal, Callable, TypedDict, Any, List, cast 6 | 7 | from twisted.internet import reactor 8 | from twisted.internet.protocol import Protocol 9 | from twisted.web.client import Agent 10 | from twisted.web.http_headers import Headers 11 | 12 | from .settings import Settings 13 | from .models.device import Device 14 | from .balena_auth import request, get_token 15 | from .pine import PineClient 16 | 17 | 18 | class Log(TypedDict): 19 | message: str 20 | createdAt: int 21 | timestamp: int 22 | isStdErr: bool 23 | isSystem: bool 24 | serviceId: Optional[int] 25 | 26 | 27 | class StreamingParser(Protocol): 28 | """ 29 | This is low level class and is not meant to be used by end users directly. 30 | """ 31 | 32 | def __init__(self, callback, error): 33 | self.callback = callback 34 | self.error = error 35 | self.pending = b"" 36 | self.is_running = True 37 | 38 | def dataReceived(self, data): 39 | obj = {} 40 | self.pending += data 41 | 42 | lines = self.pending.split(b"\n") 43 | self.pending = lines.pop() 44 | 45 | for line in lines: 46 | try: 47 | if line: 48 | obj = json.loads(line) 49 | except Exception as e: 50 | self.transport.stopProducing() # type: ignore 51 | self.transport.loseConnection() # type: ignore 52 | 53 | if self.error: 54 | self.error(e) 55 | break 56 | 57 | if self.is_running: 58 | self.callback(obj) 59 | 60 | def connectionLost(self, reason): 61 | pass 62 | 63 | 64 | def cbRequest(response, callback, error): 65 | protocol = StreamingParser(callback, error) 66 | response.deliverBody(protocol) 67 | return protocol 68 | 69 | 70 | def cbDrop(protocol): 71 | protocol.is_running = False 72 | protocol.transport.stopProducing() 73 | protocol.transport.loseConnection() 74 | 75 | 76 | class Subscription: 77 | """ 78 | This is low level class and is not meant to be used by end users directly. 79 | """ 80 | 81 | def __init__(self, settings: Settings): 82 | self.__settings = settings 83 | 84 | def add( 85 | self, 86 | uuid: str, 87 | callback: Callable[[Log], None], 88 | error: Optional[Callable[[Any], None]] = None, 89 | count: Optional[Union[int, Literal["all"]]] = None, 90 | ): 91 | query = "stream=1" 92 | if count: 93 | query = f"stream=1&count={count}" 94 | 95 | url = urljoin(cast(str, self.__settings.get("api_endpoint")), f"/device/v2/{uuid}/logs?{query}") 96 | headers = Headers({"Authorization": [f"Bearer {get_token(self.__settings)}"]}) 97 | 98 | agent = Agent(reactor) 99 | req = agent.request(b"GET", url.encode(), headers, None) 100 | req.addCallback(cbRequest, callback, error) 101 | self.run() 102 | 103 | return req 104 | 105 | def run(self): 106 | if not reactor.running: # type: ignore 107 | Thread(target=reactor.run, args=(False,)).start() # type: ignore 108 | 109 | def stop(self, d): 110 | reactor.callFromThread(d.addCallback, cbDrop) # type: ignore 111 | 112 | def stop_all(self): 113 | reactor.stop() # type: ignore 114 | 115 | 116 | class Logs: 117 | """ 118 | This class implements functions that allow processing logs from device. 119 | 120 | """ 121 | 122 | def __init__(self, pine: PineClient, settings: Settings): 123 | self.__subscriptions = defaultdict(list) 124 | self.__settings = settings 125 | self.__device = Device(pine, settings) 126 | self.__subscription_handler = Subscription(settings) 127 | 128 | def __exit__(self, exc_type, exc_value, traceback): 129 | reactor.stop() # type: ignore 130 | 131 | def subscribe( 132 | self, 133 | uuid_or_id: Union[str, int], 134 | callback: Callable[[Log], None], 135 | error: Optional[Callable[[Any], None]] = None, 136 | count: Optional[Union[int, Literal["all"]]] = None, 137 | ) -> None: 138 | """ 139 | Subscribe to device logs. 140 | 141 | Args: 142 | uuid_or_id (Union[str, int]): device uuid (string) or id (int) 143 | callback (Callable[[Log], None]): this callback is called on receiving a message. 144 | error (Optional[Callable[[Any], None]]): this callback is called on an error event. 145 | count (Optional[Union[int, Literal["all"]]]): number of historical messages to include. 146 | """ 147 | 148 | uuid = self.__device.get(uuid_or_id, {"$select": "uuid"})["uuid"] 149 | self.__subscriptions[uuid].append(self.__subscription_handler.add(uuid, callback, error, count)) 150 | 151 | def history(self, uuid_or_id: Union[str, int], count: Optional[Union[int, Literal["all"]]] = None) -> List[Log]: 152 | """ 153 | Get device logs history. 154 | 155 | Args: 156 | uuid_or_id (Union[str, int]): device uuid (string) or id (int) 157 | count (Optional[Union[int, Literal["all"]]]): number of historical messages to include. 158 | """ 159 | uuid = self.__device.get(uuid_or_id, {"$select": "uuid"})["uuid"] 160 | qs = {} 161 | if count is not None: 162 | qs["count"] = count 163 | 164 | return request(method="GET", settings=self.__settings, path=f"/device/v2/{uuid}/logs", qs=qs) 165 | 166 | def unsubscribe(self, uuid_or_id: Union[str, int]) -> None: 167 | """ 168 | Unsubscribe from device logs for a specific device. 169 | 170 | Args: 171 | uuid_or_id (Union[str, int]): device uuid (string) or id (int) 172 | """ 173 | uuid = self.__device.get(uuid_or_id, {"$select": "uuid"})["uuid"] 174 | if uuid in self.__subscriptions: 175 | for d in self.__subscriptions[uuid]: 176 | self.__subscription_handler.stop(d) 177 | del self.__subscriptions[uuid] 178 | 179 | def unsubscribe_all(self) -> None: 180 | """ 181 | Unsubscribe all subscribed devices. 182 | """ 183 | for device in self.__subscriptions: 184 | for d in self.__subscriptions[device]: 185 | self.__subscription_handler.stop(d) 186 | self.__subscriptions = {} 187 | 188 | def stop(self) -> None: 189 | """ 190 | Will grecefully unsubscribe from all devices and stop the consumer thread. 191 | """ 192 | self.unsubscribe_all() 193 | self.__subscription_handler.stop_all() 194 | -------------------------------------------------------------------------------- /balena/models/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module implements all models for balena python SDK. 3 | 4 | """ 5 | 6 | from ..pine import PineClient 7 | from .api_key import ApiKey 8 | from .application import Application 9 | from .billing import Billing 10 | from .credit_bundle import CreditBundle 11 | from .config import Config 12 | from .device import Device 13 | from .device_type import DeviceType 14 | from .image import Image 15 | from .key import Key 16 | from .organization import Organization 17 | from .os import DeviceOs 18 | from .release import Release 19 | from .service import Service 20 | from ..settings import Settings 21 | 22 | 23 | class Models: 24 | def __init__(self, pine: PineClient, settings: Settings): 25 | self.application = Application(pine, settings) 26 | self.billing = Billing(pine, settings) 27 | self.credit_bundle = CreditBundle(pine, settings) 28 | self.device = Device(pine, settings) 29 | self.device_type = DeviceType(pine, settings) 30 | self.api_key = ApiKey(pine, settings) 31 | self.key = Key(pine, settings) 32 | self.organization = Organization(pine, settings) 33 | self.os = DeviceOs(pine, settings) 34 | self.config = Config(settings) 35 | self.release = Release(pine, settings) 36 | self.service = Service(pine, settings) 37 | self.image = Image(pine, settings) 38 | -------------------------------------------------------------------------------- /balena/models/api_key.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Union 2 | 3 | from .. import exceptions 4 | from ..auth import Auth 5 | from ..balena_auth import request 6 | from ..pine import PineClient 7 | from ..types import AnyObject 8 | from ..types.models import APIKeyInfoType, APIKeyType 9 | from ..utils import merge 10 | from ..settings import Settings 11 | from .application import Application 12 | from .device import Device 13 | 14 | 15 | class ApiKey: 16 | """ 17 | This class implements user API key model for balena python SDK. 18 | 19 | """ 20 | 21 | def __init__(self, pine: PineClient, settings: Settings): 22 | self.__application = Application(pine, settings) 23 | self.__auth = Auth(pine, settings) 24 | self.__device = Device(pine, settings) 25 | self.__pine = pine 26 | self.__settings = settings 27 | 28 | def create( 29 | self, 30 | name: str, 31 | description: Optional[str] = None, 32 | expiry_date: Optional[str] = None, 33 | ) -> str: 34 | """ 35 | This method registers a new api key for the current user with the name given. 36 | 37 | Args: 38 | name (str): the API key name 39 | description (Optional[str]): the API key description 40 | expiry_date (Optional[str]): the API key expiring date 41 | 42 | Returns: 43 | str: API key 44 | 45 | Examples: 46 | >>> balena.models.api_key.create_api_key("myApiKey") 47 | >>> balena.models.api_key.create_api_key("myApiKey", "my api key description") 48 | >>> balena.models.api_key.create_api_key("myApiKey", "my descr", datetime.datetime.utcnow().isoformat()) 49 | """ 50 | api_key_body = {"name": name} 51 | 52 | if description is not None and isinstance(description, str): 53 | api_key_body["description"] = description 54 | 55 | if expiry_date is not None and isinstance(expiry_date, str): 56 | api_key_body["expiryDate"] = expiry_date 57 | 58 | return request(method="POST", path="/api-key/user/full", settings=self.__settings, body=api_key_body).strip('"') 59 | 60 | def get_all(self, options: AnyObject = {}) -> List[APIKeyType]: 61 | """ 62 | This function gets all API keys. 63 | 64 | Args: 65 | options (AnyObject): extra pine options to use 66 | 67 | Returns: 68 | List[APIKeyType]: user API key 69 | 70 | Examples: 71 | >>> balena.models.api_key.get_all() 72 | """ 73 | return self.__pine.get( 74 | { 75 | "resource": "api_key", 76 | "options": merge({"$orderby": "name asc"}, options), 77 | } 78 | ) 79 | 80 | def update(self, id: int, api_key_info: APIKeyInfoType): 81 | """ 82 | This function updates details of an API key. 83 | 84 | Args: 85 | id (str): API key id. 86 | api_key_info (APIKeyInfoType): new API key info. 87 | 88 | Examples: 89 | >>> balena.models.api_key.update(1296047, {"name":"new name"}) 90 | """ 91 | 92 | if api_key_info is None: 93 | raise exceptions.InvalidParameter("apiKeyInfo", api_key_info) 94 | 95 | if api_key_info.get("name") is not None and api_key_info.get("name") == "": 96 | raise exceptions.InvalidParameter("apiKeyInfo.name", api_key_info.get("name")) 97 | 98 | body = { 99 | "description": api_key_info.get("description"), 100 | "expiry_date": api_key_info.get("expiry_date"), 101 | } 102 | 103 | name = api_key_info.get("name") 104 | if name is not None: 105 | body["name"] = name 106 | 107 | self.__pine.patch({"resource": "api_key", "id": id, "body": body}) 108 | 109 | def revoke(self, id: int): 110 | """ 111 | This function revokes an API key. 112 | 113 | Args: 114 | id (int): API key id. 115 | 116 | Examples: 117 | >>> balena.models.api_key.revoke(1296047) 118 | """ 119 | 120 | self.__pine.delete({"resource": "api_key", "id": id}) 121 | 122 | def get_provisioning_api_keys_by_application( 123 | self, slug_or_uuid_or_id: Union[str, int], options: AnyObject = {} 124 | ) -> List[APIKeyType]: 125 | """ 126 | Get all provisioning API keys for an application. 127 | 128 | Args: 129 | slug_or_uuid_or_id (Union[str, int]): application slug (string), uuid (string) or id (number) 130 | options (AnyObject): extra pine options to use 131 | 132 | Examples: 133 | >>> balena.models.api_key.get_provisioning_api_keys_by_application(1296047) 134 | >>> balena.models.api_key.get_provisioning_api_keys_by_application("myorg/myapp") 135 | """ 136 | 137 | actor_id = self.__application.get(slug_or_uuid_or_id, {"$select": "actor"})["actor"]["__id"] 138 | return self.get_all(merge({"$filter": {"is_of__actor": actor_id}}, options)) 139 | 140 | def get_device_api_keys_by_device(self, uuid_or_id: Union[str, int], options: AnyObject = {}) -> List[APIKeyType]: 141 | """ 142 | Get all API keys for a device. 143 | 144 | Args: 145 | device_uuid (Union[str, int]): device, uuid (string) or id (int) 146 | options (AnyObject): extra pine options to use 147 | 148 | Examples: 149 | >>> balena.models.api_key.get_device_api_keys_by_device("44cc9d186") 150 | >>> balena.models.api_key.get_device_api_keys_by_device(1111386) 151 | """ 152 | 153 | actor_id = self.__device.get(uuid_or_id, {"$select": "actor"})["actor"]["__id"] 154 | return self.get_all(merge({"$filter": {"is_of__actor": actor_id}}, options)) 155 | 156 | def get_all_named_user_api_keys(self, options: AnyObject = {}) -> List[APIKeyType]: 157 | """ 158 | Get all named user API keys of the current user. 159 | 160 | Args: 161 | options (AnyObject): extra pine options to use 162 | 163 | Examples: 164 | >>> balena.models.api_key.get_all_named_user_api_keys() 165 | """ 166 | 167 | return self.get_all( 168 | merge( 169 | { 170 | "$filter": { 171 | "is_of__actor": self.__auth.get_actor_id(), 172 | "name": {"$ne": None}, 173 | } 174 | }, 175 | options, 176 | ) 177 | ) 178 | -------------------------------------------------------------------------------- /balena/models/billing.py: -------------------------------------------------------------------------------- 1 | from typing import Union, TypedDict, Literal, Optional, List 2 | from .organization import Organization 3 | from ..pine import PineClient 4 | from ..settings import Settings 5 | from ..balena_auth import request 6 | 7 | 8 | class BillingAccountAddressInfo(TypedDict): 9 | address1: str 10 | address2: str 11 | city: str 12 | state: str 13 | zip: str 14 | country: str 15 | phone: str 16 | 17 | 18 | class BillingAccountInfo(TypedDict): 19 | account_state: str 20 | first_name: str 21 | last_name: str 22 | company_name: str 23 | email: str 24 | cc_emails: str 25 | vat_number: str 26 | address: BillingAccountAddressInfo 27 | 28 | 29 | BillingInfoType = Literal["bank_account", "credit_card", "paypal"] 30 | 31 | 32 | class BillingInfo(TypedDict): 33 | full_name: str 34 | first_name: str 35 | last_name: str 36 | company: str 37 | vat_number: str 38 | address1: str 39 | address2: str 40 | city: str 41 | state: str 42 | zip: str 43 | country: str 44 | phone: str 45 | 46 | type: Optional[BillingInfoType] 47 | 48 | 49 | class CardBillingInfo(BillingInfo): 50 | card_type: str 51 | year: str 52 | month: str 53 | first_one: str 54 | last_four: str 55 | 56 | 57 | class BankAccountBillingInfo(BillingInfo): 58 | account_type: str 59 | last_four: str 60 | name_on_account: str 61 | routing_number: str 62 | 63 | 64 | TokenBillingSubmitInfo = TypedDict("TokenBillingSubmitInfo", {"token_id": str, "g-recaptcha-response": Optional[str]}) 65 | 66 | 67 | class Charge(TypedDict): 68 | itemType: str 69 | name: str 70 | code: str 71 | unitCostCents: str 72 | quantity: str 73 | isQuantifiable: Optional[bool] 74 | 75 | 76 | class BillingPlanBillingInfo(TypedDict): 77 | currency: str 78 | totalCostCents: str 79 | charges: List[Charge] 80 | 81 | 82 | class Addon(TypedDict): 83 | code: str 84 | unitCostCents: Optional[str] 85 | quantity: Optional[str] 86 | 87 | 88 | class BillingAddonPlanInfo(TypedDict): 89 | code: str 90 | currentPeriodEndDate: Optional[str] 91 | billing: BillingPlanBillingInfo 92 | addOns: List[Addon] 93 | 94 | 95 | SupportInfo = TypedDict("Support", {"name": str, "title": str}) 96 | 97 | 98 | class BillingPlanInfo(TypedDict): 99 | name: str 100 | title: str 101 | code: str 102 | tier: str 103 | currentPeriodEndDate: Optional[str] 104 | intervalUnit: Optional[str] 105 | intervalLength: Optional[str] 106 | addonPlan: Optional[BillingAddonPlanInfo] 107 | billing: BillingPlanBillingInfo 108 | support: SupportInfo 109 | 110 | 111 | class InvoiceInfo(TypedDict): 112 | closed_at: str 113 | created_at: str 114 | due_on: str 115 | currency: str 116 | invoice_number: str 117 | subtotal_in_cents: str 118 | total_in_cents: str 119 | uuid: str 120 | state: Literal["pending", "paid", "failed", "past_due"] 121 | 122 | 123 | class PlanChangeOptions(TypedDict): 124 | tier: str 125 | cycle: Literal["monthly", "annual"] 126 | planChangeReason: Optional[str] 127 | 128 | 129 | class UpdateAccountBody(TypedDict, total=False): 130 | email: str 131 | cc_emails: str 132 | 133 | 134 | class Billing: 135 | """ 136 | This class implements billing model for balena python SDK. 137 | """ 138 | 139 | def __init__(self, pine: PineClient, settings: Settings): 140 | self.__settings = settings 141 | self.__organization = Organization(pine, settings) 142 | 143 | def __get_org_id(self, organization: Union[str, int]) -> int: 144 | return self.__organization.get(organization, {"$select": "id"})["id"] 145 | 146 | def get_account(self, organization: Union[str, int]) -> BillingAccountInfo: 147 | """ 148 | Get the user's billing account 149 | 150 | Args: 151 | organization (Union[str, int]): handle (string) or id (number) of the target organization. 152 | 153 | Returns: 154 | BillingAccountInfo: billing account. 155 | 156 | Examples: 157 | >>> balena.models.billing.get_account('myorghandle') 158 | """ 159 | org_id = self.__get_org_id(organization) 160 | return request(method="GET", settings=self.__settings, path=f"/billing/v1/account/{org_id}") 161 | 162 | def get_plan(self, organization: Union[str, int]) -> BillingPlanInfo: 163 | """ 164 | Get the current billing plan 165 | 166 | Args: 167 | organization (Union[str, int]): handle (string) or id (number) of the target organization. 168 | 169 | Returns: 170 | BillingPlanInfo: billing account. 171 | 172 | Examples: 173 | >>> balena.models.billing.get_plan('myorghandle') 174 | """ 175 | org_id = self.__get_org_id(organization) 176 | return request(method="GET", settings=self.__settings, path=f"/billing/v1/account/{org_id}/plan") 177 | 178 | def get_billing_info(self, organization: Union[str, int]) -> BillingInfo: 179 | """ 180 | Get the current billing information 181 | 182 | Args: 183 | organization (Union[str, int]): handle (string) or id (number) of the target organization. 184 | 185 | Returns: 186 | BillingInfo: billing information. 187 | 188 | Examples: 189 | >>> balena.models.billing.get_billing_info('myorghandle') 190 | """ 191 | org_id = self.__get_org_id(organization) 192 | return request(method="GET", settings=self.__settings, path=f"/billing/v1/account/{org_id}/info") 193 | 194 | def update_billing_info(self, organization: Union[str, int], billing_info: TokenBillingSubmitInfo) -> BillingInfo: 195 | """ 196 | Updates the current billing information 197 | 198 | Args: 199 | organization (Union[str, int]): handle (string) or id (number) of the target organization. 200 | billing_info (TokenBillingSubmitInfo): a dict containing a billing info token_id 201 | 202 | Returns: 203 | BillingInfo: billing information. 204 | 205 | Examples: 206 | >>> balena.models.billing.update_billing_info('myorghandle', { token_id: 'xxxxxxx' }) 207 | """ 208 | org_id = self.__get_org_id(organization) 209 | return request( 210 | method="PATCH", settings=self.__settings, path=f"/billing/v1/account/{org_id}/info", body=billing_info 211 | ) 212 | 213 | def update_account_info(self, organization: Union[str, int], account_info: UpdateAccountBody) -> None: 214 | """ 215 | Update the current billing account information 216 | 217 | Args: 218 | organization (Union[str, int]): handle (string) or id (number) of the target organization. 219 | account_info (UpdateAccountBody): a dict containing billing account info 220 | 221 | Examples: 222 | >>> balena.models.billing.update_account_info('myorghandle', { email: 'hello@balena.io' }) 223 | """ 224 | org_id = self.__get_org_id(organization) 225 | return request( 226 | method="PATCH", settings=self.__settings, path=f"/billing/v1/account/{org_id}", body=account_info 227 | ) 228 | 229 | def change_plan(self, organization: Union[str, int], plan_change_options: PlanChangeOptions) -> None: 230 | """ 231 | Change the current billing plan 232 | 233 | Args: 234 | organization (Union[str, int]): handle (string) or id (number) of the target organization. 235 | plan_change_options (PlanChangeOptions): a dict containing billing account info 236 | 237 | Examples: 238 | >>> balena.models.billing.change_plan('myorghandle', { billingCode: 'prototype-v2', cycle: 'annual' }) 239 | """ 240 | org_id = self.__get_org_id(organization) 241 | body = {**plan_change_options, "annual": plan_change_options["cycle"] == "annual"} 242 | return request(method="PATCH", settings=self.__settings, path=f"/billing/v1/account/{org_id}", body=body) 243 | 244 | def get_invoices(self, organization: Union[str, int]) -> List[InvoiceInfo]: 245 | """ 246 | Get the available invoices 247 | 248 | Args: 249 | organization (Union[str, int]): handle (string) or id (number) of the target organization. 250 | 251 | Examples: 252 | >>> balena.models.billing.get_invoices('myorghandle') 253 | """ 254 | org_id = self.__get_org_id(organization) 255 | return request(method="GET", settings=self.__settings, path=f"/billing/v1/account/{org_id}/invoices") 256 | 257 | def download_invoice(self, organization: Union[str, int], invoice_number: Union[str, int]): 258 | """ 259 | Download a specific invoice 260 | 261 | Args: 262 | organization (Union[str, int]): handle (string) or id (number) of the target organization. 263 | invoice_number (Union[str, int]): an invoice number (or the number inside a string) 264 | 265 | Examples: 266 | >>> with b.models.billing.download_invoice("myorg", "0000") as stream: 267 | ... stream.raise_for_status() 268 | ... with open("myinvoice.pdf", "wb") as f: 269 | ... for chunk in stream.iter_content(chunk_size=8192): 270 | ... f.write(chunk) 271 | """ 272 | org_id = self.__get_org_id(organization) 273 | return request( 274 | method="GET", 275 | path=f"/billing/v1/account/{org_id}/invoices/{invoice_number}/download", 276 | settings=self.__settings, 277 | stream=True, 278 | return_raw=True, 279 | ) 280 | -------------------------------------------------------------------------------- /balena/models/config.py: -------------------------------------------------------------------------------- 1 | from typing import List, TypedDict, Optional 2 | 3 | from ..balena_auth import request 4 | from ..settings import Settings 5 | 6 | 7 | class GaConfig(TypedDict): 8 | site: str 9 | id: str 10 | 11 | 12 | class ConfigType(TypedDict): 13 | deployment: Optional[str] 14 | deviceUrlsBase: str 15 | adminUrl: str 16 | gitServerUrl: str 17 | ga: Optional[GaConfig] 18 | mixpanelToken: Optional[str] 19 | intercomAppId: Optional[str] 20 | recurlyPublicKey: Optional[str] 21 | DEVICE_ONLINE_ICON: str 22 | DEVICE_OFFLINE_ICON: str 23 | signupCodeRequired: bool 24 | supportedSocialProviders: List[str] 25 | 26 | 27 | class Config: 28 | """ 29 | This class implements configuration model for balena python SDK. 30 | 31 | """ 32 | 33 | def __init__(self, settings: Settings): 34 | self.__settings = settings 35 | 36 | def get_all(self) -> ConfigType: 37 | """ 38 | Get all configuration. 39 | 40 | Returns: 41 | ConfigType: configuration information. 42 | 43 | Examples: 44 | >>> balena.models.config.get_all() 45 | """ 46 | 47 | return request(method="GET", path="/config", settings=self.__settings) 48 | -------------------------------------------------------------------------------- /balena/models/credit_bundle.py: -------------------------------------------------------------------------------- 1 | from typing import Union, List 2 | from .organization import Organization 3 | from ..utils import merge 4 | from ..pine import PineClient 5 | from ..types import AnyObject 6 | from ..types.models import CreditBundleType 7 | from ..settings import Settings 8 | 9 | 10 | class CreditBundle: 11 | """ 12 | This class implements credit bundle model for balena python SDK. 13 | """ 14 | 15 | def __init__(self, pine: PineClient, settings: Settings): 16 | self.__organization = Organization(pine, settings) 17 | self.__pine = pine 18 | 19 | def __get_org_id(self, organization: Union[str, int]) -> int: 20 | return self.__organization.get(organization, {"$select": "id"})["id"] 21 | 22 | def get_all_by_org(self, organization: Union[str, int], options: AnyObject = {}) -> List[CreditBundleType]: 23 | """ 24 | Get all of the credit bundles purchased by the given org 25 | 26 | Args: 27 | organization (Union[str, int]): handle (string) or id (number) of the target organization. 28 | options (AnyObject): extra pine options to use 29 | 30 | Returns: 31 | List[CreditBundleType]: credit bundles. 32 | 33 | Examples: 34 | >>> balena.models.credit_bundle.get_all_by_org('myorghandle') 35 | """ 36 | org_id = self.__get_org_id(organization) 37 | return self.__pine.get( 38 | { 39 | "resource": "credit_bundle", 40 | "options": merge( 41 | options, {"$filter": {"belongs_to__organization": org_id}, "$orderby": {"created_at": "desc"}} 42 | ), 43 | } 44 | ) 45 | 46 | def create(self, organization: Union[str, int], feature_id: int, credits_to_purchase: float) -> CreditBundleType: 47 | """ 48 | Purchase a credit bundle for the given feature and org of the given quantity 49 | 50 | Args: 51 | organization (Union[str, int]): handle (string) or id (number) of the target organization. 52 | feature_id (int): id (number) of the feature for which credits are being purchased. 53 | credits_to_purchase (float): number of credits being purchased. 54 | 55 | Returns: 56 | CreditBundleType: credit bundle. 57 | 58 | Examples: 59 | >>> balena.models.credit_bundle.create('myorghandle', 1234, 200) 60 | """ 61 | org_id = self.__get_org_id(organization) 62 | return self.__pine.post( 63 | { 64 | "resource": "credit_bundle", 65 | "body": { 66 | "belongs_to__organization": org_id, 67 | "is_for__feature": feature_id, 68 | "original_quantity": credits_to_purchase, 69 | }, 70 | } 71 | ) 72 | -------------------------------------------------------------------------------- /balena/models/device_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from .. import exceptions 4 | from ..pine import PineClient 5 | from ..settings import Settings 6 | from ..types import AnyObject 7 | from ..types.models import DeviceTypeType 8 | from ..utils import merge 9 | 10 | 11 | class DeviceType: 12 | """ 13 | This class implements user API key model for balena python SDK. 14 | 15 | """ 16 | 17 | def __init__(self, pine: PineClient, settings: Settings): 18 | self.__pine = pine 19 | self.__settings = settings 20 | 21 | def get(self, id_or_slug: Union[str, int], options: AnyObject = {}) -> DeviceTypeType: 22 | """ 23 | Get a single device type. 24 | 25 | Args: 26 | id_or_slug (Union[str, int]): device type slug or alias (string) or id (int). 27 | options (AnyObject): extra pine options to use. 28 | 29 | Returns: 30 | DeviceTypeType: Returns the device type 31 | """ 32 | 33 | if id_or_slug is None: 34 | raise exceptions.InvalidDeviceType(id_or_slug) 35 | 36 | if isinstance(id_or_slug, str): 37 | device_types = self.get_all( 38 | merge( 39 | { 40 | "$top": 1, 41 | "$filter": { 42 | "device_type_alias": { 43 | "$any": { 44 | "$alias": "dta", 45 | "$expr": { 46 | "dta": { 47 | "is_referenced_by__alias": id_or_slug, 48 | }, 49 | }, 50 | }, 51 | }, 52 | }, 53 | }, 54 | options, 55 | ) 56 | ) 57 | device_type = None 58 | if len(device_types) > 0: 59 | device_type = device_types[0] 60 | else: 61 | device_type = self.__pine.get( 62 | { 63 | "resource": "device_type", 64 | "id": id_or_slug, 65 | "options": options, 66 | } 67 | ) 68 | 69 | if device_type is None: 70 | raise exceptions.InvalidDeviceType(id_or_slug) 71 | 72 | return device_type 73 | 74 | def get_all(self, options: AnyObject = {}) -> List[DeviceTypeType]: 75 | """ 76 | Get all device types. 77 | 78 | Args: 79 | options (AnyObject): extra pine options to use. 80 | 81 | Returns: 82 | List[DeviceTypeType]: list contains info of device types. 83 | """ 84 | opts = merge({"$orderby": "name asc"}, options) 85 | return self.__pine.get( 86 | { 87 | "resource": "device_type", 88 | "options": opts, 89 | } 90 | ) 91 | 92 | def get_all_supported(self, options: AnyObject = {}): 93 | """ 94 | Get all supported device types. 95 | 96 | Args: 97 | options (AnyObject): extra pine options to use. 98 | 99 | Returns: 100 | List[DeviceTypeType]: list contains info of all supported device types. 101 | """ 102 | 103 | return self.get_all( 104 | merge( 105 | { 106 | "$filter": { 107 | "is_default_for__application": { 108 | "$any": { 109 | "$alias": "idfa", 110 | "$expr": { 111 | "idfa": { 112 | "is_host": True, 113 | "is_archived": False, 114 | "owns__release": { 115 | "$any": { 116 | "$alias": "r", 117 | "$expr": { 118 | "r": { 119 | "status": "success", 120 | "is_final": True, 121 | "is_invalidated": False, 122 | } 123 | }, 124 | } 125 | }, 126 | } 127 | }, 128 | } 129 | } 130 | } 131 | }, 132 | options, 133 | ) 134 | ) 135 | 136 | def get_by_slug_or_name(self, slug_or_name: str, options: AnyObject = {}) -> DeviceTypeType: 137 | """ 138 | Get a single device type by slug or name. 139 | 140 | Args: 141 | slug_or_name (str): device type slug or name. 142 | options (AnyObject): extra pine options to use. 143 | 144 | Returns: 145 | DeviceTypeType: Returns the device type 146 | """ 147 | 148 | device_types = self.get_all( 149 | merge( 150 | { 151 | "$top": 1, 152 | "$filter": {"$or": [{"name": slug_or_name}, {"slug": slug_or_name}]}, 153 | }, 154 | options, 155 | ) 156 | ) 157 | 158 | device_type = device_types[0] if len(device_types) > 0 else None 159 | 160 | if device_type is None: 161 | raise exceptions.InvalidDeviceType(slug_or_name) 162 | 163 | return device_type 164 | 165 | def get_name(self, slug: str) -> str: 166 | """ 167 | Get display name for a device. 168 | 169 | Args: 170 | slug (str): device type slug. 171 | 172 | """ 173 | 174 | return self.get_by_slug_or_name(slug, {"$select": "name"})["name"] 175 | 176 | def get_slug_by_name(self, name: str) -> str: 177 | """ 178 | Get device slug. 179 | 180 | Args: 181 | name (str): device type name. 182 | 183 | """ 184 | 185 | return self.get_by_slug_or_name(name, {"$select": "slug"})["slug"] 186 | -------------------------------------------------------------------------------- /balena/models/history.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import List, Optional, Union 3 | 4 | from .. import exceptions 5 | from ..pine import PineClient 6 | from ..types import AnyObject 7 | from ..types.models import DeviceHistoryType 8 | from ..utils import is_full_uuid, is_id, merge 9 | from ..settings import Settings 10 | from .application import Application 11 | 12 | 13 | def history_timerange_filter_with_guard(from_date=None, to_date=None): 14 | from_date_filter = {} 15 | to_date_filter = {} 16 | 17 | if from_date is not None: 18 | if not isinstance(from_date, datetime): 19 | raise exceptions.InvalidParameter("from_date", from_date) 20 | else: 21 | from_date_filter = {"$ge": from_date} 22 | 23 | if to_date is not None: 24 | if not isinstance(to_date, datetime): 25 | raise exceptions.InvalidParameter("to_date", to_date) 26 | else: 27 | to_date_filter = {"$le": to_date} 28 | 29 | filter = {**from_date_filter, **to_date_filter} 30 | 31 | if filter == {}: 32 | return {} 33 | 34 | return {"created_at": filter} 35 | 36 | 37 | class DeviceHistory: 38 | """ 39 | This class implements device history model for balena python SDK. 40 | 41 | """ 42 | 43 | def __init__(self, pine: PineClient, settings: Settings): 44 | self.__pine = pine 45 | self.__application = Application(pine, settings) 46 | 47 | def get_all_by_device( 48 | self, 49 | uuid_or_id: Union[str, int], 50 | from_date: datetime = datetime.utcnow() + timedelta(days=-7), 51 | to_date: Optional[datetime] = None, 52 | options: AnyObject = {}, 53 | ) -> List[DeviceHistoryType]: 54 | """ 55 | Get all device history entries for a device. 56 | 57 | Args: 58 | uuid_or_id (str): device uuid (32 / 62 digits string) or id (number) __note__: No short IDs supported 59 | from_date (datetime): history entries newer than or equal to this timestamp. Defaults to 7 days ago 60 | to_date (datetime): history entries younger or equal to this date. 61 | options (AnyObject): extra pine options to use 62 | 63 | Returns: 64 | List[DeviceHistoryType]: device history entries. 65 | 66 | Examples: 67 | >>> balena.models.device.history.get_all_by_device('6046335305c8142883a4466d30abe211') 68 | >>> balena.models.device.history.get_all_by_device(11196426) 69 | >>> balena.models.device.history.get_all_by_device( 70 | ... 11196426, from_date=datetime.utcnow() + timedelta(days=-5) 71 | ... ) 72 | >>> balena.models.device.history.get_all_by_device( 73 | ... 11196426, 74 | ... from_date=datetime.utcnow() + timedelta(days=-10), 75 | ... to_date=from_date = datetime.utcnow() + timedelta(days=-5)) 76 | ... ) 77 | 78 | """ 79 | dollar_filter = history_timerange_filter_with_guard(from_date, to_date) 80 | if is_id(uuid_or_id): 81 | dollar_filter = {**dollar_filter, "tracks__device": uuid_or_id} 82 | elif is_full_uuid(uuid_or_id): 83 | dollar_filter = {**dollar_filter, "uuid": uuid_or_id} 84 | else: 85 | raise exceptions.InvalidParameter("uuid_or_id", uuid_or_id) 86 | 87 | return self.__pine.get({"resource": "device_history", "options": merge({"$filter": dollar_filter}, options)}) 88 | 89 | def get_all_by_application( 90 | self, 91 | slug_or_uuid_or_id: Union[str, int], 92 | from_date: datetime = datetime.utcnow() + timedelta(days=-7), 93 | to_date: Optional[datetime] = None, 94 | options: AnyObject = {}, 95 | ) -> List[DeviceHistoryType]: 96 | """ 97 | Get all device history entries for an application. 98 | 99 | Args: 100 | slug_or_uuid_or_id (Union[str, int]): application slug (string), uuid (string) or id (number) 101 | from_date (datetime): history entries newer than or equal to this timestamp. Defaults to 7 days ago 102 | to_date (datetime): history entries younger or equal to this date. 103 | options (AnyObject): extra pine options to use 104 | 105 | Returns: 106 | List[DeviceHistoryType]: device history entries. 107 | 108 | Examples: 109 | >>> balena.models.device.history.get_all_by_application('myorg/myapp') 110 | >>> balena.models.device.history.get_all_by_application(11196426) 111 | >>> balena.models.device.history.get_all_by_application( 112 | ... 11196426, from_date=datetime.utcnow() + timedelta(days=-5) 113 | ... ) 114 | >>> balena.models.device.history.get_all_by_application( 115 | ... 11196426, 116 | ... from_date=datetime.utcnow() + timedelta(days=-10), 117 | ... to_date=from_date = datetime.utcnow() + timedelta(days=-5)) 118 | ... ) 119 | """ 120 | app_id = self.__application.get(slug_or_uuid_or_id, {"$select": "id"})["id"] 121 | 122 | return self.__pine.get( 123 | { 124 | "resource": "device_history", 125 | "options": merge( 126 | { 127 | "$filter": { 128 | **history_timerange_filter_with_guard(from_date, to_date), 129 | "belongs_to__application": app_id, 130 | } 131 | }, 132 | options, 133 | ), 134 | } 135 | ) 136 | -------------------------------------------------------------------------------- /balena/models/image.py: -------------------------------------------------------------------------------- 1 | from .. import exceptions 2 | from ..pine import PineClient 3 | from ..types import AnyObject 4 | from ..types.models import ImageType 5 | from ..utils import merge 6 | from ..settings import Settings 7 | 8 | 9 | class Image: 10 | """ 11 | This class implements image model for balena python SDK. 12 | """ 13 | 14 | def __init__(self, pine: PineClient, settings: Settings): 15 | self.__pine = pine 16 | self.__settings = settings 17 | 18 | def get(self, id: int, options: AnyObject = {}) -> ImageType: 19 | """ 20 | Get a specific image. 21 | 22 | Args: 23 | id (int): image id. 24 | options (AnyObject): extra pine options to use. 25 | 26 | Returns: 27 | ImageType: image info. 28 | """ 29 | base_options = { 30 | "$select": [ 31 | "id", 32 | "content_hash", 33 | "dockerfile", 34 | "project_type", 35 | "status", 36 | "error_message", 37 | "image_size", 38 | "created_at", 39 | "push_timestamp", 40 | "start_timestamp", 41 | "end_timestamp", 42 | ] 43 | } 44 | 45 | image = self.__pine.get({"resource": "image", "id": id, "options": merge(base_options, options, True)}) 46 | 47 | if image is None: 48 | raise exceptions.ImageNotFound(id) 49 | 50 | return image 51 | 52 | def get_logs(self, id: int) -> str: 53 | """ 54 | Get the build log from an image. 55 | 56 | Args: 57 | id (str): image id. 58 | 59 | Returns: 60 | str: build log. 61 | """ 62 | return self.get(id, {"$select": "build_log"})["build_log"] 63 | -------------------------------------------------------------------------------- /balena/models/key.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from .. import exceptions 4 | from ..auth import Auth 5 | from ..pine import PineClient 6 | from ..types import AnyObject 7 | from ..types.models import SSHKeyType 8 | from ..settings import Settings 9 | 10 | 11 | class Key: 12 | """ 13 | This class implements ssh key model for balena python SDK. 14 | 15 | """ 16 | 17 | def __init__(self, pine: PineClient, settings: Settings): 18 | self.__pine = pine 19 | self.__auth = Auth(pine, settings) 20 | 21 | def get_all(self, options: AnyObject = {}) -> List[SSHKeyType]: 22 | """ 23 | Get all ssh keys. 24 | 25 | Args: 26 | options (AnyObject): extra pine options to use 27 | 28 | Returns: 29 | List[SSHKeyType]: list of ssh keys. 30 | """ 31 | return self.__pine.get({"resource": "user__has__public_key", "options": options}) 32 | 33 | def get(self, id: int) -> SSHKeyType: 34 | """ 35 | Get a single ssh key. 36 | 37 | Args: 38 | id (int): key id. 39 | 40 | Returns: 41 | SSHKeyType: ssh key info. 42 | """ 43 | 44 | key = self.__pine.get({"resource": "user__has__public_key", "id": id}) 45 | 46 | if key is None: 47 | raise exceptions.KeyNotFound(id) 48 | 49 | return key 50 | 51 | def remove(self, id: int) -> None: 52 | """ 53 | Remove a ssh key. 54 | 55 | Args: 56 | id (int): key id. 57 | """ 58 | 59 | self.__pine.delete({"resource": "user__has__public_key", "id": id}) 60 | 61 | def create(self, title: str, key: str) -> SSHKeyType: 62 | """ 63 | Create a ssh key. 64 | 65 | Args: 66 | title (str): key title. 67 | key (str): the public ssh key. 68 | 69 | Returns: 70 | SSHKeyType: new ssh key id. 71 | """ 72 | # Avoid ugly whitespaces 73 | key = key.strip() 74 | 75 | user_id = self.__auth.get_user_info()["id"] 76 | return self.__pine.post( 77 | {"resource": "user__has__public_key", "body": {"title": title, "public_key": key, "user": user_id}} 78 | ) 79 | -------------------------------------------------------------------------------- /balena/models/service.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Union, cast, TypedDict 2 | 3 | from .. import exceptions 4 | from ..dependent_resource import DependentResource 5 | from ..pine import PineClient 6 | from ..types import AnyObject 7 | from ..types.models import EnvironmentVariableBase, ServiceType 8 | from ..utils import merge 9 | from ..settings import Settings 10 | from .application import Application 11 | 12 | 13 | class ServiceNaturalKey(TypedDict): 14 | application: Union[str, int] 15 | service_name: str 16 | 17 | 18 | class Service: 19 | """ 20 | This class implements service model for balena python SDK. 21 | 22 | """ 23 | 24 | def __init__(self, pine: PineClient, settings: Settings): 25 | self.__pine = pine 26 | self.__application = Application(pine, settings, False) 27 | self.var = ServiceEnvVariable(pine, self, settings) 28 | 29 | def _get(self, id: int, options: AnyObject = {}): 30 | service = self.__pine.get({"resource": "service", "id": id, "options": options}) 31 | 32 | if service is None: 33 | raise exceptions.ServiceNotFound(id) 34 | 35 | return service 36 | 37 | def get_all_by_application(self, slug_or_uuid_or_id: Union[str, int], options: AnyObject = {}) -> List[ServiceType]: 38 | """ 39 | Get all services from an application. 40 | 41 | Args: 42 | slug_or_uuid_or_id (Union[str, int]): application slug (string), uuid (string) or id (number) 43 | options (AnyObject): extra pine options to use 44 | 45 | Returns: 46 | List[ServiceType]: service info. 47 | """ 48 | 49 | services = self.__application.get(slug_or_uuid_or_id, {"$select": "service", "$expand": {"service": options}})[ 50 | "service" 51 | ] 52 | 53 | return cast(List[ServiceType], services) 54 | 55 | 56 | class ServiceEnvVariable(DependentResource[EnvironmentVariableBase]): 57 | """ 58 | This class implements Service environment variable model for balena python SDK. 59 | 60 | """ 61 | 62 | def __init__(self, pine: PineClient, service: Service, settings: Settings): 63 | self.__service = service 64 | self.__application = Application(pine, settings, False) 65 | super(ServiceEnvVariable, self).__init__( 66 | "service_environment_variable", 67 | "name", 68 | "service", 69 | self.__get_resource_id, 70 | pine, 71 | ) 72 | 73 | def __get_resource_id(self, resource_id: Union[int, ServiceNaturalKey]): 74 | if resource_id is not None and isinstance(resource_id, dict): 75 | keys = resource_id.keys() 76 | if len(keys) != 2 or "application" not in keys or "service_name" not in keys: 77 | raise Exception(f"Unexpected type for id provided in service var model get resource id: {resource_id}") 78 | 79 | service = self.__service.get_all_by_application( 80 | resource_id["application"], {"$select": "id", "$filter": {"service_name": resource_id["service_name"]}} 81 | )[0] 82 | 83 | if service is None: 84 | raise exceptions.ServiceNotFound(resource_id["service_name"]) 85 | 86 | return service["id"] 87 | if not isinstance(resource_id, int): 88 | raise Exception(f"Unexpected type for id provided in service varModel getResourceId: {resource_id}") 89 | return self.__service._get(resource_id, {"$select": "id"})["id"] 90 | 91 | def get_all_by_service( 92 | self, service_id_or_natural_key: Union[int, ServiceNaturalKey], options: AnyObject = {} 93 | ) -> List[EnvironmentVariableBase]: 94 | """ 95 | Get all variables for a service. 96 | 97 | Args: 98 | service_id_or_natural_key (Union[int, ServiceNaturalKey]): service id (number) or appliation-service_name 99 | options (AnyObject): extra pine options to use 100 | 101 | Returns: 102 | List[EnvironmentVariableBase]: service environment variables. 103 | 104 | Examples: 105 | >>> balena.models.service.var.get_all_by_service(1234) 106 | >>> balena.models.service.var.get_all_by_service({'application': 'myorg/myapp', 'service_name': 'service'}) 107 | """ 108 | return super(ServiceEnvVariable, self)._get_all_by_parent(service_id_or_natural_key, options) 109 | 110 | def get_all_by_application( 111 | self, slug_or_uuid_or_id: Union[str, int], options: AnyObject = {} 112 | ) -> List[EnvironmentVariableBase]: 113 | """ 114 | Get all service variables by application. 115 | 116 | Args: 117 | slug_or_uuid_or_id (Union[str, int]): application slug (string), uuid (string) or id (number) 118 | options (AnyObject): extra pine options to use 119 | 120 | Returns: 121 | List[EnvironmentVariableBase]: application environment variables. 122 | 123 | Examples: 124 | >>> balena.models.service.var.get_all_by_application(9020) 125 | >>> balena.models.service.var.get_all_by_application("myorg/myslug") 126 | """ 127 | app_id = self.__application.get(slug_or_uuid_or_id, {"$select": "id"})["id"] 128 | 129 | return super(ServiceEnvVariable, self)._get_all( 130 | merge( 131 | { 132 | "$filter": { 133 | "service": { 134 | "$any": { 135 | "$alias": "s", 136 | "$expr": {"s": {"application": app_id}}, 137 | } 138 | } 139 | }, 140 | "$orderby": "name asc", 141 | }, 142 | options, 143 | ) 144 | ) 145 | 146 | def get(self, service_id_or_natural_key: Union[int, ServiceNaturalKey], key: str) -> Optional[str]: 147 | """ 148 | Get the value of a specific service variable 149 | 150 | Args: 151 | service_id_or_natural_key (Union[int, ServiceNaturalKey]): service id (number) or appliation-service_name 152 | key (str): variable name 153 | 154 | Examples: 155 | >>> balena.models.service.var.get(1234,'test_env4') 156 | >>> balena.models.service.var.get({'application': 'myorg/myapp', 'service_name': 'service'}, 'VAR') 157 | """ 158 | return super(ServiceEnvVariable, self)._get(service_id_or_natural_key, key) 159 | 160 | def set(self, service_id_or_natural_key: Union[int, ServiceNaturalKey], key: str, value: str) -> None: 161 | """ 162 | Set the value of a specific application environment variable. 163 | 164 | Args: 165 | service_id_or_natural_key (Union[int, ServiceNaturalKey]): service id (number) or appliation-service_name 166 | key (str): variable name 167 | value (str): environment variable value. 168 | 169 | Examples: 170 | >>> balena.models.service.var.set({'application': 'myorg/myapp', 'service_name': 'service'}, 'VAR', 'value') 171 | >>> balena.models.service.var.set(1234,'test_env4', 'value') 172 | """ 173 | super(ServiceEnvVariable, self)._set(service_id_or_natural_key, key, value) 174 | 175 | def remove(self, service_id_or_natural_key: Union[int, ServiceNaturalKey], key: str) -> None: 176 | """ 177 | Clear the value of a specific service variable 178 | 179 | Args: 180 | service_id_or_natural_key (Union[int, ServiceNaturalKey]): service id (number) or appliation-service_name 181 | key (str): variable name 182 | 183 | Examples: 184 | >>> balena.models.service.var.remove({'application': 'myorg/myapp', 'service_name': 'service'}, 'VAR') 185 | >>> balena.models.service.var.remove(1234,'test_env4') 186 | """ 187 | super(ServiceEnvVariable, self)._remove(service_id_or_natural_key, key) 188 | -------------------------------------------------------------------------------- /balena/pine.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, cast 2 | from urllib.parse import urljoin 3 | from ratelimit import limits, sleep_and_retry 4 | from time import sleep 5 | import mimetypes 6 | import io 7 | import requests 8 | import os 9 | from pine_client import PinejsClientCore 10 | from pine_client.client import Params 11 | 12 | from .balena_auth import get_token 13 | from .exceptions import RequestError, InvalidOption 14 | from .settings import Settings 15 | 16 | 17 | class PineClient(PinejsClientCore): 18 | def __init__(self, settings: Settings, sdk_version: str, params: Optional[Params] = None): 19 | if params is None: 20 | params = {} 21 | 22 | self.__settings = settings 23 | self.__sdk_version = sdk_version 24 | 25 | api_url = cast(str, settings.get("api_endpoint")) 26 | api_version = cast(str, settings.get("api_version")) 27 | 28 | try: 29 | calls = int(self.__settings.get("request_limit")) 30 | period = int(self.__settings.get("request_limit_interval")) 31 | 32 | self.__request = sleep_and_retry(limits(calls=calls, period=period)(self.__base_request)) 33 | except InvalidOption: 34 | self.__request = self.__base_request 35 | 36 | super().__init__({**params, "api_prefix": urljoin(api_url, api_version) + "/"}) 37 | 38 | def _request(self, method: str, url: str, body: Optional[Any] = None) -> Any: 39 | return self.__request(method, url, body) 40 | 41 | def __base_request(self, method: str, url: str, body: Optional[Any] = None) -> Any: 42 | token = get_token(self.__settings) 43 | 44 | headers = {"X-Balena-Client": f"balena-python-sdk/{self.__sdk_version}"} 45 | if token is not None: 46 | headers["Authorization"] = f"Bearer {token}" 47 | 48 | is_multipart_form_data = False 49 | files = {} 50 | values = {} 51 | if body is not None: 52 | for k, v in body.items(): 53 | if isinstance(v, io.BufferedReader): 54 | mimetype, _ = mimetypes.guess_type(v.name) 55 | files[k] = (os.path.basename(v.name), v, mimetype) 56 | is_multipart_form_data = True 57 | else: 58 | values[k] = v 59 | 60 | if is_multipart_form_data: 61 | req = requests.request(method, url=url, files=files, data=values, headers=headers) 62 | else: 63 | req = requests.request(method, url=url, json=body, headers=headers) 64 | 65 | if req.ok: 66 | try: 67 | return req.json() 68 | except Exception: 69 | return req.content.decode() 70 | else: 71 | retry_after = req.headers.get("retry-after") 72 | if ( 73 | self.__settings.get("retry_rate_limited_request") is True 74 | and req.status_code == 429 75 | and retry_after is not None 76 | and retry_after.isdigit() 77 | ): 78 | sleep(int(retry_after)) 79 | return self.__base_request(method, url, body) 80 | 81 | raise RequestError(body=req.content.decode(), status_code=req.status_code) 82 | -------------------------------------------------------------------------------- /balena/resources.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a python library for resources like message templates etc. 3 | """ 4 | 5 | 6 | class Message: 7 | """ 8 | Message templates 9 | """ 10 | 11 | # Exception Error Message 12 | NOT_LOGGED_IN = "You have to log in!" 13 | TOO_MANY_REQUESTS = "Too Many Requests" 14 | UNAUTHORIZED = "You have to log in or BALENA_API_KEY environment variable must be set!" 15 | REQUEST_ERROR = "Request error: {body}" 16 | KEY_NOT_FOUND = "Key not found: {key}" 17 | DEVICE_NOT_FOUND = "Device not found: {uuid}" 18 | APPLICATION_NOT_FOUND = "Application not found: {application}" 19 | MALFORMED_TOKEN = "Malformed token: {token}" 20 | INVALID_DEVICE_TYPE = "Invalid device type: {dev_type}" 21 | INVALID_OPTION = "Invalid option: {option}" 22 | MISSING_OPTION = "Missing option: {option}" 23 | NON_ALLOWED_OPTION = "Non allowed option: {option}" 24 | LOGIN_FAILED = "Invalid credentials" 25 | DEVICE_OFFLINE = "Device is offline: {uuid}" 26 | DEVICE_NOT_WEB_ACCESSIBLE = "Device is not web accessible: {uuid}" 27 | INCOMPATIBLE_APPLICATION = "Incompatible application: {application}" 28 | INVALID_SETTINGS = "Settings file not found or not in proper format. Rewriting default settings" " to: {path}" 29 | SUPERVISOR_VERSION_ERROR = ( 30 | "Unsupported function! Supervisor version v{req_version} required, current" 31 | " supervisor version is v{cur_version}." 32 | ) 33 | AMBIGUOUS_APPLICATION = "Application is ambiguous: {application}" 34 | AMBIGUOUS_DEVICE = "Device is ambiguous: {uuid}" 35 | INVALID_PARAMETER = "Invalid parameter: {value} is not a valid value for parameter `{parameter}`" 36 | IMAGE_NOT_FOUND = "Image not found: {id}" 37 | RELEASE_NOT_FOUND = "Release not found: {id}" 38 | AMBIGUOUS_RELEASE = "Release commit is ambiguous: {commit}" 39 | SERVICE_NOT_FOUND = "Service not found: {id}" 40 | INVALID_APPLICATION_TYPE = "Invalid application type: {app_type}" 41 | UNSUPPORTED_FEATURE = "You have to log in using credentials or Auth Token to use this function!" 42 | OS_UPDATE_ERROR = "OS update failed: {message}" 43 | DEVICE_NOT_PROVISIONED = "Device is not yet fully provisioned" 44 | DEVICE_OS_NOT_SUPPORT_LOCAL_MODE = "Device OS version does not support local mode" 45 | DEVICE_SUPERVISOR_NOT_SUPPORT_LOCAL_MODE = "Device supervisor version does not support local mode" 46 | DEVICE_OS_TYPE_NOT_SUPPORT_LOCAL_MODE = "Local mode is only supported on development OS versions" 47 | ORGANIZATION_NOT_FOUND = "Organization not found: {organization}" 48 | ORGANIZATION_MEMBERSHIP_NOT_FOUND = "Organization membership not found: {org_membership}" 49 | BALENA_DISCONTINUE_DEVICE_TYPE = "Discontinued device type: {type}" 50 | BALENA_ORG_MEMBERSHIP_ROLE_NOT_FOUND = "Organization membership role not found: {role_name}" 51 | BALENA_APP_MEMBERSHIP_ROLE_NOT_FOUND = "Application membership role not found: {role_name}" 52 | APPLICATION_MEMBERSHIP_NOT_FOUND = "Application membership not found: {membership}" 53 | BALENA_INVALID_DEVICE_TYPE = "Invalid device type: {device_type}" 54 | SUPERVISOR_LOCKED = "Supervisor is locked" 55 | -------------------------------------------------------------------------------- /balena/settings.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import os.path as Path 4 | import shutil 5 | import sys 6 | from typing import Dict, TypedDict, Optional, Union, Literal 7 | from abc import ABC, abstractmethod 8 | from copy import deepcopy 9 | 10 | from . import exceptions 11 | from .resources import Message 12 | 13 | 14 | class SettingsConfig(TypedDict, total=False): 15 | balena_host: str 16 | api_version: str 17 | device_actions_endpoint_version: str 18 | data_directory: Union[str, Literal[False]] 19 | image_cache_time: str 20 | token_refresh_interval: str 21 | timeout: str 22 | request_limit: str 23 | request_limit_interval: str 24 | retry_rate_limited_request: bool 25 | 26 | 27 | class SettingsProviderInterface(ABC): 28 | @abstractmethod 29 | def has(self, key: str) -> bool: 30 | pass 31 | 32 | @abstractmethod 33 | def get(self, key: str) -> Union[str, bool]: 34 | pass 35 | 36 | @abstractmethod 37 | def get_all(self) -> Dict[str, Union[str, bool]]: 38 | pass 39 | 40 | @abstractmethod 41 | def set(self, key: str, value: Union[str, bool]) -> None: 42 | pass 43 | 44 | @abstractmethod 45 | def remove(self, key: str) -> bool: 46 | pass 47 | 48 | 49 | DEFAULT_SETTINGS = { 50 | # These are default config values 51 | "balena_host": "balena-cloud.com", 52 | "api_version": "v7", 53 | "device_actions_endpoint_version": "v1", 54 | # cache time : 1 week in milliseconds 55 | "image_cache_time": str(1 * 1000 * 60 * 60 * 24 * 7), 56 | # token refresh interval: 1 hour in milliseconds 57 | "token_refresh_interval": str(1 * 1000 * 60 * 60), 58 | # requests timeout: 30 seconds in milliseconds 59 | "timeout": str(30 * 1000), 60 | # requests timeout: 60 seconds in seconds 61 | "request_limit_interval": str(60), 62 | "retry_rate_limited_request": False, 63 | } 64 | 65 | 66 | class FileStorageSettingsProvider(SettingsProviderInterface): 67 | """ 68 | This class handles settings for balena python SDK. 69 | 70 | Attributes: 71 | HOME_DIRECTORY (str): home directory path. 72 | CONFIG_SECTION (str): section name in configuration file. 73 | CONFIG_FILENAME (str): configuration file name. 74 | _setting (dict): default value to settings. 75 | 76 | """ 77 | 78 | HOME_DIRECTORY = os.getenv("BALENA_SETTINGS_HOME_DIRECTORY", default=Path.expanduser("~")) 79 | CONFIG_SECTION = os.getenv("BALENA_SETTINGS_CONFIG_SECTION", default="Settings") 80 | CONFIG_FILENAME = os.getenv("BALENA_SETTINGS_CONFIG_FILENAME", default="balena.cfg") 81 | DEFAULT_SETTING_KEYS = set( 82 | [ 83 | "builder_url", 84 | "pine_endpoint", 85 | "api_endpoint", 86 | "api_version", 87 | "data_directory", 88 | "image_cache_time", 89 | "token_refresh_interval", 90 | "cache_directory", 91 | "timeout", 92 | "device_actions_endpoint_version", 93 | "retry_rate_limited_request", 94 | ] 95 | ) 96 | 97 | def __init__(self, settings_config: Optional[SettingsConfig]): 98 | _base_settings = deepcopy(DEFAULT_SETTINGS) 99 | 100 | if settings_config is not None: 101 | _base_settings = {**_base_settings, **settings_config} 102 | 103 | host = _base_settings["balena_host"] 104 | _base_settings["builder_url"] = f"https://builder.{host}/" 105 | _base_settings["api_endpoint"] = f"https://api.{host}/" 106 | _base_settings["pine_endpoint"] = f"https://api.{host}/{_base_settings['api_version']}/" 107 | 108 | data_directory = _base_settings.get("data_directory") 109 | if data_directory is None or data_directory is True: 110 | _base_settings["data_directory"] = Path.join(FileStorageSettingsProvider.HOME_DIRECTORY, ".balena") 111 | 112 | _base_settings["cache_directory"] = Path.join(_base_settings["data_directory"], "cache") 113 | 114 | self.__base_settings = _base_settings 115 | self._setting = _base_settings 116 | 117 | config_file_path = Path.join(self._setting["data_directory"], self.CONFIG_FILENAME) 118 | try: 119 | self.__read_settings() 120 | if not self.DEFAULT_SETTING_KEYS.issubset(set(self._setting)): 121 | raise 122 | except Exception: 123 | # Backup old settings file if it exists. 124 | try: 125 | if Path.isfile(config_file_path): 126 | shutil.move( 127 | config_file_path, 128 | Path.join( 129 | self._setting["data_directory"], 130 | "{0}.{1}".format(self.CONFIG_FILENAME, "old"), 131 | ), 132 | ) 133 | except OSError: 134 | pass 135 | self.__write_settings(default=True) 136 | print(Message.INVALID_SETTINGS.format(path=config_file_path), file=sys.stderr) 137 | 138 | def __write_settings(self, default=None): 139 | """ 140 | Write settings to file. 141 | 142 | Args: 143 | default (Optional[bool]): write default settings. 144 | 145 | """ 146 | 147 | if default: 148 | self._setting = self.__base_settings 149 | config = configparser.ConfigParser() 150 | config.add_section(self.CONFIG_SECTION) 151 | for key in self._setting: 152 | value = self._setting[key] 153 | if isinstance(value, bool): 154 | value = "true" if value else "false" 155 | config.set(self.CONFIG_SECTION, key, value) 156 | if not Path.isdir(self._setting["data_directory"]): 157 | os.makedirs(self._setting["data_directory"]) 158 | with open(Path.join(self._setting["data_directory"], self.CONFIG_FILENAME), "w") as config_file: 159 | config.write(config_file) 160 | 161 | def __read_settings(self): 162 | config_reader = configparser.ConfigParser() 163 | config_reader.read(Path.join(self._setting["data_directory"], self.CONFIG_FILENAME)) 164 | config_data = {} 165 | options = config_reader.options(self.CONFIG_SECTION) 166 | for option in options: 167 | # Always use the default supported SDK version and pine endpoint 168 | # Unless it was explicetely defined differently (in short, do not cache this ) 169 | if option == "api_version": 170 | config_data[option] = self.__base_settings[option] 171 | continue 172 | try: 173 | config_data[option] = config_reader.get(self.CONFIG_SECTION, option) 174 | if config_data[option] == "true": 175 | config_data[option] = True 176 | if config_data[option] == "false": 177 | config_data[option] = False 178 | except Exception: 179 | config_data[option] = None 180 | # Ensure pine endpoint matches the final decided api_version 181 | config_data["pine_endpoint"] = f"https://api.{config_data['balena_host']}/{config_data['api_version']}/" 182 | self._setting = config_data 183 | 184 | def has(self, key: str) -> bool: 185 | self.__read_settings() 186 | if key in self._setting: 187 | return True 188 | return False 189 | 190 | def get(self, key: str) -> Union[str, bool]: 191 | try: 192 | self.__read_settings() 193 | return self._setting[key] 194 | except KeyError: 195 | raise exceptions.InvalidOption(key) 196 | 197 | def get_all(self) -> Dict[str, Union[str, bool]]: 198 | self.__read_settings() 199 | return self._setting 200 | 201 | def set(self, key: str, value: Union[str, bool]) -> None: 202 | self._setting[key] = str(value) 203 | self.__write_settings() 204 | 205 | def remove(self, key: str) -> bool: 206 | # if key is not in settings, return False 207 | result = self._setting.pop(key, False) 208 | if result is not False: 209 | self.__write_settings() 210 | return True 211 | return False 212 | 213 | 214 | class InMemorySettingsProvider(SettingsProviderInterface): 215 | def __init__(self, settings_config: Optional[SettingsConfig]): 216 | self._settings = deepcopy(DEFAULT_SETTINGS) 217 | 218 | if settings_config is not None: 219 | self._settings = {**DEFAULT_SETTINGS, **settings_config} 220 | 221 | host = self._settings["balena_host"] 222 | self._settings["builder_url"] = f"https://builder.{host}/" 223 | self._settings["api_endpoint"] = f"https://api.{host}/" 224 | self._settings["pine_endpoint"] = f"https://api.{host}/{self._settings['api_version']}/" 225 | 226 | def has(self, key: str) -> bool: 227 | if key in self._settings: 228 | return True 229 | return False 230 | 231 | def get(self, key: str) -> Union[str, bool]: 232 | try: 233 | return self._settings[key] 234 | except KeyError: 235 | raise exceptions.InvalidOption(key) 236 | 237 | def get_all(self) -> Dict[str, Union[str, bool]]: 238 | return self._settings 239 | 240 | def set(self, key: str, value: Union[str, bool]) -> None: 241 | self._settings[key] = str(value) 242 | 243 | def remove(self, key: str) -> bool: 244 | result = self._settings.pop(key, False) 245 | if result: 246 | return True 247 | return False 248 | 249 | 250 | class Settings(SettingsProviderInterface): 251 | """ 252 | This class handles settings for balena python SDK. 253 | 254 | """ 255 | 256 | def __init__(self, settings_config: Optional[SettingsConfig]): 257 | self.__settings_provider: SettingsProviderInterface 258 | 259 | if settings_config and settings_config.get("data_directory") is False: 260 | self.__settings_provider = InMemorySettingsProvider(settings_config) 261 | else: 262 | self.__settings_provider = FileStorageSettingsProvider(settings_config) 263 | 264 | def has(self, key: str) -> bool: 265 | """ 266 | Check if a setting exists. 267 | 268 | Args: 269 | key (str): setting. 270 | 271 | Returns: 272 | bool: True if exists, False otherwise. 273 | 274 | Examples: 275 | >>> balena.settings.has('api_endpoint') 276 | """ 277 | return self.__settings_provider.has(key) 278 | 279 | def get(self, key: str) -> Union[str, bool]: 280 | """ 281 | Get a setting value. 282 | 283 | Args: 284 | key (str): setting. 285 | 286 | Returns: 287 | str: setting value. 288 | 289 | Raises: 290 | InvalidOption: If getting a non-existent setting. 291 | 292 | Examples: 293 | >>> balena.settings.get('api_endpoint') 294 | """ 295 | return self.__settings_provider.get(key) 296 | 297 | def get_all(self) -> Dict[str, Union[str, bool]]: 298 | """ 299 | Get all settings. 300 | 301 | Returns: 302 | dict: all settings. 303 | 304 | Examples: 305 | >>> balena.settings.get_all() 306 | """ 307 | return self.__settings_provider.get_all() 308 | 309 | def set(self, key: str, value: Union[str, bool]) -> None: 310 | """ 311 | Set value for a setting. 312 | 313 | Args: 314 | key (str): setting. 315 | value (str): setting value. 316 | 317 | Examples: 318 | >>> balena.settings.set(key='tmp',value='123456') 319 | """ 320 | return self.__settings_provider.set(key, value) 321 | 322 | def remove(self, key: str) -> bool: 323 | """ 324 | Remove a setting. 325 | 326 | Args: 327 | key (str): setting. 328 | 329 | Returns: 330 | bool: True if successful, False otherwise. 331 | 332 | Examples: 333 | # Remove an existing key from settings 334 | >>> balena.settings.remove('tmp') 335 | # Remove a non-existing key from settings 336 | >>> balena.settings.remove('tmp1') 337 | """ 338 | return self.__settings_provider.remove(key) 339 | -------------------------------------------------------------------------------- /balena/twofactor_auth.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import parse_qs 2 | from typing import cast 3 | import jwt 4 | 5 | from . import exceptions 6 | from .balena_auth import request 7 | from .settings import Settings 8 | 9 | TOKEN_KEY = "token" 10 | 11 | 12 | class TwoFactorAuth: 13 | """ 14 | This class implements basic 2FA functionalities for balena python SDK. 15 | 16 | """ 17 | 18 | def __init__(self, settings: Settings): 19 | self.__settings = settings 20 | 21 | def is_enabled(self) -> bool: 22 | """ 23 | Check if two-factor authentication is enabled. 24 | 25 | Returns: 26 | bool: True if enabled. Otherwise False. 27 | 28 | Examples: 29 | >>> balena.twofactor_auth.is_enabled() 30 | """ 31 | try: 32 | token = cast(str, self.__settings.get(TOKEN_KEY)) 33 | token_data = jwt.decode(token, algorithms=["HS256"], options={"verify_signature": False}) 34 | return "twoFactorRequired" in token_data 35 | except jwt.InvalidTokenError: 36 | raise exceptions.UnsupportedFeature() 37 | 38 | def is_passed(self) -> bool: 39 | """ 40 | Check if two-factor authentication challenge was passed. 41 | If the user does not have 2FA enabled, this will be True. 42 | 43 | Returns: 44 | bool: True if passed. Otherwise False. 45 | 46 | Examples: 47 | >>> balena.twofactor_auth.is_passed() 48 | """ 49 | try: 50 | token = cast(str, self.__settings.get(TOKEN_KEY)) 51 | token_data = jwt.decode(token, algorithms=["HS256"], options={"verify_signature": False}) 52 | if "twoFactorRequired" in token_data: 53 | return not token_data["twoFactorRequired"] 54 | return True 55 | except jwt.InvalidTokenError: 56 | raise exceptions.UnsupportedFeature() 57 | 58 | def verify(self, code: str) -> str: 59 | """ 60 | Verifies two factor authentication. 61 | Note that this method not update the token automatically. 62 | You should use balena.twofactor_auth.challenge() when possible, as it takes care of that as well. 63 | 64 | Args: 65 | code (str): two-factor authentication code. 66 | 67 | Returns: 68 | str: session token. 69 | 70 | Examples: 71 | >>> balena.twofactor_auth.verify('123456') 72 | """ 73 | return request( 74 | method="POST", 75 | settings=self.__settings, 76 | path="auth/totp/verify", 77 | body={"code": code}, 78 | ) 79 | 80 | def get_setup_key(self) -> str: 81 | """ 82 | Retrieves a setup key for enabling two factor authentication. 83 | This value should be provided to your 2FA app in order to get a token. 84 | This function only works if you disable two-factor authentication or log in using Auth Token from dashboard. 85 | 86 | Returns: 87 | str: setup key. 88 | 89 | Examples: 90 | >>> balena.twofactor_auth.get_setup_key() 91 | """ 92 | otp_auth_url = request( 93 | method="GET", 94 | settings=self.__settings, 95 | path="auth/totp/setup", 96 | ) 97 | return parse_qs(otp_auth_url)["secret"][0] 98 | 99 | def enable(self, code: str) -> str: 100 | """ 101 | Enable two factor authentication. 102 | 103 | Args: 104 | code (str): two-factor authentication code. 105 | 106 | Returns: 107 | str: session token. 108 | 109 | Examples: 110 | >>> balena.twofactor_auth.enable('123456') 111 | """ 112 | token = self.verify(code) 113 | self.__settings.set(TOKEN_KEY, token) 114 | return token 115 | 116 | def challenge(self, code: str) -> None: 117 | """ 118 | Challenge two-factor authentication. 119 | If your account has two-factor authentication enabled and logging in using credentials, 120 | you need to pass two-factor authentication before being allowed to use other functions. 121 | 122 | Args: 123 | code (str): two-factor authentication code. 124 | 125 | Examples: 126 | # You need to enable two-factor authentication on dashboard first. 127 | # Check if two-factor authentication is passed for current session. 128 | >>> balena.twofactor_auth.is_passed() 129 | False 130 | >>> balena.twofactor_auth.challenge('123456') 131 | # Check again if two-factor authentication is passed for current session. 132 | >>> balena.twofactor_auth.is_passed() 133 | True 134 | """ 135 | token = self.verify(code) 136 | self.__settings.set(TOKEN_KEY, token) 137 | 138 | def disable(self, password: str) -> str: 139 | """ 140 | Disable two factor authentication. 141 | __Note__: Disable will only work when using a token that has 2FA enabled. 142 | 143 | Args: 144 | password (str): password. 145 | 146 | Returns: 147 | str: session token. 148 | 149 | Examples: 150 | >>> balena.twofactor_auth.disable('your_password') 151 | """ 152 | token = request( 153 | method="POST", 154 | settings=self.__settings, 155 | path="auth/totp/disable", 156 | body={"password": password}, 157 | ) 158 | 159 | self.__settings.set(TOKEN_KEY, token) 160 | return token 161 | -------------------------------------------------------------------------------- /balena/types/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Literal, TypedDict, Union 2 | 3 | AnyObject = Dict[str, Any] 4 | ApplicationMembershipRoles = Literal["developer", "operator", "observer"] 5 | 6 | 7 | class ShutdownOptions(TypedDict, total=False): 8 | force: bool 9 | 10 | 11 | class ApplicationInviteOptions(TypedDict, total=False): 12 | invitee: str 13 | roleName: ApplicationMembershipRoles 14 | message: str 15 | 16 | 17 | class ResourceKeyDict(TypedDict, total=False): 18 | user: int 19 | is_member_of__application: int 20 | is_member_of__organization: int 21 | 22 | 23 | ResourceKey = Union[int, ResourceKeyDict] 24 | -------------------------------------------------------------------------------- /balena/utils.py: -------------------------------------------------------------------------------- 1 | import numbers 2 | import re 3 | from collections import defaultdict 4 | from typing import Any, Callable, Dict, Literal, Optional, TypeVar 5 | from .types.models import TypeDevice, TypeDeviceWithServices 6 | 7 | from semver.version import Version 8 | 9 | from .exceptions import RequestError, SupervisorLocked 10 | 11 | SUPERVISOR_LOCKED_STATUS_CODE = 423 12 | 13 | 14 | def is_id(value: Any) -> bool: 15 | """ 16 | Return True, if the input value is a valid ID. False otherwise. 17 | 18 | """ 19 | 20 | if isinstance(value, numbers.Number): 21 | try: 22 | int(value) # type: ignore 23 | return True 24 | except ValueError: 25 | return False 26 | return False 27 | 28 | 29 | def is_full_uuid(value: Any) -> bool: 30 | """ 31 | Return True, if the input value is a valid UUID. False otherwise. 32 | 33 | """ 34 | 35 | if isinstance(value, str): 36 | if len(value) == 32 or len(value) == 62: 37 | try: 38 | str(value) 39 | return True 40 | except ValueError: 41 | return False 42 | return False 43 | 44 | 45 | def compare(a, b): 46 | """ 47 | 48 | Return 1 if a is greater than b, 0 if a is equal to b and -1 otherwise. 49 | 50 | """ 51 | 52 | if a > b: 53 | return 1 54 | 55 | if b > a: 56 | return -1 57 | 58 | return 0 59 | 60 | 61 | known_pine_option_keys = set(["$select", "$expand", "$filter", "$orderby", "$top", "$skip", "$count"]) 62 | 63 | 64 | def merge(defaults, extras=None, replace_selects=False): 65 | if extras is None: 66 | return defaults 67 | 68 | unknown_pine_option = next((key for key in extras if key not in known_pine_option_keys), None) 69 | if unknown_pine_option is not None: 70 | raise ValueError(f"Unknown pine option: {unknown_pine_option}") 71 | 72 | result = {**defaults} 73 | 74 | if extras.get("$select"): 75 | extra_select = ( 76 | extras["$select"] 77 | if isinstance(extras["$select"], list) or extras["$select"] == "*" 78 | else [extras["$select"]] 79 | ) 80 | if replace_selects: 81 | result["$select"] = extra_select 82 | elif extra_select == "*": 83 | result["$select"] = "*" 84 | else: 85 | existing_select = result.get("$select") 86 | existing_select = [existing_select] if not isinstance(existing_select, list) else existing_select 87 | extra_select = extra_select or [] 88 | merged_select = existing_select + extra_select 89 | result["$select"] = list(set(merged_select)) 90 | 91 | for key in known_pine_option_keys: 92 | if key in extras: 93 | result[key] = extras[key] 94 | 95 | if extras.get("$filter"): 96 | result["$filter"] = ( 97 | {"$and": [defaults.get("$filter", {}), extras["$filter"]]} if defaults.get("$filter") else extras["$filter"] 98 | ) 99 | 100 | if extras.get("$expand"): 101 | result["$expand"] = merge_expand_options(defaults.get("$expand"), extras["$expand"], replace_selects) 102 | 103 | return result 104 | 105 | 106 | def merge_expand_options(default_expand=None, extra_expand=None, replace_selects=False): 107 | if default_expand is None: 108 | return extra_expand 109 | 110 | default_expand = convert_expand_to_object(default_expand, True) 111 | extra_expand = convert_expand_to_object(extra_expand) 112 | 113 | for expand_key in extra_expand: 114 | default_expand[expand_key] = merge( 115 | default_expand.get(expand_key, {}), extra_expand[expand_key], replace_selects 116 | ) 117 | 118 | return default_expand 119 | 120 | 121 | def convert_expand_to_object(expand_option, clone_if_needed=False): 122 | if expand_option is None: 123 | return {} 124 | 125 | if isinstance(expand_option, str): 126 | return {expand_option: {}} 127 | 128 | if isinstance(expand_option, list): 129 | return {k: v for d in expand_option for k, v in (d.items() if isinstance(d, dict) else {d: {}}.items())} 130 | 131 | unknown_pine_option = next((key for key in expand_option if key not in known_pine_option_keys), None) 132 | if unknown_pine_option is not None: 133 | raise ValueError(f"Unknown pine expand options: {unknown_pine_option}") 134 | 135 | return {**expand_option} if clone_if_needed else expand_option 136 | 137 | 138 | def get_current_service_details_pine_expand( 139 | expand_release: bool, 140 | ) -> Dict[str, Any]: 141 | return { 142 | "image_install": { 143 | "$select": ["id", "download_progress", "status", "install_date"], 144 | "$filter": { 145 | "status": { 146 | "$ne": "deleted", 147 | }, 148 | }, 149 | "$expand": { 150 | "image": { 151 | "$select": ["id"], 152 | "$expand": { 153 | "is_a_build_of__service": { 154 | "$select": ["id", "service_name"], 155 | }, 156 | }, 157 | }, 158 | **( 159 | { 160 | "is_provided_by__release": { 161 | "$select": ["id", "commit", "raw_version"], 162 | }, 163 | } 164 | if expand_release 165 | else {} 166 | ), 167 | }, 168 | }, 169 | } 170 | 171 | 172 | def get_single_install_summary(raw_data: Any) -> Any: 173 | # TODO: Please compare me to node-sdk version 174 | """ 175 | Builds summary data for an image install 176 | """ 177 | 178 | image = raw_data["image"][0] 179 | service = image["is_a_build_of__service"][0] 180 | release = None 181 | 182 | if "is_provided_by__release" in raw_data: 183 | release = raw_data["is_provided_by__release"][0] 184 | 185 | install = { 186 | "service_name": service["service_name"], 187 | "image_id": image["id"], 188 | "service_id": service["id"], 189 | } 190 | 191 | if release: 192 | install["commit"] = release["commit"] 193 | 194 | raw_data.pop("is_provided_by__release", None) 195 | raw_data.pop("image", None) 196 | install.update(raw_data) 197 | 198 | return install 199 | 200 | 201 | def generate_current_service_details(raw_device: TypeDevice) -> TypeDeviceWithServices: 202 | # TODO: Please compare me to node-sdk version 203 | grouped_services = defaultdict(list) 204 | 205 | for obj in [get_single_install_summary(i) for i in raw_device.get("image_install", [])]: # type: ignore 206 | grouped_services[obj.pop("service_name", None)].append(obj) 207 | 208 | raw_device["current_services"] = dict(grouped_services) # type: ignore 209 | raw_device.pop("image_install", None) # type: ignore 210 | 211 | return raw_device # type: ignore 212 | 213 | 214 | def is_provisioned(device: Any) -> bool: 215 | return ( 216 | device.get("supervisor_version") is not None 217 | and len(device.get("supervisor_version")) > 0 218 | and device.get("last_connectivity_event") is not None 219 | ) 220 | 221 | 222 | T = TypeVar("T") 223 | 224 | 225 | def with_supervisor_locked_error(fn: Callable[[], T]) -> T: 226 | try: 227 | return fn() 228 | except RequestError as e: 229 | if e.status_code == SUPERVISOR_LOCKED_STATUS_CODE: 230 | raise SupervisorLocked() 231 | raise e 232 | 233 | 234 | def normalize_balena_semver(os_version: str) -> str: 235 | """ 236 | safeSemver and trimOsText from resin-semver in Python. 237 | ref: https://github.com/balena-io-modules/resin-semver/blob/master/src/index.js#L5-L24 238 | 239 | """ 240 | 241 | # fix major.minor.patch.rev to use rev as build metadata 242 | version = re.sub(r"(\.[0-9]+)\.rev", r"\1+rev", os_version) 243 | # fix major.minor.patch.prod to be treat .dev & .prod as build metadata 244 | version = re.sub(r"([0-9]+\.[0-9]+\.[0-9]+)\.(dev|prod)", r"\1+\2", version) 245 | # if there are no build metadata, then treat the parenthesized value as one 246 | version = re.sub( 247 | r"([0-9]+\.[0-9]+\.[0-9]+(?:[-\.][0-9a-z]+)*) \(([0-9a-z]+)\)", 248 | r"\1+\2", 249 | version, 250 | ) 251 | # if there are build metadata, then treat the parenthesized value as point value 252 | version = re.sub( 253 | r"([0-9]+\.[0-9]+\.[0-9]+(?:[-\+\.][0-9a-z]+)*) \(([0-9a-z]+)\)", 254 | r"\1.\2", 255 | version, 256 | ) 257 | # Remove "Resin OS" and "Balena OS" text 258 | version = re.sub(r"(resin|balena)\s*os\s*", "", version, flags=re.IGNORECASE) 259 | # remove optional versioning, eg "(prod)", "(dev)" 260 | version = re.sub(r"\s+\(\w+\)$", "", version) 261 | # remove "v" prefix 262 | version = re.sub(r"^v", "", version) 263 | return version 264 | 265 | 266 | def ensure_version_compatibility( 267 | version: str, 268 | min_version: str, 269 | version_type: Literal["supervisor", "host OS"], 270 | ) -> None: 271 | version = normalize_balena_semver(version) 272 | 273 | if version and Version.parse(version) < Version.parse(min_version): 274 | raise ValueError(f"Incompatible {version_type} version: {version} - must be >= {min_version}") 275 | 276 | 277 | def get_device_os_semver_with_variant(os_version: str, os_variant: Optional[str] = None): 278 | if not os_version: 279 | return None 280 | 281 | version_info = Version.parse(normalize_balena_semver(os_version)) 282 | 283 | if not version_info: 284 | return os_version 285 | 286 | tmp = [] 287 | if version_info.prerelease: 288 | tmp = version_info.prerelease.split(".") 289 | if version_info.build: 290 | tmp = tmp + version_info.build.split(".") 291 | 292 | builds = [] 293 | pre_releases = [] 294 | 295 | if version_info.build: 296 | builds = version_info.build.split(".") 297 | 298 | if version_info.prerelease: 299 | pre_releases = version_info.prerelease.split(".") 300 | 301 | if os_variant and os_variant not in pre_releases and os_variant not in builds: 302 | builds.append(os_variant) 303 | 304 | return str( 305 | Version( 306 | version_info.major, 307 | version_info.minor, 308 | version_info.patch, 309 | version_info.prerelease, 310 | ".".join(builds), 311 | ) 312 | ) 313 | -------------------------------------------------------------------------------- /docs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/balena-sdk-python/7f92c104904383f36086359268f291b414420591/docs/__init__.py -------------------------------------------------------------------------------- /docs_generator.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | 4 | 5 | balena = importlib.import_module("balena", ".") 6 | doc2md = importlib.import_module("docs.doc2md", ".") 7 | 8 | TOC_ROOT = 0 9 | TOC_L1 = 1 10 | TOC_L2 = 2 11 | TOC_L3 = 3 12 | TOC_L4 = 4 13 | 14 | Table_Of_Content = [ 15 | ("balena", TOC_ROOT, None), 16 | (".models", TOC_L1, "Models"), 17 | (".application", TOC_L2, balena.models.Application), 18 | (".tags", TOC_L3, balena.models.application.ApplicationTag), 19 | (".config_var", TOC_L3, balena.models.application.ApplicationConfigVariable), 20 | (".env_var", TOC_L3, balena.models.application.ApplicationEnvVariable), 21 | (".build_var", TOC_L3, balena.models.application.BuildEnvVariable), 22 | (".membership", TOC_L3, balena.models.application.ApplicationMembership), 23 | (".invite", TOC_L3, balena.models.application.ApplicationInvite), 24 | (".device", TOC_L2, balena.models.Device), 25 | (".tags", TOC_L3, balena.models.device.DeviceTag), 26 | (".config_var", TOC_L3, balena.models.device.DeviceConfigVariable), 27 | (".env_var", TOC_L3, balena.models.device.DeviceEnvVariable), 28 | (".service_var", TOC_L3, balena.models.device.DeviceServiceEnvVariable), 29 | (".history", TOC_L3, balena.models.device.DeviceHistory), 30 | (".device_type", TOC_L2, balena.models.DeviceType), 31 | (".api_key", TOC_L2, balena.models.ApiKey), 32 | (".key", TOC_L2, balena.models.Key), 33 | (".organization", TOC_L2, balena.models.Organization), 34 | (".membership", TOC_L3, balena.models.organization.OrganizationMembership), 35 | (".tags", TOC_L4, balena.models.organization.OrganizationMembershipTag), 36 | (".invite", TOC_L3, balena.models.organization.OrganizationInvite), 37 | (".os", TOC_L2, balena.models.DeviceOs), 38 | (".config", TOC_L2, balena.models.Config), 39 | (".release", TOC_L2, balena.models.Release), 40 | (".tags", TOC_L3, balena.models.release.ReleaseTag), 41 | (".service", TOC_L2, balena.models.Service), 42 | (".var", TOC_L3, balena.models.service.ServiceEnvVariable), 43 | (".image", TOC_L2, balena.models.Image), 44 | (".auth", TOC_L1, balena.auth.Auth), 45 | (".two_factor", TOC_L2, balena.twofactor_auth.TwoFactorAuth), 46 | (".logs", TOC_L1, balena.logs.Logs), 47 | (".settings", TOC_L1, type(balena.settings)), 48 | (".types", TOC_L1, balena.types), 49 | ] 50 | 51 | FUNCTION_NAME_TEMPLATE = "{f_name}({f_args})" 52 | 53 | 54 | def print_newline(): 55 | """ 56 | Add new line 57 | 58 | """ 59 | print("") 60 | 61 | 62 | def print_functions(baseclass, model_hints): 63 | for func_name, blah in inspect.getmembers(baseclass, predicate=inspect.isfunction): 64 | if func_name != "__init__" and not func_name.startswith("_"): 65 | func = getattr(baseclass, func_name) 66 | print(f'\n') 67 | 68 | print_name, func_output_hint = doc2md.make_function_name(func, func_name) 69 | 70 | hint_ref = None 71 | for model_hint in model_hints: 72 | # if the func_output_hint includes the name of a type, create the reference for that type 73 | # for example, when child_hint is List[AType] we want it to be able to navigate to AType ref 74 | if model_hint in func_output_hint: 75 | hint_ref = model_hint.lower() 76 | 77 | if hint_ref: 78 | print_name = f"{print_name} ⇒ [{func_output_hint}](#{hint_ref})" 79 | else: 80 | print_name = f"{print_name} ⇒ {func_output_hint}" 81 | 82 | print(doc2md.doc2md(func.__doc__, print_name, type=1)) 83 | 84 | 85 | def main(): 86 | hints = [] 87 | model_hints = inspect.getmembers(balena.types.models) 88 | for type_tuple in model_hints: 89 | if not type_tuple[0].startswith("__") and not str(type_tuple[1]).startswith("typing"): 90 | hints.append(type_tuple[0]) 91 | 92 | print(doc2md.doc2md(balena.__doc__, "Balena Python SDK", type=0)) 93 | print_newline() 94 | print("## Table of Contents") 95 | print(doc2md.make_toc(Table_Of_Content, hints)) 96 | print_newline() 97 | print(doc2md.doc2md(balena.models.__doc__, "Models", type=0)) 98 | print(doc2md.doc2md(balena.models.application.Application.__doc__, "Application", type=0)) 99 | print_functions(balena.models.application.Application, hints) 100 | print( 101 | doc2md.doc2md( 102 | balena.models.application.ApplicationTag.__doc__, 103 | "ApplicationTag", 104 | type=0, 105 | ), 106 | ) 107 | print_functions(balena.models.application.ApplicationTag, hints) 108 | print( 109 | doc2md.doc2md( 110 | balena.models.application.ApplicationConfigVariable.__doc__, 111 | "ApplicationConfigVariable", 112 | type=0, 113 | ) 114 | ) 115 | print_functions(balena.models.application.ApplicationConfigVariable, hints) 116 | print( 117 | doc2md.doc2md( 118 | balena.models.application.ApplicationEnvVariable.__doc__, 119 | "ApplicationEnvVariable", 120 | type=0, 121 | ) 122 | ) 123 | print_functions(balena.models.application.ApplicationEnvVariable, hints) 124 | print( 125 | doc2md.doc2md( 126 | balena.models.application.BuildEnvVariable.__doc__, 127 | "BuildEnvVariable", 128 | type=0, 129 | ) 130 | ) 131 | print_functions(balena.models.application.BuildEnvVariable, hints) 132 | print( 133 | doc2md.doc2md( 134 | balena.models.application.ApplicationMembership.__doc__, 135 | "ApplicationMembership", 136 | type=0, 137 | ) 138 | ) 139 | print_functions(balena.models.application.ApplicationMembership, hints) 140 | print( 141 | doc2md.doc2md( 142 | balena.models.application.ApplicationInvite.__doc__, 143 | "ApplicationInvite", 144 | type=0, 145 | ) 146 | ) 147 | print_functions(balena.models.application.ApplicationInvite, hints) 148 | print(doc2md.doc2md(balena.models.device.Device.__doc__, "Device", type=0)) 149 | print_functions(balena.models.device.Device, hints) 150 | 151 | print( 152 | doc2md.doc2md( 153 | balena.models.device.DeviceTag.__doc__, 154 | "DeviceTag", 155 | type=0, 156 | ) 157 | ) 158 | print_functions(balena.models.device.DeviceTag, hints) 159 | 160 | print( 161 | doc2md.doc2md( 162 | balena.models.device.DeviceConfigVariable.__doc__, 163 | "DeviceConfigVariable", 164 | type=0, 165 | ) 166 | ) 167 | print_functions(balena.models.device.DeviceConfigVariable, hints) 168 | 169 | print( 170 | doc2md.doc2md( 171 | balena.models.device.DeviceEnvVariable.__doc__, 172 | "DeviceEnvVariable", 173 | type=0, 174 | ) 175 | ) 176 | print_functions(balena.models.device.DeviceEnvVariable, hints) 177 | 178 | print( 179 | doc2md.doc2md( 180 | balena.models.device.DeviceServiceEnvVariable.__doc__, 181 | "DeviceServiceEnvVariable", 182 | type=0, 183 | ) 184 | ) 185 | print_functions(balena.models.device.DeviceServiceEnvVariable, hints) 186 | 187 | print( 188 | doc2md.doc2md( 189 | balena.models.device.DeviceHistory.__doc__, 190 | "DeviceHistory", 191 | ) 192 | ) 193 | 194 | print_functions(balena.models.device.DeviceHistory, hints) 195 | 196 | print(doc2md.doc2md(balena.models.device_type.DeviceType.__doc__, "DeviceType", type=0)) 197 | print_functions(balena.models.device_type.DeviceType, hints) 198 | 199 | print(doc2md.doc2md(balena.models.api_key.ApiKey.__doc__, "ApiKey", type=0)) 200 | print_functions(balena.models.api_key.ApiKey, hints) 201 | 202 | print(doc2md.doc2md(balena.models.key.Key.__doc__, "Key", type=0)) 203 | print_functions(balena.models.key.Key, hints) 204 | 205 | print(doc2md.doc2md(balena.models.organization.Organization.__doc__, "Organization", type=0)) 206 | print_functions(balena.models.organization.Organization, hints) 207 | 208 | print( 209 | doc2md.doc2md( 210 | balena.models.organization.OrganizationMembership.__doc__, 211 | "OrganizationMembership", 212 | type=0, 213 | ) 214 | ) 215 | print_functions(balena.models.organization.OrganizationMembership, hints) 216 | 217 | print( 218 | doc2md.doc2md( 219 | balena.models.organization.OrganizationMembershipTag.__doc__, 220 | "OrganizationMembershipTag", 221 | type=0, 222 | ) 223 | ) 224 | print_functions(balena.models.organization.OrganizationMembershipTag, hints) 225 | 226 | print( 227 | doc2md.doc2md( 228 | balena.models.organization.OrganizationInvite.__doc__, 229 | "OrganizationInvite", 230 | type=0, 231 | ) 232 | ) 233 | print_functions(balena.models.organization.OrganizationInvite, hints) 234 | 235 | print(doc2md.doc2md(balena.models.os.DeviceOs.__doc__, "DeviceOs", type=0)) 236 | print_functions(balena.models.os.DeviceOs, hints) 237 | 238 | print(doc2md.doc2md(balena.models.config.Config.__doc__, "Config", type=0)) 239 | print_functions(balena.models.config.Config, hints) 240 | 241 | print(doc2md.doc2md(balena.models.release.Release.__doc__, "Release", type=0)) 242 | print_functions(balena.models.release.Release, hints) 243 | 244 | print(doc2md.doc2md(balena.models.release.ReleaseTag.__doc__, "ReleaseTag", type=0)) 245 | print_functions(balena.models.release.ReleaseTag, hints) 246 | 247 | print(doc2md.doc2md(balena.models.Service.__doc__, "Service", type=0)) 248 | print_functions(balena.models.Service, hints) 249 | 250 | print( 251 | doc2md.doc2md( 252 | balena.models.service.ServiceEnvVariable.__doc__, 253 | "ServiceEnvVariable", 254 | type=0, 255 | ) 256 | ) 257 | print_functions(balena.models.service.ServiceEnvVariable, hints) 258 | 259 | print(doc2md.doc2md(balena.models.Image.__doc__, "Image", type=0)) 260 | print_functions(balena.models.Image, hints) 261 | 262 | print(doc2md.doc2md(balena.auth.Auth.__doc__, "Auth", type=0)) 263 | print_functions(balena.auth.Auth, hints) 264 | 265 | print(doc2md.doc2md(balena.twofactor_auth.TwoFactorAuth.__doc__, "TwoFactorAuth", type=0)) 266 | print_functions(balena.twofactor_auth.TwoFactorAuth, hints) 267 | 268 | print(doc2md.doc2md(balena.logs.Logs.__doc__, "Logs", type=0)) 269 | print_functions(balena.logs.Logs, hints) 270 | 271 | print(doc2md.doc2md(type(balena.settings).__doc__, "Settings", type=0)) 272 | print_functions(type(balena.settings), hints) 273 | 274 | doc2md.print_types(balena.types.models) 275 | 276 | 277 | if __name__ == "__main__": 278 | main() 279 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "balena-sdk-python", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "balena-sdk" 3 | version = "15.1.4" 4 | description = "" 5 | authors = ["Balena "] 6 | license = "Apache License 2.0" 7 | readme = "README.md" 8 | packages = [{include = "balena"}] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.8.1" 12 | PyJWT = ">=2.0.0" 13 | requests = ">=2.19.1" 14 | pyOpenSSL = ">=18.0.0" 15 | Twisted = ">=18.7.0" 16 | service-identity = "*" 17 | semver = "^3.0.0" 18 | pine-client= "*" 19 | typing_extensions = "*" 20 | deprecated = "^1.2.13" 21 | ratelimit = "^2.2.1" 22 | 23 | [tool.poetry.dev-dependencies] 24 | black = {version = "*", python = ">=3.8.1"} 25 | pydocstyle = "*" 26 | flake8 = "*" 27 | pytest= "*" 28 | 29 | [tool.pytest.ini_options] 30 | # Tests are run via the custom action in .github/actions/test/action.yml 31 | addopts = "-s" 32 | python_files = ["skip.py"] 33 | 34 | 35 | [build-system] 36 | requires = ["poetry-core"] 37 | build-backend = "poetry.core.masonry.api" 38 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/balena-sdk-python/7f92c104904383f36086359268f291b414420591/tests/__init__.py -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/balena-sdk-python/7f92c104904383f36086359268f291b414420591/tests/functional/__init__.py -------------------------------------------------------------------------------- /tests/functional/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/balena-sdk-python/7f92c104904383f36086359268f291b414420591/tests/functional/models/__init__.py -------------------------------------------------------------------------------- /tests/functional/models/test-data/balena-python-sdk-test-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/balena-sdk-python/7f92c104904383f36086359268f291b414420591/tests/functional/models/test-data/balena-python-sdk-test-logo.png -------------------------------------------------------------------------------- /tests/functional/models/test_api_key.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import datetime 3 | 4 | from tests.helper import TestHelper 5 | 6 | 7 | class TestApiKey(unittest.TestCase): 8 | @classmethod 9 | def setUpClass(cls): 10 | cls.helper = TestHelper() 11 | cls.balena = cls.helper.balena 12 | cls.helper.wipe_application() 13 | cls.helper.reset_user() 14 | cls.app_info = cls.helper.create_multicontainer_app() 15 | cls.device = cls.balena.models.device.register( 16 | cls.app_info["app"]["id"], cls.balena.models.device.generate_uuid() 17 | ) 18 | 19 | cls.balena.models.application.generate_provisioning_key(cls.app_info["app"]["id"], "provisionTestKey") 20 | cls.balena.models.device.generate_device_key(cls.device["uuid"], "deviceTestKey") 21 | 22 | @classmethod 23 | def tearDownClass(cls): 24 | cls.helper.wipe_organization() 25 | cls.helper.wipe_application() 26 | 27 | def __assert_matching_keys(self, expected_keys, actual_keys): 28 | expected_set = set((d["name"], d["description"]) for d in expected_keys) 29 | actual_set = set((d["name"], d["description"]) for d in actual_keys) 30 | 31 | self.assertEqual(expected_set, actual_set) 32 | for key in actual_keys: 33 | self.assertIsInstance(key["id"], int) 34 | self.assertIsInstance(key["created_at"], str) 35 | 36 | def test_01_create_api_key(self): 37 | key = self.balena.models.api_key.create("apiKey1") 38 | self.assertIsInstance(key, str) 39 | 40 | def test_02_create_api_key_wth_description(self): 41 | key = self.balena.models.api_key.create("apiKey2", "apiKey2Description") 42 | self.assertIsInstance(key, str) 43 | 44 | def test_03_should_be_able_to_create_key_with_expiry_date(self): 45 | tomorrow = (datetime.datetime.utcnow() + datetime.timedelta(days=1)).isoformat() 46 | key = self.balena.models.api_key.create( 47 | "apiKeyWithExpiry", 48 | "apiKeyDescription", 49 | tomorrow, 50 | ) 51 | self.assertIsInstance(key, str) 52 | user_keys = self.balena.models.api_key.get_all_named_user_api_keys() 53 | expiry_key = [key for key in user_keys if key["name"] == "apiKeyWithExpiry"] 54 | self.assertEqual(expiry_key[0]["expiry_date"][0:10], tomorrow[0:10]) # type: ignore 55 | 56 | def test_04_get_all_named_user_api_keys(self): 57 | keys = self.balena.models.api_key.get_all_named_user_api_keys() 58 | TestApiKey.named_user_api_key = keys[0] 59 | self.__assert_matching_keys( 60 | [ 61 | { 62 | "name": "apiKey1", 63 | "description": None, 64 | }, 65 | { 66 | "name": "apiKey2", 67 | "description": "apiKey2Description", 68 | }, 69 | { 70 | "name": "apiKeyWithExpiry", 71 | "description": "apiKeyDescription", 72 | }, 73 | ], 74 | keys, 75 | ) 76 | 77 | def test_05_get_provisioning_api_keys_by_application_for_non_existing(self): 78 | with self.assertRaises(self.helper.balena_exceptions.ApplicationNotFound): 79 | self.balena.models.api_key.get_provisioning_api_keys_by_application( 80 | "nonExistentOrganization/nonExistentApp" 81 | ) 82 | 83 | def test_06_get_provisioning_api_keys_by_application(self): 84 | keys = self.balena.models.api_key.get_provisioning_api_keys_by_application(self.app_info["app"]["id"]) 85 | provisioning_keys_names = set(map(lambda k: k["name"], keys)) 86 | self.assertIn("provisionTestKey", provisioning_keys_names) 87 | 88 | def test_07_get_device_api_keys_by_device_for_non_existing(self): 89 | with self.assertRaises(self.helper.balena_exceptions.DeviceNotFound): 90 | self.balena.models.api_key.get_device_api_keys_by_device("nonexistentuuid") 91 | 92 | def test_08_get_device_api_keys_by_device_for_non_existing(self): 93 | keys = self.balena.models.api_key.get_device_api_keys_by_device(self.device["uuid"]) 94 | device_keys_names = set(map(lambda k: k["name"], keys)) 95 | self.assertIn("deviceTestKey", device_keys_names) 96 | 97 | def test_09_should_be_able_to_update_a_key_name(self): 98 | app_key_id = TestApiKey.named_user_api_key["id"] 99 | self.balena.models.api_key.update(app_key_id, {"name": "updatedApiKeyName"}) 100 | 101 | keys = self.balena.models.api_key.get_all_named_user_api_keys() 102 | names = set(map(lambda k: k["name"], keys)) 103 | self.assertIn("updatedApiKeyName", names) 104 | 105 | def test_10_should_be_able_to_update_a_key_descr(self): 106 | app_key_id = TestApiKey.named_user_api_key["id"] 107 | self.balena.models.api_key.update(app_key_id, {"description": "updatedApiKeyDescription"}) 108 | 109 | keys = self.balena.models.api_key.get_all_named_user_api_keys() 110 | new_description = [k for k in keys if k["name"] == "updatedApiKeyName"][0]["description"] 111 | self.assertEqual(new_description, "updatedApiKeyDescription") 112 | 113 | def test_11_update_to_set_null_to_key_descr(self): 114 | app_key_id = TestApiKey.named_user_api_key["id"] 115 | self.balena.models.api_key.update(app_key_id, {"description": None}) 116 | 117 | keys = self.balena.models.api_key.get_all_named_user_api_keys() 118 | new_description = [k for k in keys if k["name"] == "updatedApiKeyName"][0]["description"] 119 | self.assertIsNone(new_description) 120 | 121 | def test_12_update_to_set_empty_str_to_key_descr(self): 122 | app_key_id = TestApiKey.named_user_api_key["id"] 123 | self.balena.models.api_key.update(app_key_id, {"description": ""}) 124 | 125 | keys = self.balena.models.api_key.get_all_named_user_api_keys() 126 | new_description = [k for k in keys if k["name"] == "updatedApiKeyName"][0]["description"] 127 | self.assertEqual(new_description, "") 128 | 129 | def test_13_revoke(self): 130 | app_key_id = TestApiKey.named_user_api_key["id"] 131 | self.balena.models.api_key.revoke(app_key_id) 132 | keys = self.balena.models.api_key.get_all_named_user_api_keys() 133 | ids = set(map(lambda k: k["id"], keys)) 134 | 135 | self.assertNotIn(app_key_id, ids) 136 | 137 | 138 | if __name__ == "__main__": 139 | unittest.main() 140 | -------------------------------------------------------------------------------- /tests/functional/models/test_application.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import datetime 3 | 4 | from tests.helper import TestHelper 5 | 6 | 7 | class TestApplication(unittest.TestCase): 8 | @classmethod 9 | def setUpClass(cls): 10 | cls.helper = TestHelper() 11 | cls.balena = cls.helper.balena 12 | cls.org_handle = cls.helper.default_organization["handle"] 13 | cls.org_id = cls.helper.default_organization["id"] 14 | cls.app_slug = f"{cls.org_handle}/FooBar" 15 | cls.helper.wipe_application() 16 | 17 | @classmethod 18 | def tearDownClass(cls): 19 | cls.helper.wipe_application() 20 | cls.helper.wipe_organization() 21 | 22 | def test_01_get_all_empty(self): 23 | self.assertEqual(self.balena.models.application.get_all(), []) 24 | 25 | def test_02_has_any_empty(self): 26 | self.assertFalse(self.balena.models.application.has_any()) 27 | 28 | def test_03_create(self): 29 | with self.assertRaises(self.helper.balena_exceptions.InvalidDeviceType): 30 | self.balena.models.application.create("FooBar", "Foo", self.helper.default_organization["id"]) 31 | 32 | with self.assertRaises(Exception) as cm: 33 | self.balena.models.application.create("Fo", "raspberry-pi2", self.helper.default_organization["id"]) 34 | self.assertIn( 35 | "It is necessary that each application has an app name that has a Length" 36 | " (Type) that is greater than or equal to 4 and is less than or equal" 37 | " to 100", 38 | cm.exception.message, # type: ignore 39 | ) 40 | 41 | TestApplication.app = self.balena.models.application.create( 42 | "FooBar", "raspberry-pi2", self.helper.default_organization["id"] 43 | ) 44 | self.assertEqual(TestApplication.app["app_name"], "FooBar") 45 | 46 | app1 = self.balena.models.application.create( 47 | "FooBar1", "raspberrypi3", self.helper.default_organization["id"], "block" 48 | ) 49 | self.assertEqual(app1["app_name"], "FooBar1") 50 | self.assertEqual(app1["is_of__class"], "block") 51 | 52 | def test_04_get_all(self): 53 | all_apps = self.balena.models.application.get_all() 54 | self.assertEqual(len(all_apps), 2) 55 | self.assertNotEqual(all_apps[0]["app_name"], all_apps[1]["app_name"]) 56 | 57 | def test_05_get(self): 58 | with self.assertRaises(self.helper.balena_exceptions.ApplicationNotFound): 59 | self.balena.models.application.get("AppNotExist") 60 | 61 | self.assertEqual(self.balena.models.application.get(self.app_slug)["app_name"], "FooBar") 62 | 63 | def test_06_get_all_by_organization(self): 64 | with self.assertRaises(self.helper.balena_exceptions.OrganizationNotFound): 65 | self.balena.models.application.get_all_by_organization("OrgNotExist") 66 | 67 | self.assertEqual( 68 | self.balena.models.application.get_all_by_organization(self.org_handle)[0]["app_name"], "FooBar" 69 | ) 70 | 71 | self.assertEqual( 72 | self.balena.models.application.get_all_by_organization(self.org_id)[0]["app_name"], "FooBar" 73 | ) 74 | 75 | def test_07_has(self): 76 | self.assertFalse(self.balena.models.application.has("AppNotExist")) 77 | self.assertTrue(self.balena.models.application.has(self.app_slug)) 78 | 79 | def test_08_has_any(self): 80 | self.assertTrue(self.balena.models.application.has_any()) 81 | 82 | def test_09_get_by_id(self): 83 | with self.assertRaises(self.helper.balena_exceptions.ApplicationNotFound): 84 | self.balena.models.application.get(1) 85 | 86 | app = TestApplication.app 87 | self.assertEqual(self.balena.models.application.get(app["id"])["id"], app["id"]) 88 | 89 | def test_10_remove(self): 90 | self.assertEqual(len(self.balena.models.application.get_all()), 2) 91 | self.balena.models.application.remove(f"{self.org_handle}/FooBar1") 92 | self.assertEqual(len(self.balena.models.application.get_all()), 1) 93 | 94 | def test_11_generate_provisioning_key(self): 95 | with self.assertRaises(self.helper.balena_exceptions.ApplicationNotFound): 96 | self.balena.models.application.generate_provisioning_key("app/notexists") 97 | 98 | app = TestApplication.app 99 | key = self.balena.models.application.generate_provisioning_key(app["id"]) 100 | self.assertEqual(len(key), 32) 101 | 102 | key = self.balena.models.application.generate_provisioning_key( 103 | app["id"], "FooBar Key", "FooBar Key Description" 104 | ) 105 | self.assertEqual(len(key), 32) 106 | 107 | def test_14_enable_device_urls(self): 108 | app = TestApplication.app 109 | device = self.balena.models.device.register(app["id"], self.balena.models.device.generate_uuid()) 110 | TestApplication.device = device 111 | self.balena.models.application.enable_device_urls(app["id"]) 112 | self.assertTrue(self.balena.models.device.has_device_url(device["uuid"])) 113 | 114 | def test_15_disable_device_urls(self): 115 | app = TestApplication.app 116 | device = TestApplication.device 117 | self.balena.models.application.enable_device_urls(app["id"]) 118 | self.balena.models.application.disable_device_urls(app["id"]) 119 | self.assertFalse(self.balena.models.device.has_device_url(device["uuid"])) 120 | 121 | def test_16_grant_support_access(self): 122 | app = TestApplication.app 123 | expiry_timestamp = int(self.helper.datetime_to_epoch_ms(datetime.utcnow()) - 10000) 124 | with self.assertRaises(self.helper.balena_exceptions.InvalidParameter): 125 | self.balena.models.application.grant_support_access(app["id"], expiry_timestamp) 126 | 127 | expiry_time = int(self.helper.datetime_to_epoch_ms(datetime.utcnow()) + 3600 * 1000) 128 | self.balena.models.application.grant_support_access(app["id"], expiry_time) 129 | 130 | support_date = datetime.strptime( 131 | self.balena.models.application.get(self.app_slug)["is_accessible_by_support_until__date"], 132 | "%Y-%m-%dT%H:%M:%S.%fZ", 133 | ) 134 | self.assertEqual(self.helper.datetime_to_epoch_ms(support_date), expiry_time) 135 | 136 | def test_17_revoke_support_access(self): 137 | app = TestApplication.app 138 | expiry_time = int((datetime.utcnow() - datetime.utcfromtimestamp(0)).total_seconds() * 1000 + 3600 * 1000) 139 | self.balena.models.application.grant_support_access(app["id"], expiry_time) 140 | self.balena.models.application.revoke_support_access(app["id"]) 141 | 142 | app = self.balena.models.application.get(self.app_slug) 143 | self.assertIsNone(app["is_accessible_by_support_until__date"]) 144 | 145 | def test_18_will_track_new_releases(self): 146 | app_info = self.helper.create_app_with_releases(app_name="FooBarWithReleases") 147 | TestApplication.app_info = app_info 148 | self.assertTrue(self.balena.models.application.will_track_new_releases(app_info["app"]["id"])) 149 | 150 | def test_19_get_target_release_hash(self): 151 | app_info = TestApplication.app_info 152 | self.assertEqual( 153 | self.balena.models.application.get_target_release_hash(app_info["app"]["id"]), 154 | app_info["current_release"]["commit"], 155 | ) 156 | 157 | def test_21_pin_to_release(self): 158 | app_info = TestApplication.app_info 159 | self.balena.models.application.pin_to_release(app_info["app"]["id"], app_info["old_release"]["commit"]) 160 | self.assertEqual( 161 | self.balena.models.application.get_target_release_hash(app_info["app"]["id"]), 162 | app_info["old_release"]["commit"], 163 | ) 164 | self.assertFalse(self.balena.models.application.will_track_new_releases(app_info["app"]["id"])) 165 | self.assertFalse(self.balena.models.application.is_tracking_latest_release(app_info["app"]["id"])) 166 | 167 | def test_22_track_latest_release(self): 168 | app_info = TestApplication.app_info 169 | self.balena.models.application.pin_to_release(app_info["app"]["id"], app_info["old_release"]["commit"]) 170 | self.assertEqual( 171 | self.balena.models.application.get_target_release_hash(app_info["app"]["id"]), 172 | app_info["old_release"]["commit"], 173 | ) 174 | self.assertFalse(self.balena.models.application.will_track_new_releases(app_info["app"]["id"])) 175 | self.assertFalse(self.balena.models.application.is_tracking_latest_release(app_info["app"]["id"])) 176 | self.balena.models.application.track_latest_release(app_info["app"]["id"]) 177 | self.assertEqual( 178 | self.balena.models.application.get_target_release_hash(app_info["app"]["id"]), 179 | app_info["current_release"]["commit"], 180 | ) 181 | self.assertTrue(self.balena.models.application.will_track_new_releases(app_info["app"]["id"])) 182 | self.assertTrue(self.balena.models.application.is_tracking_latest_release(app_info["app"]["id"])) 183 | 184 | def test_23_get_dashboard_url(self): 185 | with self.assertRaises(self.helper.balena_exceptions.InvalidParameter): 186 | self.balena.models.application.get_dashboard_url("1476418a") # type: ignore 187 | 188 | url = self.balena.models.application.get_dashboard_url("1476418") # type: ignore 189 | self.assertEqual(url, "https://dashboard.balena-cloud.com/apps/1476418") 190 | 191 | def test_24_invite_get_all_empty(self): 192 | invite_list = self.balena.models.application.invite.get_all() 193 | self.assertEqual(0, len(invite_list)) 194 | 195 | def test_25_invite_create(self): 196 | app = TestApplication.app 197 | invite = self.balena.models.application.invite.create( 198 | app["id"], 199 | {"invitee": self.helper.credentials["email"], "roleName": "developer", "message": "Python SDK test invite"}, 200 | ) 201 | TestApplication.invite = invite 202 | self.assertEqual(invite["message"], "Python SDK test invite") 203 | self.assertEqual(invite["is_invited_to__application"]["__id"], app["id"]) 204 | 205 | with self.assertRaises(self.helper.balena_exceptions.BalenaApplicationMembershipRoleNotFound): 206 | self.balena.models.application.invite.create( 207 | app["id"], 208 | { 209 | "invitee": self.helper.credentials["email"], 210 | "roleName": "developer1", # type: ignore 211 | "message": "Python SDK test invite", 212 | }, 213 | ) 214 | 215 | def test_26_invite_get_all(self): 216 | invite_list = self.balena.models.application.invite.get_all() 217 | self.assertEqual(1, len(invite_list)) 218 | 219 | def test_27_invite_get_all_by_application(self): 220 | app = TestApplication.app 221 | 222 | invite_list = self.balena.models.application.invite.get_all_by_application(app["id"]) 223 | self.assertEqual(1, len(invite_list)) 224 | 225 | def test_28_invite_revoke(self): 226 | self.balena.models.application.invite.revoke(TestApplication.invite["id"]) 227 | invite_list = self.balena.models.application.invite.get_all() 228 | self.assertEqual(0, len(invite_list)) 229 | 230 | def test_29_membership_get_all_empty(self): 231 | membership_list = self.balena.models.application.membership.get_all() 232 | self.assertEqual(0, len(membership_list)) 233 | 234 | def test_30_membership_get_all_by_application_empty(self): 235 | app = TestApplication.app 236 | membership_list = self.balena.models.application.membership.get_all_by_application(app["id"]) 237 | self.assertEqual(0, len(membership_list)) 238 | 239 | def test_31_membership_create(self): 240 | app = TestApplication.app 241 | membership = self.balena.models.application.membership.create(app["id"], "device_tester1") 242 | TestApplication.membership = membership 243 | self.assertEqual(membership["is_member_of__application"]["__id"], app["id"]) 244 | 245 | with self.assertRaises(self.helper.balena_exceptions.BalenaApplicationMembershipRoleNotFound): 246 | self.balena.models.application.membership.create( 247 | app["id"], self.helper.credentials["email"], "developer1" # type: ignore 248 | ) 249 | 250 | def test_32_membership_get_all(self): 251 | membership_list = self.balena.models.application.membership.get_all() 252 | self.assertEqual(1, len(membership_list)) 253 | 254 | def test_33_membership_get_all_by_application(self): 255 | app = TestApplication.app 256 | membership_list = self.balena.models.application.membership.get_all_by_application(app["id"]) 257 | self.assertEqual(1, len(membership_list)) 258 | 259 | def test_34_membership_remove(self): 260 | self.balena.models.application.membership.remove(TestApplication.membership["id"]) 261 | membership_list = self.balena.models.application.membership.get_all() 262 | self.assertEqual(0, len(membership_list)) 263 | 264 | 265 | if __name__ == "__main__": 266 | unittest.main() 267 | -------------------------------------------------------------------------------- /tests/functional/models/test_device_os.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from balena.hup import get_hup_action_type 4 | from tests.helper import TestHelper 5 | 6 | 7 | class TestDevice(unittest.TestCase): 8 | @classmethod 9 | def setUpClass(cls): 10 | cls.helper = TestHelper() 11 | cls.balena = cls.helper.balena 12 | cls.helper.wipe_application() 13 | cls.app = cls.balena.models.application.create("FooBar", "raspberry-pi2", cls.helper.default_organization["id"]) 14 | 15 | @classmethod 16 | def tearDownClass(cls): 17 | cls.helper.wipe_organization() 18 | 19 | def test_01_get_supported_os_versions_by_device_type_slug(self): 20 | # should become the manifest if the slug is valid. 21 | supported_device_os_versions = self.balena.models.os.get_supported_os_update_versions( 22 | "raspberrypi3", "2.9.6+rev1.prod" 23 | ) 24 | self.assertEqual(supported_device_os_versions["current"], "2.9.6+rev1.prod") 25 | self.assertIsInstance(supported_device_os_versions["recommended"], str) 26 | self.assertIsInstance(supported_device_os_versions["versions"], list) 27 | self.assertGreater(len(supported_device_os_versions["versions"]), 2) 28 | 29 | def test_02_get_hup_action_type(self): 30 | testVersion = [ 31 | "2.108.1+rev2", 32 | "2.106.7", 33 | "2.98.11+rev4", 34 | "2.98.11+rev3", 35 | "2.98.11+rev2", 36 | "2.98.11", 37 | "2.91.5", 38 | "2.85.2+rev4.prod", 39 | ] 40 | for ver in testVersion: 41 | get_hup_action_type("", ver, ver) 42 | 43 | def test_03_get_supervisor_releases_for_cpu_architecture(self): 44 | # return an empty array if no image was found 45 | svRelease = self.balena.models.os.get_supervisor_releases_for_cpu_architecture("notACpuArch") 46 | self.assertEqual(svRelease, []) 47 | 48 | # by default include the id, semver and known_issue_list 49 | dt = self.balena.models.device_type.get( 50 | "raspberrypi4-64", {"$select": "slug", "$expand": {"is_of__cpu_architecture": {"$select": "slug"}}} 51 | ) 52 | 53 | svReleases = self.balena.models.os.get_supervisor_releases_for_cpu_architecture( 54 | dt["is_of__cpu_architecture"][0]["slug"] 55 | ) 56 | 57 | self.assertGreater(len(svReleases), 0) 58 | svRelease = svReleases[0] 59 | self.assertListEqual(sorted(svRelease.keys()), sorted(["id", "raw_version", "known_issue_list"])) 60 | 61 | # return the right string when asking for raspberrypi4-64 and v12.11.0 62 | dt = self.balena.models.device_type.get( 63 | "raspberrypi4-64", {"$select": "slug", "$expand": {"is_of__cpu_architecture": {"$select": "slug"}}} 64 | ) 65 | svReleases = self.balena.models.os.get_supervisor_releases_for_cpu_architecture( 66 | dt["is_of__cpu_architecture"][0]["slug"], 67 | { 68 | "$select": "id", 69 | "$expand": { 70 | "release_image": { 71 | "$select": "id", 72 | "$expand": {"image": {"$select": "is_stored_at__image_location"}}, 73 | }, 74 | }, 75 | "$filter": {"raw_version": "12.11.0"}, 76 | }, 77 | ) 78 | 79 | self.assertEqual(len(svReleases), 1) 80 | svRelease = svReleases[0] 81 | imageLocation = svRelease["release_image"][0]["image"][0]["is_stored_at__image_location"] 82 | self.assertRegex(imageLocation, r"registry2\.[a-z0-9_\-.]+\.[a-z]+\/v2\/[0-9a-f]+") 83 | self.assertEqual(imageLocation, "registry2.balena-cloud.com/v2/4ca706e1c624daff7e519b3009746b2c") 84 | 85 | def test_04_start_os_update(self): 86 | uuid = self.balena.models.device.generate_uuid() 87 | device = self.balena.models.device.register(self.app["id"], uuid) 88 | # sanity check 89 | self.assertEqual(device["uuid"], uuid) 90 | device["is_online"] = False 91 | self.assertEqual(device["is_online"], False) 92 | 93 | # Perform sanity checks on input 94 | with self.assertRaises(self.helper.balena_exceptions.DeviceNotFound): 95 | self.balena.models.device.start_os_update('999999999999', '6.0.10') 96 | 97 | with self.assertRaises(self.helper.balena_exceptions.InvalidParameter): 98 | self.balena.models.device.start_os_update(uuid, None) 99 | # device is offline 100 | with self.assertRaises(self.helper.balena_exceptions.OsUpdateError): 101 | self.balena.models.device.start_os_update(uuid, '99.99.0') 102 | 103 | 104 | if __name__ == "__main__": 105 | unittest.main() 106 | -------------------------------------------------------------------------------- /tests/functional/models/test_device_type.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tests.helper import TestHelper 4 | 5 | 6 | class TestDeviceType(unittest.TestCase): 7 | @classmethod 8 | def setUpClass(cls): 9 | cls.helper = TestHelper() 10 | cls.balena = cls.helper.balena 11 | 12 | @classmethod 13 | def tearDownClass(cls): 14 | cls.helper.wipe_application() 15 | 16 | def test_get(self): 17 | # should get the device type for a known slug. 18 | dt = self.balena.models.device_type.get("raspberry-pi") 19 | self.assertEqual(dt["slug"], "raspberry-pi") 20 | self.assertEqual(dt["name"], "Raspberry Pi (v1 / Zero / Zero W)") 21 | 22 | # should get the device type for a known id. 23 | dt = self.balena.models.device_type.get(dt["id"]) 24 | self.assertEqual(dt["slug"], "raspberry-pi") 25 | self.assertEqual(dt["name"], "Raspberry Pi (v1 / Zero / Zero W)") 26 | 27 | # should be rejected if the slug is invalid. 28 | with self.assertRaises(self.helper.balena_exceptions.InvalidDeviceType): 29 | self.balena.models.device_type.get("PYTHONSDK") 30 | 31 | # should be rejected if the id is invalid. 32 | with self.assertRaises(self.helper.balena_exceptions.InvalidDeviceType): 33 | self.balena.models.device_type.get(99999) 34 | 35 | def test_get_by_slug_or_name(self): 36 | # should be the slug from a display name. 37 | dt = self.balena.models.device_type.get_by_slug_or_name("Raspberry Pi (v1 / Zero / Zero W)") 38 | self.assertEqual(dt["slug"], "raspberry-pi") 39 | self.assertEqual(dt["name"], "Raspberry Pi (v1 / Zero / Zero W)") 40 | 41 | dt = self.balena.models.device_type.get_by_slug_or_name("raspberry-pi") 42 | self.assertEqual(dt["slug"], "raspberry-pi") 43 | self.assertEqual(dt["name"], "Raspberry Pi (v1 / Zero / Zero W)") 44 | 45 | # should be rejected if the display name is invalid. 46 | with self.assertRaises(self.helper.balena_exceptions.InvalidDeviceType): 47 | self.balena.models.device_type.get("PYTHONSDK") 48 | 49 | def test_get_name(self): 50 | # should get the display name for a known slug. 51 | self.assertEqual( 52 | self.balena.models.device_type.get_name("raspberry-pi"), 53 | "Raspberry Pi (v1 / Zero / Zero W)", 54 | ) 55 | 56 | # should be rejected if the slug is invalid. 57 | with self.assertRaises(self.helper.balena_exceptions.InvalidDeviceType): 58 | self.balena.models.device_type.get_name("PYTHONSDK") 59 | 60 | def test_get_slug_by_name(self): 61 | # should get the display name for a known name. 62 | self.assertEqual( 63 | self.balena.models.device_type.get_slug_by_name("Raspberry Pi (v1 / Zero / Zero W)"), 64 | "raspberry-pi", 65 | ) 66 | 67 | # should be rejected if the slug is invalid. 68 | with self.assertRaises(self.helper.balena_exceptions.InvalidDeviceType): 69 | self.balena.models.device_type.get_slug_by_name("PYTHONSDK") 70 | 71 | def test_get_supported_device_types(self): 72 | # should return a non empty array. 73 | self.assertGreater(len(self.balena.models.device_type.get_all_supported()), 0) 74 | 75 | # should return all valid display names. 76 | for dev_type in self.balena.models.device_type.get_all_supported(): 77 | self.assertTrue(self.balena.models.device_type.get(dev_type["slug"])) 78 | 79 | 80 | if __name__ == "__main__": 81 | unittest.main() 82 | -------------------------------------------------------------------------------- /tests/functional/models/test_history.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import datetime, timedelta 3 | 4 | from tests.helper import TestHelper 5 | 6 | 7 | class TestHistory(unittest.TestCase): 8 | @classmethod 9 | def setUpClass(cls): 10 | cls.helper = TestHelper() 11 | cls.balena = cls.helper.balena 12 | # Wipe all apps before the tests run. 13 | cls.helper.wipe_application() 14 | 15 | def tearDown(self): 16 | # Wipe all apps after every test case. 17 | self.helper.wipe_application() 18 | 19 | def _test_device_history(self, test_model): 20 | app_info = self.helper.create_multicontainer_app() 21 | # generate some arbitrary devices to test filtering for specific device ID. 22 | deviceOne = self.balena.models.device.register(app_info["app"]["id"], self.balena.models.device.generate_uuid()) 23 | deviceTwo = self.balena.models.device.register(app_info["app"]["id"], self.balena.models.device.generate_uuid()) 24 | 25 | def check_device_history_by_device(device_history): 26 | device_history.sort(key=lambda entry: entry["created_at"], reverse=True) 27 | # this entry should be the newest and have no end_timestamp => not ended history record 28 | self.assertIsNone(device_history[0]["end_timestamp"]) 29 | # now check 30 | for history_entry in device_history: 31 | self.assertEqual(history_entry["tracks__device"]["__id"], app_info["device"]["id"]) 32 | self.assertEqual(history_entry["uuid"], app_info["device"]["uuid"]) 33 | 34 | check_device_history_by_application(device_history) 35 | 36 | def check_device_history_by_application(device_history): 37 | for history_entry in device_history: 38 | self.assertEqual( 39 | history_entry["belongs_to__application"]["__id"], 40 | app_info["app"]["id"], 41 | ) 42 | 43 | # should set the device to track the current application release. 44 | self.balena.models.device.pin_to_release(app_info["device"]["uuid"], app_info["old_release"]["commit"]) 45 | self.balena.models.device.pin_to_release(deviceOne["uuid"], app_info["old_release"]["commit"]) 46 | self.balena.models.device.pin_to_release(deviceTwo["uuid"], app_info["old_release"]["commit"]) 47 | 48 | # get by device uuid 49 | device_history = test_model.get_all_by_device(app_info["device"]["uuid"]) 50 | check_device_history_by_device(device_history) 51 | 52 | # get by device id 53 | device_history = test_model.get_all_by_device(app_info["device"]["id"]) 54 | check_device_history_by_device(device_history) 55 | 56 | # get by application id 57 | device_history = test_model.get_all_by_application(app_info["app"]["id"]) 58 | check_device_history_by_application(device_history) 59 | 60 | with self.assertRaises(Exception) as cm: 61 | test_model.get_all_by_device(app_info["device"]["uuid"] + "toManyDigits") 62 | self.assertIn("Invalid parameter:", cm.exception.message) # type: ignore 63 | 64 | for test_set in [ 65 | { 66 | "method": "get_all_by_device", 67 | "by": "device", 68 | "checker": check_device_history_by_device, 69 | }, 70 | { 71 | "method": "get_all_by_application", 72 | "by": "app", 73 | "checker": check_device_history_by_application, 74 | }, 75 | ]: 76 | method_under_test = getattr(test_model, test_set["method"]) 77 | device_history = method_under_test(app_info[test_set["by"]]["id"]) 78 | 79 | test_set["checker"](device_history) 80 | 81 | # set time range to return device history entries 82 | device_history = method_under_test( 83 | app_info[test_set["by"]]["id"], 84 | from_date=datetime.utcnow() + timedelta(days=-10), 85 | to_date=datetime.utcnow() + timedelta(days=+1), 86 | ) 87 | test_set["checker"](device_history) 88 | 89 | # set time range to return now data 90 | device_history = method_under_test( 91 | app_info[test_set["by"]]["id"], 92 | from_date=datetime.utcnow() + timedelta(days=-3000), 93 | to_date=datetime.utcnow() + timedelta(days=-2000), 94 | ) 95 | self.assertEqual(len(device_history), 0) 96 | 97 | for invalidParameter in [[], {}, "invalid", 1]: 98 | with self.assertRaises(Exception) as cm: 99 | device_history = method_under_test(app_info[test_set["by"]]["id"], from_date=invalidParameter) 100 | self.assertIn("Invalid parameter:", cm.exception.message) # type: ignore 101 | 102 | with self.assertRaises(Exception) as cm: 103 | device_history = method_under_test(app_info[test_set["by"]]["id"], to_date=invalidParameter) 104 | self.assertIn("Invalid parameter:", cm.exception.message) # type: ignore 105 | 106 | def test_device_model_get_device_history(self): 107 | self._test_device_history(self.balena.models.device.history) 108 | 109 | 110 | if __name__ == "__main__": 111 | unittest.main() 112 | -------------------------------------------------------------------------------- /tests/functional/models/test_image.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tests.helper import TestHelper 4 | 5 | 6 | class TestImage(unittest.TestCase): 7 | @classmethod 8 | def setUpClass(cls): 9 | cls.helper = TestHelper() 10 | cls.balena = cls.helper.balena 11 | cls.current_web_image = cls.helper.create_multicontainer_app()["current_web_image"] 12 | 13 | @classmethod 14 | def tearDownClass(cls): 15 | cls.helper.wipe_application() 16 | 17 | def test_01_should_reject_if_image_id_does_not_exist(self): 18 | with self.assertRaises(self.helper.balena_exceptions.ImageNotFound): 19 | self.balena.models.image.get(123) 20 | 21 | with self.assertRaises(self.helper.balena_exceptions.ImageNotFound): 22 | self.balena.models.image.get_logs(123) 23 | 24 | def test_02_should_get_image(self): 25 | id = self.current_web_image["id"] 26 | img = self.balena.models.image.get(id) 27 | self.assertEqual(img["project_type"], "dockerfile") 28 | self.assertEqual(img["status"], "success") 29 | self.assertEqual(img["id"], id) 30 | self.assertIsNone(img.get("build_log")) 31 | 32 | def test_03_should_get_image_build_logs(self): 33 | id = self.current_web_image["id"] 34 | logs = self.balena.models.image.get_logs(id) 35 | self.assertEqual(logs, "new web log") 36 | -------------------------------------------------------------------------------- /tests/functional/models/test_key.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tests.helper import TestHelper 4 | 5 | PUBLIC_KEY = """ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDMBWf5hwmL97rtCD8Gljz30+25vLAV8jumD9SPG9JxNbBTVot1tYNQw6rvpdN/dLlf13G1qG9AMkAwlgRFXPDFrVheKH13HzGJZYTu7sKLENHMFKzdANa5XHoVX9kthbYsJT0mBPtxRfSxhx6ALapfr8zqdAQSxspsMzwiTTRoVwIRQthEWxqSASpOYw2/OFwKgZdg0EbHQHUJgOa2bSd6lxAU3o3zbnG/8Bww9b8/avS0GCZ9XnLT0RSBMrtxzLKt+mr22pUDmwFMq275rxUqjQmdRChpLcizaJAiSxSTdaRwWphtf/8myKwezgmH8pbU7WkHUU6xEJz6xRj2P0ZB 6 | """ # noqa 7 | 8 | 9 | class TestKey(unittest.TestCase): 10 | @classmethod 11 | def setUpClass(cls): 12 | cls.helper = TestHelper() 13 | cls.balena = cls.helper.balena 14 | 15 | @classmethod 16 | def tearDownClass(cls): 17 | cls.helper.wipe_application() 18 | cls.helper.reset_user() 19 | 20 | def test_01_should_be_empty_array(self): 21 | result = self.balena.models.key.get_all() 22 | self.assertEqual(result, []) 23 | 24 | def test_02_should_be_able_to_create_key_with_whitespaces(self): 25 | key = self.balena.models.key.create("MyKey", f" {PUBLIC_KEY} ") 26 | 27 | self.assertEqual(key["title"], "MyKey") 28 | self.assertEqual(key["public_key"].strip(" \n"), PUBLIC_KEY.strip(" \n")) 29 | 30 | result = self.balena.models.key.get_all() 31 | self.assertEqual(result[0]["title"], "MyKey") 32 | self.assertEqual(result[0]["public_key"].strip(" \n"), PUBLIC_KEY.strip(" \n")) 33 | 34 | def test_03_should_be_able_to_create_key(self): 35 | self.helper.reset_user() 36 | TestKey.key = self.balena.models.key.create("MyKey", PUBLIC_KEY) 37 | 38 | self.assertEqual(TestKey.key["title"], "MyKey") 39 | self.assertEqual(TestKey.key["public_key"].strip(" \n"), PUBLIC_KEY.strip(" \n")) 40 | 41 | result = self.balena.models.key.get_all() 42 | self.assertEqual(result[0]["title"], "MyKey") 43 | self.assertEqual(result[0]["public_key"].strip(" \n"), PUBLIC_KEY.strip(" \n")) 44 | 45 | def test_04_should_support_pinejs_options(self): 46 | [key] = self.balena.models.key.get_all( 47 | { 48 | "$select": ["public_key"], 49 | } 50 | ) 51 | 52 | self.assertEqual(key["public_key"].strip(" \n"), PUBLIC_KEY.strip(" \n")) 53 | self.assertIsNone(key.get("title")) 54 | 55 | def test_05_should_get_key(self): 56 | key = self.balena.models.key.get(TestKey.key["id"]) 57 | 58 | self.assertEqual(key["public_key"].strip(" \n"), PUBLIC_KEY.strip(" \n")) 59 | self.assertEqual(key["title"], "MyKey") 60 | 61 | with self.assertRaises(self.helper.balena_exceptions.RequestError): 62 | self.balena.models.key.get(99999999999) 63 | 64 | def test_06_should_remove_key(self): 65 | self.balena.models.key.remove(TestKey.key["id"]) 66 | 67 | with self.assertRaises(self.helper.balena_exceptions.KeyNotFound): 68 | self.balena.models.key.get(TestKey.key["id"]) 69 | self.assertEqual(self.balena.models.key.get_all(), []) 70 | -------------------------------------------------------------------------------- /tests/functional/models/test_organization.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import datetime 3 | import os 4 | from tests.helper import TestHelper 5 | 6 | 7 | class TestOrganization(unittest.TestCase): 8 | @classmethod 9 | def setUpClass(cls): 10 | cls.helper = TestHelper() 11 | cls.balena = cls.helper.balena 12 | cls.test_org_admin_role = cls.helper.get_org_admin_role() 13 | time = datetime.now().strftime("%H_%M_%S") 14 | cls.test_org_handle = f"python_sdk_org_test_{time}" 15 | cls.test_org_name = cls.test_org_handle + " name" 16 | cls.test_org_custom_handle = cls.test_org_handle + "_python_sdk_test" 17 | 18 | # Wipe all apps before the tests run. 19 | cls.helper.wipe_organization() 20 | 21 | @classmethod 22 | def tearDownClass(cls): 23 | # Wipe all apps after the tests run. 24 | cls.helper.wipe_organization() 25 | 26 | def test_create(self): 27 | # should be able to create a new organization 28 | TestOrganization.org1 = self.balena.models.organization.create(self.test_org_name) 29 | self.assertEqual(TestOrganization.org1["name"], self.test_org_name) 30 | self.assertIsInstance(TestOrganization.org1["handle"], str) 31 | 32 | # should be able to create a new organization with handle 33 | TestOrganization.org2 = self.balena.models.organization.create(self.test_org_name, self.test_org_custom_handle) 34 | self.assertEqual(TestOrganization.org2["name"], self.test_org_name) 35 | self.assertEqual(TestOrganization.org2["handle"], self.test_org_custom_handle) 36 | 37 | # should be able to create a new organization with the same name & a file 38 | current_directory = os.path.dirname(os.path.abspath(__file__)) 39 | filename = os.path.join(current_directory, 'test-data', 'balena-python-sdk-test-logo.png') 40 | 41 | with open(filename, 'rb') as f: 42 | TestOrganization.org3 = self.balena.models.organization.create(self.test_org_name, None, f) 43 | 44 | self.assertEqual(TestOrganization.org3["name"], self.test_org_name) 45 | self.assertNotEqual(TestOrganization.org3["handle"], TestOrganization.org1["handle"]) 46 | 47 | org_with_logo = self.balena.models.organization.get(TestOrganization.org3["id"], {"$select": ["logo_image"]}) 48 | self.assertIsInstance(org_with_logo["logo_image"]["href"], str) 49 | self.assertGreater(len(org_with_logo["logo_image"]["href"]), 0) 50 | self.assertEqual(org_with_logo["logo_image"]["filename"], 'balena-python-sdk-test-logo.png') 51 | 52 | def test_get_all(self): 53 | # given three extra non-user organization, should retrieve all organizations. 54 | orgs = self.balena.models.organization.get_all() 55 | orgs = sorted(orgs, key=lambda k: k["created_at"]) 56 | self.assertEqual(len(orgs), 4) 57 | self.assertEqual(orgs[0]["handle"], self.helper.credentials["user_id"]) 58 | self.assertEqual(orgs[1]["name"], self.test_org_name) 59 | self.assertEqual(orgs[2]["name"], self.test_org_name) 60 | self.assertEqual(orgs[2]["handle"], self.test_org_custom_handle) 61 | self.assertEqual(orgs[3]["name"], self.test_org_name) 62 | 63 | def test_get(self): 64 | # should be rejected if the organization id does not exist and raise balena.exceptions.OrganizationNotFound. 65 | with self.assertRaises(self.helper.balena_exceptions.OrganizationNotFound) as cm: 66 | self.balena.models.organization.get("999999999") 67 | self.assertIn("Organization not found: 999999999", cm.exception.message) 68 | 69 | org = self.balena.models.organization.get(TestOrganization.org2["id"]) 70 | self.assertEqual(org["handle"], self.test_org_custom_handle) 71 | self.assertEqual(org["name"], self.test_org_name) 72 | self.assertEqual( 73 | self.balena.models.organization.get(self.test_org_custom_handle)["id"], 74 | org["id"], 75 | ) 76 | 77 | def test_remove(self): 78 | # should remove an organization by id. 79 | orgs_count = len(self.balena.models.organization.get_all()) 80 | self.balena.models.organization.remove(TestOrganization.org3["id"]) 81 | self.assertEqual(len(self.balena.models.organization.get_all()), orgs_count - 1) 82 | 83 | def test_invite_create(self): 84 | # should create and return an organization invite 85 | invite = self.balena.models.organization.invite.create( 86 | TestOrganization.org1["id"], 87 | self.helper.credentials["email"], 88 | "member", 89 | "Python SDK test invite", 90 | ) 91 | self.assertEqual(invite["message"], "Python SDK test invite") 92 | self.assertEqual(invite["is_invited_to__organization"]["__id"], TestOrganization.org1["id"]) 93 | self.balena.models.organization.invite.revoke(invite["id"]) 94 | 95 | # should throw an error when role is not found 96 | # raise balena.exceptions.BalenaOrganizationMembershipRoleNotFound if role is not found. 97 | with self.assertRaises(self.helper.balena_exceptions.BalenaOrganizationMembershipRoleNotFound): 98 | invite = self.balena.models.organization.invite.create( 99 | TestOrganization.org1["id"], 100 | self.helper.credentials["email"], 101 | "member1", 102 | "Python SDK test invite", 103 | ) 104 | 105 | def test_invite_get_all(self): 106 | # shoud return an empty list 107 | invite_list = self.balena.models.organization.invite.get_all() 108 | self.assertEqual(0, len(invite_list)) 109 | 110 | # shoud return an invite list with length equals 1. 111 | self.balena.models.organization.invite.create( 112 | TestOrganization.org1["id"], 113 | self.helper.credentials["email"], 114 | "member", 115 | "Python SDK test invite", 116 | ) 117 | invite_list = self.balena.models.organization.invite.get_all() 118 | self.assertEqual(1, len(invite_list)) 119 | 120 | def test_invite_get_all_by_organization(self): 121 | invite_list = self.balena.models.organization.invite.get_all_by_organization(TestOrganization.org1["id"]) 122 | self.assertEqual(1, len(invite_list)) 123 | 124 | def test_membership_get_all_by_organization(self): 125 | # shoud return only the user's own membership 126 | memberships = self.balena.models.organization.membership.get_all_by_organization(TestOrganization.org1["id"]) 127 | self.assertEqual(1, len(memberships)) 128 | self.assertEqual(memberships[0]["user"]["__id"], self.balena.auth.get_user_info()["id"]) 129 | self.assertEqual(memberships[0]["is_member_of__organization"]["__id"], TestOrganization.org1["id"]) 130 | self.assertEqual( 131 | memberships[0]["organization_membership_role"]["__id"], 132 | self.test_org_admin_role["id"], 133 | ) 134 | 135 | def test_membership_tags(self): 136 | org_id = TestOrganization.org1["id"] 137 | memberships = self.balena.models.organization.membership.get_all_by_organization(org_id) 138 | membership_id = memberships[0]["id"] 139 | 140 | membership_tag_model = self.balena.models.organization.membership.tags 141 | self.assertEqual(0, len(membership_tag_model.get_all_by_organization(org_id))) 142 | self.assertEqual( 143 | 0, 144 | len(membership_tag_model.get_all_by_organization_membership(membership_id)), 145 | ) 146 | 147 | membership_tag_model.set(membership_id, "test", "v1") 148 | self.__assert_tags_changed(org_id, membership_id, "test", "v1") 149 | 150 | membership_tag_model.set(membership_id, "test", "v2") 151 | self.__assert_tags_changed(org_id, membership_id, "test", "v2") 152 | self.__assert_tags_changed(org_id, membership_id, "test2", None) 153 | 154 | membership_tag_model.remove(membership_id, "test") 155 | self.__assert_tags_changed(org_id, membership_id, "test", None) 156 | 157 | def __assert_tags_changed(self, org_id, membership_id, key, value): 158 | membership_tag_model = self.balena.models.organization.membership.tags 159 | 160 | if value is not None: 161 | self.assertEqual(1, len(membership_tag_model.get_all_by_organization(org_id))) 162 | self.assertEqual( 163 | 1, 164 | len(membership_tag_model.get_all_by_organization_membership(membership_id)), 165 | ) 166 | 167 | self.assertEqual( 168 | membership_tag_model.get_all_by_organization(org_id)[0].get("value"), 169 | value, 170 | ) 171 | self.assertEqual( 172 | membership_tag_model.get_all_by_organization_membership(membership_id)[0].get("value"), 173 | value, 174 | ) 175 | 176 | self.assertEqual(membership_tag_model.get(membership_id, key), value) 177 | 178 | 179 | if __name__ == "__main__": 180 | unittest.main() 181 | -------------------------------------------------------------------------------- /tests/functional/models/test_service.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tests.helper import TestHelper 4 | 5 | 6 | class TestImage(unittest.TestCase): 7 | @classmethod 8 | def setUpClass(cls): 9 | cls.helper = TestHelper() 10 | cls.balena = cls.helper.balena 11 | cls.app = cls.helper.create_multicontainer_app()["app"] 12 | cls.empty_app = cls.balena.models.application.create( 13 | "ServiceTestApp", "raspberry-pi2", cls.helper.default_organization["id"] 14 | ) 15 | 16 | @classmethod 17 | def tearDownClass(cls): 18 | cls.helper.wipe_application() 19 | 20 | def test_01_should_reject_if_application_does_not_exist(self): 21 | with self.assertRaises(self.helper.balena_exceptions.ApplicationNotFound): 22 | self.balena.models.service.get_all_by_application(123) 23 | 24 | with self.assertRaises(self.helper.balena_exceptions.ApplicationNotFound): 25 | self.balena.models.service.get_all_by_application("AppDoesNotExist") 26 | 27 | def test_02_should_get_image(self): 28 | for prop in self.helper.application_retrieval_fields: 29 | services = self.balena.models.service.get_all_by_application(self.empty_app[prop]) 30 | self.assertEqual(services, []) 31 | 32 | def test_03_should_get_image_build_logs(self): 33 | services = self.balena.models.service.get_all_by_application(self.app["id"]) 34 | services = sorted(services, key=lambda s: s["service_name"]) 35 | self.assertEqual(services[0]["service_name"], "db") 36 | self.assertEqual(services[0]["application"]["__id"], self.app["id"]) 37 | self.assertEqual(services[1]["service_name"], "web") 38 | self.assertEqual(services[1]["application"]["__id"], self.app["id"]) 39 | -------------------------------------------------------------------------------- /tests/functional/models/test_tag.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tests.helper import TestHelper 4 | 5 | 6 | def find_with_tag_key(tag_list, tag_key): 7 | for item in tag_list: 8 | if item["tag_key"] == tag_key: 9 | return item 10 | return None 11 | 12 | 13 | class BaseTagTest: 14 | def __init__( 15 | self, 16 | test_obj, 17 | associated_resource_name, 18 | ): 19 | self.test_obj = test_obj 20 | self.associated_resource_name = associated_resource_name 21 | 22 | def assert_get_all_by_associated_resource(self, test_runner, tags): 23 | tags = sorted(tags, key=lambda k: k["tag_key"]) 24 | test_runner.assertEqual(len(tags), 4) 25 | test_runner.assertEqual(tags[0]["tag_key"], "Foo") 26 | test_runner.assertEqual(tags[0]["value"], "Bar") 27 | test_runner.assertEqual(tags[1]["tag_key"], "Foo1") 28 | test_runner.assertEqual(tags[1]["value"], "1") 29 | test_runner.assertEqual(tags[2]["tag_key"], "Foo2") 30 | test_runner.assertEqual(tags[2]["value"], "BarBar") 31 | test_runner.assertEqual(tags[3]["tag_key"], "Foo21") 32 | test_runner.assertEqual(tags[3]["value"], "Bar1") 33 | 34 | def test_get_all_by_application(self, test_runner, resource_id, app_id): 35 | # given a tag 36 | self.test_obj.set(resource_id, "Foo_get_all_by_application", "Bar") 37 | 38 | # should retrieve the tag by resource id 39 | tags = self.test_obj.get_all_by_application(app_id) 40 | tag = find_with_tag_key(tags, "Foo_get_all_by_application") 41 | test_runner.assertEqual(tag["value"], "Bar") 42 | 43 | def test_get_all(self, test_runner, resource_id): 44 | # given 2 tags 45 | current_length = len(self.test_obj.get_all()) 46 | self.test_obj.set(resource_id, "Foo_get_all", "Bar") 47 | self.test_obj.set(resource_id, "Foo1_get_all", "Bar1") 48 | 49 | # should retrieve all the tags 50 | test_runner.assertEqual(len(self.test_obj.get_all()), (current_length + 2)) 51 | 52 | def test_set(self, test_runner, resource_id): 53 | # should be able to create a tag given a resource id 54 | current_length = len(self.test_obj.get_all()) 55 | self.test_obj.set(resource_id, "Foo", "Bar") 56 | 57 | new_tag_value = self.test_obj.get(resource_id, "Foo") 58 | 59 | test_runner.assertEqual(len(self.test_obj.get_all()), (current_length + 1)) 60 | test_runner.assertEqual(new_tag_value, "Bar") 61 | 62 | # should be able to create a numeric tag 63 | current_length = len(self.test_obj.get_all()) 64 | self.test_obj.set(resource_id, "Foo1", "1") 65 | 66 | new_tag_value = self.test_obj.get(resource_id, "Foo1") 67 | 68 | test_runner.assertEqual(len(self.test_obj.get_all()), (current_length + 1)) 69 | test_runner.assertEqual(new_tag_value, "1") 70 | 71 | # should not allow creating a balena tag 72 | with test_runner.assertRaises(Exception) as cm: 73 | self.test_obj.set(resource_id, "io.balena.test", "not allowed") 74 | test_runner.assertIn("Tag keys beginning with io.balena. are reserved.", cm.exception.message) 75 | 76 | # should not allow creating a resin tag 77 | with test_runner.assertRaises(Exception) as cm: 78 | self.test_obj.set(resource_id, "io.resin.test", "not allowed") 79 | test_runner.assertIn("Tag keys beginning with io.resin. are reserved.", cm.exception.message) 80 | 81 | # should not allow creating a tag with a name containing a whitespace 82 | with test_runner.assertRaises(Exception) as cm: 83 | self.test_obj.set(resource_id, "Foo 1", "not allowed") 84 | test_runner.assertIn("Tag keys cannot contain whitespace.", cm.exception.message) 85 | 86 | # should be rejected if the resource id does not exist 87 | with test_runner.assertRaises(Exception) as cm: 88 | self.test_obj.set(99999999, "Foo", "not allowed") 89 | 90 | # should be rejected if the tag_key is None 91 | with test_runner.assertRaises(Exception) as cm: 92 | self.test_obj.set(resource_id, None, "not allowed") 93 | test_runner.assertIn("cannot be null", cm.exception.message) 94 | 95 | # should be rejected if the tag_key is empty 96 | with test_runner.assertRaises(Exception) as cm: 97 | self.test_obj.set(resource_id, "", "not allowed") 98 | test_runner.assertIn( 99 | ( 100 | f"It is necessary that each {self.associated_resource_name} tag has a " 101 | "tag key that has a Length (Type) that is greater than 0." 102 | ), 103 | cm.exception.message, 104 | ) 105 | 106 | # given 2 tags, should be able to update a tag without affecting the rest 107 | self.test_obj.set(resource_id, "Foo2", "Bar") 108 | self.test_obj.set(resource_id, "Foo21", "Bar1") 109 | self.test_obj.set(resource_id, "Foo2", "BarBar") 110 | tag_list = self.test_obj.get_all() 111 | 112 | tag1 = find_with_tag_key(tag_list, "Foo2") 113 | tag2 = find_with_tag_key(tag_list, "Foo21") 114 | 115 | test_runner.assertEqual(tag1["value"], "BarBar") 116 | test_runner.assertEqual(tag2["value"], "Bar1") 117 | 118 | def test_remove(self, test_runner, resource_id, get_resources_func=None): 119 | # should be able to remove a tag by resource id 120 | self.test_obj.set(resource_id, "Foo_remove", "Bar") 121 | if get_resources_func is not None: 122 | previous_length = len(get_resources_func(resource_id)) 123 | else: 124 | previous_length = len(self.test_obj.get_all()) 125 | 126 | self.test_obj.remove(resource_id, "Foo_remove") 127 | # after removing a tag, current_length should be reduced by 1 128 | if get_resources_func is not None: 129 | current_length = len(get_resources_func(resource_id)) 130 | else: 131 | current_length = len(self.test_obj.get_all()) 132 | 133 | test_runner.assertEqual(current_length, previous_length - 1) 134 | 135 | 136 | class TestDeviceTag(unittest.TestCase): 137 | @classmethod 138 | def setUpClass(cls): 139 | cls.helper = TestHelper() 140 | cls.balena = cls.helper.balena 141 | cls.device_tag = cls.balena.models.device.tags 142 | cls.base_tag_test = BaseTagTest(cls.device_tag, "device") 143 | # Wipe all apps before every test case. 144 | cls.helper.wipe_application() 145 | app, device = cls.helper.create_device() 146 | cls.app = app 147 | cls.device = device 148 | 149 | @classmethod 150 | def tearDownClass(cls): 151 | # Wipe all apps after every test case. 152 | cls.helper.wipe_application() 153 | 154 | def test_01_set(self): 155 | self.base_tag_test.test_set(self, self.device["uuid"]) 156 | 157 | def test_02_get_all_by_device(self): 158 | device_uuid = self.device["uuid"] 159 | 160 | # should retrieve all the tags by uuid 161 | tags = self.device_tag.get_all_by_device(device_uuid) 162 | self.base_tag_test.assert_get_all_by_associated_resource(self, tags) 163 | 164 | def test_03_get_all_by_application(self): 165 | self.base_tag_test.test_get_all_by_application(self, self.device["uuid"], self.app["id"]) 166 | 167 | def test_04_get_all(self): 168 | self.base_tag_test.test_get_all(self, self.device["uuid"]) 169 | 170 | def test_05_remove(self): 171 | self.base_tag_test.test_remove(self, self.device["uuid"]) 172 | 173 | 174 | class TestApplicationTag(unittest.TestCase): 175 | @classmethod 176 | def setUpClass(cls): 177 | cls.helper = TestHelper() 178 | cls.balena = cls.helper.balena 179 | cls.base_tag_test = BaseTagTest(cls.balena.models.application.tags, "application") 180 | app, device = cls.helper.create_device() 181 | cls.app = app 182 | cls.device = device 183 | 184 | @classmethod 185 | def tearDownClass(cls): 186 | # Wipe all apps after every test case. 187 | cls.helper.wipe_application() 188 | 189 | def test_01_get_all_by_application(self): 190 | self.base_tag_test.test_get_all_by_application(self, type(self).app["id"], type(self).app["id"]) 191 | 192 | def test_03_remove(self): 193 | self.base_tag_test.test_remove( 194 | self, type(self).app["id"], self.balena.models.application.tags.get_all_by_application 195 | ) 196 | 197 | 198 | class TestReleaseTag(unittest.TestCase): 199 | @classmethod 200 | def setUpClass(cls): 201 | cls.helper = TestHelper() 202 | cls.balena = cls.helper.balena 203 | cls.release_tag = cls.balena.models.release.tags 204 | cls.base_tag_test = BaseTagTest(cls.release_tag, "release") 205 | # Wipe all apps before every test case. 206 | cls.helper.wipe_application() 207 | cls.app_info = cls.helper.create_multicontainer_app() 208 | 209 | @classmethod 210 | def tearDownClass(cls): 211 | # Wipe all apps after every test case. 212 | cls.helper.wipe_application() 213 | 214 | def test_01_set(self): 215 | self.base_tag_test.test_set(self, type(self).app_info["current_release"]["id"]) 216 | 217 | def test_02_get_all_by_release(self): 218 | release_id = type(self).app_info["current_release"]["id"] 219 | 220 | # should retrieve all the tags by uuid 221 | tags = self.release_tag.get_all_by_release(release_id) 222 | self.base_tag_test.assert_get_all_by_associated_resource(self, tags) 223 | 224 | def test_03_get_all_by_application(self): 225 | self.base_tag_test.test_get_all_by_application( 226 | self, 227 | type(self).app_info["current_release"]["id"], 228 | type(self).app_info["app"]["id"], 229 | ) 230 | 231 | def test_04_get_all(self): 232 | self.base_tag_test.test_get_all(self, type(self).app_info["current_release"]["id"]) 233 | 234 | def test_05_remove(self): 235 | self.base_tag_test.test_remove(self, type(self).app_info["current_release"]["id"]) 236 | 237 | 238 | if __name__ == "__main__": 239 | unittest.main() 240 | -------------------------------------------------------------------------------- /tests/functional/test_auth.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from balena import Balena 4 | from balena.exceptions import NotLoggedIn, LoginFailed 5 | from balena.balena_auth import get_token 6 | from tests.helper import TestHelper 7 | from typing import cast 8 | from datetime import datetime, timedelta 9 | from balena.auth import ApplicationKeyWhoAmIResponse, UserKeyWhoAmIResponse, DeviceKeyWhoAmIResponse 10 | import jwt 11 | 12 | 13 | class TestAuth(unittest.TestCase): 14 | @classmethod 15 | def setUpClass(cls): 16 | cls.helper = TestHelper() 17 | cls.balena = Balena() 18 | cls.app_info = cls.helper.create_multicontainer_app() 19 | 20 | @classmethod 21 | def tearDownClass(cls): 22 | cls.balena.auth.login(**TestAuth.creds) 23 | cls.helper.wipe_application() 24 | cls.helper.wipe_organization() 25 | 26 | def test_01_is_logged_in_false_when_not_logged_in(self): 27 | self.balena.auth.logout() 28 | self.assertFalse(self.balena.auth.is_logged_in()) 29 | 30 | def test_02_logout_does_not_fail_when_not_logged_in(self): 31 | self.balena.auth.logout() 32 | 33 | def test_03_not_save_token_with_valid_credentials_on_authenticate(self): 34 | TestHelper.load_env() 35 | TestAuth.creds = { 36 | "username": TestHelper.credentials["user_id"], 37 | "password": TestHelper.credentials["password"], 38 | } 39 | 40 | token = self.balena.auth.authenticate(**TestAuth.creds) 41 | self.assertGreater(len(token), 1) 42 | 43 | # does not save token given valid credentials 44 | self.assertFalse(self.balena.auth.is_logged_in()) 45 | 46 | def test_04_rejects_with_invalid_credentials(self): 47 | with self.assertRaises(LoginFailed): 48 | self.balena.auth.authenticate(**{"username": TestAuth.creds["username"], "password": "NOT-CORRECT"}) 49 | 50 | def test_05_should_reject_to_get_token_when_not_logged_in(self): 51 | self.assertIsNone(self.balena.auth.get_token()) 52 | 53 | def test_06_should_be_able_to_login_with_session_token(self): 54 | token = self.balena.auth.authenticate(**TestAuth.creds) 55 | self.balena.auth.login_with_token(token) 56 | self.assertEqual(self.balena.auth.get_token(), token) 57 | 58 | def test_07_should_be_able_to_login_with_api_key(self): 59 | key = self.balena.models.api_key.create("apiKey") 60 | self.balena.auth.logout() 61 | self.balena.auth.login_with_token(key) 62 | self.assertEqual(self.balena.auth.get_token(), key) 63 | TestAuth.test_api_key = key 64 | 65 | def test_08_getters_should_throw_when_not_logged_in(self): 66 | self.balena.auth.logout() 67 | 68 | with self.assertRaises(NotLoggedIn): 69 | self.balena.auth.whoami() 70 | 71 | with self.assertRaises(NotLoggedIn): 72 | self.balena.auth.get_actor_id() 73 | 74 | with self.assertRaises(NotLoggedIn): 75 | self.balena.auth.get_user_info() 76 | 77 | def test_09_should_not_throw_login_with_malformed_token(self): 78 | token = self.balena.auth.authenticate(**TestAuth.creds) 79 | self.balena.auth.login_with_token(f"{token}malformingsuffix") 80 | self.assertEqual(self.balena.auth.get_token(), f"{token}malformingsuffix") 81 | 82 | def test_10_is_logged_in_should_be_false_with_malformed_token(self): 83 | self.assertFalse(self.balena.auth.is_logged_in()) 84 | 85 | def test_11_getters_should_throw_with_malformed_token(self): 86 | with self.assertRaises(NotLoggedIn): 87 | self.balena.auth.whoami() 88 | 89 | with self.assertRaises(NotLoggedIn): 90 | self.balena.auth.get_actor_id() 91 | 92 | with self.assertRaises(NotLoggedIn): 93 | self.balena.auth.get_user_info() 94 | 95 | def test_12_should_get_logged_in_after_logged_in(self): 96 | TestAuth.creds = { 97 | "username": TestHelper.credentials["user_id"], 98 | "password": TestHelper.credentials["password"], 99 | } 100 | self.balena.auth.login(**TestAuth.creds) 101 | self.assertTrue(self.balena.auth.is_logged_in()) 102 | 103 | def test_13_getters_should_return_info_when_logged_in(self): 104 | whoami = cast(UserKeyWhoAmIResponse, self.balena.auth.whoami()) 105 | 106 | self.assertEqual(whoami["actorType"], "user") 107 | self.assertEqual(whoami["username"], TestAuth.creds["username"]) 108 | self.assertEqual(whoami["email"], TestHelper.credentials["email"]) 109 | self.assertIsInstance(whoami["id"], int) 110 | self.assertIsInstance(whoami["actorTypeId"], int) 111 | 112 | actor_id = self.balena.auth.get_actor_id() 113 | self.assertIsInstance(actor_id, int) 114 | self.assertGreater(actor_id, 0) 115 | 116 | user_info = self.balena.auth.get_user_info() 117 | self.assertEqual(user_info["username"], TestAuth.creds["username"]) 118 | self.assertEqual(user_info["email"], TestHelper.credentials["email"]) 119 | self.assertEqual(user_info["actor"], actor_id) 120 | self.assertIsInstance(user_info["id"], int) 121 | self.assertGreater(user_info["id"], 0) 122 | 123 | def test_14_should_not_return_logged_in_when_logged_out(self): 124 | self.balena.auth.logout() 125 | self.assertFalse(self.balena.auth.is_logged_in()) 126 | 127 | def test_15_getters_should_return_info_when_logged_in_with_api_key(self): 128 | self.balena.auth.login_with_token(TestAuth.test_api_key) 129 | self.assertTrue(self.balena.auth.is_logged_in()) 130 | 131 | user_id = self.balena.auth.get_user_info()["id"] 132 | self.assertIsInstance(user_id, int) 133 | self.assertGreater(user_id, 0) 134 | 135 | actor_id = self.balena.auth.get_actor_id() 136 | self.assertIsInstance(actor_id, int) 137 | self.assertGreater(actor_id, 0) 138 | 139 | self.balena.auth.logout() 140 | self.assertFalse(self.balena.auth.is_logged_in()) 141 | 142 | self.assertIsNone(self.balena.auth.get_token()) 143 | 144 | def test_16_2fa(self): 145 | self.balena.auth.login(**TestAuth.creds) 146 | 147 | self.assertFalse(self.balena.auth.two_factor.is_enabled()) 148 | self.assertTrue(self.balena.auth.two_factor.is_passed()) 149 | 150 | secret = self.balena.auth.two_factor.get_setup_key() 151 | self.assertEqual(len(secret), 32) 152 | 153 | def test_17_should_login_with_device_key(self): 154 | device_uuid = self.app_info["device"]["uuid"] 155 | 156 | device_key = self.balena.models.device.generate_device_key(device_uuid) 157 | self.balena.auth.login_with_token(device_key) 158 | 159 | self.assertTrue(self.balena.auth.is_logged_in()) 160 | whoami = cast(DeviceKeyWhoAmIResponse, self.balena.auth.whoami()) 161 | 162 | self.assertEqual(whoami["actorType"], "device") 163 | self.assertEqual(whoami["actorTypeId"], self.app_info["device"]["id"]) 164 | self.assertEqual(whoami["uuid"], device_uuid) 165 | self.assertEqual(whoami["id"], self.app_info["device"]["actor"]["__id"]) 166 | 167 | self.assertEqual(self.balena.auth.get_actor_id(), self.app_info["device"]["actor"]["__id"]) 168 | 169 | errMsg = "The authentication credentials in use are not of a user" 170 | with self.assertRaises(Exception) as cm: 171 | self.balena.auth.get_user_info() 172 | self.assertIn(errMsg, str(cm.exception)) 173 | 174 | self.balena.auth.logout() 175 | self.assertFalse(self.balena.auth.is_logged_in()) 176 | 177 | self.assertIsNone(self.balena.auth.get_token()) 178 | 179 | def test_18_should_login_with_app_key(self): 180 | TestAuth.creds = { 181 | "username": TestHelper.credentials["user_id"], 182 | "password": TestHelper.credentials["password"], 183 | } 184 | self.balena.auth.login(**TestAuth.creds) 185 | self.assertTrue(self.balena.auth.is_logged_in()) 186 | app_id = self.app_info["app"]["id"] 187 | 188 | app_key = self.balena.models.application.generate_provisioning_key(app_id) 189 | self.balena.auth.login_with_token(app_key) 190 | 191 | self.assertTrue(self.balena.auth.is_logged_in()) 192 | whoami = cast(ApplicationKeyWhoAmIResponse, self.balena.auth.whoami()) 193 | 194 | self.assertEqual(whoami["actorType"], "application") 195 | self.assertEqual(whoami["actorTypeId"], app_id) 196 | self.assertEqual(whoami["id"], self.app_info["app"]["actor"]["__id"]) 197 | self.assertEqual(whoami["slug"], self.app_info["app"]["slug"]) 198 | 199 | self.assertEqual(self.balena.auth.get_actor_id(), self.app_info["app"]["actor"]["__id"]) 200 | 201 | errMsg = "The authentication credentials in use are not of a user" 202 | with self.assertRaises(Exception) as cm: 203 | self.balena.auth.get_user_info() 204 | self.assertIn(errMsg, str(cm.exception)) 205 | 206 | self.balena.auth.logout() 207 | self.assertFalse(self.balena.auth.is_logged_in()) 208 | 209 | self.assertIsNone(self.balena.auth.get_token()) 210 | 211 | def test_19_should_continue_using_current_token_if_refresh_fails(self): 212 | TestAuth.creds = { 213 | "username": TestHelper.credentials["user_id"], 214 | "password": TestHelper.credentials["password"], 215 | } 216 | self.balena.auth.login(**TestAuth.creds) 217 | self.assertTrue(self.balena.auth.is_logged_in()) 218 | 219 | token = self.balena.auth.get_token() 220 | self.assertIsNotNone(token) 221 | 222 | self.balena.auth.logout() 223 | 224 | # force token refresh with an invalid old token should not throw 225 | year_ago = int((datetime.utcnow() - timedelta(days=1 * 365)).timestamp()) 226 | new_token = jwt.encode({"iat": year_ago}, "dummy_secret", algorithm="HS256") 227 | self.balena.auth.login_with_token(new_token) 228 | token = get_token(self.balena.auth._Auth__settings) 229 | self.assertEqual(self.balena.auth.get_token(), new_token) 230 | -------------------------------------------------------------------------------- /tests/functional/test_logs.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from typing import List, Any 3 | 4 | from balena.balena_auth import request 5 | from tests.helper import TestHelper 6 | import time 7 | 8 | 9 | # Logs may sometimes take time to appear in the logs history. 10 | # 11 | # To handle this, the approach uses a more complex setup for detecting the initial burst of logs: 12 | # 13 | # We check for the first burst of logs every WAIT_FOR_FIRSTLOGS_TIMEOUT_S seconds 14 | # repeating this process up to WAIT_FOR_FIRSTLOGS_ATTEMPTS times. 15 | # Once the initial burst is received, the logging behavior becomes more stable, so we switch to fixed timeouts 16 | # The fixed timeouts include: 17 | # 18 | # WAIT_FOR_LOGS_TIMEOUT_S: The duration spent waiting for logs after the initial burst 19 | # WAIT_AFTER_SUBSCRIBE_TIMEOUT_S: Time after subscribing, ensuring the client has time to start or stop receiving logs 20 | 21 | 22 | WAIT_FOR_FIRSTLOGS_TIMEOUT_S = 5 23 | WAIT_FOR_FIRSTLOGS_ATTEMPTS = 20 24 | WAIT_FOR_LOGS_TIMEOUT_S = 20 25 | WAIT_AFTER_SUBSCRIBE_TIMEOUT_S = 10 26 | 27 | 28 | def send_log_messages(uuid: str, device_api_key: str, messages: List[Any], settings): 29 | request(method="POST", settings=settings, path=f"/device/v2/{uuid}/logs", token=device_api_key, body=messages) 30 | 31 | 32 | class TestAuth(unittest.TestCase): 33 | @classmethod 34 | def setUpClass(cls): 35 | cls.helper = TestHelper() 36 | cls.balena = cls.helper.balena 37 | cls.helper.wipe_application() 38 | cls.app = cls.balena.models.application.create( 39 | "FooBarLogs", "raspberry-pi2", cls.helper.default_organization["id"] 40 | ) 41 | 42 | cls.uuid = cls.balena.models.device.generate_uuid() 43 | registration_info = cls.balena.models.device.register(cls.app["id"], cls.uuid) 44 | cls.device_api_key = registration_info["api_key"] 45 | 46 | @classmethod 47 | def tearDownClass(cls): 48 | print("unsubscribes all") 49 | cls.balena.logs.unsubscribe_all() 50 | print("stop") 51 | cls.balena.logs.stop() 52 | 53 | # cls.balena.pine.delete({"resource": "device", "options": {"$filter": {"1": 1}}}) 54 | # cls.helper.wipe_organization() 55 | 56 | def __collect_logs(self, timeout: int = WAIT_FOR_LOGS_TIMEOUT_S, count=None): 57 | results = [] 58 | 59 | def cb(data): 60 | results.append(data) 61 | 62 | self.balena.logs.subscribe(self.uuid, cb, count=count) 63 | time.sleep(WAIT_AFTER_SUBSCRIBE_TIMEOUT_S) 64 | 65 | time.sleep(timeout) 66 | 67 | self.balena.logs.unsubscribe(self.uuid) 68 | time.sleep(WAIT_AFTER_SUBSCRIBE_TIMEOUT_S) 69 | 70 | return results 71 | 72 | def test_01_should_load_historical_logs_and_limit_by_count(self): 73 | send_log_messages( 74 | self.uuid, 75 | self.device_api_key, 76 | [ 77 | {"message": "1 message", "timestamp": int(time.time() * 1000)}, 78 | {"message": "2 message", "timestamp": int((time.time() * 1000))}, 79 | ], 80 | self.balena.settings, 81 | ) 82 | 83 | timeout = time.time() + WAIT_FOR_FIRSTLOGS_TIMEOUT_S * WAIT_FOR_FIRSTLOGS_ATTEMPTS 84 | expected_messages = ["1 message", "2 message"] 85 | 86 | while time.time() < timeout: 87 | messages = [log["message"] for log in self.balena.logs.history(self.uuid)] 88 | if messages == expected_messages: 89 | break 90 | time.sleep(WAIT_FOR_FIRSTLOGS_TIMEOUT_S) 91 | 92 | self.assertEqual(messages, expected_messages) 93 | 94 | messages = [log["message"] for log in self.balena.logs.history(self.uuid, count=1)] 95 | self.assertEqual(messages, ["2 message"]) 96 | 97 | def test_02_subscribe_should_not_fetch_historical_by_default(self): 98 | send_log_messages( 99 | self.uuid, 100 | self.device_api_key, 101 | [ 102 | {"message": "3 message", "timestamp": int(time.time() * 1000)}, 103 | {"message": "4 message", "timestamp": int(time.time() * 1000)}, 104 | ], 105 | self.balena.settings, 106 | ) 107 | 108 | logs = self.__collect_logs() 109 | self.assertEqual(logs, []) 110 | 111 | def test_03_subscribe_should_fetch_historical_data_if_requested(self): 112 | send_log_messages( 113 | self.uuid, 114 | self.device_api_key, 115 | [ 116 | {"message": "5 message", "timestamp": int(time.time() * 1000)}, 117 | {"message": "6 message", "timestamp": int(time.time() * 1000)}, 118 | ], 119 | self.balena.settings, 120 | ) 121 | 122 | log_messages = [log["message"] for log in self.__collect_logs(count="all")] 123 | print('log messages are', log_messages) 124 | self.assertTrue(all(msg in log_messages for msg in ["1 message", "2 message", "3 message", "4 message"])) 125 | 126 | def test_04_subscribe_should_limit_historical_data_if_requested(self): 127 | log_messages = [log["message"] for log in self.__collect_logs(count=1)] 128 | self.assertEqual(log_messages, ["6 message"]) 129 | 130 | def test_05_subscribe_should_stream_new_logs(self): 131 | results = [] 132 | 133 | def cb(data): 134 | results.append(data["message"]) 135 | 136 | self.balena.logs.subscribe(self.uuid, cb) 137 | time.sleep(WAIT_AFTER_SUBSCRIBE_TIMEOUT_S) 138 | 139 | send_log_messages( 140 | self.uuid, 141 | self.device_api_key, 142 | [ 143 | {"message": "7 message", "timestamp": int(time.time() * 1000)}, 144 | {"message": "8 message", "timestamp": int(time.time() * 1000)}, 145 | ], 146 | self.balena.settings, 147 | ) 148 | 149 | time.sleep(WAIT_FOR_LOGS_TIMEOUT_S) 150 | self.assertEqual(results, ["7 message", "8 message"]) 151 | 152 | self.balena.logs.unsubscribe(self.uuid) 153 | 154 | def test_06_should_allow_to_unsubscribe(self): 155 | results = [] 156 | 157 | def cb(data): 158 | results.append(data["message"]) 159 | 160 | self.balena.logs.subscribe(self.uuid, cb) 161 | time.sleep(WAIT_AFTER_SUBSCRIBE_TIMEOUT_S) 162 | self.balena.logs.unsubscribe(self.uuid) 163 | time.sleep(WAIT_AFTER_SUBSCRIBE_TIMEOUT_S) 164 | 165 | send_log_messages( 166 | self.uuid, 167 | self.device_api_key, 168 | [ 169 | {"message": "9 message", "timestamp": int(time.time() * 1000)}, 170 | {"message": "10 message", "timestamp": int(time.time() * 1000)}, 171 | ], 172 | self.balena.settings, 173 | ) 174 | 175 | self.assertEqual(results, []) 176 | -------------------------------------------------------------------------------- /tests/pep8-git-pr-checker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import shutil 4 | import subprocess 5 | import sys 6 | import tempfile 7 | 8 | # 501: line too long 9 | ignore_codes = ['E501', 'E731'] 10 | 11 | def system(*args, **kwargs): 12 | kwargs.setdefault('stdout', subprocess.PIPE) 13 | proc = subprocess.Popen(args, **kwargs) 14 | out, err = proc.communicate() 15 | return out 16 | 17 | 18 | def main(): 19 | files = [] 20 | output = system('git', 'ls-files').decode("utf-8") 21 | for tmp_file in output.split(): 22 | if tmp_file.endswith('.py'): 23 | files.append(tmp_file) 24 | 25 | tempdir = tempfile.mkdtemp() 26 | for name in files: 27 | filename = os.path.join(tempdir, name) 28 | filepath = os.path.dirname(filename) 29 | if not os.path.exists(filepath): 30 | os.makedirs(filepath) 31 | with open(filename, 'w') as f: 32 | system('git', 'show', ':{0}'.format(name), stdout=f) 33 | 34 | args = ['pep8'] 35 | args.extend(('--ignore', ','.join(ignore_codes))) 36 | args.append('.') 37 | output = system(*args, cwd=tempdir) 38 | shutil.rmtree(tempdir) 39 | if output: 40 | print(output) 41 | sys.exit(1) 42 | 43 | 44 | if __name__ == '__main__': 45 | main() 46 | -------------------------------------------------------------------------------- /tests/skip.py: -------------------------------------------------------------------------------- 1 | def test_skip(): 2 | print("Tests are run via the custom action in .github/actions/test/action.yml") 3 | assert True 4 | -------------------------------------------------------------------------------- /versionist.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const execSync = require('child_process').execSync; 4 | const exec = require('child_process').exec; 5 | const path = require('path'); 6 | 7 | const getAuthor = (commitHash) => { 8 | return execSync(`git show --quiet --format="%an" ${commitHash}`, { 9 | encoding: 'utf8' 10 | }).replace('\n', ''); 11 | }; 12 | 13 | const isIncrementalCommit = (changeType) => { 14 | return Boolean(changeType) && changeType.trim().toLowerCase() !== 'none'; 15 | }; 16 | 17 | module.exports = { 18 | // This setup allows the editing and parsing of footer tags to get version and type information, 19 | // as well as ensuring tags of the type 'v..' are used. 20 | // It increments in a semver compatible fashion and allows the updating of NPM package info. 21 | editChangelog: true, 22 | parseFooterTags: true, 23 | getGitReferenceFromVersion: 'v-prefix', 24 | incrementVersion: 'semver', 25 | updateVersion: (cwd, version, callback) => { 26 | execSync(`sed -i '/^__version__ = ".*"/ s/^__version__ = ".*"/__version__ = "${version}"/g' balena/__init__.py`, {encoding: 'utf8'}); 27 | 28 | const pyprojectToml = path.join(cwd, 'pyproject.toml'); 29 | return exec(`sed -i '/\[tool\.poetry\]/,/^version = ".*"/ s/^version = ".*"/version = "${version}"/g' ${pyprojectToml}`, 30 | { 31 | encoding: 'utf8', 32 | }, callback); 33 | }, 34 | 35 | // Always add the entry to the top of the Changelog, below the header. 36 | addEntryToChangelog: { 37 | preset: 'prepend', 38 | fromLine: 6 39 | }, 40 | 41 | // Only include a commit when there is a footer tag of 'change-type'. 42 | // Ensures commits which do not up versions are not included. 43 | includeCommitWhen: (commit) => { 44 | return isIncrementalCommit(commit.footer['change-type']); 45 | }, 46 | 47 | // Determine the type from 'change-type:' tag. 48 | // Should no explicit change type be made, then no changes are assumed. 49 | 50 | getIncrementLevelFromCommit: (commit) => { 51 | const match = commit.subject.match(/^(patch|minor|major):/i); 52 | if (Array.isArray(match) && isIncrementalCommit(match[1])) { 53 | return match[1].trim().toLowerCase(); 54 | } 55 | 56 | if (isIncrementalCommit(commit.footer['change-type'])) { 57 | return commit.footer['change-type'].trim().toLowerCase(); 58 | } 59 | }, 60 | 61 | // If a 'changelog-entry' tag is found, use this as the subject rather than the 62 | // first line of the commit. 63 | transformTemplateData: (data) => { 64 | data.commits.forEach((commit) => { 65 | commit.subject = commit.footer['changelog-entry'] || commit.subject; 66 | commit.author = getAuthor(commit.hash); 67 | }); 68 | 69 | return data; 70 | }, 71 | 72 | template: [ 73 | '## v{{version}} - {{moment date "Y-MM-DD"}}', 74 | '', 75 | '{{#each commits}}', 76 | '{{#if this.author}}', 77 | '* {{capitalize this.subject}} [{{this.author}}]', 78 | '{{else}}', 79 | '* {{capitalize this.subject}}', 80 | '{{/if}}', 81 | '{{/each}}' 82 | ].join('\n') 83 | }; 84 | --------------------------------------------------------------------------------