From c6601bad95bb05dbda1e868f6b3680741eec4f5b Mon Sep 17 00:00:00 2001 From: Lyonel Martinez Date: Wed, 23 Aug 2023 17:52:23 +0200 Subject: [PATCH 1/8] chore(hatch): hatch builder + ci + fix imports --- .github/workflows/ci.yml | 68 +++++++++ .github/workflows/deploy-release.yml | 28 ++++ README.md | 17 +++ pyproject.toml | 129 +++++++++++++++++- {vaultwarden => src/vaultwarden}/__init__.py | 0 .../vaultwarden}/__version__.py | 0 .../vaultwarden/clients}/__init__.py | 0 .../vaultwarden}/clients/bitwarden.py | 57 +++----- .../vaultwarden}/clients/vaultwarden.py | 18 +-- .../vaultwarden/models}/__init__.py | 0 .../vaultwarden}/models/api_models.py | 0 .../vaultwarden}/models/exception_models.py | 0 src/vaultwarden/utils/__init__.py | 0 .../vaultwarden}/utils/logger.py | 0 .../vaultwarden}/utils/tools.py | 0 15 files changed, 258 insertions(+), 59 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy-release.yml rename {vaultwarden => src/vaultwarden}/__init__.py (100%) rename {vaultwarden => src/vaultwarden}/__version__.py (100%) rename {vaultwarden/models => src/vaultwarden/clients}/__init__.py (100%) rename {vaultwarden => src/vaultwarden}/clients/bitwarden.py (91%) rename {vaultwarden => src/vaultwarden}/clients/vaultwarden.py (91%) rename {vaultwarden/utils => src/vaultwarden/models}/__init__.py (100%) rename {vaultwarden => src/vaultwarden}/models/api_models.py (100%) rename {vaultwarden => src/vaultwarden}/models/exception_models.py (100%) create mode 100644 src/vaultwarden/utils/__init__.py rename {vaultwarden => src/vaultwarden}/utils/logger.py (100%) rename {vaultwarden => src/vaultwarden}/utils/tools.py (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bf619f7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: [push, pull_request] + +jobs: + test: + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11'] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade hatch + - name: Run tests + run: | + hatch run +py=${{ matrix.py || matrix.python-version }} test:with-coverage + - name: Upload Codecov Results + if: success() + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: unittests + name: ${{ matrix.os }}/${{ matrix.python-version }} + fail_ci_if_error: false + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Install Python dependencies + run: | + python -m pip install --upgrade hatch + - name: Check with black + isort + if: always() + run: hatch run style:format && git diff --exit-code + - name: Check with flake8 + if: always() + run: hatch run style:lint + - name: Check with mypy + if: always() + run: hatch run types:check + + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Install Hatch + run: | + python -m pip install -U hatch + - name: Build package + run: | + hatch build \ No newline at end of file diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml new file mode 100644 index 0000000..1b6bbd5 --- /dev/null +++ b/.github/workflows/deploy-release.yml @@ -0,0 +1,28 @@ +name: deploy-release + +on: + push: + tags: + - '*' + +jobs: + pypi: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Install Hatch + run: | + python -m pip install -U hatch + - name: Build package + run: | + hatch build + - name: Publish + run: | + hatch publish + env: + HATCH_INDEX_USER: __token__ + HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} \ No newline at end of file diff --git a/README.md b/README.md index 6e581cd..c724be0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # python-vaultwarden +[![PyPI Version][pypi-v-image]][pypi-v-link] +[![Build Status][GHAction-image]][GHAction-link] +[![Coverage Status][codecov-image]][codecov-link] + A python library for vaultwarden ## Clients @@ -17,6 +21,19 @@ target user. The cryptographic part is handled by the [bitwardentools library](https://github.com/corpusops/bitwardentools). + +[codecov-image]: https://codecov.io/github/numberly/python-vaultwarden/coverage.svg?branch=main +[codecov-link]: https://codecov.io/github/numberly/python-vaultwarden?branch=main +[pypi-v-image]: https://img.shields.io/pypi/v/python-vaultwarden.svg +[pypi-v-link]: https://pypi.org/project/python-vaultwarden/ +[GHAction-image]: https://github.com/numberly/python-vaultwarden/workflows/CI/badge.svg?branch=main&event=push +[GHAction-link]: https://github.com/numberly/python-vaultwarden/actions?query=event%3Apush+branch%3Amain + +[Issue]: https://github.com/numberly/python-vaultwarden/issues +[Discussions]: https://github.com/numberly/python-vaultwarden/discussions +[PyPA Code of Conduct]: https://www.pypa.io/en/latest/code-of-conduct/ + + ## License Python-vaultwarden is distributed under the terms of the [Apache](https://spdx.org/licenses/Apache-2.0.html) license. diff --git a/pyproject.toml b/pyproject.toml index 1335ddd..d10bb98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,28 +1,143 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "python-vaultwarden" version = "0.7.0" -description = "The Vautlwarden Python Client" -requires-python = ">=3.11" +description = "Admin Vautlwarden and Simple Bitwarden Python Client" authors = [ { name = "Lyonel Martinez", email = "lyonel.martinez@numberly.com" }, { name = "Mathis Ribet", email = "mathis.ribet@numberly.com" }, ] +license = "MIT" +readme = "README.md" +repository = "https://github.com/numberly/python-vaultwarden" +documentation = "https://numberly.github.io/python-vaultwarden/" +packages = [ + { include = "vaultwarden", from = "src" }, +] classifiers = [ "Development Status :: 4 - Beta", - "Environment :: Web Environment", "Intended Audience :: Developers", + "Environment :: Web Environment", + "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Typing :: Typed", "Topic :: Internet :: WWW/HTTP", + # Include this classifier to prevent accidently publishing private code to PyPI. + # https://pypi.org/classifiers/ + "Private :: Do Not Upload", +] +requires-python = ">=3.10" +dependencies = [ + "bitwardentools >=1.0.55", + "httpx >=0.24.1", +] + +[project.optional-dependencies] +dev = [ + "black >=23.7.0", + "commitizen >=3.6.0", + "isort >=5.9.3", + "mypy >=0.910", +] +test = [ + "pytest~=6.2.5", +] + +[tool.hatch.version] +path = "src/vaultwarden/__version__.py" + +[tool.hatch.build] +packages = [ + "src/vaultwarden", +] +include = [ + "/tests", +] + +[tool.hatch.build.targets.sdist] +include = ["/src/vaultwarden/**/*.py"] +[tool.hatch.build.targets.wheel] +packages = [ + "src/vaultwarden", ] + +[tool.hatch.envs.test] +dependencies = [ + "coverage", +] + +[tool.hatch.envs.test.scripts] +test = "coverage run --source=src/vaultwarden -m unittest discover -p 'test_*.py' tests --top-level-directory ." +_coverage = ["test", "coverage xml", "coverage report --show-missing"] +with-coverage = "test" +[[tool.hatch.envs.test.matrix]] +python = ["3.10", "3.11"] +type = ["default"] +[tool.hatch.envs.test.overrides] +matrix.type.scripts = [ + { key = "with-coverage", value = "_coverage", if = ["default"] }, +] + +[tool.hatch.envs.types] +dependencies = [ + "mypy", + "types-PyYAML", + "types-setuptools", + "typing-extensions", +] +[tool.hatch.envs.types.scripts] +check = "mypy src/vaultwarden" + +[tool.hatch.envs.style] +detached = true dependencies = [ - "bitwardentools==1.0.55", - "httpx==0.24.1", + "black", + "isort", + "flake8", + "flake8-type-checking", +] +[tool.hatch.envs.style.scripts] +lint = [ + "flake8 src/vaultwarden", +] +check = [ + "isort --check-only --diff src/vaultwarden", + "black -q --check --diff src/vaultwarden", + "lint", +] +format = [ + "isort -q src/vaultwarden", + "black -q src/vaultwarden", ] + +[tool.black] +line-length = 99 +target-version = ["py310", "py311"] + +[tool.isort] +profile = "black" +line_length = 100 + +[tool.mypy] +ignore_missing_imports = true +warn_unreachable = true +no_implicit_optional = true +show_error_codes = true + [tool.commitizen] version = "0.7.0" +tag_format = "$version" +update_changelog_on_bump = true version_files = [ - "pyproject.toml:version", - "vaultwarden/__version__.py", + "src/vaultwarden/__version__.py", ] diff --git a/vaultwarden/__init__.py b/src/vaultwarden/__init__.py similarity index 100% rename from vaultwarden/__init__.py rename to src/vaultwarden/__init__.py diff --git a/vaultwarden/__version__.py b/src/vaultwarden/__version__.py similarity index 100% rename from vaultwarden/__version__.py rename to src/vaultwarden/__version__.py diff --git a/vaultwarden/models/__init__.py b/src/vaultwarden/clients/__init__.py similarity index 100% rename from vaultwarden/models/__init__.py rename to src/vaultwarden/clients/__init__.py diff --git a/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py similarity index 91% rename from vaultwarden/clients/bitwarden.py rename to src/vaultwarden/clients/bitwarden.py index d14b856..2537bb7 100644 --- a/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -6,10 +6,10 @@ from bitwardentools.crypto import make_master_key, decrypt, encrypt from httpx import Client, Response, HTTPError -from ..models.api_models import ApiToken -from ..models.exception_models import BitwardenException -from ..utils.logger import logger -from ..utils.tools import ( +from vaultwarden.models.api_models import ApiToken +from vaultwarden.models.exception_models import BitwardenException +from vaultwarden.utils.logger import logger +from vaultwarden.utils.tools import ( log_raise_for_status, get_collection_id_from_ditcs, get_matching_ids_from_ditcs, @@ -53,9 +53,7 @@ def _refresh_api_token(self) -> None: "grant_type": "refresh_token", "refresh_token": f"{self.api_token.token.get('refresh_token')}", } - resp = self._http_client.post( - "identity/connect/token", headers=headers, data=payload - ) + resp = self._http_client.post("identity/connect/token", headers=headers, data=payload) json_resp = resp.json() self.api_token.refresh(json_resp) @@ -83,9 +81,7 @@ def _api_login(self) -> None: "deviceIdentifier": f"{self.device_id}", "deviceName": "python-vaultwarden", } - resp = self._http_client.post( - "identity/connect/token", headers=headers, data=payload - ) + resp = self._http_client.post("identity/connect/token", headers=headers, data=payload) json_resp = resp.json() master_key = make_master_key( password=self.password, @@ -178,9 +174,7 @@ def deduplicate_collection(self, organization_id): # Building user list by merging all accesses and choose which collection will not be deleted # (based on most users) for collection_id in id_list: - coll_users = self.get_users_of_collection( - organization_id, collection_id - ) + coll_users = self.get_users_of_collection(organization_id, collection_id) if len(coll_users.values()) >= nb_users: base_id = collection_id nb_users = len(coll_users.values()) @@ -194,9 +188,7 @@ def deduplicate_collection(self, organization_id): for collection_id in id_list: # List items inside the current collection and move these items to the chosen collection - ciphers = self.get_organizations_collection_items( - organization_id, collection_id - ) + ciphers = self.get_organizations_collection_items(organization_id, collection_id) for cipher in ciphers: cipher_collections = set(cipher.get("CollectionIds")) cipher_collections.remove(collection_id) @@ -210,9 +202,7 @@ def deduplicate_collection(self, organization_id): def add_collection_to_user( self, organization_id: str, user_org_id: str, accesses: dict, coll_id: str ): - return self.add_collections_to_user( - organization_id, user_org_id, accesses, [coll_id] - ) + return self.add_collections_to_user(organization_id, user_org_id, accesses, [coll_id]) def add_collections_to_user( self, @@ -247,9 +237,7 @@ def add_collections_to_user( def remove_collection_to_user( self, organization_id: str, user_org_id: str, accesses: dict, coll_id: str ): - return self.remove_collections_to_user( - organization_id, user_org_id, accesses, [coll_id] - ) + return self.remove_collections_to_user(organization_id, user_org_id, accesses, [coll_id]) def remove_collections_to_user( self, @@ -290,9 +278,7 @@ def get_organization_items(self, organization_id, deleted=False): def get_organizations_collection_items(self, organization_id, collections_id): ciphers = self.get_organization_items(organization_id) - return list( - filter(lambda cipher: (collections_id in cipher["CollectionIds"]), ciphers) - ) + return list(filter(lambda cipher: (collections_id in cipher["CollectionIds"]), ciphers)) def change_collections_item(self, item_id, collection_ids): self._api_request( @@ -305,10 +291,7 @@ def change_collections_item(self, item_id, collection_ids): def create_collection( self, org_id, collection_name, collections_names=None, collections_ids=None ): - if ( - collections_names is not None - and collections_names.get(collection_name) is not None - ): + if collections_names is not None and collections_names.get(collection_name) is not None: return collections_names[collection_name][-1] data = { "Name": encrypt(2, collection_name, self.get_org_key(org_id)), @@ -428,15 +411,11 @@ def get_user_org_accesses(self, user_email, user_organization_ids): return res, warn_not_maintain # Invitation - def invite_organisation_collection( - self, org_id, collection_id, email, access_type=2 - ): + def invite_organisation_collection(self, org_id, collection_id, email, access_type=2): new_access = { "emails": [email], "Groups": [], - "Collections": [ - {"hidePasswords": False, "id": collection_id, "readOnly": False} - ], + "Collections": [{"hidePasswords": False, "id": collection_id, "readOnly": False}], "accessAll": False, "type": access_type, } @@ -444,9 +423,7 @@ def invite_organisation_collection( "POST", f"api/organizations/{org_id}/users/invite", json=new_access ) - def invite_organisation_collections( - self, org_id, collections_id, email, access_type=2 - ): + def invite_organisation_collections(self, org_id, collections_id, email, access_type=2): new_access = { "emails": [email], "Groups": [], @@ -469,9 +446,7 @@ def invite_organisation(self, org_id, user_accesses, email): "accessAll": user_accesses["AccessAll"], "type": user_accesses["Type"], } - return self._api_request( - "POST", f"api/organizations/{org_id}/users/invite", json=data - ) + return self._api_request("POST", f"api/organizations/{org_id}/users/invite", json=data) # Re-invite a user with the same accesses. Return True if at least one organization has been re-invited def invite_with_accesses(self, organizations, email): diff --git a/vaultwarden/clients/vaultwarden.py b/src/vaultwarden/clients/vaultwarden.py similarity index 91% rename from vaultwarden/clients/vaultwarden.py rename to src/vaultwarden/clients/vaultwarden.py index 2cdf4cf..9db1116 100644 --- a/vaultwarden/clients/vaultwarden.py +++ b/src/vaultwarden/clients/vaultwarden.py @@ -4,11 +4,11 @@ from httpx import Response, Client, HTTPStatusError -from .bitwarden import BitwardenClient -from ..models.api_models import VaultWardenUser -from ..models.exception_models import VaultwardenAdminException -from ..utils.logger import logger -from ..utils.tools import log_raise_for_status +from vaultwarden.clients.bitwarden import BitwardenClient +from vaultwarden.models.api_models import VaultWardenUser +from vaultwarden.models.exception_models import VaultwardenAdminException +from vaultwarden.utils.logger import logger +from vaultwarden.utils.tools import log_raise_for_status class VaultwardenAdminClient: @@ -42,9 +42,7 @@ def _admin_login(self) -> None: # Refresh self._http_client.post("", data={"token": self.admin_secret_token}) - def _admin_request( - self, method: Literal["GET", "POST"], path: str, **kwargs: Any - ) -> Response: + def _admin_request(self, method: Literal["GET", "POST"], path: str, **kwargs: Any) -> Response: self._admin_login() return self._http_client.request(method, path, **kwargs) # type: ignore @@ -139,9 +137,7 @@ def transfer_account_rights( user_email=previous_email, user_organization_ids=user.get("Organizations") ) if warning: - logger.warning( - "A organisation in the rights is not maintain by SOC account" - ) + logger.warning("A organisation in the rights is not maintain by SOC account") if len(accesses) == 0: logger.warning("No organisation in the rights") res = self.invite(new_email) is not None diff --git a/vaultwarden/utils/__init__.py b/src/vaultwarden/models/__init__.py similarity index 100% rename from vaultwarden/utils/__init__.py rename to src/vaultwarden/models/__init__.py diff --git a/vaultwarden/models/api_models.py b/src/vaultwarden/models/api_models.py similarity index 100% rename from vaultwarden/models/api_models.py rename to src/vaultwarden/models/api_models.py diff --git a/vaultwarden/models/exception_models.py b/src/vaultwarden/models/exception_models.py similarity index 100% rename from vaultwarden/models/exception_models.py rename to src/vaultwarden/models/exception_models.py diff --git a/src/vaultwarden/utils/__init__.py b/src/vaultwarden/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vaultwarden/utils/logger.py b/src/vaultwarden/utils/logger.py similarity index 100% rename from vaultwarden/utils/logger.py rename to src/vaultwarden/utils/logger.py diff --git a/vaultwarden/utils/tools.py b/src/vaultwarden/utils/tools.py similarity index 100% rename from vaultwarden/utils/tools.py rename to src/vaultwarden/utils/tools.py From 9de2ae469af34ef58e093d3843227bafc97a74db Mon Sep 17 00:00:00 2001 From: Lyonel Martinez Date: Wed, 23 Aug 2023 18:25:38 +0200 Subject: [PATCH 2/8] chore(format-lint): formatting and linting --- .github/workflows/ci.yml | 6 +- .github/workflows/deploy-release.yml | 2 +- README.md | 10 +- pyproject.toml | 5 +- src/vaultwarden/clients/bitwarden.py | 164 +++++++++++++++++++------ src/vaultwarden/clients/vaultwarden.py | 46 +++++-- src/vaultwarden/models/api_models.py | 6 +- src/vaultwarden/utils/tools.py | 9 +- 8 files changed, 187 insertions(+), 61 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf619f7..acb41c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,14 @@ name: CI -on: [push, pull_request] +on: [ push, pull_request ] jobs: test: strategy: fail-fast: false matrix: - python-version: ['3.10', '3.11'] - os: [ubuntu-latest] + python-version: [ '3.10', '3.11' ] + os: [ ubuntu-latest ] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index 1b6bbd5..a460dda 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -3,7 +3,7 @@ name: deploy-release on: push: tags: - - '*' + - '*' jobs: pypi: diff --git a/README.md b/README.md index c724be0..406f883 100644 --- a/README.md +++ b/README.md @@ -22,17 +22,25 @@ The cryptographic part is handled by the [bitwardentools library](https://github + [codecov-image]: https://codecov.io/github/numberly/python-vaultwarden/coverage.svg?branch=main + [codecov-link]: https://codecov.io/github/numberly/python-vaultwarden?branch=main + [pypi-v-image]: https://img.shields.io/pypi/v/python-vaultwarden.svg + [pypi-v-link]: https://pypi.org/project/python-vaultwarden/ + [GHAction-image]: https://github.com/numberly/python-vaultwarden/workflows/CI/badge.svg?branch=main&event=push + [GHAction-link]: https://github.com/numberly/python-vaultwarden/actions?query=event%3Apush+branch%3Amain + [Issue]: https://github.com/numberly/python-vaultwarden/issues + [Discussions]: https://github.com/numberly/python-vaultwarden/discussions -[PyPA Code of Conduct]: https://www.pypa.io/en/latest/code-of-conduct/ +[PyPA Code of Conduct]: https://www.pypa.io/en/latest/code-of-conduct/ ## License diff --git a/pyproject.toml b/pyproject.toml index d10bb98..2fc8021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,12 +121,13 @@ format = [ [tool.black] -line-length = 99 +line-length = 79 target-version = ["py310", "py311"] [tool.isort] profile = "black" -line_length = 100 +line_length = 80 + [tool.mypy] ignore_missing_imports = true diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 2537bb7..0e257fb 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -1,18 +1,20 @@ # Class Bitwarden client with a httpx client -from typing import Optional, Literal, Any, List -from uuid import UUID +from typing import TYPE_CHECKING, Any, List, Literal, Optional + +if TYPE_CHECKING: + from uuid import UUID from bitwardentools import caseinsentive_key_search -from bitwardentools.crypto import make_master_key, decrypt, encrypt -from httpx import Client, Response, HTTPError +from bitwardentools.crypto import decrypt, encrypt, make_master_key +from httpx import Client, HTTPError, Response from vaultwarden.models.api_models import ApiToken from vaultwarden.models.exception_models import BitwardenException from vaultwarden.utils.logger import logger from vaultwarden.utils.tools import ( - log_raise_for_status, get_collection_id_from_ditcs, get_matching_ids_from_ditcs, + log_raise_for_status, ) @@ -27,7 +29,9 @@ def __init__( device_id: UUID | str, ): # if one of the parameters is None, raise an exception - if not all([url, email, password, client_id, client_secret, device_id]): + if not all( + [url, email, password, client_id, client_secret, device_id] + ): raise BitwardenException("All parameters are required") self.email = email self.password = password @@ -36,7 +40,8 @@ def __init__( self.device_id = device_id self.url = url.strip("/") self._http_client = Client( - base_url=f"{self.url}/", event_hooks={"response": [log_raise_for_status]} + base_url=f"{self.url}/", + event_hooks={"response": [log_raise_for_status]}, ) self.api_token: Optional[ApiToken] = None self.sync = None @@ -53,7 +58,9 @@ def _refresh_api_token(self) -> None: "grant_type": "refresh_token", "refresh_token": f"{self.api_token.token.get('refresh_token')}", } - resp = self._http_client.post("identity/connect/token", headers=headers, data=payload) + resp = self._http_client.post( + "identity/connect/token", headers=headers, data=payload + ) json_resp = resp.json() self.api_token.refresh(json_resp) @@ -81,17 +88,24 @@ def _api_login(self) -> None: "deviceIdentifier": f"{self.device_id}", "deviceName": "python-vaultwarden", } - resp = self._http_client.post("identity/connect/token", headers=headers, data=payload) + resp = self._http_client.post( + "identity/connect/token", headers=headers, data=payload + ) json_resp = resp.json() master_key = make_master_key( password=self.password, salt=self.email, iterations=caseinsentive_key_search(json_resp, "KdfIterations"), ) - self.api_token = ApiToken(json_resp, master_key, json_resp["expires_in"]) + self.api_token = ApiToken( + json_resp, master_key, json_resp["expires_in"] + ) def _api_request( - self, method: Literal["GET", "POST", "DELETE", "PUT"], path: str, **kwargs: Any + self, + method: Literal["GET", "POST", "DELETE", "PUT"], + path: str, + **kwargs: Any, ) -> Response: self._api_login() headers = { @@ -99,7 +113,9 @@ def _api_request( "content-type": "application/json; charset=utf-8", "Accept": "*/*", } - return self._http_client.request(method, path, headers=headers, **kwargs) + return self._http_client.request( + method, path, headers=headers, **kwargs + ) def get_sync(self): if self.sync is None: @@ -108,7 +124,9 @@ def get_sync(self): return self.sync # Organization Management - def get_organization_user_details(self, organization_id: str, user_org_id: str): + def get_organization_user_details( + self, organization_id: str, user_org_id: str + ): return self._api_request( "GET", f"api/organizations/{organization_id}/users/{user_org_id}", @@ -174,7 +192,9 @@ def deduplicate_collection(self, organization_id): # Building user list by merging all accesses and choose which collection will not be deleted # (based on most users) for collection_id in id_list: - coll_users = self.get_users_of_collection(organization_id, collection_id) + coll_users = self.get_users_of_collection( + organization_id, collection_id + ) if len(coll_users.values()) >= nb_users: base_id = collection_id nb_users = len(coll_users.values()) @@ -188,21 +208,31 @@ def deduplicate_collection(self, organization_id): for collection_id in id_list: # List items inside the current collection and move these items to the chosen collection - ciphers = self.get_organizations_collection_items(organization_id, collection_id) + ciphers = self.get_organizations_collection_items( + organization_id, collection_id + ) for cipher in ciphers: cipher_collections = set(cipher.get("CollectionIds")) cipher_collections.remove(collection_id) cipher_collections.add(base_id) - self.change_collections_item(cipher["Id"], list(cipher_collections)) + self.change_collections_item( + cipher["Id"], list(cipher_collections) + ) # delete the current collection self.delete_collection(organization_id, collection_id) # Collections users Management def add_collection_to_user( - self, organization_id: str, user_org_id: str, accesses: dict, coll_id: str + self, + organization_id: str, + user_org_id: str, + accesses: dict, + coll_id: str, ): - return self.add_collections_to_user(organization_id, user_org_id, accesses, [coll_id]) + return self.add_collections_to_user( + organization_id, user_org_id, accesses, [coll_id] + ) def add_collections_to_user( self, @@ -218,7 +248,11 @@ def add_collections_to_user( ] logger.debug("User info | %s", accesses) known_collection = next( - (item for item in coll_list if item["Id"] not in accesses["Collections"]), + ( + item + for item in coll_list + if item["Id"] not in accesses["Collections"] + ), False, ) if known_collection is False: @@ -235,9 +269,15 @@ def add_collections_to_user( ) def remove_collection_to_user( - self, organization_id: str, user_org_id: str, accesses: dict, coll_id: str + self, + organization_id: str, + user_org_id: str, + accesses: dict, + coll_id: str, ): - return self.remove_collections_to_user(organization_id, user_org_id, accesses, [coll_id]) + return self.remove_collections_to_user( + organization_id, user_org_id, accesses, [coll_id] + ) def remove_collections_to_user( self, @@ -248,7 +288,12 @@ def remove_collections_to_user( ): data = {} known_collection = next( - (item for item in accesses["Collections"] if item["Id"] in coll_ids), False + ( + item + for item in accesses["Collections"] + if item["Id"] in coll_ids + ), + False, ) if known_collection is False: return @@ -276,9 +321,16 @@ def get_organization_items(self, organization_id, deleted=False): .get("Data") ) - def get_organizations_collection_items(self, organization_id, collections_id): + def get_organizations_collection_items( + self, organization_id, collections_id + ): ciphers = self.get_organization_items(organization_id) - return list(filter(lambda cipher: (collections_id in cipher["CollectionIds"]), ciphers)) + return list( + filter( + lambda cipher: (collections_id in cipher["CollectionIds"]), + ciphers, + ) + ) def change_collections_item(self, item_id, collection_ids): self._api_request( @@ -289,9 +341,16 @@ def change_collections_item(self, item_id, collection_ids): # Collections Management def create_collection( - self, org_id, collection_name, collections_names=None, collections_ids=None + self, + org_id, + collection_name, + collections_names=None, + collections_ids=None, ): - if collections_names is not None and collections_names.get(collection_name) is not None: + if ( + collections_names is not None + and collections_names.get(collection_name) is not None + ): return collections_names[collection_name][-1] data = { "Name": encrypt(2, collection_name, self.get_org_key(org_id)), @@ -311,7 +370,9 @@ def create_collection( # Return 2 dicts: one indexed by name, one indexed by id, both containing the collections details def get_organization_collections_dicts(self, org_id): - resp = self._api_request("GET", f"api/organizations/{org_id}/collections") + resp = self._api_request( + "GET", f"api/organizations/{org_id}/collections" + ) colls = resp.json().get("Data") res_by_name = {} res_by_id = {} @@ -327,7 +388,9 @@ def get_organization_collections_dicts(self, org_id): # Return a list of tuple (collection_name, collection_details) def get_organization_collections(self, org_id): - resp = self._api_request("GET", f"api/organizations/{org_id}/collections") + resp = self._api_request( + "GET", f"api/organizations/{org_id}/collections" + ) colls = resp.json().get("Data") res = [] org_key = self.get_org_key(org_id) @@ -337,7 +400,11 @@ def get_organization_collections(self, org_id): return res def get_collection_id_or_create( - self, org_id, collection_name, collections_names=None, collections_ids=None + self, + org_id, + collection_name, + collections_names=None, + collections_ids=None, ): if collections_names is None or collections_ids is None: ( @@ -355,11 +422,16 @@ def get_collection_id_or_create( def delete_collection(self, organization_id, collection_id): return self._api_request( - "DELETE", f"api/organizations/{organization_id}/collections/{collection_id}" + "DELETE", + f"api/organizations/{organization_id}/collections/{collection_id}", ) def get_matching_collections_ids_or_create( - self, org_id, collection_name, collections_names=None, collections_ids=None + self, + org_id, + collection_name, + collections_names=None, + collections_ids=None, ): if collections_names is None or collections_ids is None: ( @@ -376,7 +448,9 @@ def get_matching_collections_ids_or_create( return res def get_users_of_collection(self, organization_id, collection_id): - users = self.get_users_of_collection_raw(organization_id, collection_id) + users = self.get_users_of_collection_raw( + organization_id, collection_id + ) return {u["Id"]: u for u in users} def get_users_of_collection_raw(self, organization_id, collection_id): @@ -411,11 +485,19 @@ def get_user_org_accesses(self, user_email, user_organization_ids): return res, warn_not_maintain # Invitation - def invite_organisation_collection(self, org_id, collection_id, email, access_type=2): + def invite_organisation_collection( + self, org_id, collection_id, email, access_type=2 + ): new_access = { "emails": [email], "Groups": [], - "Collections": [{"hidePasswords": False, "id": collection_id, "readOnly": False}], + "Collections": [ + { + "hidePasswords": False, + "id": collection_id, + "readOnly": False, + } + ], "accessAll": False, "type": access_type, } @@ -423,12 +505,18 @@ def invite_organisation_collection(self, org_id, collection_id, email, access_ty "POST", f"api/organizations/{org_id}/users/invite", json=new_access ) - def invite_organisation_collections(self, org_id, collections_id, email, access_type=2): + def invite_organisation_collections( + self, org_id, collections_id, email, access_type=2 + ): new_access = { "emails": [email], "Groups": [], "Collections": [ - {"hidePasswords": False, "id": collection_id, "readOnly": False} + { + "hidePasswords": False, + "id": collection_id, + "readOnly": False, + } for collection_id in collections_id ], "accessAll": False, @@ -446,7 +534,9 @@ def invite_organisation(self, org_id, user_accesses, email): "accessAll": user_accesses["AccessAll"], "type": user_accesses["Type"], } - return self._api_request("POST", f"api/organizations/{org_id}/users/invite", json=data) + return self._api_request( + "POST", f"api/organizations/{org_id}/users/invite", json=data + ) # Re-invite a user with the same accesses. Return True if at least one organization has been re-invited def invite_with_accesses(self, organizations, email): diff --git a/src/vaultwarden/clients/vaultwarden.py b/src/vaultwarden/clients/vaultwarden.py index 9db1116..c0689cc 100644 --- a/src/vaultwarden/clients/vaultwarden.py +++ b/src/vaultwarden/clients/vaultwarden.py @@ -1,21 +1,27 @@ import http -from http.cookiejar import Cookie -from typing import Optional, Literal, Any +from typing import TYPE_CHECKING, Any, Literal, Optional -from httpx import Response, Client, HTTPStatusError +if TYPE_CHECKING: + from http.cookiejar import Cookie + +from httpx import Client, HTTPStatusError, Response -from vaultwarden.clients.bitwarden import BitwardenClient -from vaultwarden.models.api_models import VaultWardenUser from vaultwarden.models.exception_models import VaultwardenAdminException from vaultwarden.utils.logger import logger from vaultwarden.utils.tools import log_raise_for_status +if TYPE_CHECKING: + from vaultwarden.clients.bitwarden import BitwardenClient + from vaultwarden.models.api_models import VaultWardenUser + class VaultwardenAdminClient: def __init__(self, url: str, admin_secret_token: str, preload_users: bool): # If url or admin_secret_token is None, raise an exception if not url or not admin_secret_token: - raise VaultwardenAdminException("Missing url or admin_secret_token") + raise VaultwardenAdminException( + "Missing url or admin_secret_token" + ) self.admin_secret_token = admin_secret_token self.url = url.strip("/") self._http_client = Client( @@ -29,7 +35,9 @@ def __init__(self, url: str, admin_secret_token: str, preload_users: bool): def _get_admin_cookie(self) -> Optional[Cookie]: """Get the session cookie, required to authenticate requests""" - bw_cookies = (c for c in self._http_client.cookies.jar if c.name == "VW_ADMIN") + bw_cookies = ( + c for c in self._http_client.cookies.jar if c.name == "VW_ADMIN" + ) return next(bw_cookies, None) def _admin_login(self) -> None: @@ -42,7 +50,9 @@ def _admin_login(self) -> None: # Refresh self._http_client.post("", data={"token": self.admin_secret_token}) - def _admin_request(self, method: Literal["GET", "POST"], path: str, **kwargs: Any) -> Response: + def _admin_request( + self, method: Literal["GET", "POST"], path: str, **kwargs: Any + ) -> Response: self._admin_login() return self._http_client.request(method, path, **kwargs) # type: ignore @@ -81,7 +91,9 @@ def remove_2fa(self, email: str) -> None: user = self.get_user(email) if user is None: raise VaultwardenAdminException(f"User {email} not found") - self._admin_request("POST", f"users/{email}/remove-2fa").raise_for_status() + self._admin_request( + "POST", f"users/{email}/remove-2fa" + ).raise_for_status() def get_user(self, search: str) -> Any | None: """Search term is either an email in cache or a UUID. @@ -104,7 +116,9 @@ def get_user(self, search: str) -> Any | None: return resp.json() def get_all_users(self) -> list[VaultWardenUser]: - users: list[VaultWardenUser] = self._admin_request("GET", "users").json() + users: list[VaultWardenUser] = self._admin_request( + "GET", "users" + ).json() self._fill_id_mail_pool(users) return users @@ -129,15 +143,21 @@ def reset_account(self, email: str, bitwarden_client: BitwardenClient): return None def transfer_account_rights( - self, previous_email: str, new_email: str, bitwarden_client: BitwardenClient + self, + previous_email: str, + new_email: str, + bitwarden_client: BitwardenClient, ): res = True user: VaultWardenUser = self.get_user(previous_email) accesses, warning = bitwarden_client.get_user_org_accesses( - user_email=previous_email, user_organization_ids=user.get("Organizations") + user_email=previous_email, + user_organization_ids=user.get("Organizations"), ) if warning: - logger.warning("A organisation in the rights is not maintain by SOC account") + logger.warning( + "A organisation in the rights is not maintain by SOC account" + ) if len(accesses) == 0: logger.warning("No organisation in the rights") res = self.invite(new_email) is not None diff --git a/src/vaultwarden/models/api_models.py b/src/vaultwarden/models/api_models.py index d5fa538..d1db3a7 100644 --- a/src/vaultwarden/models/api_models.py +++ b/src/vaultwarden/models/api_models.py @@ -1,5 +1,5 @@ import time -from typing import TypedDict, Literal +from typing import Literal, TypedDict from bitwardentools.crypto import decrypt @@ -12,7 +12,9 @@ def __init__(self, token, master_key, expires_in: int): self.token = token self.expires = time.time() + expires_in self.token["user_key"] = decrypt(token["Key"], master_key) - self.token["orgs_key"] = decrypt(token["PrivateKey"], self.token["user_key"]) + self.token["orgs_key"] = decrypt( + token["PrivateKey"], self.token["user_key"] + ) def is_expired(self, now=None): if now is None: diff --git a/src/vaultwarden/utils/tools.py b/src/vaultwarden/utils/tools.py index 3ace545..3e58676 100644 --- a/src/vaultwarden/utils/tools.py +++ b/src/vaultwarden/utils/tools.py @@ -1,4 +1,7 @@ -from httpx import Response +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from httpx import Response from .logger import logger @@ -19,7 +22,9 @@ def get_matching_ids_from_ditcs(collections_names, collection_name): def log_raise_for_status(response: Response) -> None: if response.status_code == 403: - logger.error("Error: 403 Forbidden. Given Account has not access the data.") + logger.error( + "Error: 403 Forbidden. Given Account has not access the data." + ) if response.status_code >= 400: logger.error(f"Error: {response.status_code}") response.raise_for_status() From 41440047d969b13c6384d1fc7c8e6897e1c011a2 Mon Sep 17 00:00:00 2001 From: Lyonel Martinez Date: Wed, 23 Aug 2023 18:28:38 +0200 Subject: [PATCH 3/8] chore(format-lint): add ignores --- .flake8 | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..8b95ac5 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +extend-ignore = E501, E203, TC004 From feb6b68de11a5a2d02184796d718b7b46c3ef506 Mon Sep 17 00:00:00 2001 From: Lyonel Martinez Date: Wed, 23 Aug 2023 19:23:05 +0200 Subject: [PATCH 4/8] types(api-token): api-token as Property and fix tests --- src/vaultwarden/clients/bitwarden.py | 19 ++++++++++++------- src/vaultwarden/clients/vaultwarden.py | 12 ++++++------ src/vaultwarden/models/api_models.py | 1 - src/vaultwarden/utils/tools.py | 7 +------ tests/__init__.py | 0 tests/utils/__init__.py | 0 6 files changed, 19 insertions(+), 20 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/utils/__init__.py diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index 0e257fb..bee18f4 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -43,11 +43,17 @@ def __init__( base_url=f"{self.url}/", event_hooks={"response": [log_raise_for_status]}, ) - self.api_token: Optional[ApiToken] = None + self._api_token: Optional[ApiToken] = None self.sync = None - def _get_api_token(self) -> Optional[ApiToken]: - return self.api_token + @property + def api_token(self) -> ApiToken: + assert self._api_token is not None + return self._api_token + + @api_token.setter + def api_token(self, value: ApiToken): + self._api_token = value # refresh api token if expired def _refresh_api_token(self) -> None: @@ -67,11 +73,10 @@ def _refresh_api_token(self) -> None: # login to api def _api_login(self) -> None: - token = self._get_api_token() - if token and not token.is_expired(): + if self._api_token and not self.api_token.is_expired(): return - if token and token.is_expired(): + if self._api_token and self.api_token.is_expired(): self._refresh_api_token() return @@ -97,7 +102,7 @@ def _api_login(self) -> None: salt=self.email, iterations=caseinsentive_key_search(json_resp, "KdfIterations"), ) - self.api_token = ApiToken( + self._api_token = ApiToken( json_resp, master_key, json_resp["expires_in"] ) diff --git a/src/vaultwarden/clients/vaultwarden.py b/src/vaultwarden/clients/vaultwarden.py index c0689cc..3250a29 100644 --- a/src/vaultwarden/clients/vaultwarden.py +++ b/src/vaultwarden/clients/vaultwarden.py @@ -28,7 +28,7 @@ def __init__(self, url: str, admin_secret_token: str, preload_users: bool): base_url=f"{self.url}/admin/", event_hooks={"response": [log_raise_for_status]}, ) - self._id_mail_pool: Optional[dict[str, str]] = None + self._id_mail_pool: dict[str, str] = {} # Preload all users infos if preload_users: _ = self.get_all_users() @@ -95,20 +95,20 @@ def remove_2fa(self, email: str) -> None: "POST", f"users/{email}/remove-2fa" ).raise_for_status() - def get_user(self, search: str) -> Any | None: + def get_user(self, search: str) -> VaultWardenUser: """Search term is either an email in cache or a UUID. For textual search, use get_all_resources (expensive)""" assert isinstance(search, str) - if self._id_mail_pool is None: + if not self._id_mail_pool: self.get_all_users() if search in self._id_mail_pool: search = self._id_mail_pool[search] elif "@" in search: # search is not a UUID (probably an email) but wasn't found in cache - return None + raise VaultwardenAdminException(f"User {search} not found") # else assume it's a UUID resp = self._admin_request("GET", f"users/{search}") resp.raise_for_status() @@ -138,7 +138,7 @@ def reset_account(self, email: str, bitwarden_client: BitwardenClient): logger.warning( f"Doing reset on {email} despite having not complete information on its accesses" ) - self.delete(user.get("Id")) + self.delete(user["Id"]) bitwarden_client.invite_with_accesses(accesses, user.get("Email")) return None @@ -164,5 +164,5 @@ def transfer_account_rights( else: res = bitwarden_client.invite_with_accesses(accesses, new_email) if res: - self.set_user_enabled(user.get("Id"), enabled=False) + self.set_user_enabled(user["Id"], enabled=False) return res diff --git a/src/vaultwarden/models/api_models.py b/src/vaultwarden/models/api_models.py index d1db3a7..191a178 100644 --- a/src/vaultwarden/models/api_models.py +++ b/src/vaultwarden/models/api_models.py @@ -58,4 +58,3 @@ class VaultWardenUser(TypedDict): Organizations: list[VaultWardenOrg] UserEnabled: bool TwoFactorEnabled: bool - CreatedAt: str diff --git a/src/vaultwarden/utils/tools.py b/src/vaultwarden/utils/tools.py index 3e58676..21d58c7 100644 --- a/src/vaultwarden/utils/tools.py +++ b/src/vaultwarden/utils/tools.py @@ -1,8 +1,3 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from httpx import Response - from .logger import logger @@ -20,7 +15,7 @@ def get_matching_ids_from_ditcs(collections_names, collection_name): return res -def log_raise_for_status(response: Response) -> None: +def log_raise_for_status(response) -> None: if response.status_code == 403: logger.error( "Error: 403 Forbidden. Given Account has not access the data." diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 From b70accbb84da47f45fdc19bb77d66fa0fece08f3 Mon Sep 17 00:00:00 2001 From: Lyonel Martinez Date: Thu, 24 Aug 2023 12:26:39 +0200 Subject: [PATCH 5/8] chore(lint): integrate Ruff and formatting --- .github/workflows/ci.yml | 2 +- pyproject.toml | 36 ++++++++++-------- src/vaultwarden/clients/bitwarden.py | 43 ++++++++++++---------- src/vaultwarden/clients/vaultwarden.py | 35 ++++++++---------- src/vaultwarden/models/exception_models.py | 4 +- 5 files changed, 62 insertions(+), 58 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acb41c5..febe653 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: - name: Check with black + isort if: always() run: hatch run style:format && git diff --exit-code - - name: Check with flake8 + - name: Check with ruff if: always() run: hatch run style:lint - name: Check with mypy diff --git a/pyproject.toml b/pyproject.toml index 2fc8021..b458ff9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,17 +41,6 @@ dependencies = [ "httpx >=0.24.1", ] -[project.optional-dependencies] -dev = [ - "black >=23.7.0", - "commitizen >=3.6.0", - "isort >=5.9.3", - "mypy >=0.910", -] -test = [ - "pytest~=6.2.5", -] - [tool.hatch.version] path = "src/vaultwarden/__version__.py" @@ -102,23 +91,39 @@ detached = true dependencies = [ "black", "isort", - "flake8", - "flake8-type-checking", + "ruff", ] [tool.hatch.envs.style.scripts] lint = [ - "flake8 src/vaultwarden", + "ruff check --fix src/vaultwarden", ] check = [ "isort --check-only --diff src/vaultwarden", "black -q --check --diff src/vaultwarden", - "lint", + "ruff check src/vaultwarden", ] format = [ "isort -q src/vaultwarden", "black -q src/vaultwarden", + "lint" ] +[tool.ruff] +# Add "Q" to the list of enabled codes. +select = ["B", "E", "F", "I", "N", "Q", "RUF", "SIM", "TCH"] +fixable = ["ALL"] +src = ["src/vaultwarden", "tests"] +target-version = "py310" +line-length = 79 + +[tool.ruff.flake8-quotes] +docstring-quotes = "double" + +[tool.ruff.flake8-bugbear] +extend-immutable-calls = ["typer.Argument"] + +[tool.ruff.isort] +force-sort-within-sections = true [tool.black] line-length = 79 @@ -128,7 +133,6 @@ target-version = ["py310", "py311"] profile = "black" line_length = 80 - [tool.mypy] ignore_missing_imports = true warn_unreachable = true diff --git a/src/vaultwarden/clients/bitwarden.py b/src/vaultwarden/clients/bitwarden.py index bee18f4..ed7159a 100644 --- a/src/vaultwarden/clients/bitwarden.py +++ b/src/vaultwarden/clients/bitwarden.py @@ -1,15 +1,13 @@ # Class Bitwarden client with a httpx client -from typing import TYPE_CHECKING, Any, List, Literal, Optional - -if TYPE_CHECKING: - from uuid import UUID +from typing import Any, List, Literal, Optional +from uuid import UUID from bitwardentools import caseinsentive_key_search from bitwardentools.crypto import decrypt, encrypt, make_master_key from httpx import Client, HTTPError, Response from vaultwarden.models.api_models import ApiToken -from vaultwarden.models.exception_models import BitwardenException +from vaultwarden.models.exception_models import BitwardenError from vaultwarden.utils.logger import logger from vaultwarden.utils.tools import ( get_collection_id_from_ditcs, @@ -32,7 +30,7 @@ def __init__( if not all( [url, email, password, client_id, client_secret, device_id] ): - raise BitwardenException("All parameters are required") + raise BitwardenError("All parameters are required") self.email = email self.password = password self.client_id = client_id @@ -138,8 +136,8 @@ def get_organization_user_details( params={"includeCollections": True, "includeGroups": True}, ).json() - # Get all users in an organization. Returns a dict with email as key and user details as value. - # If raw is True, returns the raw json response + # Get all users in an organization. Returns a dict with email as key and + # user details as value. If raw is True, returns the raw json response def get_organization_users(self, organization_id: str, raw=False): users = ( self._api_request( @@ -158,10 +156,10 @@ def get_org_key(self, org_id): sync = self.get_sync() profile = sync.get("Profile", None) if profile is None: - raise BitwardenException("No profile in Sync") + raise BitwardenError("No profile in Sync") orgs = profile.get("Organizations", None) if orgs is None: - raise BitwardenException("No Organizations in Sync[Profile]") + raise BitwardenError("No Organizations in Sync[Profile]") raw_key = None for org in orgs: if org.get("Id") == org_id: @@ -169,18 +167,19 @@ def get_org_key(self, org_id): break if raw_key is not None: return decrypt(raw_key, self.api_token.get("orgs_key")) - raise BitwardenException(f"No Organizations `{org_id}` found") + raise BitwardenError(f"No Organizations `{org_id}` found") def deduplicate_collection(self, organization_id): """ - Deduplicate collections with the same name in a given org, by moving users - and secrets into the bigger (by user count) collection + Deduplicate collections with the same name in a given org, by moving + users and secrets into the bigger (by user count) collection """ collections = self.get_organization_collections(organization_id) seen_names = {} duplicated = {} - # List duplicated collections, indexed on the name, referencing a list of collections id + # List duplicated collections, indexed on the name, referencing a + # list of collections id for name, collection in collections: if seen_names.get(name) is None: seen_names[name] = collection["Id"] @@ -194,8 +193,8 @@ def deduplicate_collection(self, organization_id): base_id = None nb_users = 0 users = {} - # Building user list by merging all accesses and choose which collection will not be deleted - # (based on most users) + # Building user list by merging all accesses and choose which + # collection will not be deleted (based on most users) for collection_id in id_list: coll_users = self.get_users_of_collection( organization_id, collection_id @@ -207,12 +206,14 @@ def deduplicate_collection(self, organization_id): users_list = list(users.values()) - # Removing the chosen collection from the list of collection to delete + # Removing the chosen collection from the list of collection to + # delete id_list.remove(base_id) self.set_users_of_collection(organization_id, base_id, users_list) for collection_id in id_list: - # List items inside the current collection and move these items to the chosen collection + # List items inside the current collection and move these + # items to the chosen collection ciphers = self.get_organizations_collection_items( organization_id, collection_id ) @@ -373,7 +374,8 @@ def create_collection( collections_ids[data["Id"]] = data return data - # Return 2 dicts: one indexed by name, one indexed by id, both containing the collections details + # Return 2 dicts: one indexed by name, one indexed by id, + # both containing the collections details def get_organization_collections_dicts(self, org_id): resp = self._api_request( "GET", f"api/organizations/{org_id}/collections" @@ -543,7 +545,8 @@ def invite_organisation(self, org_id, user_accesses, email): "POST", f"api/organizations/{org_id}/users/invite", json=data ) - # Re-invite a user with the same accesses. Return True if at least one organization has been re-invited + # Re-invite a user with the same accesses. Return True if at least one + # organization has been re-invited def invite_with_accesses(self, organizations, email): logger.info("Re-invite with accesses") for org_id, accesses in organizations.items(): diff --git a/src/vaultwarden/clients/vaultwarden.py b/src/vaultwarden/clients/vaultwarden.py index 3250a29..25e6dc6 100644 --- a/src/vaultwarden/clients/vaultwarden.py +++ b/src/vaultwarden/clients/vaultwarden.py @@ -1,27 +1,21 @@ import http -from typing import TYPE_CHECKING, Any, Literal, Optional - -if TYPE_CHECKING: - from http.cookiejar import Cookie +from http.cookiejar import Cookie +from typing import Any, Literal, Optional from httpx import Client, HTTPStatusError, Response -from vaultwarden.models.exception_models import VaultwardenAdminException +from vaultwarden.clients.bitwarden import BitwardenClient +from vaultwarden.models.api_models import VaultWardenUser +from vaultwarden.models.exception_models import VaultwardenAdminError from vaultwarden.utils.logger import logger from vaultwarden.utils.tools import log_raise_for_status -if TYPE_CHECKING: - from vaultwarden.clients.bitwarden import BitwardenClient - from vaultwarden.models.api_models import VaultWardenUser - class VaultwardenAdminClient: def __init__(self, url: str, admin_secret_token: str, preload_users: bool): # If url or admin_secret_token is None, raise an exception if not url or not admin_secret_token: - raise VaultwardenAdminException( - "Missing url or admin_secret_token" - ) + raise VaultwardenAdminError("Missing url or admin_secret_token") self.admin_secret_token = admin_secret_token self.url = url.strip("/") self._http_client = Client( @@ -54,12 +48,13 @@ def _admin_request( self, method: Literal["GET", "POST"], path: str, **kwargs: Any ) -> Response: self._admin_login() - return self._http_client.request(method, path, **kwargs) # type: ignore + return self._http_client.request(method, path, **kwargs) def _fill_id_mail_pool(self, users: list[VaultWardenUser]) -> None: """Cache the email->GUID mapping for the given users - Necessary since Vaultwarden does not offer a search or query-by-email endpoint + Necessary since Vaultwarden does not offer a search or + query-by-email endpoint """ self._id_mail_pool |= {u["Email"]: u["Id"] for u in users} @@ -90,7 +85,7 @@ def set_user_enabled(self, identifier: str, enabled: bool) -> None: def remove_2fa(self, email: str) -> None: user = self.get_user(email) if user is None: - raise VaultwardenAdminException(f"User {email} not found") + raise VaultwardenAdminError(f"User {email} not found") self._admin_request( "POST", f"users/{email}/remove-2fa" ).raise_for_status() @@ -107,8 +102,8 @@ def get_user(self, search: str) -> VaultWardenUser: if search in self._id_mail_pool: search = self._id_mail_pool[search] elif "@" in search: - # search is not a UUID (probably an email) but wasn't found in cache - raise VaultwardenAdminException(f"User {search} not found") + # search is not UUID (probably an email) but wasn't found in cache + raise VaultwardenAdminError(f"User {search} not found") # else assume it's a UUID resp = self._admin_request("GET", f"users/{search}") resp.raise_for_status() @@ -129,14 +124,16 @@ def reset_account(self, email: str, bitwarden_client: BitwardenClient): ) if warning: check = input( - "WARNING: A organisation where you where present is not maintain by SOC account\n" + "WARNING: A organisation where you where present is not " + "maintain by SOC account\n" "Press 'yes' if you still want to reset the account" ) if check != "yes": logger.warning("Cancelling the reset") return logger.warning( - f"Doing reset on {email} despite having not complete information on its accesses" + f"Doing reset on {email} despite having not complete " + f"information on its accesses" ) self.delete(user["Id"]) bitwarden_client.invite_with_accesses(accesses, user.get("Email")) diff --git a/src/vaultwarden/models/exception_models.py b/src/vaultwarden/models/exception_models.py index 0cfd9b4..eb39bb8 100644 --- a/src/vaultwarden/models/exception_models.py +++ b/src/vaultwarden/models/exception_models.py @@ -1,10 +1,10 @@ -class BitwardenException(Exception): +class BitwardenError(Exception): """BitwardenClient Exception class""" pass -class VaultwardenAdminException(Exception): +class VaultwardenAdminError(Exception): """VaultwardenAdminClient Exception class""" pass From b648a21506c5e81af6085500c1f46dfa1c679975 Mon Sep 17 00:00:00 2001 From: Lyonel Martinez Date: Thu, 24 Aug 2023 13:39:05 +0200 Subject: [PATCH 6/8] fix(license): fix license property and license classifier = fix typo --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b458ff9..7166463 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,12 @@ build-backend = "hatchling.build" [project] name = "python-vaultwarden" version = "0.7.0" -description = "Admin Vautlwarden and Simple Bitwarden Python Client" +description = "Admin Vaultwarden and Simple Bitwarden Python Client" authors = [ { name = "Lyonel Martinez", email = "lyonel.martinez@numberly.com" }, { name = "Mathis Ribet", email = "mathis.ribet@numberly.com" }, ] -license = "MIT" +license = "Apache-2.0" readme = "README.md" repository = "https://github.com/numberly/python-vaultwarden" documentation = "https://numberly.github.io/python-vaultwarden/" @@ -21,7 +21,7 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Environment :: Web Environment", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Intended Audience :: Developers", "Programming Language :: Python", From 751a34618e119f7330a72d98ad38eefd1c4c2b82 Mon Sep 17 00:00:00 2001 From: Lyonel Martinez Date: Wed, 30 Aug 2023 10:25:32 +0200 Subject: [PATCH 7/8] chore(coverage): remove coverage --- .github/workflows/ci.yml | 8 -------- README.md | 5 ----- 2 files changed, 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index febe653..901bd69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,14 +22,6 @@ jobs: - name: Run tests run: | hatch run +py=${{ matrix.py || matrix.python-version }} test:with-coverage - - name: Upload Codecov Results - if: success() - uses: codecov/codecov-action@v3 - with: - file: ./coverage.xml - flags: unittests - name: ${{ matrix.os }}/${{ matrix.python-version }} - fail_ci_if_error: false lint: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 406f883..30daaea 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ [![PyPI Version][pypi-v-image]][pypi-v-link] [![Build Status][GHAction-image]][GHAction-link] -[![Coverage Status][codecov-image]][codecov-link] A python library for vaultwarden @@ -23,10 +22,6 @@ The cryptographic part is handled by the [bitwardentools library](https://github -[codecov-image]: https://codecov.io/github/numberly/python-vaultwarden/coverage.svg?branch=main - -[codecov-link]: https://codecov.io/github/numberly/python-vaultwarden?branch=main - [pypi-v-image]: https://img.shields.io/pypi/v/python-vaultwarden.svg [pypi-v-link]: https://pypi.org/project/python-vaultwarden/ From 7e6f66cdf1f423f3b7c9a4a7c41587cb12291841 Mon Sep 17 00:00:00 2001 From: Lyonel Martinez Date: Wed, 6 Sep 2023 10:55:08 +0200 Subject: [PATCH 8/8] chore(ruff): remove .flake8 file --- .flake8 | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .flake8 diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 8b95ac5..0000000 --- a/.flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -extend-ignore = E501, E203, TC004