├── tests ├── __init__.py ├── test_health.py ├── test_logs.py ├── test_settings.py ├── prep.py ├── test_backups.py ├── test_bugs.py ├── conftest.py ├── test_record_auth.py ├── test_admin.py ├── test_collection.py ├── test_files.py ├── test_realtime.py └── test_record.py ├── src └── pocketbase │ ├── py.typed │ ├── models │ ├── __init__.py │ ├── options.py │ ├── errors.py │ └── dtos.py │ ├── utils │ ├── __init__.py │ ├── filter.py │ └── types.py │ ├── services │ ├── __init__.py │ ├── health.py │ ├── collection.py │ ├── file.py │ ├── log.py │ ├── authorization.py │ ├── base.py │ ├── backup.py │ ├── settings.py │ ├── crud.py │ ├── realtime.py │ └── record.py │ ├── __init__.py │ └── client.py ├── ci.bash ├── SECURITY.md ├── .github ├── ISSUE_TEMPLATE │ ├── 3-other.md │ ├── config.yml │ ├── 1-bug.md │ └── 2-feature-request.md ├── pull_request_template.md └── workflows │ ├── release.yml │ └── check.yml ├── examples ├── README.md ├── realtime_updates.py ├── hello_world.py └── working_with_files.py ├── LICENSE.txt ├── .gitignore ├── pyproject.toml ├── CONTRIBUTING.md ├── images └── logo.svg ├── README.md ├── CODE_OF_CONDUCT.md └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pocketbase/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pocketbase/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pocketbase/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pocketbase/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pocketbase/utils/filter.py: -------------------------------------------------------------------------------- 1 | # TODO: filter expression helper 2 | -------------------------------------------------------------------------------- /ci.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux -o pipefail 4 | 5 | ruff format src tests examples 6 | ruff check --fix src tests 7 | mypy src 8 | pytest --cov=pocketbase 9 | coverage report -m --skip-covered --skip-empty 10 | -------------------------------------------------------------------------------- /src/pocketbase/__init__.py: -------------------------------------------------------------------------------- 1 | from pocketbase.client import PocketBase 2 | from pocketbase.models.errors import PocketBaseError 3 | from pocketbase.utils.types import FileUpload 4 | 5 | __all__ = ["FileUpload", "PocketBase", "PocketBaseError"] 6 | -------------------------------------------------------------------------------- /tests/test_health.py: -------------------------------------------------------------------------------- 1 | from pocketbase import PocketBase 2 | 3 | 4 | async def test_health(superuser_client: PocketBase): 5 | health = await superuser_client.health.check({"params": {"fields": "*"}}) 6 | assert health["code"] == 200 7 | assert health["data"]["canBackup"] 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | As long as this project remains in beta only the latest version is guaranteed to receive updates. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Please contact opensource [at] tmiedema.com if you find something that could impact the security of this project. 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-other.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Other 3 | about: Something that is not a bug or feature. 4 | title: '' 5 | labels: unsorted 6 | assignees: '' 7 | 8 | --- 9 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discussions 4 | url: https://github.com/thijsmie/pocketbase-async/discussions 5 | about: Here you can freely ask questions and discuss things. 6 | - name: Security Guidelines 7 | url: https://github.com/thijsmie/pocketbase-async/blob/main/SECURITY.md 8 | about: Please read this to report security vulnerabilities. -------------------------------------------------------------------------------- /src/pocketbase/services/health.py: -------------------------------------------------------------------------------- 1 | from pocketbase.models.dtos import HealthCheckResponse 2 | from pocketbase.models.options import CommonOptions, SendOptions 3 | from pocketbase.services.base import Service 4 | 5 | 6 | class HealthService(Service): 7 | __base_sub_path__ = "/api/health" 8 | 9 | async def check(self, options: CommonOptions | None = None) -> HealthCheckResponse: 10 | send_options: SendOptions = {"method": "GET"} 11 | 12 | if options: 13 | send_options.update(options) 14 | 15 | return await self._send("", send_options) # type: ignore 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Checklist 2 | 3 | * [ ] I have read the Contribution Guidelines 4 | * [ ] I have read and agree to the Code of Conduct 5 | * [ ] I have read and agree that my contributions will be included under the project license 6 | * [ ] I have added a description of my changes and why I want them _or_ linked the relevant pull request below 7 | * [ ] I have ran the formatter, linter and tests locally and ensured they give their all green 8 | * [ ] If contributing new code: I've ensured my new code is covered sufficiently by unit and integration tests 9 | 10 | ### Description of Changes 11 | 12 | Describe your changes here 13 | 14 | ### Related Issues 15 | 16 | List related issues here 17 | -------------------------------------------------------------------------------- /tests/test_logs.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from pocketbase import PocketBase 4 | 5 | 6 | async def test_get_logs(superuser_client: PocketBase): 7 | """ 8 | Note that this test doesn't really test this properly 9 | As there are probably no logs in the api yet. 10 | """ 11 | 12 | # Empirically: logs get added slowly, this is enough to ensure there is logs 13 | await superuser_client.logs.get_list() 14 | await asyncio.sleep(4) 15 | logs = await superuser_client.logs.get_list() 16 | await superuser_client.logs.get_list(options={"filter": "level>0"}) 17 | await superuser_client.logs.get_stats() 18 | await superuser_client.logs.get_stats(options={"filter": 'data.auth!="superuser"'}) 19 | assert logs["items"] 20 | assert logs["items"][0] == await superuser_client.logs.get_one(logs["items"][0]["id"]) 21 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | 5 | from pocketbase import PocketBase, PocketBaseError 6 | 7 | 8 | async def test_write_setting(superuser_client: PocketBase): 9 | app_name = uuid4().hex 10 | await superuser_client._settings.update( 11 | body={ 12 | "meta": { 13 | "appName": app_name, 14 | }, 15 | } 16 | ) 17 | settings = await superuser_client._settings.get_all() 18 | assert settings["meta"]["appName"] == app_name 19 | 20 | 21 | async def test_s3(superuser_client: PocketBase): 22 | # The context manager pytest.raises works with async code 23 | with pytest.raises(PocketBaseError) as exc: 24 | # The new test_s3 method can take 'options' 25 | await superuser_client._settings.test_s3() 26 | assert exc.value.status == 400 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | pypi-publish: 9 | name: Upload release to PyPI 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: release 13 | url: https://pypi.org/p/pocketbase-async 14 | permissions: 15 | id-token: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python 3.12 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.12 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | python -m pip install poetry 26 | python -m poetry install --without dev 27 | - name: Poetry build 28 | run: | 29 | poetry build 30 | - name: Publish package distributions to PyPI 31 | uses: pypa/gh-action-pypi-publish@release/v1 32 | -------------------------------------------------------------------------------- /src/pocketbase/services/collection.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from pocketbase.models.dtos import Collection, CollectionModel 4 | from pocketbase.models.options import CommonOptions, SendOptions 5 | from pocketbase.services.crud import CrudService 6 | from pocketbase.utils.types import BodyField 7 | 8 | 9 | class CollectionService(CrudService[CollectionModel]): 10 | __base_sub_path__ = "/api/collections" 11 | 12 | async def import_collections( 13 | self, collections: list[Collection], delete_missing: bool = False, options: CommonOptions | None = None 14 | ) -> None: 15 | send_options: SendOptions = { 16 | "method": "PUT", 17 | "body": { 18 | "collections": cast(BodyField, collections), 19 | "deleteMissing": delete_missing, 20 | }, 21 | } 22 | 23 | if options: 24 | send_options.update(options) 25 | 26 | await self._send_noreturn("/import", send_options) 27 | -------------------------------------------------------------------------------- /src/pocketbase/models/options.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, TypedDict 2 | 3 | from httpx._types import PrimitiveData 4 | 5 | from pocketbase.utils.types import BodyDict, SendableFiles 6 | 7 | 8 | class SendOptions(TypedDict, total=False): 9 | method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] 10 | headers: dict[str, str] 11 | body: BodyDict 12 | params: dict[str, PrimitiveData] 13 | files: SendableFiles 14 | 15 | 16 | class CommonOptions(TypedDict, total=False): 17 | headers: dict[str, str] 18 | params: dict[str, PrimitiveData] 19 | 20 | 21 | class ListOptions(CommonOptions, total=False): 22 | sort: str 23 | filter: str 24 | 25 | 26 | class FullListOptions(ListOptions, total=False): 27 | batch: int 28 | 29 | 30 | class FirstOptions(CommonOptions, total=False): 31 | filter: str 32 | 33 | 34 | class LogStatsOptions(CommonOptions, total=False): 35 | filter: str 36 | 37 | 38 | class FileOptions(CommonOptions, total=False): 39 | thumb: str 40 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | In this folder are some examples on how to use the functionality provided by PocketBase with this Python package. The examples all assume there is already a PocketBase instance running locally on port `8123` and an superuser account with credentials `email = test@example.com` and `password = test`. You can set these up like so: 4 | 5 | ```sh 6 | # note: you cannot create an superuser before having run the server at least once to init the database 7 | pocketbase serve --http=127.0.0.1:8123 8 | # ctrl-c 9 | pocketbase superuser create test@example.com test 10 | pocketbase serve --http=127.0.0.1:8123 11 | ``` 12 | 13 | If your port, url or credentials are different you can change them in the constants at the top of each example. 14 | 15 | ## Hello World 16 | 17 | A basic overview of the use of the API. 18 | 19 | ## Working with files 20 | 21 | How to upload and download files in PocketBase records. 22 | 23 | ## Realtime updates 24 | 25 | How to subscribe to realtime updates. 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2024 Thijs Miedema 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help track down problems in the codebase. 4 | title: 'Bug: ' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 19 | 20 | # Description 21 | 22 | 23 | # Steps to reproduce 24 | 25 | 26 | # Expected behavior 27 | 28 | 29 | # [Optional] Stacktrace 30 | 31 | ``` 32 | Stacktrace 33 | ``` 34 | 35 | # Environment 36 | - Operating system: 37 | - Python version: 38 | - Upstream PocketBase version: 39 | - pocketbase-async version: 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Autodownloaded executables 2 | tests/executable 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # Unit test / coverage reports 33 | htmlcov/ 34 | .tox/ 35 | .nox/ 36 | .coverage 37 | .coverage.* 38 | nosetests.xml 39 | coverage.xml 40 | *.cover 41 | *.py,cover 42 | .hypothesis/ 43 | .pytest_cache/ 44 | cover/ 45 | 46 | # Environments 47 | .env 48 | .venv 49 | env/ 50 | venv/ 51 | ENV/ 52 | env.bak/ 53 | venv.bak/ 54 | 55 | # mkdocs documentation 56 | /site 57 | 58 | # mypy 59 | .dmypy.json 60 | dmypy.json 61 | 62 | # Pyre type checker 63 | .pyre/ 64 | 65 | # pytype static type analyzer 66 | .pytype/ 67 | 68 | # Cython debug symbols 69 | cython_debug/ 70 | 71 | # Caches 72 | .cache 73 | .*_cache 74 | 75 | # IDEs 76 | .vscode 77 | .idea 78 | -------------------------------------------------------------------------------- /tests/prep.py: -------------------------------------------------------------------------------- 1 | import io 2 | from pathlib import Path 3 | from zipfile import ZipFile 4 | 5 | import httpx 6 | 7 | POCKETBASE_VERSION = "0.28.1" 8 | POCKETBASE_PLATFORM = "linux_amd64" 9 | 10 | 11 | def ensure_pocketbase_executable() -> Path: 12 | p = Path(__file__).parent / "executable" / f"pocketbase_{POCKETBASE_VERSION}_{POCKETBASE_PLATFORM}" 13 | if p.exists(): 14 | return p / "pocketbase" 15 | 16 | print( 17 | "https://github.com/pocketbase/pocketbase" 18 | "/releases/download/" 19 | f"v{POCKETBASE_VERSION}" 20 | f"/pocketbase_{POCKETBASE_VERSION}_{POCKETBASE_PLATFORM}.zip" 21 | ) 22 | 23 | resp = httpx.get( 24 | "https://github.com/pocketbase/pocketbase" 25 | "/releases/download/" 26 | f"v{POCKETBASE_VERSION}" 27 | f"/pocketbase_{POCKETBASE_VERSION}_{POCKETBASE_PLATFORM}.zip", 28 | follow_redirects=True, 29 | ) 30 | p.mkdir(exist_ok=True, parents=True) 31 | zipfile = ZipFile(file=io.BytesIO(resp.content)) 32 | zipfile.extract("pocketbase", p) 33 | exe = p / "pocketbase" 34 | exe.chmod(0o777) 35 | return exe 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'Feature: ' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 18 | 19 | # The Why 20 | 24 | 25 | # The How 26 | 30 | 31 | # The How Not 32 | 36 | 37 | # But There Is More 38 | 42 | -------------------------------------------------------------------------------- /src/pocketbase/utils/types.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | from typing import TypeAlias, cast 3 | 4 | from httpx._types import FileTypes 5 | 6 | 7 | class FileUpload: 8 | def __init__(self, *files: FileTypes): 9 | self.files = files 10 | 11 | 12 | FileType: TypeAlias = FileTypes 13 | SendableFiles: TypeAlias = list[tuple[str, FileTypes] | tuple[str, FileTypes, str]] 14 | JsonType = None | int | str | bool | list["JsonType"] | dict[str, "JsonType"] 15 | BodyField: TypeAlias = JsonType | FileUpload | datetime 16 | BodyDict: TypeAlias = dict[str, BodyField] 17 | 18 | 19 | def transform(data: BodyDict) -> tuple[dict[str, JsonType], SendableFiles]: 20 | files: SendableFiles = [] 21 | 22 | for key, value in list(data.items()): 23 | if isinstance(value, datetime): 24 | if value.tzinfo is None: 25 | data[key] = value.isoformat(timespec="milliseconds") + "Z" 26 | else: 27 | data[key] = value.astimezone(UTC).isoformat(timespec="milliseconds").split("+")[0] + "Z" 28 | elif isinstance(value, FileUpload) and value.files: 29 | files.extend((key, file) for file in value.files) 30 | del data[key] 31 | 32 | return cast(dict[str, JsonType], data), files 33 | -------------------------------------------------------------------------------- /tests/test_backups.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from pocketbase import PocketBase 4 | 5 | 6 | async def test_backups(superuser_client: PocketBase): 7 | bk = await superuser_client.backups.get_full_list() 8 | await superuser_client.backups.create("test.zip") 9 | bk2 = await superuser_client.backups.get_full_list() 10 | 11 | assert len(bk) == len(bk2) - 1 12 | assert any(b["key"] == "test.zip" for b in bk2) 13 | 14 | await superuser_client.backups.restore("test.zip") 15 | backup = await superuser_client.backups.download("test.zip") 16 | await superuser_client.backups.delete("test.zip") 17 | bk3 = await superuser_client.backups.get_full_list() 18 | 19 | assert len(bk) == len(bk3) 20 | assert not any(b["key"] == "test.zip" for b in bk3) 21 | 22 | await superuser_client.backups.upload(("test.zip", backup)) 23 | bk2 = await superuser_client.backups.get_full_list() 24 | 25 | assert len(bk) == len(bk2) - 1 26 | assert any(b["key"] == "test.zip" for b in bk2) 27 | 28 | assert "token=" in await superuser_client.backups.get_download_url("test.zip") 29 | 30 | # This takes a couple seconds and can disturb the next test 31 | await superuser_client.backups.restore("test.zip") 32 | await asyncio.sleep(5) 33 | -------------------------------------------------------------------------------- /tests/test_bugs.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from uuid import uuid4 3 | 4 | from pocketbase import PocketBase 5 | from pocketbase.models.dtos import Collection 6 | from pocketbase.models.errors import PocketBaseNotFoundError 7 | 8 | 9 | async def create_collection(client: PocketBase) -> tuple[Collection, str]: 10 | collection = await client.collections.create( 11 | { 12 | "name": uuid4().hex, 13 | "type": "base", 14 | "fields": [ 15 | { 16 | "name": "title", 17 | "type": "text", 18 | "required": True, 19 | "min": 10, 20 | }, 21 | ], 22 | } 23 | ) 24 | return collection, collection["name"] 25 | 26 | 27 | async def test_github_issue_21(superuser_client: PocketBase): 28 | _, name = await create_collection(superuser_client) 29 | collection = superuser_client.collection(name) 30 | params = {} 31 | 32 | await collection.get_full_list({"params": params}) 33 | 34 | with suppress(PocketBaseNotFoundError): 35 | await collection.get_one("bla", {"params": params}) 36 | 37 | with suppress(PocketBaseNotFoundError): 38 | await collection.get_first({"filter": 'id="bla"', "params": params}) 39 | 40 | assert params == {} 41 | -------------------------------------------------------------------------------- /src/pocketbase/services/file.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import quote 2 | 3 | from pocketbase.models.options import CommonOptions, FileOptions, SendOptions 4 | from pocketbase.services.base import Service 5 | 6 | 7 | class FileService(Service): 8 | __base_sub_path__ = "/api/files" 9 | 10 | def get_url(self, collection: str, record_id: str, filename: str) -> str: 11 | return self._build_url(f"/{quote(collection)}/{quote(record_id)}/{filename}") 12 | 13 | async def download_file( 14 | self, collection: str, record_id: str, filename: str, options: FileOptions | None = None 15 | ) -> bytes: 16 | url = f"/{quote(collection)}/{quote(record_id)}/{filename}" 17 | send_options: SendOptions = {"method": "GET", "params": {"download": True}} 18 | if options and "params" in options: 19 | send_options["params"].update(options["params"]) 20 | 21 | if options and "headers" in options: 22 | send_options["headers"] = options["headers"] 23 | 24 | if options and "thumb" in options: 25 | send_options["params"]["thumb"] = options["thumb"] 26 | 27 | return (await self._send_raw(url, send_options)).content 28 | 29 | async def get_token(self, options: CommonOptions | None = None) -> str: 30 | send_options: SendOptions = {"method": "POST"} 31 | 32 | if options: 33 | send_options.update(options) 34 | 35 | return (await self._send("/token", send_options))["token"] # type: ignore 36 | -------------------------------------------------------------------------------- /src/pocketbase/models/errors.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from httpx import Response 4 | 5 | 6 | class PocketBaseError(Exception): 7 | def __init__(self, url: str, status: int, data: Any) -> None: 8 | self.url = url 9 | self.status = status 10 | self.data = data 11 | 12 | def __repr__(self) -> str: 13 | return f"{self.__class__.__name__}(url={self.url},status={self.status},data={self.data})" 14 | 15 | __str__ = __repr__ 16 | 17 | @classmethod 18 | def raise_for_status(cls, response: Response) -> None: 19 | if response.status_code == 400: 20 | raise PocketBaseBadRequestError(str(response.url), response.status_code, response.json()) 21 | elif response.status_code == 401: 22 | raise PocketBaseUnauthorizedError(str(response.url), response.status_code, response.json()) 23 | elif response.status_code == 403: 24 | raise PocketBaseForbiddenError(str(response.url), response.status_code, response.json()) 25 | elif response.status_code == 404: 26 | raise PocketBaseNotFoundError(str(response.url), response.status_code, response.json()) 27 | elif response.status_code == 500: 28 | raise PocketBaseServerError(str(response.url), response.status_code, response.json()) 29 | elif response.status_code >= 400: 30 | raise PocketBaseError(str(response.url), response.status_code, response.json()) 31 | 32 | 33 | class PocketBaseNotFoundError(PocketBaseError): 34 | pass 35 | 36 | 37 | class PocketBaseBadRequestError(PocketBaseError): 38 | pass 39 | 40 | 41 | class PocketBaseUnauthorizedError(PocketBaseError): 42 | pass 43 | 44 | 45 | class PocketBaseForbiddenError(PocketBaseError): 46 | pass 47 | 48 | 49 | class PocketBaseServerError(PocketBaseError): 50 | pass 51 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | environment: main 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ["3.11", "3.12", "3.13"] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | python -m pip install poetry 28 | python -m poetry install --with dev 29 | - name: Run code quality checks 30 | run: | 31 | poetry run ruff format --diff src tests examples 32 | poetry run ruff check --diff src tests 33 | poetry run mypy src 34 | - name: Test with pytest 35 | run: | 36 | poetry run pytest --cov=pocketbase 37 | poetry run coverage report -m --skip-covered --skip-empty 38 | export TOTAL=$(poetry run coverage report --precision=1 --format total) 39 | echo "COVERAGE=${TOTAL}%" >> $GITHUB_ENV 40 | echo "TOTAL=${TOTAL}" >> $GITHUB_ENV 41 | poetry run coverage report --format total 42 | - name: Coverage badge 43 | if: github.ref == 'refs/heads/main' && matrix.python-version == '3.12' 44 | uses: schneegans/dynamic-badges-action@v1.7.0 45 | with: 46 | auth: ${{ secrets.GIST_SECRET }} 47 | gistID: a41c81ee9f5d3944d2f9946c3eae4aae 48 | filename: coverage.json 49 | style: for-the-badge 50 | label: Coverage 51 | message: ${{ env.COVERAGE }} 52 | valColorRange: ${{ env.TOTAL }} 53 | maxColorRange: 90 54 | minColorRange: 50 55 | -------------------------------------------------------------------------------- /src/pocketbase/services/log.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import quote 2 | 3 | from pocketbase.models.dtos import HourlyStats, ListResult, LogModel 4 | from pocketbase.models.options import CommonOptions, ListOptions, LogStatsOptions, SendOptions 5 | from pocketbase.services.base import Service 6 | 7 | 8 | class LogService(Service): 9 | __base_sub_path__ = "/api/logs" 10 | 11 | async def get_list( 12 | self, page: int = 1, per_page: int = 30, options: ListOptions | None = None 13 | ) -> ListResult[LogModel]: 14 | send_options: SendOptions = {"method": "GET"} 15 | 16 | if options: 17 | send_options.update(options) 18 | 19 | send_options["params"] = send_options.get("params", {}) 20 | send_options["params"]["page"] = page 21 | send_options["params"]["perPage"] = per_page 22 | 23 | if options and "filter" in options: 24 | send_options["params"]["filter"] = options["filter"] 25 | 26 | if options and "sort" in options: 27 | send_options["params"]["sort"] = options["sort"] 28 | 29 | return await self._send("", send_options) # type: ignore 30 | 31 | async def get_one(self, record_id: str, options: CommonOptions | None = None) -> LogModel: 32 | send_options: SendOptions = {"method": "GET"} 33 | 34 | if options: 35 | send_options.update(options) 36 | 37 | return await self._send(f"/{quote(record_id)}", send_options) # type: ignore 38 | 39 | async def get_stats(self, options: LogStatsOptions | None = None) -> list[HourlyStats]: 40 | send_options: SendOptions = {"method": "GET"} 41 | 42 | if options: 43 | send_options.update(options) 44 | 45 | if "filter" in options: 46 | send_options["params"] = send_options.get("params", {}).copy() 47 | send_options["params"]["filter"] = options["filter"] 48 | 49 | return await self._send("/stats", send_options) # type: ignore 50 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pocketbase-async" 3 | version = "0.12.1" 4 | description = "Async pocketbase SDK for Python" 5 | repository = "https://github.com/thijsmie/pocketbase-async" 6 | authors = ["Thijs Miedema "] 7 | license = "MIT" 8 | readme = "README.md" 9 | packages = [ 10 | { include = "pocketbase", from="src" } 11 | ] 12 | classifiers = [ 13 | "Intended Audience :: Developers", 14 | "Operating System :: OS Independent", 15 | "Natural Language :: English", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | ] 20 | 21 | [tool.poetry.dependencies] 22 | python = ">=3.11,<3.14" 23 | httpx = ">=0.25.1,<1" 24 | httpx-sse = ">=0.4.0,<1" 25 | 26 | [tool.poetry.group.dev.dependencies] 27 | pytest = "^8.3.2" 28 | pytest-cov = "^6.0.0" 29 | pytest-asyncio = ">=0.21.0,<1" 30 | ruff = "0.11.*" 31 | mypy = "^1.1.1" 32 | 33 | [build-system] 34 | requires = ["poetry-core"] 35 | build-backend = "poetry.core.masonry.api" 36 | 37 | [tool.ruff] 38 | line-length = 120 39 | target-version = "py311" 40 | 41 | [tool.ruff.lint] 42 | select = ["A", "C", "E", "F", "UP", "RUF", "I", "PL", "PTH", "TID252", "SIM"] 43 | ignore = ["E402", "PLR2004", "PLR0913"] 44 | fixable = ["C", "E", "F", "UP", "I", "PL", "RUF", "PTH", "PLC", "TID252", "SIM"] 45 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 46 | 47 | [tool.ruff.lint.mccabe] 48 | max-complexity = 10 49 | 50 | [tool.mypy] 51 | python_version = "3.11" 52 | warn_unused_configs = true 53 | warn_unused_ignores = true 54 | show_error_codes = true 55 | check_untyped_defs = true 56 | show_column_numbers = true 57 | no_implicit_optional = true 58 | ignore_missing_imports = true 59 | disallow_incomplete_defs = true 60 | disallow_untyped_defs = true 61 | disallow_untyped_calls = true 62 | 63 | [tool.pytest.ini_options] 64 | asyncio_mode = "auto" 65 | 66 | [tool.coverage.report] 67 | exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"] 68 | -------------------------------------------------------------------------------- /examples/realtime_updates.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import datetime 3 | 4 | from pocketbase import PocketBase 5 | from pocketbase.models.dtos import RealtimeEvent 6 | 7 | CONNECTION_URL = "http://localhost:8123" 8 | SUPERUSER_EMAIL = "test@example.com" 9 | SUPERUSER_PASSWORD = "test" 10 | COLLECTION_NAME = "example_collection" 11 | 12 | 13 | async def callback(event: RealtimeEvent) -> None: 14 | """Callback function for handling Realtime events. 15 | 16 | Args: 17 | event (RealtimeEvent): The event object containing information about the record change. 18 | """ 19 | # This will get called for every event 20 | # Lets print what is going on 21 | at = datetime.now().isoformat() 22 | print(f"[{at}] {event['action'].upper()}: {event['record']}") 23 | 24 | 25 | async def realtime_updates(): 26 | """Establishes a PocketBase connection, authenticates, and subscribes to Realtime events.""" 27 | unsubscribe = None 28 | 29 | try: 30 | # Instantiate the PocketBase connector 31 | pb = PocketBase(CONNECTION_URL) 32 | 33 | # Authenticate as a superuser 34 | await pb.collection("_superusers").auth.with_password(email=SUPERUSER_EMAIL, password=SUPERUSER_PASSWORD) 35 | 36 | # Get the collection object 37 | col = pb.collection(COLLECTION_NAME) 38 | 39 | # Subscribe to Realtime events for the specific record ID in the collection 40 | unsubscribe = await col.subscribe_all(callback=callback) 41 | 42 | # Infinite loop to wait for events (adjusted from the second snippet) 43 | while True: 44 | await asyncio.sleep(60 * 60) # Sleep for an hour to avoid hitting PocketBase's rate limits 45 | 46 | except Exception as e: 47 | print(f"Error: {e}") 48 | 49 | finally: 50 | # Unsubscribe if still active 51 | if unsubscribe: 52 | try: 53 | await unsubscribe() 54 | except Exception as e: 55 | print(f"Error unsubscribing: {e}") 56 | 57 | 58 | if __name__ == "__main__": 59 | asyncio.run(realtime_updates()) 60 | -------------------------------------------------------------------------------- /examples/hello_world.py: -------------------------------------------------------------------------------- 1 | from pocketbase import PocketBase, PocketBaseError 2 | 3 | CONNECTION_URL = "http://localhost:8123" 4 | SUPERUSER_EMAIL = "test@example.com" 5 | SUPERUSER_PASSWORD = "test" 6 | 7 | 8 | async def hello_world(): 9 | # Instantiate the PocketBase connector 10 | pb = PocketBase("http://localhost:8123") 11 | 12 | # Authenticate as a superuser 13 | await pb.collection("_superusers").auth.with_password(email=SUPERUSER_EMAIL, password=SUPERUSER_PASSWORD) 14 | 15 | # Create a collection to store records in 16 | # It is a base collection (not "view" or "auth") with one column "content" 17 | # and it will have the regular "id" column. 18 | try: 19 | await pb.collections.create( 20 | { 21 | "name": "hello_world", 22 | "type": "base", 23 | "fields": [ 24 | { 25 | "name": "content", 26 | "type": "text", 27 | "required": True, 28 | }, 29 | ], 30 | } 31 | ) 32 | except PocketBaseError: 33 | # You probably ran this example before, and the collection already exists! 34 | # No problem, we'll continue as normal :) 35 | pass 36 | 37 | # Get the collection instance we can work with 38 | collection = pb.collection("hello_world") 39 | 40 | # Add a new record. 41 | await collection.create(params={"content": "Hello, World!"}) 42 | 43 | # All the different ways to get records 44 | first = await collection.get_first() 45 | list_records = await collection.get_list(page=1, per_page=10) 46 | all_records = await collection.get_full_list() 47 | one = await collection.get_one(record_id=first["id"]) 48 | 49 | print(one) 50 | 51 | # Update a record 52 | updated = await collection.update(record_id=one["id"], params={"contents": "Good to see you again!"}) 53 | 54 | print(updated) 55 | 56 | # Delete a record 57 | await collection.delete(record_id=one["id"]) 58 | 59 | 60 | if __name__ == "__main__": 61 | import asyncio 62 | 63 | asyncio.run(hello_world()) 64 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import socket 3 | from collections.abc import Generator 4 | from contextlib import closing 5 | from pathlib import Path 6 | from subprocess import DEVNULL, Popen 7 | from time import sleep 8 | 9 | import pytest 10 | 11 | from pocketbase import PocketBase 12 | from tests.prep import ensure_pocketbase_executable 13 | 14 | 15 | def find_free_port() -> int: 16 | with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: 17 | s.bind(("", 0)) 18 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 19 | return s.getsockname()[1] 20 | 21 | 22 | @pytest.fixture(scope="session") 23 | def executable() -> Path: 24 | return ensure_pocketbase_executable() 25 | 26 | 27 | @pytest.fixture(scope="session") 28 | def superuser() -> tuple[str, str]: 29 | return (f"{secrets.token_urlsafe(6)}@test.com", secrets.token_urlsafe(12)) 30 | 31 | 32 | @pytest.fixture(scope="session") 33 | def port() -> int: 34 | return find_free_port() 35 | 36 | 37 | @pytest.fixture(scope="session", autouse=True) 38 | def process(superuser: tuple[str, str], port: int, executable: Path, tmpdir_factory) -> Generator[Popen, None, None]: 39 | directory = tmpdir_factory.mktemp("data") 40 | # Adding a --dev in the command below can be helpful when debugging tests 41 | p = Popen( 42 | args=["_", "serve", f"--dir={directory}", f"--http=127.0.0.1:{port}"], 43 | executable=executable, 44 | ) 45 | sleep(0.3) 46 | Popen( 47 | args=["_", "superuser", "create", superuser[0], superuser[1], f"--dir={directory}"], 48 | executable=executable, 49 | stdout=DEVNULL, 50 | stderr=DEVNULL, 51 | ).communicate() 52 | yield p 53 | p.kill() 54 | p.communicate() 55 | 56 | 57 | @pytest.fixture(scope="session") 58 | def client_url(port): 59 | return f"http://127.0.0.1:{port}" 60 | 61 | 62 | @pytest.fixture() 63 | def client(client_url: str) -> PocketBase: 64 | return PocketBase(client_url) 65 | 66 | 67 | @pytest.fixture() 68 | async def superuser_client(client_url: str, superuser: tuple[str, str]) -> PocketBase: 69 | pb = PocketBase(client_url) 70 | await pb.collection("_superusers").auth.with_password(*superuser) 71 | return pb 72 | -------------------------------------------------------------------------------- /tests/test_record_auth.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | 5 | from pocketbase import PocketBase 6 | from pocketbase.models.dtos import Record 7 | from pocketbase.models.errors import PocketBaseError 8 | 9 | 10 | @pytest.fixture 11 | async def user(superuser_client: PocketBase) -> tuple[Record, str, str]: 12 | email = f"{uuid4().hex[:16]}@{uuid4().hex[:16]}.com" 13 | password = uuid4().hex 14 | await superuser_client.collection("users").create( 15 | { 16 | "email": email, 17 | "password": password, 18 | "passwordConfirm": password, 19 | "verified": False, 20 | } 21 | ) 22 | return email, password 23 | 24 | 25 | async def test_login_user(client: PocketBase, user: tuple[str, str]): 26 | client._inners.auth.clean() 27 | await client.collection("users").auth.with_password(*user) 28 | 29 | assert client._inners.auth._authority["collectionName"] == "users" 30 | assert client._inners.auth._authority["email"] == user[0] 31 | assert client._inners.auth._token is not None 32 | 33 | 34 | async def test_auth_refresh(client: PocketBase, user: tuple[str, str]): 35 | client._inners.auth.clean() 36 | await client.collection("users").auth.with_password(*user) 37 | await client.collection("users").auth.refresh() 38 | 39 | 40 | async def test_delete_user(superuser_client: PocketBase, client: PocketBase, user: tuple[str, str]): 41 | await client.collection("users").auth.with_password(*user) 42 | user = await superuser_client.collection("users").get_first({"filter": f'email = "{user[0]}"'}) 43 | await superuser_client.collection("users").delete(user["id"]) 44 | 45 | 46 | async def test_invalid_login_exception(client: PocketBase): 47 | with pytest.raises(PocketBaseError) as exc: 48 | await client.collection("users").auth.with_password(uuid4().hex, uuid4().hex) 49 | assert exc.value.status == 400 50 | 51 | 52 | async def test_list_auth_methods(client: PocketBase): 53 | val = await client.collection("users").auth.methods() 54 | assert isinstance(val["password"]["enabled"], bool) 55 | assert isinstance(val["oauth2"]["enabled"], bool) 56 | assert isinstance(val["oauth2"]["providers"], list) 57 | assert isinstance(val["mfa"]["enabled"], bool) 58 | -------------------------------------------------------------------------------- /src/pocketbase/services/authorization.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | from datetime import UTC, datetime 4 | from typing import TYPE_CHECKING 5 | 6 | from httpx import Request 7 | 8 | from pocketbase.models.dtos import AuthResult, Record 9 | from pocketbase.services.base import Service 10 | from pocketbase.utils.types import JsonType 11 | 12 | if TYPE_CHECKING: 13 | from pocketbase.client import PocketBase, PocketBaseInners 14 | 15 | 16 | def get_token_payload(token: str | None) -> dict[str, JsonType]: 17 | if not token: 18 | return {} 19 | 20 | payload = token.split(".")[1] 21 | padded = payload + "=" * divmod(len(payload), 4)[1] 22 | jsondata = base64.urlsafe_b64decode(padded) 23 | return json.loads(jsondata) 24 | 25 | 26 | class AuthStore(Service): 27 | _expiration_threshold_ = 60.0 28 | 29 | def __init__(self, pocketbase: "PocketBase", inners: "PocketBaseInners") -> None: 30 | super().__init__(pocketbase, inners) 31 | self._authority: Record | None = None 32 | self._token: str | None = None 33 | self._payload: dict[str, JsonType] = {} 34 | self._refreshing: bool = False 35 | 36 | def set_is_refreshing(self, refreshing: bool) -> None: 37 | self._refreshing = refreshing 38 | 39 | async def authorize(self, request: Request) -> None: 40 | if self._token: 41 | if not self._refreshing and self._is_token_expired() and self._authority: 42 | col: str = self._authority.get("collectionName", "users") 43 | await self._pb.collection(col).auth.refresh() 44 | 45 | request.headers["Authorization"] = self._token 46 | 47 | def set_user(self, model: AuthResult) -> None: 48 | self._authority = model.get("record", self._authority) 49 | self._token = model.get("token", self._token) 50 | self._payload = get_token_payload(self._token) 51 | 52 | def clean(self) -> None: 53 | self._authority = None 54 | self._token = None 55 | self._payload = {} 56 | 57 | def _is_token_expired(self) -> bool: 58 | exp = self._payload.get("exp") 59 | if exp and isinstance(exp, (float | int)): 60 | now = datetime.now(tz=UTC).timestamp() 61 | if now > float(exp) - self._expiration_threshold_: 62 | return True 63 | return False 64 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | 5 | from pocketbase import PocketBase 6 | from pocketbase.models.dtos import Record 7 | from pocketbase.models.errors import PocketBaseError 8 | 9 | 10 | async def test_login(superuser_client: PocketBase, superuser: tuple[str, str]): 11 | assert superuser_client._inners.auth._authority["email"] == superuser[0] 12 | 13 | 14 | async def create_superuser(superuser_client: PocketBase) -> Record: 15 | email = f"{uuid4().hex[:16]}@{uuid4().hex[:16]}.com" 16 | password = uuid4().hex 17 | return ( 18 | await superuser_client.collection("_superusers").create( 19 | { 20 | "email": email, 21 | "password": password, 22 | } 23 | ), 24 | email, 25 | password, 26 | ) 27 | 28 | 29 | async def test_create_superuser(superuser_client: PocketBase): 30 | superuser, _, _ = await create_superuser(superuser_client) 31 | # should stay logged in as previous superuser 32 | assert superuser_client._inners.auth._authority["id"] != superuser["id"] 33 | 34 | 35 | async def test_login_as_created_superuser(superuser_client: PocketBase, client: PocketBase): 36 | superuser, email, password = await create_superuser(superuser_client) 37 | await client.collection("_superusers").auth.with_password(email, password) 38 | assert client._inners.auth._authority["id"] != superuser_client._inners.auth._authority["id"] 39 | assert client._inners.auth._authority["id"] == superuser["id"] 40 | 41 | 42 | async def test_delete_superuser(superuser_client: PocketBase): 43 | superuser, _, _ = await create_superuser(superuser_client) 44 | await superuser_client.collection("_superusers").delete(superuser["id"]) 45 | 46 | with pytest.raises(PocketBaseError) as e: 47 | await superuser_client.collection("_superusers").get_one(superuser["id"]) 48 | 49 | assert e.value.status == 404 50 | 51 | 52 | async def test_invalid_login_exception(client: PocketBase): 53 | with pytest.raises(PocketBaseError) as exc: 54 | await client.collection("_superusers").auth.with_password(uuid4().hex, uuid4().hex) 55 | assert exc.value.status == 400 # invalid login 56 | 57 | 58 | async def test_auth_refresh(superuser_client: PocketBase): 59 | oldid = superuser_client._inners.auth._authority["id"] 60 | ar = await superuser_client.collection("_superusers").auth.refresh() 61 | assert superuser_client._inners.auth._token == ar["token"] 62 | assert superuser_client._inners.auth._authority["id"] == oldid 63 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to pocketbase-async 2 | 3 | First off, thank you for considering contributing to pocketbase-async! ❤️ 4 | 5 | All types of contributions are encouraged and valued. This is a small project, so just get in touch via discussions, issues, twitter, email or even linkedin before embarking on a major rewrite :wink:. 6 | 7 | > ### Legal Notice 8 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license (MIT). 9 | 10 | ## Code of Conduct 11 | 12 | We work under the unmodified Contributor Covenant [Code of Conduct](https://github.com/thijsmie/pocketbase-async/tree/main/CODE_OF_CONDUCT.md). It is my personal hope to never need it. 13 | 14 | ## I Want To Contribute 15 | 16 | There are many ways to do this! Here is a list of the main ways you can go about this: 17 | 18 | ### Reporting Bugs 19 | 20 | Be sure to check the existing issues on the project and make sure you are not on an old version. When that is taken care of you can write a new issue. Be sure to include the steps to reproduce the bug and some information about your environment. This is also included in the issue template for bugs. 21 | 22 | ### Reporting Security Issues 23 | 24 | If you think your bug might be a security vurnerability _do not_ create an issue here on the public repository but make a responsible disclosure as outlined in the [Security Guidelines](https://github.com/thijsmie/pocketbase-async/tree/main/SECURITY.md) 25 | 26 | ### Feature Requests 27 | 28 | Be sure to check if the latest release doesn't already include your feature or that it has been requested before (also check the closed issues). You can first start a discussion instead of opening an issue, this generally has a lower barrier to entry. Try to outline your feature as clearly as possible, include some code samples of how it would work. 29 | 30 | ### Contributing Code 31 | 32 | Contributing code is very much appreciated, but make sure that your contribution doesn't come out of the blue! You should be contributing code if: 33 | 34 | - There is an existing bug report and you've commented you'll fix it. 35 | - There is an existing feature request and you've commented you want to build it. 36 | - There is a small enhancement: a simple obvious bugfix, a couple typos or a new example or test. 37 | 38 | Please make sure that anything you contribute is formatted, linted and tested. You can use the steps as outlined in the `check` github actions workflow to make sure your contribution is conforming. 39 | 40 | ### Improving The Documentation 41 | 42 | These are most welcome of all, big or small. 43 | -------------------------------------------------------------------------------- /examples/working_with_files.py: -------------------------------------------------------------------------------- 1 | from pocketbase import FileUpload, PocketBase, PocketBaseError 2 | 3 | CONNECTION_URL = "http://localhost:8123" 4 | SUPERUSER_EMAIL = "test@example.com" 5 | SUPERUSER_PASSWORD = "test" 6 | 7 | 8 | async def working_with_files(): 9 | # Instantiate the PocketBase connector 10 | pb = PocketBase("http://localhost:8123") 11 | 12 | # Authenticate as a superuser 13 | await pb.collection("_superusers").auth.with_password(email=SUPERUSER_EMAIL, password=SUPERUSER_PASSWORD) 14 | 15 | # Create a collection 16 | try: 17 | await pb.collections.create( 18 | { 19 | "name": "working_with_files", 20 | "type": "base", 21 | "fields": [ 22 | { 23 | "name": "name", 24 | "type": "text", 25 | "required": True, 26 | }, 27 | { 28 | "name": "file", 29 | "type": "file", 30 | "required": True, 31 | }, 32 | ], 33 | } 34 | ) 35 | except PocketBaseError: 36 | # Collection probably exists 37 | pass 38 | 39 | # Get the collection instance we can work with 40 | collection = pb.collection("working_with_files") 41 | 42 | # Upload a file 43 | # Note that FileUpload takes _tuples_, this is because you can have 44 | # fields that take multiple files. They are structed as: 45 | # tuple(filename, content) or tuple(filename, content, mimetype) 46 | # Content can be anything file-like such as bytes, a string, a file descriptor from 47 | # open() or any io stream object. It uses httpx under the hood. 48 | record = await collection.create( 49 | params={ 50 | "name": "important_data.txt", 51 | "file": FileUpload(("important_data.txt", b"The answer to life, the universe and everything is 42.")), 52 | } 53 | ) 54 | 55 | # Download a file 56 | # Note: the filename here crucially is not what you originally used 57 | # but whatever PocketBase decided to call it for storage. You 58 | # can always find it back inside the database record. 59 | file = await pb.files.download_file( 60 | collection=record["collectionName"], record_id=record["id"], filename=record["file"] 61 | ) 62 | 63 | print(file) 64 | 65 | # Update the file 66 | updated = await collection.update( 67 | record_id=record["id"], params={"file": FileUpload(("important_question.txt", b"But what is the question?"))} 68 | ) 69 | 70 | print(updated) 71 | 72 | # Clean up after ourselves 73 | await collection.delete(record_id=record["id"]) 74 | 75 | 76 | if __name__ == "__main__": 77 | import asyncio 78 | 79 | asyncio.run(working_with_files()) 80 | -------------------------------------------------------------------------------- /src/pocketbase/client.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient, Request, Response 2 | 3 | from pocketbase.services.authorization import AuthStore 4 | from pocketbase.services.backup import BackupService 5 | from pocketbase.services.collection import CollectionService 6 | from pocketbase.services.file import FileService 7 | from pocketbase.services.health import HealthService 8 | from pocketbase.services.log import LogService 9 | from pocketbase.services.realtime import RealtimeService 10 | from pocketbase.services.record import RecordService 11 | from pocketbase.services.settings import SettingsService 12 | 13 | 14 | class PocketBaseInners: 15 | auth: AuthStore 16 | client: AsyncClient 17 | 18 | def __init__(self, pocketbase: "PocketBase", base_url: str) -> None: 19 | self.auth = AuthStore(pocketbase, self) 20 | self.client = AsyncClient(base_url=base_url) 21 | 22 | 23 | class PocketBase: 24 | _inner_cls_: type[PocketBaseInners] = PocketBaseInners 25 | 26 | def __init__(self, base_url: str) -> None: 27 | self._inners = self._inner_cls_(self, base_url) 28 | self._collections_service: CollectionService = CollectionService(self, self._inners) 29 | self._file_service: FileService = FileService(self, self._inners) 30 | self._log_service: LogService = LogService(self, self._inners) 31 | self._realtime_service: RealtimeService = RealtimeService(self, self._inners) 32 | self._health_service: HealthService = HealthService(self, self._inners) 33 | self._backup_service: BackupService = BackupService(self, self._inners) 34 | self._settings: SettingsService = SettingsService(self, self._inners) 35 | self._collections: dict[str, RecordService] = {} 36 | 37 | def headers(self) -> dict[str, str]: 38 | return {"Accept-Language": "en-US"} 39 | 40 | async def before_send(self, request: Request) -> Request | None: 41 | pass 42 | 43 | async def after_send(self, response: Response) -> Response | None: 44 | pass 45 | 46 | @property 47 | def collections(self) -> CollectionService: 48 | return self._collections_service 49 | 50 | @property 51 | def files(self) -> FileService: 52 | return self._file_service 53 | 54 | @property 55 | def logs(self) -> LogService: 56 | return self._log_service 57 | 58 | @property 59 | def realtime(self) -> RealtimeService: 60 | return self._realtime_service 61 | 62 | @property 63 | def health(self) -> HealthService: 64 | return self._health_service 65 | 66 | @property 67 | def backups(self) -> BackupService: 68 | return self._backup_service 69 | 70 | def collection(self, id_or_name: str) -> RecordService: 71 | if id_or_name not in self._collections: 72 | self._collections[id_or_name] = RecordService(self, self._inners, id_or_name) 73 | return self._collections[id_or_name] 74 | -------------------------------------------------------------------------------- /src/pocketbase/services/base.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from httpx import Request, Response 4 | 5 | from pocketbase.models.errors import PocketBaseError 6 | from pocketbase.models.options import SendOptions 7 | from pocketbase.utils.types import JsonType, SendableFiles, transform 8 | 9 | if TYPE_CHECKING: 10 | from pocketbase.client import PocketBase, PocketBaseInners 11 | 12 | 13 | class Service: 14 | __base_sub_path__: str 15 | 16 | def __init__(self, pocketbase: "PocketBase", inners: "PocketBaseInners") -> None: 17 | self._pb = pocketbase 18 | self._in = inners 19 | 20 | async def _send_raw(self, path: str, options: SendOptions) -> Response: 21 | request = self._init_send(path, options) 22 | await self._in.auth.authorize(request) 23 | 24 | if self._pb.before_send != self._pb.__class__.before_send: 25 | request = (await self._pb.before_send(request)) or request 26 | 27 | response = await self._in.client.send(request) 28 | 29 | if self._pb.after_send != self._pb.__class__.after_send: 30 | response = (await self._pb.after_send(response)) or response 31 | 32 | return response 33 | 34 | async def _send(self, path: str, options: SendOptions) -> JsonType: 35 | response = await self._send_raw(path, options) 36 | PocketBaseError.raise_for_status(response) 37 | 38 | try: 39 | return response.json() 40 | except ValueError as e: 41 | raise PocketBaseError(str(response.url), response.status_code, "PocketBase returned invalid JSON") from e 42 | 43 | async def _send_noreturn(self, path: str, options: SendOptions) -> None: 44 | response = await self._send_raw(path, options) 45 | PocketBaseError.raise_for_status(response) 46 | 47 | def _init_send(self, path: str, options: SendOptions) -> Request: 48 | headers = self._pb.headers() 49 | 50 | if options.get("headers"): 51 | headers.update(options["headers"]) 52 | 53 | headers["accept"] = "application/json" 54 | 55 | body = options.get("body") 56 | data: dict[str, JsonType] | None = None 57 | files: SendableFiles | None = options.get("files") 58 | if body and files is None: 59 | data, files = transform(body) 60 | if not files: 61 | files = None 62 | data = None 63 | else: 64 | body = None 65 | elif body and files is not None: 66 | data, sfiles = transform(body) 67 | files.extend(sfiles) 68 | 69 | return self._in.client.build_request( 70 | url=self._build_url(path), 71 | method=options.get("method", "GET"), 72 | json=body, 73 | data=data, 74 | files=files, # type: ignore 75 | params=options.get("params"), 76 | headers=headers, 77 | ) 78 | 79 | def _build_url(self, path: str) -> str: 80 | return f"{self.__base_sub_path__}{path}" 81 | -------------------------------------------------------------------------------- /images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/test_collection.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | 5 | from pocketbase import PocketBase 6 | from pocketbase.models.dtos import Collection 7 | from pocketbase.models.errors import PocketBaseError 8 | 9 | 10 | async def create_collection(client: PocketBase) -> tuple[Collection, str]: 11 | collection = await client.collections.create( 12 | { 13 | "name": uuid4().hex, 14 | "type": "base", 15 | "fields": [ 16 | { 17 | "name": "title", 18 | "type": "text", 19 | "required": True, 20 | "min": 10, 21 | }, 22 | ], 23 | } 24 | ) 25 | return collection, collection["name"] 26 | 27 | 28 | async def test_create(superuser_client: PocketBase): 29 | collection, _ = await create_collection(superuser_client) 30 | assert collection["type"] == "base" 31 | 32 | 33 | async def test_update(superuser_client: PocketBase): 34 | collection, _ = await create_collection(superuser_client) 35 | new_name = uuid4().hex 36 | c2 = await superuser_client.collections.update( 37 | collection["id"], 38 | { 39 | "name": new_name, 40 | "fields": [ 41 | { 42 | "name": "title", 43 | "type": "text", 44 | "required": True, 45 | "min": 10, 46 | }, 47 | { 48 | "name": "status", 49 | "type": "bool", 50 | }, 51 | ], 52 | }, 53 | ) 54 | assert c2["name"] != collection["name"] 55 | assert c2["name"] == new_name 56 | 57 | 58 | async def test_delete(superuser_client: PocketBase): 59 | collection, _ = await create_collection(superuser_client) 60 | await superuser_client.collections.delete(collection["id"]) 61 | with pytest.raises(PocketBaseError) as exc: 62 | await superuser_client.collections.delete(collection["id"]) 63 | assert exc.value.status == 404 # double already deleted 64 | 65 | 66 | async def test_delete_nonexisting_exception(superuser_client: PocketBase): 67 | with pytest.raises(PocketBaseError) as exc: 68 | await superuser_client.collections.delete(uuid4().hex) 69 | assert exc.value.status == 404 # delete nonexisting 70 | 71 | 72 | async def test_get_nonexisting_exception(superuser_client: PocketBase): 73 | with pytest.raises(PocketBaseError) as exc: 74 | await superuser_client.collections.get_one(uuid4().hex) 75 | assert exc.value.status == 404 76 | 77 | 78 | async def test_import_collection(superuser_client: PocketBase): 79 | data = [ 80 | { 81 | "name": uuid4().hex, 82 | "fields": [ 83 | { 84 | "name": "status", 85 | "type": "bool", 86 | }, 87 | ], 88 | }, 89 | { 90 | "name": uuid4().hex, 91 | "fields": [ 92 | { 93 | "name": "title", 94 | "type": "text", 95 | }, 96 | ], 97 | }, 98 | ] 99 | await superuser_client.collections.import_collections(data) 100 | -------------------------------------------------------------------------------- /src/pocketbase/models/dtos.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, Literal, NotRequired, TypedDict, TypeVar 2 | 3 | from pocketbase.utils.types import JsonType 4 | 5 | _T = TypeVar("_T") 6 | 7 | 8 | class ListResult(TypedDict, Generic[_T]): 9 | page: int 10 | per_page: int 11 | total_items: int 12 | total_pages: int 13 | items: list[_T] 14 | 15 | 16 | class BaseDict(TypedDict): 17 | id: str 18 | 19 | 20 | class CollectionField(TypedDict): 21 | id: str 22 | name: str 23 | type: str 24 | system: bool 25 | required: bool 26 | presentable: bool 27 | options: dict[str, JsonType] 28 | 29 | 30 | class CollectionModel(BaseDict): 31 | name: str 32 | type: str 33 | fields: list[CollectionField] 34 | indexes: list[str] 35 | system: bool 36 | options: dict[str, JsonType] 37 | listRule: NotRequired[str] 38 | viewRule: NotRequired[str] 39 | createRule: NotRequired[str] 40 | updateRule: NotRequired[str] 41 | deleteRule: NotRequired[str] 42 | 43 | 44 | class Collection(TypedDict, total=False): 45 | name: str 46 | type: str 47 | fields: list[CollectionField] 48 | indexes: list[str] 49 | system: bool 50 | options: dict[str, JsonType] 51 | listRule: NotRequired[str] 52 | viewRule: NotRequired[str] 53 | createRule: NotRequired[str] 54 | updateRule: NotRequired[str] 55 | deleteRule: NotRequired[str] 56 | 57 | 58 | class ExternalAuthModel(BaseDict): 59 | recordId: str 60 | collectionId: str 61 | provider: str 62 | providerId: str 63 | 64 | 65 | class LogModel(BaseDict): 66 | level: str 67 | message: str 68 | data: dict[str, JsonType] 69 | 70 | 71 | class HourlyStats(TypedDict): 72 | total: int 73 | date: str 74 | 75 | 76 | class HealthCheckResponse(TypedDict): 77 | code: int 78 | message: str 79 | data: JsonType 80 | 81 | 82 | class AuthProvider(TypedDict): 83 | name: str 84 | state: str 85 | codeVerifier: str 86 | codeChallenge: str 87 | codeChallengeMethod: str 88 | authUrl: str 89 | 90 | 91 | class Oauth2Payload(TypedDict): 92 | provider: str 93 | code: str 94 | codeVerifier: str 95 | redirectUrl: str 96 | createData: NotRequired[dict[str, JsonType]] 97 | 98 | 99 | class Record(TypedDict, total=False): 100 | collectionId: str 101 | collectionName: str 102 | id: str 103 | extend: JsonType 104 | 105 | 106 | class AuthResult(TypedDict): 107 | token: str 108 | record: Record 109 | meta: NotRequired[JsonType] 110 | 111 | 112 | class OTPResult(TypedDict): 113 | otpId: str 114 | 115 | 116 | class RealtimeEvent(TypedDict): 117 | action: Literal["create", "update", "delete"] 118 | record: Record 119 | 120 | 121 | class AuthMethodPassword(TypedDict): 122 | enabled: bool 123 | identityFields: list[str] 124 | 125 | 126 | class AuthMethodOauth2(TypedDict): 127 | enabled: bool 128 | providers: list[AuthProvider] 129 | 130 | 131 | class AuthMethodMfa(TypedDict): 132 | enabled: bool 133 | duration: int 134 | 135 | 136 | class AuthMethodOtp(TypedDict): 137 | enabled: bool 138 | duration: int 139 | 140 | 141 | class AuthMethods(TypedDict): 142 | password: AuthMethodPassword 143 | oauth2: AuthMethodOauth2 144 | mfa: AuthMethodMfa 145 | otp: AuthMethodOtp 146 | -------------------------------------------------------------------------------- /src/pocketbase/services/backup.py: -------------------------------------------------------------------------------- 1 | from typing import TypeAlias, TypedDict, cast 2 | from urllib.parse import quote 3 | 4 | from httpx._types import FileTypes 5 | 6 | from pocketbase.models.errors import PocketBaseError 7 | from pocketbase.models.options import CommonOptions, SendOptions 8 | from pocketbase.services.base import Service 9 | 10 | ZipFileName: TypeAlias = str 11 | 12 | 13 | class BackupFileInfo(TypedDict): 14 | key: ZipFileName 15 | size: int 16 | modified: str 17 | 18 | 19 | class BackupService(Service): 20 | __base_sub_path__ = "/api/backups" 21 | 22 | async def get_full_list(self, options: CommonOptions | None = None) -> list[BackupFileInfo]: 23 | send_options: SendOptions = {"method": "GET"} 24 | 25 | if options: 26 | send_options.update(options) 27 | send_options["params"] = send_options.get("params", {}).copy() 28 | 29 | backups = await self._send("", send_options) 30 | return cast(list[BackupFileInfo], backups) 31 | 32 | async def create(self, key: ZipFileName | None = None, options: CommonOptions | None = None) -> None: 33 | send_options: SendOptions = {"method": "POST", "body": {}} 34 | 35 | if key: 36 | send_options["body"]["name"] = key 37 | 38 | if options: 39 | send_options.update(options) 40 | send_options["params"] = send_options.get("params", {}).copy() 41 | 42 | await self._send_noreturn("", send_options) 43 | 44 | async def upload(self, file: FileTypes, options: CommonOptions | None = None) -> None: 45 | send_options: SendOptions = { 46 | "method": "POST", 47 | "files": [("file", file)], 48 | } 49 | 50 | if options: 51 | send_options.update(options) 52 | send_options["params"] = send_options.get("params", {}).copy() 53 | 54 | await self._send_noreturn("/upload", send_options) 55 | 56 | async def delete(self, key: ZipFileName, options: CommonOptions | None = None) -> None: 57 | send_options: SendOptions = {"method": "DELETE"} 58 | 59 | if options: 60 | send_options.update(options) 61 | send_options["params"] = send_options.get("params", {}).copy() 62 | 63 | await self._send_noreturn(f"/{quote(key)}", send_options) 64 | 65 | async def download(self, key: ZipFileName, options: CommonOptions | None = None) -> bytes: 66 | send_options: SendOptions = {"method": "GET"} 67 | 68 | if options: 69 | send_options.update(options) 70 | send_options["params"] = send_options.get("params", {}).copy() 71 | 72 | send_options["params"] = send_options.get("params", {}) 73 | send_options["params"]["token"] = await self._pb.files.get_token() 74 | 75 | response = await self._send_raw(f"/{quote(key)}", send_options) 76 | 77 | PocketBaseError.raise_for_status(response) 78 | 79 | return response.content 80 | 81 | async def restore(self, key: ZipFileName, options: CommonOptions | None = None) -> None: 82 | send_options: SendOptions = {"method": "POST"} 83 | 84 | if options: 85 | send_options.update(options) 86 | send_options["params"] = send_options.get("params", {}).copy() 87 | 88 | await self._send_noreturn(f"/{quote(key)}/restore", send_options) 89 | 90 | async def get_download_url(self, key: ZipFileName) -> str: 91 | token = await self._pb.files.get_token() 92 | return self._build_url(f"{quote(key)}?token={quote(token)}") 93 | -------------------------------------------------------------------------------- /src/pocketbase/services/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from pocketbase.models.errors import PocketBaseError 6 | from pocketbase.models.options import CommonOptions, SendOptions 7 | from pocketbase.services.base import Service 8 | from pocketbase.utils.types import BodyDict 9 | 10 | if TYPE_CHECKING: 11 | from pocketbase.client import PocketBase, PocketBaseInners 12 | 13 | 14 | class SettingsService(Service): 15 | __base_sub_path__: str = "/api/settings" 16 | 17 | def __init__(self, pocketbase: PocketBase, inners: PocketBaseInners) -> None: 18 | super().__init__(pocketbase, inners) 19 | 20 | async def get_all(self, options: CommonOptions | None = None) -> dict[str, Any]: 21 | """Fetch all available app settings.""" 22 | send_options: SendOptions = {"method": "GET"} 23 | 24 | if options: 25 | send_options.update(options) 26 | 27 | return await self._send("", send_options) # type: ignore 28 | 29 | async def update( 30 | self, 31 | body: BodyDict | None = None, 32 | options: CommonOptions | None = None, 33 | ) -> dict[str, Any]: 34 | """Bulk updates app settings.""" 35 | send_options: SendOptions = {"method": "PATCH"} 36 | 37 | if body: 38 | send_options["body"] = body 39 | 40 | if options: 41 | send_options.update(options) 42 | 43 | return await self._send("", send_options) # type: ignore 44 | 45 | async def test_s3(self, options: CommonOptions | None = None) -> bool: 46 | """Performs a S3 storage connection test.""" 47 | send_options: SendOptions = {"method": "POST"} 48 | 49 | if options: 50 | send_options.update(options) 51 | 52 | await self._send("/test/s3", send_options) 53 | return True 54 | 55 | async def test_email( 56 | self, 57 | to_email: str, 58 | email_template: str, 59 | options: CommonOptions | None = None, 60 | ) -> bool: 61 | """ 62 | Sends a test email. 63 | 64 | The possible `email_template` values are: 65 | - verification 66 | - password-reset 67 | - email-change 68 | """ 69 | body: BodyDict = {"email": to_email, "template": email_template} 70 | send_options: SendOptions = {"method": "POST", "body": body} 71 | 72 | if options: 73 | send_options.update(options) 74 | 75 | await self._send("/test/email", send_options) 76 | return True 77 | 78 | async def generate_apple_client_secret( 79 | self, 80 | client_id: str, 81 | team_id: str, 82 | key_id: str, 83 | private_key: str, 84 | duration: int, 85 | options: CommonOptions | None = None, 86 | ) -> str: 87 | """Generates a new Apple client secret.""" 88 | body: BodyDict = { 89 | "clientId": client_id, 90 | "teamId": team_id, 91 | "keyId": key_id, 92 | "privateKey": private_key, 93 | "duration": duration, 94 | } 95 | send_options: SendOptions = {"method": "POST", "body": body} 96 | 97 | if options: 98 | send_options.update(options) 99 | 100 | response = await self._send("/apple/generate-client-secret", send_options) 101 | 102 | if isinstance(response, dict) and "secret" in response and isinstance(response["secret"], str): 103 | return response["secret"] 104 | else: 105 | raise PocketBaseError( 106 | "/apple/generate-client-secret", 107 | 500, 108 | "Failed to generate Apple client secret or unexpected response format.", 109 | ) 110 | -------------------------------------------------------------------------------- /tests/test_files.py: -------------------------------------------------------------------------------- 1 | from random import getrandbits 2 | from uuid import uuid4 3 | 4 | from pocketbase import FileUpload, PocketBase 5 | 6 | 7 | async def create_file_collection(superuser_client: PocketBase): 8 | schema = [ 9 | { 10 | "name": "title", 11 | "type": "text", 12 | "required": True, 13 | }, 14 | { 15 | "name": "image", 16 | "type": "file", 17 | "required": False, 18 | "maxSelect": 3, 19 | "maxSize": 5242880, 20 | "mimeTypes": [ 21 | "application/octet-stream", 22 | "text/plain", 23 | ], 24 | }, 25 | ] 26 | 27 | return await superuser_client.collections.create( 28 | { 29 | "name": uuid4().hex, 30 | "type": "base", 31 | "fields": schema, 32 | } 33 | ) 34 | 35 | 36 | async def test_create_three_file_record(superuser_client: PocketBase): 37 | coll = await create_file_collection(superuser_client) 38 | col = superuser_client.collection(coll["id"]) 39 | name1 = uuid4().hex 40 | name2 = uuid4().hex 41 | name3 = uuid4().hex 42 | acontent = uuid4().hex 43 | bcontent = getrandbits(1024 * 8).to_bytes(1024, "little") 44 | ccontent = uuid4().hex 45 | record = await col.create( 46 | { 47 | "title": uuid4().hex, 48 | "image": FileUpload( 49 | (name1 + ".txt", acontent, "text/plain"), 50 | (name2 + ".txt", bcontent, "application/octet-stream"), 51 | (name3 + ".txt", ccontent, "text/plain"), 52 | ), 53 | } 54 | ) 55 | assert len(record["image"]) == 3 56 | for fn in record["image"]: 57 | if fn.startswith(name2): 58 | break 59 | 60 | rel = await col.get_one(record["id"]) 61 | assert len(rel["image"]) == 3 62 | 63 | rcontent = await superuser_client.files.download_file(coll["id"], rel["id"], fn) 64 | assert rcontent == bcontent 65 | 66 | 67 | async def test_remove_file_from_record(superuser_client: PocketBase): 68 | coll = await create_file_collection(superuser_client) 69 | col = superuser_client.collection(coll["id"]) 70 | record = await col.create({"title": "bla", "image": FileUpload(("a.png", b"jajaj"), ("b.png", b"jbjbj"))}) 71 | 72 | # delete some of the files from record but keep the file named "filename" 73 | get_record = await col.update(record["id"], {"image": [record["image"][0]]}) 74 | assert record["image"] != get_record["image"] 75 | assert len(get_record["image"]) == 1 76 | 77 | 78 | async def test_create_one_file_record(superuser_client: PocketBase): 79 | coll = await create_file_collection(superuser_client) 80 | col = superuser_client.collection(coll["id"]) 81 | name1 = uuid4().hex 82 | acontent = uuid4().hex 83 | record = await col.create( 84 | { 85 | "title": uuid4().hex, 86 | "image": FileUpload((name1 + ".txt", acontent, "text/plain")), 87 | } 88 | ) 89 | assert len(record["image"]) == 1 90 | for fn in record["image"]: 91 | assert fn.startswith(name1) 92 | 93 | rel = await col.get_one(record["id"]) 94 | assert len(rel["image"]) == 1 95 | 96 | r = await superuser_client.files.download_file(rel["collectionName"], rel["id"], rel["image"][0]) 97 | assert r.decode("utf-8") == acontent 98 | 99 | 100 | async def test_create_without_file_record2(superuser_client: PocketBase): 101 | coll = await create_file_collection(superuser_client) 102 | col = superuser_client.collection(coll["id"]) 103 | record = await col.create( 104 | { 105 | "title": uuid4().hex, 106 | "image": None, 107 | } 108 | ) 109 | assert len(record["image"]) == 0 110 | 111 | rel = await col.get_one(record["id"]) 112 | assert len(rel["image"]) == 0 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![Static Badge](https://img.shields.io/badge/status-beta-blue?style=for-the-badge&label=status)](https://github.com/thijsmie/pocketbase-async) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](https://github.com/thijsmie/pocketbase-async/blob/main/LICENSE.txt) 5 | [![Checks status](https://img.shields.io/github/actions/workflow/status/thijsmie/pocketbase-async/check.yml?style=for-the-badge&label=Checks)](https://github.com/thijsmie/pocketbase-async/actions) 6 | [![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/thijsmie/a41c81ee9f5d3944d2f9946c3eae4aae/raw/coverage.json)](https://github.com/thijsmie/pocketbase-async/actions) 7 | [![GitHub Release](https://img.shields.io/github/v/release/thijsmie/pocketbase-async?style=for-the-badge)](https://github.com/thijsmie/pocketbase-async/releases) 8 | 9 |
10 |
11 | 12 | Logo 13 | 14 |

pocketbase-async

15 | 16 |

17 | An async Python 3.11+ PocketBase SDK 18 |


19 | Repository 20 |
21 | Report Bug 22 | · 23 | Request Feature 24 |

25 |
26 | 27 | ## About 28 | 29 | PocketBase is an amazing tool to have in your developers backpack for a quick backend for your project. I found it pleasant to work with in Python but Vaphes existing Python SDK is in sync code while most of my application development is async these days. I started with a fork of Vaphes' SDK and tried to add async support but I gave up quite quickly and just started from scratch. You see the results here. 30 | 31 | ## Installation 32 | 33 | Note that this package is compatible with Python 3.11 and up. You can install this package directly from PyPi: 34 | ```bash 35 | pip install pocketbase-async 36 | # or if you use poetry 37 | poetry add pocketbase-async 38 | ``` 39 | 40 | ## Usage 41 | 42 | The API is mostly the same as the official JS SDK and the Vaphes Python SDK, with some exceptions. Authentication methods are namespaced under an extra `.auth`. There are some examples to help you along. More info to come. 43 | 44 | - [View the examples](https://github.com/thijsmie/pocketbase-async/tree/main/examples) 45 | 46 | ## Roadmap 47 | 48 | See the [project board](https://github.com/thijsmie/pocketbase-async/projects?query=is%3Aopen) for the list of planned work. See the [open issues](https://github.com/thijsmie/pocketbase-async/issues) for a full list of proposed features (and known issues). 49 | 50 | ## Contributing 51 | 52 | Contributions are welcome and appreciated, be it typo-fix, feature or extensive rework. I recommend you to open an issue if you plan to spend significant effort on making a pull request, to avoid dual work or getting your work rejected if it really doesn't fit this project. 53 | 54 | Don't forget to give the project a star! Thanks again! 55 | 56 | 1. Fork the project 57 | 2. Create your feature branch (`git checkout -b feat/some-nice-feature`) 58 | 3. Commit your changes (`git commit -m 'feat: Add some AmazingFeature'`) 59 | 4. Push to the pranch (`git push -u origin feat/some-nice-feature`) 60 | 5. Open a pull request 61 | 62 | ## License 63 | 64 | Distributed under the MIT License. See `LICENSE.txt` for more information. 65 | 66 | ## Attributions 67 | 68 | The `pocketbase-async` package was inspired and guided in implementation by several other projects: 69 | 70 | - The official js sdk: https://github.com/pocketbase/js-sdk 71 | - The Python SDK by Vaphes: https://github.com/vaphes/pocketbase 72 | - The official documentation: https://pocketbase.io/docs/ 73 | 74 | Furthermore, a lot of the API tests were adapted from Vaphes' work (licensed MIT). 75 | 76 | 77 |
78 | :arrow_up_small: Back to top 79 | -------------------------------------------------------------------------------- /tests/test_realtime.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from contextlib import asynccontextmanager 4 | from unittest.mock import MagicMock, patch 5 | from urllib.parse import quote 6 | from uuid import uuid4 7 | 8 | import httpx 9 | from httpx_sse import ServerSentEvent 10 | 11 | from pocketbase import PocketBase 12 | from pocketbase.services.realtime import RealtimeEvent 13 | 14 | 15 | async def test_realtime(superuser_client: PocketBase) -> None: 16 | await superuser_client.collections.create( 17 | { 18 | "name": "test", 19 | "type": "base", 20 | "fields": [ 21 | { 22 | "name": "title", 23 | "type": "text", 24 | "required": True, 25 | }, 26 | ], 27 | } 28 | ) 29 | col = superuser_client.collection("test") 30 | 31 | event_trigger = asyncio.Event() 32 | event_payload: RealtimeEvent | None = None 33 | 34 | async def test(event: RealtimeEvent) -> None: 35 | nonlocal event_trigger, event_payload 36 | event_payload = event 37 | event_trigger.set() 38 | 39 | unsub = await col.subscribe_all(callback=test) 40 | record = await col.create({"title": "hi"}) 41 | 42 | async with asyncio.timeout(0.5): 43 | await event_trigger.wait() 44 | 45 | assert event_payload 46 | assert event_payload["action"] == "create" 47 | assert event_payload["record"] == record 48 | await unsub() 49 | 50 | event_trigger.clear() 51 | unsub = await col.subscribe(callback=test, record_id=record["id"]) 52 | record = await col.update(record["id"], {"title": "ho"}) 53 | 54 | async with asyncio.timeout(0.5): 55 | await event_trigger.wait() 56 | 57 | assert event_payload 58 | assert event_payload["action"] == "update" 59 | assert event_payload["record"] == record 60 | await unsub() 61 | 62 | 63 | # The test below takes five minutes at least (until we get disconnected) 64 | # so we don't run it normally, you'll have to turn it on manually 65 | # Feature request, configurable timeout for realtime connections? 66 | 67 | 68 | class MockAsyncIteratorCallable: 69 | def __init__(self, generator_function): 70 | self.generator_function = generator_function 71 | self.case = -1 72 | 73 | def __aiter__(self): 74 | return self 75 | 76 | async def __anext__(self): 77 | try: 78 | self.case += 1 79 | return await self.generator_function(self.case) 80 | except StopIteration: 81 | raise StopAsyncIteration 82 | 83 | 84 | async def test_realtime_all_records(superuser_client: PocketBase) -> None: 85 | def handler(_r): 86 | return httpx.Response(200, json={}) 87 | 88 | transport = httpx.MockTransport(handler) 89 | pb = PocketBase("http://bla.com") 90 | pb._inners.client = httpx.AsyncClient(base_url="http://bla.com", transport=transport) 91 | 92 | col = pb.collection("test_realtime_disconnect_all_records") 93 | 94 | event_counter = 0 95 | 96 | async def callback(event: RealtimeEvent) -> None: 97 | nonlocal event_counter 98 | event_counter += 1 99 | 100 | async def behaviour(case: int) -> ServerSentEvent: 101 | match case: 102 | case 0: 103 | return ServerSentEvent(event="PB_CONNECT", data=str(uuid4()), id="something") 104 | case 1: 105 | return ServerSentEvent( 106 | event=quote("test_realtime_disconnect_all_records"), 107 | data=json.dumps({"action": "create", "record": {"title": "Hi"}}), 108 | id="something", 109 | ) 110 | case 2: 111 | await asyncio.sleep(0.02) 112 | raise TimeoutError() 113 | 114 | @asynccontextmanager 115 | async def repl_aconnect_sse(*args, **kwargs): 116 | mock = MagicMock() 117 | mock.aiter_sse.return_value = MockAsyncIteratorCallable(behaviour) 118 | yield mock 119 | 120 | with patch("pocketbase.services.realtime.aconnect_sse", repl_aconnect_sse): 121 | unsub = await col.subscribe_all(callback=callback) 122 | await asyncio.sleep(0.1) 123 | await unsub() 124 | 125 | assert event_counter > 1 126 | -------------------------------------------------------------------------------- /src/pocketbase/services/crud.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar 2 | from urllib.parse import quote 3 | 4 | from pocketbase.models.dtos import ListResult 5 | from pocketbase.models.errors import PocketBaseNotFoundError 6 | from pocketbase.models.options import CommonOptions, FirstOptions, FullListOptions, ListOptions, SendOptions 7 | from pocketbase.services.base import Service 8 | from pocketbase.utils.types import BodyDict 9 | 10 | _T = TypeVar("_T") 11 | 12 | 13 | class CrudService(Service, Generic[_T]): 14 | async def get_list( 15 | self, 16 | page: int = 1, 17 | per_page: int = 30, 18 | options: ListOptions | SendOptions | None = None, 19 | ) -> ListResult[_T]: 20 | send_options: SendOptions = {"method": "GET"} 21 | 22 | if options: 23 | send_options.update(options) 24 | 25 | send_options["params"] = send_options.get("params", {}).copy() 26 | send_options["params"]["page"] = page 27 | send_options["params"]["perPage"] = per_page 28 | 29 | if options and "filter" in options: 30 | send_options["params"]["filter"] = options["filter"] # type: ignore 31 | del send_options["filter"] # type: ignore 32 | 33 | if options and "sort" in options: 34 | send_options["params"]["sort"] = options["sort"] # type: ignore 35 | del send_options["sort"] # type: ignore 36 | 37 | return await self._send("", send_options) # type: ignore 38 | 39 | async def get_full_list(self, options: FullListOptions | None = None) -> list[_T]: 40 | list_options: ListOptions = {} 41 | batch = options.get("batch", 500) if options else 500 42 | 43 | if options: 44 | list_options.update(options) 45 | 46 | list_options["params"] = list_options.get("params", {}).copy() 47 | list_options["params"]["skipTotal"] = 1 48 | 49 | page = 1 50 | items: list[_T] = [] 51 | result = None 52 | 53 | while result is None or len(result["items"]) == batch: 54 | result = await self.get_list(page, batch, list_options) 55 | items.extend(result["items"]) 56 | page += 1 57 | 58 | return items 59 | 60 | async def get_first(self, options: FirstOptions | None = None) -> _T: 61 | list_options: ListOptions = {} 62 | 63 | if options: 64 | list_options.update(options) 65 | 66 | list_options["params"] = list_options.get("params", {}).copy() 67 | list_options["params"]["skipTotal"] = 1 68 | result = await self.get_list(1, 1, list_options) 69 | if not result["items"]: 70 | raise PocketBaseNotFoundError( 71 | url=self._build_url(""), 72 | status=404, 73 | data={"code": 404, "message": "The requested resource wasn't found.", "data": {}}, 74 | ) 75 | return result["items"][0] 76 | 77 | async def get_one(self, record_id: str, options: CommonOptions | None = None) -> _T: 78 | send_options: SendOptions = {"method": "GET"} 79 | 80 | if options: 81 | send_options.update(options) 82 | send_options["params"] = send_options.get("params", {}).copy() 83 | 84 | return await self._send(f"/{quote(record_id)}", send_options) # type: ignore 85 | 86 | async def create(self, params: BodyDict, options: CommonOptions | None = None) -> _T: 87 | if "password" in params and "passwordConfirm" not in params: 88 | params["passwordConfirm"] = params["password"] 89 | 90 | send_options: SendOptions = {"method": "POST", "body": params} 91 | 92 | if options: 93 | send_options.update(options) 94 | send_options["params"] = send_options.get("params", {}).copy() 95 | 96 | return await self._send("", send_options) # type: ignore 97 | 98 | async def update(self, record_id: str, params: BodyDict, options: CommonOptions | None = None) -> _T: 99 | send_options: SendOptions = {"method": "PATCH", "body": params} 100 | 101 | if options: 102 | send_options.update(options) 103 | send_options["params"] = send_options.get("params", {}).copy() 104 | 105 | return await self._send(f"/{quote(record_id)}", send_options) # type: ignore 106 | 107 | async def delete(self, record_id: str, options: CommonOptions | None = None) -> None: 108 | send_options: SendOptions = {"method": "DELETE"} 109 | 110 | if options: 111 | send_options.update(options) 112 | send_options["params"] = send_options.get("params", {}).copy() 113 | 114 | await self._send_noreturn(f"/{quote(record_id)}", send_options) 115 | -------------------------------------------------------------------------------- /src/pocketbase/services/realtime.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from asyncio.tasks import Task 5 | from collections import defaultdict 6 | from collections.abc import Awaitable, Callable 7 | from contextlib import suppress 8 | from typing import TYPE_CHECKING, Any 9 | from urllib.parse import quote 10 | 11 | from httpx import ReadError, RemoteProtocolError 12 | from httpx_sse import aconnect_sse 13 | 14 | from pocketbase.models.dtos import RealtimeEvent 15 | from pocketbase.models.options import CommonOptions 16 | from pocketbase.services.base import Service 17 | 18 | if TYPE_CHECKING: 19 | from pocketbase.client import PocketBase, PocketBaseInners 20 | 21 | 22 | Callback = Callable[[RealtimeEvent], Awaitable[None]] 23 | 24 | 25 | class RealtimeService(Service): 26 | __base_sub_path__ = "/api/realtime" 27 | 28 | def __init__(self, pocketbase: "PocketBase", inners: "PocketBaseInners") -> None: 29 | super().__init__(pocketbase, inners) 30 | self._last_transmit: set[str] = set() 31 | self._subscriptions: dict[str, list[Callback]] = defaultdict(list) 32 | self._client_id: str | None = None 33 | self._connection: Task | None = None 34 | 35 | async def _ensure_connection(self) -> None: 36 | if self._connection and not self._connection.done(): 37 | # Already running 38 | return 39 | 40 | sentinel = asyncio.Event() 41 | self._connection = asyncio.create_task(self._make_connection(sentinel)) 42 | await sentinel.wait() 43 | 44 | async def _make_connection(self, sentinel: asyncio.Event) -> None: 45 | headers: dict[str, Any] = {} 46 | last_event_id: Any | None = None 47 | try: 48 | while True: 49 | try: 50 | async with aconnect_sse( 51 | self._in.client, "GET", self.__base_sub_path__, headers=headers, timeout=900 52 | ) as sse: 53 | async for message in sse.aiter_sse(): 54 | if message.event == "PB_CONNECT": 55 | self._client_id = message.id 56 | await self._transmit_subscriptions(force=True) 57 | sentinel.set() 58 | continue 59 | 60 | last_event_id = message.id 61 | 62 | if message.event in self._subscriptions: 63 | for callback in self._subscriptions[message.event]: 64 | try: 65 | await callback(message.json()) 66 | except: # noqa: E722 67 | # We never want any exception to break the realtime handler. 68 | logging.exception("Unhandled exception in realtime event handler") 69 | except (TimeoutError, ReadError, RemoteProtocolError): 70 | logging.debug("Connection lost, reconnecting automatically") 71 | if last_event_id: 72 | headers["Last-Event-ID"] = last_event_id 73 | 74 | finally: 75 | # Disconnected, reset to plain state. 76 | logging.exception("Connection to realtime endpoint lost") 77 | self._connection = None 78 | self._client_id = None 79 | self._last_transmit = set() 80 | sentinel.set() 81 | 82 | async def _transmit_subscriptions(self, force: bool = False) -> None: 83 | to_transmit = set(self._subscriptions.keys()) 84 | if not force and (to_transmit == self._last_transmit or not self._client_id): 85 | return 86 | 87 | self._last_transmit = to_transmit 88 | 89 | await self._send_noreturn( 90 | "", {"method": "POST", "body": {"clientId": self._client_id, "subscriptions": list(to_transmit)}} 91 | ) 92 | 93 | async def subscribe( 94 | self, topic: str, callback: Callback, options: CommonOptions | None = None 95 | ) -> Callable[[], Awaitable[None]]: 96 | key = quote(topic) 97 | 98 | if options: 99 | value = json.dumps({"query": options["params"], "headers": options["headers"]}) 100 | key += f"?options={quote(value)}" 101 | 102 | async def unsubscribe() -> None: 103 | try: 104 | self._subscriptions[key].remove(callback) 105 | if not self._subscriptions[key]: 106 | del self._subscriptions[key] 107 | if not self._subscriptions: 108 | await self.close() 109 | except ValueError: 110 | pass 111 | 112 | self._subscriptions[key].append(callback) 113 | await self._transmit_subscriptions() 114 | await self._ensure_connection() 115 | return unsubscribe 116 | 117 | async def close(self) -> None: 118 | if self._connection: 119 | self._connection.cancel() 120 | with suppress(asyncio.CancelledError): 121 | await self._connection 122 | self._connection = None 123 | self._client_id = None 124 | self._last_transmit = set() 125 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official email address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [INSERT CONTACT METHOD]. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /src/pocketbase/services/record.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable, Callable 2 | from typing import TYPE_CHECKING, cast 3 | from urllib.parse import quote 4 | 5 | from pocketbase.models.dtos import AuthMethods, AuthResult, Oauth2Payload, OTPResult, Record 6 | from pocketbase.models.options import CommonOptions, SendOptions 7 | from pocketbase.services.base import Service 8 | from pocketbase.services.crud import CrudService 9 | from pocketbase.services.realtime import Callback 10 | from pocketbase.utils.types import BodyDict 11 | 12 | if TYPE_CHECKING: 13 | from pocketbase.client import PocketBase, PocketBaseInners 14 | 15 | 16 | class RecordService(CrudService[Record]): 17 | __base_sub_path__: str 18 | 19 | def __init__(self, pocketbase: "PocketBase", inners: "PocketBaseInners", collection: str) -> None: 20 | super().__init__(pocketbase, inners) 21 | self._collection = collection 22 | self.__base_sub_path__ = f"/api/collections/{quote(collection)}/records" 23 | self._auth = RecordAuthService(pocketbase, inners, collection) 24 | 25 | @property 26 | def auth(self) -> "RecordAuthService": 27 | return self._auth 28 | 29 | async def subscribe( 30 | self, 31 | callback: Callback, 32 | record_id: str, 33 | options: CommonOptions | None = None, 34 | ) -> Callable[[], Awaitable[None]]: 35 | """ 36 | Subscribes to a specific record identified by `record_id`. 37 | 38 | Args: 39 | callback: Function to be called when updates occur for the record. 40 | record_id: The ID of the record to subscribe to. 41 | options: Additional options for the subscription (optional). 42 | 43 | Raises: 44 | ValueError: If `record_id` is empty or None. 45 | """ 46 | 47 | if not record_id: 48 | raise ValueError("Invalid record_id: cannot be empty or None") 49 | 50 | return await self._pb.realtime.subscribe(f"{self._collection}/{record_id}", callback, options) 51 | 52 | async def subscribe_all( 53 | self, callback: Callback, options: CommonOptions | None = None 54 | ) -> Callable[[], Awaitable[None]]: 55 | """ 56 | Subscribes to all records in the current collection. 57 | 58 | Args: 59 | callback: Function to be called when updates occur for any record. 60 | options: Additional options for the subscription (optional). 61 | 62 | Returns: 63 | A function to unsubscribe from all records. 64 | """ 65 | 66 | return await self._pb.realtime.subscribe(self._collection, callback, options) 67 | 68 | 69 | class RecordAuthService(Service): 70 | def __init__(self, pocketbase: "PocketBase", inners: "PocketBaseInners", collection: str) -> None: 71 | super().__init__(pocketbase, inners) 72 | self.__base_sub_path__ = f"/api/collections/{quote(collection)}" 73 | 74 | async def methods(self, options: CommonOptions | None = None) -> AuthMethods: 75 | send_options: SendOptions = {"method": "GET"} 76 | 77 | if options: 78 | send_options.update(options) 79 | 80 | return await self._send("/auth-methods", send_options) # type: ignore 81 | 82 | async def with_password( 83 | self, 84 | username_or_email: str, 85 | password: str, 86 | identity_field: str | None = None, 87 | options: CommonOptions | None = None, 88 | ) -> AuthResult: 89 | body = {"identity": username_or_email, "password": password} 90 | 91 | if identity_field: 92 | body["identityField"] = identity_field 93 | 94 | send_options: SendOptions = {"method": "POST", "body": body} # type: ignore 95 | 96 | if options: 97 | send_options.update(options) 98 | 99 | result: AuthResult = await self._send("/auth-with-password", send_options) # type: ignore 100 | self._in.auth.set_user(result) 101 | return result 102 | 103 | async def with_oauth2(self, payload: Oauth2Payload, options: CommonOptions | None = None) -> AuthResult: 104 | send_options: SendOptions = {"method": "POST", "body": cast(BodyDict, payload)} 105 | 106 | if options: 107 | send_options.update(options) 108 | 109 | result: AuthResult = await self._send("/auth-with-oauth2", send_options) # type: ignore 110 | self._in.auth.set_user(result) 111 | return result 112 | 113 | async def with_otp(self, otp_id: str, password: str, options: CommonOptions | None = None) -> AuthResult: 114 | send_options: SendOptions = {"method": "POST", "body": {"otpId": otp_id, "password": password}} 115 | 116 | if options: 117 | send_options.update(options) 118 | 119 | result: AuthResult = await self._send("/auth-with-otp", send_options) # type: ignore 120 | self._in.auth.set_user(result) 121 | return result 122 | 123 | async def request_otp(self, email: str, option: CommonOptions | None = None) -> OTPResult: 124 | send_options: SendOptions = {"method": "POST", "body": {"email": email}} 125 | 126 | if option: 127 | send_options.update(option) 128 | 129 | return await self._send("/request-otp", send_options) # type: ignore 130 | 131 | async def refresh(self, options: CommonOptions | None = None) -> AuthResult: 132 | send_options: SendOptions = {"method": "POST"} 133 | 134 | if options: 135 | send_options.update(options) 136 | 137 | self._in.auth.set_is_refreshing(True) 138 | result: AuthResult = await self._send("/auth-refresh", send_options) # type: ignore 139 | self._in.auth.set_is_refreshing(False) 140 | self._in.auth.set_user(result) 141 | return result 142 | 143 | async def impersonate( 144 | self, record_id: str, duration: int | None = None, options: CommonOptions | None = None 145 | ) -> AuthResult: 146 | body = {} 147 | 148 | if duration: 149 | body["duration"] = duration 150 | 151 | send_options: SendOptions = {"method": "POST", "body": body} # type: ignore 152 | 153 | if options: 154 | send_options.update(options) 155 | 156 | result: AuthResult = await self._send(f"/impersonate/{record_id}", send_options) # type: ignore 157 | self._in.auth.set_user(result) 158 | return result 159 | -------------------------------------------------------------------------------- /tests/test_record.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | from uuid import uuid4 3 | 4 | import pytest 5 | 6 | from pocketbase import PocketBase, PocketBaseError 7 | from pocketbase.models.dtos import CollectionModel 8 | 9 | 10 | @pytest.fixture 11 | async def collection(superuser_client: PocketBase) -> CollectionModel: 12 | schema = [ 13 | {"name": "created", "onCreate": True, "onUpdate": False, "type": "autodate"}, 14 | {"name": "updated", "onCreate": True, "onUpdate": True, "type": "autodate"}, 15 | { 16 | "name": "title", 17 | "type": "text", 18 | "required": True, 19 | }, 20 | ] 21 | coll = await superuser_client.collections.create( 22 | { 23 | "name": uuid4().hex, 24 | "type": "base", 25 | "fields": schema, 26 | } 27 | ) 28 | schema.append( 29 | { 30 | "name": "rel", 31 | "type": "relation", 32 | "required": False, 33 | "collectionId": coll["id"], 34 | "cascadeDelete": False, 35 | "maxSelect": 1, 36 | }, 37 | ) 38 | schema.append( 39 | { 40 | "name": "multirel", 41 | "type": "relation", 42 | "required": False, 43 | "collectionId": coll["id"], 44 | "cascadeDelete": False, 45 | "maxSelect": 5, 46 | }, 47 | ) 48 | return await superuser_client.collections.update(coll["id"], {"fields": schema}) 49 | 50 | 51 | async def test_create_record(superuser_client: PocketBase, collection: CollectionModel): 52 | bname = uuid4().hex 53 | col = superuser_client.collection(collection["id"]) 54 | record = await col.create( 55 | { 56 | "title": bname, 57 | } 58 | ) 59 | assert record["title"] == bname 60 | assert record == await col.get_first() 61 | 62 | 63 | async def test_create_multiple_record(superuser_client: PocketBase, collection: CollectionModel): 64 | records = [] 65 | col = superuser_client.collection(collection["id"]) 66 | 67 | for _ in range(10): 68 | records.append( 69 | ( 70 | await col.create( 71 | { 72 | "title": uuid4().hex, 73 | "rel": records[-1] if records else None, 74 | } 75 | ) 76 | )["id"] 77 | ) 78 | 79 | # expansion 80 | 81 | rel = await col.get_one(records[-1], {"params": {"expand": "rel.rel.rel.rel.rel.rel"}}) 82 | 83 | for i, r in enumerate(reversed(records)): 84 | assert rel["id"] == r 85 | if i > 5: 86 | break 87 | rel = rel["expand"]["rel"] 88 | 89 | 90 | async def test_create_multi_relation_record(superuser_client: PocketBase, collection: CollectionModel): 91 | records = [] 92 | col = superuser_client.collection(collection["id"]) 93 | 94 | for _ in range(5): 95 | records.append( 96 | ( 97 | await superuser_client.collection(collection["id"]).create( 98 | { 99 | "title": uuid4().hex, 100 | "multirel": records if records else [], 101 | } 102 | ) 103 | )["id"] 104 | ) 105 | 106 | # expansion 107 | 108 | rel = await col.get_one(records[-1], {"params": {"expand": "multirel.multirel.multirel.multirel"}}) 109 | 110 | for i, r in enumerate(reversed(records)): 111 | assert rel["id"] == r 112 | if i >= 4: 113 | break 114 | assert len(rel["expand"]["multirel"]) == len(records) - i - 1 115 | rel = rel["expand"]["multirel"][-1] 116 | 117 | 118 | async def test_get_record(superuser_client: PocketBase, collection: CollectionModel): 119 | bname = uuid4().hex 120 | col = superuser_client.collection(collection["id"]) 121 | record = await col.create( 122 | { 123 | "title": bname, 124 | } 125 | ) 126 | first = await col.get_first() 127 | byid = await col.get_one(record["id"]) 128 | full_list = await col.get_full_list() 129 | partial_list = await col.get_list() 130 | assert record == first == byid == full_list[0] == partial_list["items"][0] 131 | assert record["title"] == bname 132 | 133 | 134 | async def test_get_filter(superuser_client: PocketBase, collection: CollectionModel): 135 | col = superuser_client.collection(collection["id"]) 136 | record_a = await col.create( 137 | { 138 | "title": "a", 139 | } 140 | ) 141 | record_b = await col.create( 142 | { 143 | "title": "b", 144 | } 145 | ) 146 | 147 | assert record_a == await col.get_first({"filter": 'title = "a"'}) 148 | assert record_b == await col.get_first({"filter": 'title = "b"'}) 149 | assert [record_a] == await col.get_full_list({"filter": 'title = "a"'}) 150 | assert [record_b] == await col.get_full_list({"filter": 'title = "b"'}) 151 | assert [record_a] == (await col.get_list(options={"filter": 'title = "a"'}))["items"] 152 | assert [record_b] == (await col.get_list(options={"filter": 'title = "b"'}))["items"] 153 | 154 | 155 | async def test_get_sorted(superuser_client: PocketBase, collection: CollectionModel): 156 | col = superuser_client.collection(collection["id"]) 157 | record_a = await col.create( 158 | { 159 | "title": "a", 160 | } 161 | ) 162 | record_c = await col.create( 163 | { 164 | "title": "c", 165 | } 166 | ) 167 | record_b = await col.create( 168 | { 169 | "title": "b", 170 | } 171 | ) 172 | 173 | falling = await col.get_list(options={"sort": "-title"}) 174 | rising = await col.get_list(options={"sort": "+title"}) 175 | assert [record_a, record_b, record_c] == rising["items"] 176 | assert [record_c, record_b, record_a] == falling["items"] 177 | 178 | 179 | async def test_update(superuser_client: PocketBase, collection: CollectionModel): 180 | col = superuser_client.collection(collection["id"]) 181 | record = await col.create({"title": "a"}) 182 | updated = await col.update(record["id"], {"title": "b"}) 183 | assert record != updated 184 | assert updated["updated"] > updated["created"] 185 | assert updated["created"] == record["created"] 186 | 187 | 188 | async def test_delete(superuser_client: PocketBase, collection: CollectionModel): 189 | col = superuser_client.collection(collection["id"]) 190 | record = await col.create({"title": "a"}) 191 | await col.delete(record["id"]) 192 | # deleting already deleted record should give 404 193 | with pytest.raises(PocketBaseError) as exc: 194 | await col.delete(record["id"]) 195 | assert exc.value.status == 404 196 | 197 | 198 | async def test_get_one(superuser_client: PocketBase, collection: CollectionModel): 199 | col = superuser_client.collection(collection["id"]) 200 | with pytest.raises(PocketBaseError) as exc: 201 | await col.get_one("blblblbllb") 202 | assert exc.value.status == 404 203 | 204 | 205 | async def test_datetime(superuser_client: PocketBase): 206 | await superuser_client.collections.create( 207 | { 208 | "name": "datetime", 209 | "type": "base", 210 | "fields": [ 211 | { 212 | "name": "when", 213 | "type": "date", 214 | "required": True, 215 | }, 216 | ], 217 | } 218 | ) 219 | await superuser_client.collection("datetime").create({"when": datetime.now()}) 220 | await superuser_client.collection("datetime").create({"when": datetime.now(tz=UTC)}) 221 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "anyio" 5 | version = "4.9.0" 6 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 7 | optional = false 8 | python-versions = ">=3.9" 9 | groups = ["main"] 10 | files = [ 11 | {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, 12 | {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, 13 | ] 14 | 15 | [package.dependencies] 16 | idna = ">=2.8" 17 | sniffio = ">=1.1" 18 | typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} 19 | 20 | [package.extras] 21 | doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] 22 | test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] 23 | trio = ["trio (>=0.26.1)"] 24 | 25 | [[package]] 26 | name = "certifi" 27 | version = "2025.4.26" 28 | description = "Python package for providing Mozilla's CA Bundle." 29 | optional = false 30 | python-versions = ">=3.6" 31 | groups = ["main"] 32 | files = [ 33 | {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, 34 | {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, 35 | ] 36 | 37 | [[package]] 38 | name = "colorama" 39 | version = "0.4.6" 40 | description = "Cross-platform colored terminal text." 41 | optional = false 42 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 43 | groups = ["dev"] 44 | markers = "sys_platform == \"win32\"" 45 | files = [ 46 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 47 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 48 | ] 49 | 50 | [[package]] 51 | name = "coverage" 52 | version = "7.8.0" 53 | description = "Code coverage measurement for Python" 54 | optional = false 55 | python-versions = ">=3.9" 56 | groups = ["dev"] 57 | files = [ 58 | {file = "coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe"}, 59 | {file = "coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28"}, 60 | {file = "coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3"}, 61 | {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676"}, 62 | {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d"}, 63 | {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a"}, 64 | {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c"}, 65 | {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f"}, 66 | {file = "coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f"}, 67 | {file = "coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23"}, 68 | {file = "coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27"}, 69 | {file = "coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea"}, 70 | {file = "coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7"}, 71 | {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040"}, 72 | {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543"}, 73 | {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2"}, 74 | {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318"}, 75 | {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9"}, 76 | {file = "coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c"}, 77 | {file = "coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78"}, 78 | {file = "coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc"}, 79 | {file = "coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6"}, 80 | {file = "coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d"}, 81 | {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05"}, 82 | {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a"}, 83 | {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6"}, 84 | {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47"}, 85 | {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe"}, 86 | {file = "coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545"}, 87 | {file = "coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b"}, 88 | {file = "coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd"}, 89 | {file = "coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00"}, 90 | {file = "coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64"}, 91 | {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067"}, 92 | {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008"}, 93 | {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733"}, 94 | {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323"}, 95 | {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3"}, 96 | {file = "coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d"}, 97 | {file = "coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487"}, 98 | {file = "coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25"}, 99 | {file = "coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42"}, 100 | {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502"}, 101 | {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1"}, 102 | {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4"}, 103 | {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73"}, 104 | {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a"}, 105 | {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883"}, 106 | {file = "coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada"}, 107 | {file = "coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257"}, 108 | {file = "coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f"}, 109 | {file = "coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a"}, 110 | {file = "coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82"}, 111 | {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814"}, 112 | {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c"}, 113 | {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd"}, 114 | {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4"}, 115 | {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899"}, 116 | {file = "coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f"}, 117 | {file = "coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3"}, 118 | {file = "coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd"}, 119 | {file = "coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7"}, 120 | {file = "coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501"}, 121 | ] 122 | 123 | [package.extras] 124 | toml = ["tomli ; python_full_version <= \"3.11.0a6\""] 125 | 126 | [[package]] 127 | name = "h11" 128 | version = "0.16.0" 129 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 130 | optional = false 131 | python-versions = ">=3.8" 132 | groups = ["main"] 133 | files = [ 134 | {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, 135 | {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, 136 | ] 137 | 138 | [[package]] 139 | name = "httpcore" 140 | version = "1.0.9" 141 | description = "A minimal low-level HTTP client." 142 | optional = false 143 | python-versions = ">=3.8" 144 | groups = ["main"] 145 | files = [ 146 | {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, 147 | {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, 148 | ] 149 | 150 | [package.dependencies] 151 | certifi = "*" 152 | h11 = ">=0.16" 153 | 154 | [package.extras] 155 | asyncio = ["anyio (>=4.0,<5.0)"] 156 | http2 = ["h2 (>=3,<5)"] 157 | socks = ["socksio (==1.*)"] 158 | trio = ["trio (>=0.22.0,<1.0)"] 159 | 160 | [[package]] 161 | name = "httpx" 162 | version = "0.28.1" 163 | description = "The next generation HTTP client." 164 | optional = false 165 | python-versions = ">=3.8" 166 | groups = ["main"] 167 | files = [ 168 | {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, 169 | {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, 170 | ] 171 | 172 | [package.dependencies] 173 | anyio = "*" 174 | certifi = "*" 175 | httpcore = "==1.*" 176 | idna = "*" 177 | 178 | [package.extras] 179 | brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] 180 | cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] 181 | http2 = ["h2 (>=3,<5)"] 182 | socks = ["socksio (==1.*)"] 183 | zstd = ["zstandard (>=0.18.0)"] 184 | 185 | [[package]] 186 | name = "httpx-sse" 187 | version = "0.4.0" 188 | description = "Consume Server-Sent Event (SSE) messages with HTTPX." 189 | optional = false 190 | python-versions = ">=3.8" 191 | groups = ["main"] 192 | files = [ 193 | {file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"}, 194 | {file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"}, 195 | ] 196 | 197 | [[package]] 198 | name = "idna" 199 | version = "3.10" 200 | description = "Internationalized Domain Names in Applications (IDNA)" 201 | optional = false 202 | python-versions = ">=3.6" 203 | groups = ["main"] 204 | files = [ 205 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 206 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 207 | ] 208 | 209 | [package.extras] 210 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 211 | 212 | [[package]] 213 | name = "iniconfig" 214 | version = "2.1.0" 215 | description = "brain-dead simple config-ini parsing" 216 | optional = false 217 | python-versions = ">=3.8" 218 | groups = ["dev"] 219 | files = [ 220 | {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, 221 | {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, 222 | ] 223 | 224 | [[package]] 225 | name = "mypy" 226 | version = "1.15.0" 227 | description = "Optional static typing for Python" 228 | optional = false 229 | python-versions = ">=3.9" 230 | groups = ["dev"] 231 | files = [ 232 | {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, 233 | {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, 234 | {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, 235 | {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, 236 | {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, 237 | {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, 238 | {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, 239 | {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, 240 | {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, 241 | {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, 242 | {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, 243 | {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, 244 | {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, 245 | {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, 246 | {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, 247 | {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, 248 | {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, 249 | {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, 250 | {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, 251 | {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, 252 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, 253 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, 254 | {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, 255 | {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, 256 | {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, 257 | {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, 258 | {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, 259 | {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, 260 | {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, 261 | {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, 262 | {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, 263 | {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, 264 | ] 265 | 266 | [package.dependencies] 267 | mypy_extensions = ">=1.0.0" 268 | typing_extensions = ">=4.6.0" 269 | 270 | [package.extras] 271 | dmypy = ["psutil (>=4.0)"] 272 | faster-cache = ["orjson"] 273 | install-types = ["pip"] 274 | mypyc = ["setuptools (>=50)"] 275 | reports = ["lxml"] 276 | 277 | [[package]] 278 | name = "mypy-extensions" 279 | version = "1.1.0" 280 | description = "Type system extensions for programs checked with the mypy type checker." 281 | optional = false 282 | python-versions = ">=3.8" 283 | groups = ["dev"] 284 | files = [ 285 | {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, 286 | {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, 287 | ] 288 | 289 | [[package]] 290 | name = "packaging" 291 | version = "25.0" 292 | description = "Core utilities for Python packages" 293 | optional = false 294 | python-versions = ">=3.8" 295 | groups = ["dev"] 296 | files = [ 297 | {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, 298 | {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, 299 | ] 300 | 301 | [[package]] 302 | name = "pluggy" 303 | version = "1.5.0" 304 | description = "plugin and hook calling mechanisms for python" 305 | optional = false 306 | python-versions = ">=3.8" 307 | groups = ["dev"] 308 | files = [ 309 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 310 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 311 | ] 312 | 313 | [package.extras] 314 | dev = ["pre-commit", "tox"] 315 | testing = ["pytest", "pytest-benchmark"] 316 | 317 | [[package]] 318 | name = "pytest" 319 | version = "8.3.5" 320 | description = "pytest: simple powerful testing with Python" 321 | optional = false 322 | python-versions = ">=3.8" 323 | groups = ["dev"] 324 | files = [ 325 | {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, 326 | {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, 327 | ] 328 | 329 | [package.dependencies] 330 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 331 | iniconfig = "*" 332 | packaging = "*" 333 | pluggy = ">=1.5,<2" 334 | 335 | [package.extras] 336 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 337 | 338 | [[package]] 339 | name = "pytest-asyncio" 340 | version = "0.26.0" 341 | description = "Pytest support for asyncio" 342 | optional = false 343 | python-versions = ">=3.9" 344 | groups = ["dev"] 345 | files = [ 346 | {file = "pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0"}, 347 | {file = "pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f"}, 348 | ] 349 | 350 | [package.dependencies] 351 | pytest = ">=8.2,<9" 352 | 353 | [package.extras] 354 | docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] 355 | testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] 356 | 357 | [[package]] 358 | name = "pytest-cov" 359 | version = "6.1.1" 360 | description = "Pytest plugin for measuring coverage." 361 | optional = false 362 | python-versions = ">=3.9" 363 | groups = ["dev"] 364 | files = [ 365 | {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"}, 366 | {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"}, 367 | ] 368 | 369 | [package.dependencies] 370 | coverage = {version = ">=7.5", extras = ["toml"]} 371 | pytest = ">=4.6" 372 | 373 | [package.extras] 374 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] 375 | 376 | [[package]] 377 | name = "ruff" 378 | version = "0.11.9" 379 | description = "An extremely fast Python linter and code formatter, written in Rust." 380 | optional = false 381 | python-versions = ">=3.7" 382 | groups = ["dev"] 383 | files = [ 384 | {file = "ruff-0.11.9-py3-none-linux_armv6l.whl", hash = "sha256:a31a1d143a5e6f499d1fb480f8e1e780b4dfdd580f86e05e87b835d22c5c6f8c"}, 385 | {file = "ruff-0.11.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:66bc18ca783b97186a1f3100e91e492615767ae0a3be584e1266aa9051990722"}, 386 | {file = "ruff-0.11.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd576cd06962825de8aece49f28707662ada6a1ff2db848d1348e12c580acbf1"}, 387 | {file = "ruff-0.11.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b1d18b4be8182cc6fddf859ce432cc9631556e9f371ada52f3eaefc10d878de"}, 388 | {file = "ruff-0.11.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f3f46f759ac623e94824b1e5a687a0df5cd7f5b00718ff9c24f0a894a683be7"}, 389 | {file = "ruff-0.11.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f34847eea11932d97b521450cf3e1d17863cfa5a94f21a056b93fb86f3f3dba2"}, 390 | {file = "ruff-0.11.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f33b15e00435773df97cddcd263578aa83af996b913721d86f47f4e0ee0ff271"}, 391 | {file = "ruff-0.11.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b27613a683b086f2aca8996f63cb3dd7bc49e6eccf590563221f7b43ded3f65"}, 392 | {file = "ruff-0.11.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e0d88756e63e8302e630cee3ce2ffb77859797cc84a830a24473939e6da3ca6"}, 393 | {file = "ruff-0.11.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:537c82c9829d7811e3aa680205f94c81a2958a122ac391c0eb60336ace741a70"}, 394 | {file = "ruff-0.11.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:440ac6a7029f3dee7d46ab7de6f54b19e34c2b090bb4f2480d0a2d635228f381"}, 395 | {file = "ruff-0.11.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:71c539bac63d0788a30227ed4d43b81353c89437d355fdc52e0cda4ce5651787"}, 396 | {file = "ruff-0.11.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c67117bc82457e4501473c5f5217d49d9222a360794bfb63968e09e70f340abd"}, 397 | {file = "ruff-0.11.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e4b78454f97aa454586e8a5557facb40d683e74246c97372af3c2d76901d697b"}, 398 | {file = "ruff-0.11.9-py3-none-win32.whl", hash = "sha256:7fe1bc950e7d7b42caaee2a8a3bc27410547cc032c9558ee2e0f6d3b209e845a"}, 399 | {file = "ruff-0.11.9-py3-none-win_amd64.whl", hash = "sha256:52edaa4a6d70f8180343a5b7f030c7edd36ad180c9f4d224959c2d689962d964"}, 400 | {file = "ruff-0.11.9-py3-none-win_arm64.whl", hash = "sha256:bcf42689c22f2e240f496d0c183ef2c6f7b35e809f12c1db58f75d9aa8d630ca"}, 401 | {file = "ruff-0.11.9.tar.gz", hash = "sha256:ebd58d4f67a00afb3a30bf7d383e52d0e036e6195143c6db7019604a05335517"}, 402 | ] 403 | 404 | [[package]] 405 | name = "sniffio" 406 | version = "1.3.1" 407 | description = "Sniff out which async library your code is running under" 408 | optional = false 409 | python-versions = ">=3.7" 410 | groups = ["main"] 411 | files = [ 412 | {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, 413 | {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, 414 | ] 415 | 416 | [[package]] 417 | name = "typing-extensions" 418 | version = "4.13.2" 419 | description = "Backported and Experimental Type Hints for Python 3.8+" 420 | optional = false 421 | python-versions = ">=3.8" 422 | groups = ["main", "dev"] 423 | files = [ 424 | {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, 425 | {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, 426 | ] 427 | markers = {main = "python_version < \"3.13\""} 428 | 429 | [metadata] 430 | lock-version = "2.1" 431 | python-versions = ">=3.11,<3.14" 432 | content-hash = "c22bede1c1f3d4724a82b3190109bcf851dce70eb1901c62017e746db711dbf7" 433 | --------------------------------------------------------------------------------