Compare commits

...

40 Commits

Author SHA1 Message Date
Josh McCulloch
99aefa0cda
Added client timeout. (#32) 2023-04-26 20:28:44 -04:00
Vithor Jaeger
9ff559b85e fix version 2023-04-26 20:20:54 -04:00
Vithor Jaeger
99fccb3b0e publish version with oauth fixes 2023-04-26 20:19:45 -04:00
Dan Sikes
c0afb20503
adding dataclass to record model (#33)
Co-authored-by: Dan Sikes <dansikes@Dans-MacBook-Pro.local>
2023-04-09 12:39:09 -04:00
Rafael Stauffer
90bd223664
fix url auth with oauth2 (#36)
Co-authored-by: Rafael Stauffer <rafael.staffer@hotmail.de>
2023-04-09 12:36:31 -04:00
Vithor Jaeger
aacd12bafe
Create python-publish.yml
add auto publishing to Pypi on realease
2023-02-10 14:28:52 -04:00
Paulo Coutinho
a690d451ab
fix readme (#23)
* fix readme

* fix version

* fix imports
2023-02-10 14:19:17 -04:00
Vithor Jaeger
d320125c58
0.8.0 rc2 (#8)
* fix services

* Port of PR #6 to branch 0.8.0 (#7)

* Switch from JSON to multipart file encoding for file upload

File upload is detected when body data contains a value of class FileUpload
Remaining JSON is converted to FormData
Enitre message is sent as multipart

* Switch from JSON to multipart file encoding for file upload (#6)

File upload is detected when body data contains a value of class FileUpload
Remaining JSON is converted to FormData
Enitre message is sent as multipart

* fix readme

* fix client

* Remove "@" chars (#11)

* Remove "@" chars that led to empty collectionId, collectionName and expand

* Make load method more generic

* fix license

---------

Co-authored-by: Paulo Coutinho <paulocoutinhox@gmail.com>
Co-authored-by: Martin <mahe@quantentunnel.de>
Co-authored-by: Eoin Fennessy <85010533+eoinfennessy@users.noreply.github.com>
2023-02-10 13:46:31 -04:00
Paulo Coutinho
efe4bd8f67
fix auth (#20) 2023-02-10 12:44:08 -04:00
Vithor Jaeger
f953d6723c
Update test_utils.py 2023-02-01 08:30:14 -04:00
Vithor Jaeger
cf40d82d28 v0.3.0 2023-02-01 08:26:02 -04:00
Heri Hermawan
7bb6e97880
Update: auth url and parameter for Admins (#16)
Co-authored-by: Heri Hermawan <heri.hermawan@bizhare.id>
2023-01-13 15:24:55 -05:00
Martin
c8297852ce
Switch from JSON to multipart file encoding for file upload (#6)
File upload is detected when body data contains a value of class FileUpload
Remaining JSON is converted to FormData
Enitre message is sent as multipart
2022-11-23 14:30:47 -04:00
Vithor Jaeger
9ae4712c29 remove coverage as it makes no sense yet 2022-09-29 09:24:39 -04:00
Vithor Jaeger
129b42613d more cosmetics and some tests improvements 2022-09-29 09:22:27 -04:00
Vithor Jaeger
f917b60ea0 cosmetics 2022-09-26 14:55:49 -04:00
Vithor Jaeger
7882598922 realtime 'working' 2022-09-23 17:41:27 -04:00
Vithor Jaeger
85c818539a ok 2022-09-22 18:05:34 -04:00
Vithor Jaeger
78010eafcf realtime developemnt started STILL LOTS OF BUGS 2022-09-22 18:00:49 -04:00
Vithor Jaeger
8363b315c3 more cleanup 2022-09-22 15:20:20 -04:00
Vithor Jaeger
976c7e580f cleanup 2022-09-22 14:56:44 -04:00
Vithor Jaeger
c66e6cc66b annotations fix 2022-09-22 14:56:02 -04:00
Vithor Jaeger
cadd4889b6 sse client 2022-09-22 14:46:38 -04:00
Vithor Jaeger
1d92b331e2 badge update 2022-09-19 17:01:18 -04:00
Vithor Jaeger
c740884f5f ok 2022-09-19 16:59:45 -04:00
Vithor Jaeger
58f9117df6 readme and actions update 2022-09-19 16:58:12 -04:00
Vithor Jaeger
aa1684b9c4 readme update 2022-09-19 16:57:04 -04:00
Vithor Jaeger
115f8c42ec readme update and actions 2022-09-19 16:56:21 -04:00
Vithor Jaeger
2b9d0e1212 future annotation fix 2022-09-19 16:48:05 -04:00
Vithor Jaeger
4688b5bfd9 ok 2 2022-09-19 16:43:53 -04:00
Vithor Jaeger
892b68f0aa ok 2022-09-19 16:30:23 -04:00
Vithor Jaeger
9b2902e747 future annotations test 2022-09-19 13:45:48 -04:00
Vithor Jaeger
3bcfa1bd1c fixes for python < 3.9 2022-09-19 13:42:55 -04:00
Vithor Jaeger
afe03f72f0 first "ok" commit message :P 2022-09-19 11:59:45 -04:00
Vithor Jaeger
3f8d2bb108 pypi fixes 2022-09-19 11:57:04 -04:00
Vithor Jaeger
0efe5c901e status badges 2022-09-19 11:41:24 -04:00
Vithor Jaeger
561b4db23b separate github actions 2022-09-19 11:39:54 -04:00
Vithor Jaeger
34a6e3b2f1 create requirements.txt 2022-09-19 11:34:00 -04:00
Vithor Jaeger
4b32c0133c bugfix and toml update 2022-09-19 11:29:01 -04:00
Vithor Jaeger
a57d0e9efe
Create python-package.yml 2022-09-19 11:27:15 -04:00
70 changed files with 1328 additions and 686 deletions

39
.github/workflows/python-publish.yml vendored Normal file
View File

@ -0,0 +1,39 @@
# This workflow will upload a Python Package using Twine when a release is created
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Upload Python Package
on:
release:
types: [published]
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v3
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build
- name: Build package
run: python -m build
- name: Publish package
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}

39
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,39 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Tests
on:
push:
branches: ["master"]
pull_request:
branches: ["master"]
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest

21
LICENCE.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 vaphes
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:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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.

View File

@ -1,5 +1,86 @@
# pocketbase # PocketBase Python SDK
Python client for PocketBase database. [![Tests](https://github.com/vaphes/pocketbase/actions/workflows/tests.yml/badge.svg)](https://github.com/vaphes/pocketbase/actions/workflows/tests.yml)
This is in early development, and at first is just a translations for the javascript lib. Python client SDK for the <a href="https://pocketbase.io/">PocketBase</a> backend.
This is in early development, and at first is just a translation of <a href="https://github.com/pocketbase/js-sdk">the javascript lib</a> using <a href="https://github.com/encode/httpx/">HTTPX</a>.
---
## Installation
Install PocketBase using PIP:
```shell
python3 -m pip install pocketbase
```
## Usage
The rule of thumb here is just to use it as you would <a href="https://github.com/pocketbase/js-sdk">the javascript lib</a>, but in a pythonic way of course!
```python
from pocketbase import PocketBase # Client also works the same
from pocketbase.client import FileUpload
client = PocketBase('http://127.0.0.1:8090')
# authenticate as regular user
user_data = client.collection("users").auth_with_password(
"user@example.com", "0123456789")
# or as admin
admin_data = client.admins.auth_with_password("test@example.com", "0123456789")
# list and filter "example" collection records
result = client.collection("example").get_list(
1, 20, {"filter": 'status = true && created > "2022-08-01 10:00:00"'})
# create record and upload file to image field
result = client.collection("example").create(
{
"status": "true",
"image": FileUpload(("image.png", open("image.png", "rb"))),
})
# and much more...
```
> More detailed API docs and copy-paste examples could be found in the [API documentation for each service](https://pocketbase.io/docs/api-authentication). Just remember to 'pythonize it' 🙃.
## Development
These are the requirements for local development:
* Python 3.7+
* Poetry (https://python-poetry.org/)
You can install locally:
```shell
poetry install
```
Or can build and generate a package:
```shell
poetry build
```
But if you are using only PIP, use this command:
```shell
python3 -m pip install -e .
```
## Tests
To execute the tests use this command:
```
poetry run pytest
```
## License
The PocketBase Python SDK is <a href="https://github.com/vaphes/pocketbase/blob/master/LICENCE.txt">MIT licensed</a> code.

View File

@ -1,3 +1,17 @@
__version__ = "0.1.0" __title__ = "pocketbase"
__description__ = "PocketBase client SDK for python."
__version__ = "0.8.1"
from .client import Client, ClientResponseError
from .client import Client
class PocketBase(Client):
"""
Proxy class for `Client`
This is for cosmetic reasons only as you can use the
`Client` class just the same
"""
pass

View File

@ -1,71 +1,59 @@
from typing import Any from __future__ import annotations
from urllib.parse import urlencode
from typing import Any, Dict
from urllib.parse import quote, urlencode
import httpx import httpx
from pocketbase.services.admins import Admins from pocketbase.models import FileUpload
from pocketbase.services.collections import Collections from pocketbase.models.record import Record
from pocketbase.services.logs import Logs from pocketbase.services.admin_service import AdminService
from pocketbase.services.realtime import Realtime from pocketbase.services.collection_service import CollectionService
from pocketbase.services.records import Records from pocketbase.services.log_service import LogService
from pocketbase.services.users import Users from pocketbase.services.realtime_service import RealtimeService
from pocketbase.services.settings import Settings from pocketbase.services.record_service import RecordService
from pocketbase.services.settings_service import SettingsService
from pocketbase.stores.base_auth_store import BaseAuthStore from pocketbase.stores.base_auth_store import BaseAuthStore
from pocketbase.utils import ClientResponseError
# from pocketbase.stores.local_auth_store import LocalAuthStore
class ClientResponseError(Exception):
url: str = ""
status: int = 0
data: dict = {}
is_abort: bool = False
original_error: Any = None
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args)
self.url = kwargs.get("url", "")
self.status = kwargs.get("status", 0)
self.data = kwargs.get("data", {})
self.is_abort = kwargs.get("is_abort", False)
self.original_error = kwargs.get("original_error", None)
class Client: class Client:
base_url: str base_url: str
lang: str lang: str
auth_store: BaseAuthStore auth_store: BaseAuthStore
settings: Settings settings: SettingsService
admins: Admins admins: AdminService
users: Users records: Record
collections: Collections collections: CollectionService
records: Records records: RecordService
logs: Logs logs: LogService
realtime: Realtime realtime: RealtimeService
record_service: Dict[str, RecordService]
def __init__( def __init__(
self, self,
base_url: str = "/", base_url: str = "/",
lang: str = "en-US", lang: str = "en-US",
auth_store: BaseAuthStore = None, auth_store: BaseAuthStore | None = None,
timeout: float = 120,
) -> None: ) -> None:
self.base_url = base_url self.base_url = base_url
self.lang = lang self.lang = lang
self.auth_store = auth_store or BaseAuthStore() # LocalAuthStore() self.auth_store = auth_store or BaseAuthStore() # LocalAuthStore()
self.timeout = timeout
# services # services
self.admins = Admins(self) self.admins = AdminService(self)
self.users = Users(self) self.collections = CollectionService(self)
self.records = Records(self) self.logs = LogService(self)
self.collections = Collections(self) self.settings = SettingsService(self)
self.logs = Logs(self) self.realtime = RealtimeService(self)
self.settings = Settings(self) self.record_service = {}
self.realtime = Realtime(self)
def cancel_request(self, cancel_key: str): def collection(self, id_or_name: str) -> RecordService:
return self """Returns the RecordService associated to the specified collection."""
if id_or_name not in self.record_service:
def cancel_all_requests(self): self.record_service[id_or_name] = RecordService(self, id_or_name)
return self return self.record_service[id_or_name]
def send(self, path: str, req_config: dict[str:Any]) -> Any: def send(self, path: str, req_config: dict[str:Any]) -> Any:
"""Sends an api http request.""" """Sends an api http request."""
@ -73,15 +61,10 @@ class Client:
config.update(req_config) config.update(req_config)
# check if Authorization header can be added # check if Authorization header can be added
if self.auth_store.token and ( if self.auth_store.token and (
not "headers" in config or "Authorization" not in config["headers"] "headers" not in config or "Authorization" not in config["headers"]
): ):
auth_type = "Admin"
if hasattr(self.auth_store.model, "verified"):
auth_type = "User"
config["headers"] = config.get("headers", {}) config["headers"] = config.get("headers", {})
config["headers"].update( config["headers"].update({"Authorization": self.auth_store.token})
{"Authorization": f"{auth_type} {self.auth_store.token}"}
)
# build url + path # build url + path
url = self.build_url(path) url = self.build_url(path)
# send the request # send the request
@ -89,6 +72,21 @@ class Client:
params = config.get("params", None) params = config.get("params", None)
headers = config.get("headers", None) headers = config.get("headers", None)
body = config.get("body", None) body = config.get("body", None)
# handle requests including files as multipart:
data = {}
files = ()
for k, v in (body if isinstance(body, dict) else {}).items():
if isinstance(v, FileUpload):
files += v.get(k)
else:
data[k] = v
if len(files) > 0:
# discard body, switch to multipart encoding
body = None
else:
# discard files+data (do not use multipart encoding)
files = None
data = None
try: try:
response = httpx.request( response = httpx.request(
method=method, method=method,
@ -96,7 +94,9 @@ class Client:
params=params, params=params,
headers=headers, headers=headers,
json=body, json=body,
timeout=120, data=data,
files=files,
timeout=self.timeout,
) )
except Exception as e: except Exception as e:
raise ClientResponseError( raise ClientResponseError(
@ -116,6 +116,21 @@ class Client:
) )
return data return data
def get_file_url(self, record: Record, filename: str, query_params: dict):
parts = [
"api",
"files",
quote(record.collection_id or record.collection_name),
quote(record.id),
quote(filename),
]
result = self.build_url("/".join(parts))
if len(query_params) != 0:
params: str = urlencode(query_params)
result += "&" if "?" in result else "?"
result += params
return result
def build_url(self, path: str) -> str: def build_url(self, path: str) -> str:
url = self.base_url url = self.base_url
if not self.base_url.endswith("/"): if not self.base_url.endswith("/"):
@ -123,22 +138,3 @@ class Client:
if path.startswith("/"): if path.startswith("/"):
path = path[1:] path = path[1:]
return url + path return url + path
if __name__ == "__main__":
from pocketbase.stores.local_auth_store import LocalAuthStore
pb = Client(base_url="http://ares.olimpo:8090/", auth_store=LocalAuthStore())
# pb.admins.auth_via_email("vaphes@gmail.com", "vaphes2007")
print(pb.auth_store.token)
books = pb.collections.get_one("books")
print("ok")
# sacd = "nwvgaw6iiibv4fp"
# book = {
# "author": sacd,
# "name": "A study in red",
# "rating": 4.5,
# "summary": "The worst Sherlock Homes book",
# }
# data = pb.records.create("books", book)
# print(data)

View File

@ -3,4 +3,4 @@ from .collection import Collection
from .external_auth import ExternalAuth from .external_auth import ExternalAuth
from .log_request import LogRequest from .log_request import LogRequest
from .record import Record from .record import Record
from .user import User from .file_upload import FileUpload

View File

@ -1,4 +1,5 @@
from typing import Any, Union from __future__ import annotations
import datetime import datetime
from pocketbase.utils import to_datetime from pocketbase.utils import to_datetime
@ -8,10 +9,8 @@ from pocketbase.models.utils.base_model import BaseModel
class Admin(BaseModel): class Admin(BaseModel):
avatar: int avatar: int
email: str email: str
last_reset_sent_at: Union[str, datetime.datetime]
def load(self, data: dict[str:Any]) -> None: def load(self, data: dict) -> None:
super().load(data) super().load(data)
self.avatar = data.get("avatar", 0) self.avatar = data.get("avatar", 0)
self.email = data.get("email", "") self.email = data.get("email", "")
self.last_reset_sent_at = to_datetime(data.get("lastResetSentAt", ""))

View File

@ -1,4 +1,4 @@
from typing import Any, Optional from __future__ import annotations
from pocketbase.models.utils.base_model import BaseModel from pocketbase.models.utils.base_model import BaseModel
from pocketbase.models.utils.schema_field import SchemaField from pocketbase.models.utils.schema_field import SchemaField
@ -6,24 +6,41 @@ from pocketbase.models.utils.schema_field import SchemaField
class Collection(BaseModel): class Collection(BaseModel):
name: str name: str
type: str
schema: list[SchemaField] schema: list[SchemaField]
system: bool system: bool
list_rule: Optional[str] list_rule: str | None
view_rule: Optional[str] view_rule: str | None
create_rule: Optional[str] create_rule: str | None
update_rule: Optional[str] update_rule: str | None
delete_rule: Optional[str] delete_rule: str | None
options: dict
def load(self, data: dict[str:Any]) -> None: def load(self, data: dict) -> None:
super().load(data) super().load(data)
self.name = data.get("name", "") self.name = data.get("name", "")
self.system = data.get("system", False) self.system = data.get("system", False)
self.type = data.get("type", "base")
self.options = data.get("options", {})
# rules
self.list_rule = data.get("listRule", None) self.list_rule = data.get("listRule", None)
self.view_rule = data.get("viewRule", None) self.view_rule = data.get("viewRule", None)
self.create_rule = data.get("createRule", None) self.create_rule = data.get("createRule", None)
self.update_rule = data.get("updateRule", None) self.update_rule = data.get("updateRule", None)
self.delete_rule = data.get("deleteRule", "") self.delete_rule = data.get("deleteRule", "")
# schema
schema = data.get("schema", []) schema = data.get("schema", [])
self.schema = [] self.schema = []
for field in schema: for field in schema:
self.schema.append(SchemaField(**field)) self.schema.append(SchemaField(**field))
def is_base(self):
return self.type == "base"
def is_auth(self):
return self.type == "auth"
def is_single(self):
return self.type == "single"

View File

@ -1,14 +1,17 @@
from typing import Any from __future__ import annotations
from pocketbase.models.utils.base_model import BaseModel from pocketbase.models.utils.base_model import BaseModel
class ExternalAuth(BaseModel): class ExternalAuth(BaseModel):
user_id: str record_id: str
collection_id: str
provider: str provider: str
provider_id: str provider_id: str
def load(self, data: dict[str:Any]) -> None: def load(self, data: dict) -> None:
super().load(data) super().load(data)
self.user_id = data.get("userId", "") self.record_id = data.get("recordId", "")
self.collection_id = data.get("collectionId", "")
self.provider = data.get("provider", "") self.provider = data.get("provider", "")
self.provider_id = data.get("providerId", "") self.provider_id = data.get("providerId", "")

View File

@ -0,0 +1,14 @@
from httpx._types import FileTypes
from typing import Sequence, Union
FileUploadTypes = Union[FileTypes, Sequence[FileTypes]]
class FileUpload:
def __init__(self, *args):
self.files: FileUploadTypes = args
def get(self, key: str):
if isinstance(self.files[0], Sequence):
return tuple((key, i) for i in self.files)
return ((key, self.files),)

View File

@ -1,4 +1,4 @@
from typing import Any from __future__ import annotations
from pocketbase.models.utils.base_model import BaseModel from pocketbase.models.utils.base_model import BaseModel
@ -12,9 +12,9 @@ class LogRequest(BaseModel):
user_ip: str user_ip: str
referer: str referer: str
user_agent: str user_agent: str
meta: dict[str:Any] meta: dict
def load(self, data: dict[str:Any]) -> None: def load(self, data: dict) -> None:
super().load(data) super().load(data)
self.url = data.get("url", "") self.url = data.get("url", "")
self.method = data.get("method", "") self.method = data.get("method", "")

View File

@ -1,28 +1,25 @@
from typing import Any from __future__ import annotations
from pocketbase.models.utils.base_model import BaseModel from pocketbase.models.utils.base_model import BaseModel
from pocketbase.utils import camel_to_snake from pocketbase.utils import camel_to_snake
from dataclasses import dataclass, field
@dataclass
class Record(BaseModel): class Record(BaseModel):
collection_id: str collection_id: str
collection_name: str collection_name: str = ""
expand: dict[str:Any] expand: dict = field(default_factory=dict)
def load(self, data: dict[str:Any]) -> None: def load(self, data: dict) -> None:
super().load(data) super().load(data)
self.expand = {}
for key, value in data.items(): for key, value in data.items():
key = camel_to_snake(key).replace("@", "") key = camel_to_snake(key).replace("@", "")
setattr(self, key, value) setattr(self, key, value)
self.collection_id = data.get("@collectionId", "") self.load_expanded()
self.collection_name = data.get("@collectionName", "")
expand = data.get("@expand", {})
if expand:
self.expand = expand
self.load_expanded()
@classmethod @classmethod
def parse_expanded(cls, data: dict[str:Any]): def parse_expanded(cls, data: dict):
return cls(data) return cls(data)
def load_expanded(self) -> None: def load_expanded(self) -> None:

View File

@ -1,27 +0,0 @@
from typing import Any, Optional, Union
import datetime
from pocketbase.utils import to_datetime
from pocketbase.models.record import Record
from pocketbase.models.utils.base_model import BaseModel
class User(BaseModel):
email: str
verified: bool
last_reset_sent_at: Union[str, datetime.datetime]
last_verification_sent_at: Union[str, datetime.datetime]
profile: Optional[Record]
def load(self, data: dict[str:Any]) -> None:
super().load(data)
self.email = data.get("email", "")
self.verified = data.get("verified", "")
self.last_reset_sent_at = to_datetime(data.get("lastResetSentAt", ""))
self.last_verification_sent_at = to_datetime(
data.get("lastVerificationSentAt", "")
)
profile = data.get("profile", None)
self.profile = None
if profile:
self.profile = Record(profile)

View File

@ -1,5 +1,6 @@
from __future__ import annotations
from abc import ABC from abc import ABC
from typing import Any, Union
import datetime import datetime
from pocketbase.utils import to_datetime from pocketbase.utils import to_datetime
@ -7,14 +8,20 @@ from pocketbase.utils import to_datetime
class BaseModel(ABC): class BaseModel(ABC):
id: str id: str
created: Union[str, datetime.datetime] created: str | datetime.datetime
updated: Union[str, datetime.datetime] updated: str | datetime.datetime
def __init__(self, data: dict[str:Any] = {}) -> None: def __init__(self, data: dict = {}) -> None:
super().__init__() super().__init__()
self.load(data) self.load(data)
def load(self, data: dict[str:Any]) -> None: def __str__(self) -> str:
return f"<{self.__class__.__name__}: {self.id}>"
def __repr__(self) -> str:
return self.__str__()
def load(self, data: dict) -> None:
"""Loads `data` into the current model.""" """Loads `data` into the current model."""
self.id = data.pop("id", "") self.id = data.pop("id", "")
self.created = to_datetime(data.pop("created", "")) self.created = to_datetime(data.pop("created", ""))
@ -23,4 +30,4 @@ class BaseModel(ABC):
@property @property
def is_new(self) -> bool: def is_new(self) -> bool:
"""Returns whether the current loaded data represent a stored db record.""" """Returns whether the current loaded data represent a stored db record."""
return not self.id or self.id == "00000000-0000-0000-0000-000000000000" return not self.id

View File

@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pocketbase.models.utils.base_model import BaseModel from pocketbase.models.utils.base_model import BaseModel

View File

@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any
@dataclass @dataclass
@ -10,4 +11,4 @@ class SchemaField:
system: bool = False system: bool = False
required: bool = False required: bool = False
unique: bool = False unique: bool = False
options: dict[str:Any] = field(default_factory=dict) options: dict = field(default_factory=dict)

View File

@ -1,7 +1,6 @@
from .admins import Admins, AdminAuthResponse from .admin_service import AdminService, AdminAuthResponse
from .collections import Collections from .collection_service import CollectionService
from .logs import Logs, HourlyStats from .log_service import LogService, HourlyStats
from .realtime import Realtime from .realtime_service import RealtimeService
from .records import Records from .record_service import RecordService
from .settings import Settings from .settings_service import SettingsService
from .users import Users, UserAuthResponse, AuthMethodsList, AuthProviderInfo

View File

@ -1,4 +1,5 @@
from typing import Any from __future__ import annotations
from pocketbase.models.utils.base_model import BaseModel from pocketbase.models.utils.base_model import BaseModel
from pocketbase.services.utils.crud_service import CrudService from pocketbase.services.utils.crud_service import CrudService
from pocketbase.models.admin import Admin from pocketbase.models.admin import Admin
@ -15,13 +16,45 @@ class AdminAuthResponse:
setattr(self, key, value) setattr(self, key, value)
class Admins(CrudService): class AdminService(CrudService):
def decode(self, data: dict[str:Any]) -> BaseModel: def decode(self, data: dict) -> BaseModel:
return Admin(data) return Admin(data)
def base_crud_path(self) -> str: def base_crud_path(self) -> str:
return "/api/admins" return "/api/admins"
def update(self, id: str, body_params: dict, query_params: dict) -> BaseModel:
"""
If the current `client.auth_store.model` matches with the updated id,
then on success the `client.auth_store.model` will be updated with the result.
"""
item = super(AdminService).update(id, body_params)
try:
if (
self.client.auth_store.model.collection_id is not None
and item.id == self.client.auth_store.model.id
):
self.client.auth_store.save(self.client.auth_store.token, item)
except:
pass
return item
def delete(self, id: str, body_params: dict, query_params: dict) -> BaseModel:
"""
If the current `client.auth_store.model` matches with the deleted id,
then on success the `client.auth_store` will be cleared.
"""
item = super(AdminService).delete(id, body_params)
try:
if (
self.client.auth_store.model.collection_id is not None
and item.id == self.client.auth_store.model.id
):
self.client.auth_store.save(self.client.auth_store.token, item)
except:
pass
return item
def auth_response(self, response_data: dict) -> AdminAuthResponse: def auth_response(self, response_data: dict) -> AdminAuthResponse:
"""Prepare successful authorize response.""" """Prepare successful authorize response."""
admin = self.decode(response_data.pop("admin", {})) admin = self.decode(response_data.pop("admin", {}))
@ -30,18 +63,18 @@ class Admins(CrudService):
self.client.auth_store.save(token, admin) self.client.auth_store.save(token, admin)
return AdminAuthResponse(token=token, admin=admin, **response_data) return AdminAuthResponse(token=token, admin=admin, **response_data)
def auth_via_email( def auth_with_password(
self, email: str, password: str, body_params: dict = {}, query_params: dict = {} self, email: str, password: str, body_params: dict = {}, query_params: dict = {}
) -> AdminAuthResponse: ) -> AdminAuthResponse:
""" """
Authenticate an admin account by its email and password Authenticate an admin account with its email and password
and returns a new admin token and data. and returns a new admin token and data.
On success this method automatically updates the client's AuthStore data. On success this method automatically updates the client's AuthStore data.
""" """
body_params.update({"email": email, "password": password}) body_params.update({"identity": email, "password": password})
response_data = self.client.send( response_data = self.client.send(
self.base_crud_path() + "/auth-via-email", self.base_crud_path() + "/auth-with-password",
{ {
"method": "POST", "method": "POST",
"params": query_params, "params": query_params,
@ -51,7 +84,7 @@ class Admins(CrudService):
) )
return self.auth_response(response_data) return self.auth_response(response_data)
def refresh( def authRefresh(
self, body_params: dict = {}, query_params: dict = {} self, body_params: dict = {}, query_params: dict = {}
) -> AdminAuthResponse: ) -> AdminAuthResponse:
""" """
@ -62,7 +95,7 @@ class Admins(CrudService):
""" """
return self.auth_response( return self.auth_response(
self.client.send( self.client.send(
self.base_crud_path() + "/refresh", self.base_crud_path() + "/auth-refresh",
{"method": "POST", "params": query_params, "body": body_params}, {"method": "POST", "params": query_params, "body": body_params},
) )
) )

View File

@ -1,12 +1,12 @@
from typing import Any from __future__ import annotations
from pocketbase.services.utils.crud_service import CrudService from pocketbase.services.utils.crud_service import CrudService
from pocketbase.models.utils.base_model import BaseModel from pocketbase.models.utils.base_model import BaseModel
from pocketbase.models.collection import Collection from pocketbase.models.collection import Collection
class Collections(CrudService): class CollectionService(CrudService):
def decode(self, data: dict[str:Any]) -> BaseModel: def decode(self, data: dict) -> BaseModel:
return Collection(data) return Collection(data)
def base_crud_path(self) -> str: def base_crud_path(self) -> str:
@ -14,7 +14,7 @@ class Collections(CrudService):
def import_collections( def import_collections(
self, self,
collections: list[Collection], collections: list,
delete_missing: bool = False, delete_missing: bool = False,
query_params: dict = {}, query_params: dict = {},
) -> bool: ) -> bool:

View File

@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Union from typing import Union
from urllib.parse import quote from urllib.parse import quote
@ -15,7 +17,7 @@ class HourlyStats:
date: Union[str, datetime.datetime] date: Union[str, datetime.datetime]
class Logs(BaseService): class LogService(BaseService):
def get_request_list( def get_request_list(
self, page: int = 1, per_page: int = 30, query_params: dict = {} self, page: int = 1, per_page: int = 30, query_params: dict = {}
) -> ListResult: ) -> ListResult:

View File

@ -1,49 +0,0 @@
from typing import Callable, Optional
from pocketbase.services.utils.base_service import BaseService
from pocketbase.models.record import Record
class Realtime(BaseService):
client_id: str
subscriptions: dict
def subscribe(self, subscription: str, callback: Callable) -> None:
"""Inits the sse connection (if not already) and register the subscription."""
self.subscriptions[subscription] = callback
def unsubscribe(self, subscription: Optional[str] = None) -> None:
"""
Unsubscribe from a subscription.
If the `subscription` argument is not set,
then the client will unsubscribe from all registered subscriptions.
The related sse connection will be autoclosed if after the
unsubscribe operations there are no active subscriptions left.
"""
pass
def _submit_subscriptions(self) -> bool:
self.client.send(
"/api/realtime",
{
"method": "POST",
"body": {
"clientId": self.client_id,
"subscriptions": self.subscriptions.keys(),
},
},
)
return True
def _add_subscription_listeners(self) -> None:
pass
def _remove_subscription_listeners(self) -> None:
pass
def _connect(self) -> None:
pass
def _disconnect(self) -> None:
pass

View File

@ -0,0 +1,151 @@
from __future__ import annotations
from typing import Callable, List
import dataclasses
import json
from pocketbase.services.utils.base_service import BaseService
from pocketbase.services.utils.sse import Event, SSEClient
from pocketbase.models.record import Record
@dataclasses.dataclass
class MessageData:
action: str
record: Record
class RealtimeService(BaseService):
subscriptions: dict
client_id: str = ""
event_source: SSEClient | None = None
def __init__(self, client) -> None:
super().__init__(client)
self.subscriptions = {}
self.client_id = ""
self.event_source = None
def subscribe(
self, subscription: str, callback: Callable[[MessageData], None]
) -> None:
"""Inits the sse connection (if not already) and register the subscription."""
# unsubscribe existing
if subscription in self.subscriptions and self.event_source:
self.event_source.remove_event_listener(subscription, callback)
# register subscription
self.subscriptions[subscription] = self._make_subscription(callback)
if not self.event_source:
self._connect()
elif self.client_id:
self._submit_subscriptions()
def unsubscribe_by_prefix(self, subscription_prefix: str):
"""
Unsubscribe from all subscriptions starting with the provided prefix.
This method is no-op if there are no active subscriptions with the provided prefix.
The related sse connection will be autoclosed if after the
unsubscribe operation there are no active subscriptions left.
"""
to_unsubscribe = []
for sub in self.subscriptions:
if sub.startswith(subscription_prefix):
to_unsubscribe.append(sub)
if len(to_unsubscribe) == 0:
return
return self.unsubscribe(*to_unsubscribe)
def unsubscribe(self, subscriptions: List[str] | None = None) -> None:
"""
Unsubscribe from a subscription.
If the `subscriptions` argument is not set,
then the client will unsubscribe from all registered subscriptions.
The related sse connection will be autoclosed if after the
unsubscribe operations there are no active subscriptions left.
"""
if not subscriptions or len(subscriptions) == 0:
# remove all subscriptions
self._remove_subscription_listeners()
self.subscriptions = {}
else:
# remove each passed subscription
found = False
for sub in self.subscriptions:
found = True
self.event_source.remove_event_listener(sub, self.subscriptions[sub])
self.subscriptions.pop(sub)
if not found:
return
if self.client_id:
self._submit_subscriptions()
# no more subscriptions -> close the sse connection
if not self.subscriptions:
self._disconnect()
def _make_subscription(
self, callback: Callable[[MessageData], None]
) -> Callable[[Event], None]:
def listener(event: Event) -> None:
data = json.loads(event.data)
if "record" in data and "action" in data:
callback(
MessageData(
action=data["action"],
record=Record(
data=data["record"],
),
)
)
return listener
def _submit_subscriptions(self) -> bool:
self._add_subscription_listeners()
self.client.send(
"/api/realtime",
{
"method": "POST",
"body": {
"clientId": self.client_id,
"subscriptions": list(self.subscriptions.keys()),
},
},
)
return True
def _add_subscription_listeners(self) -> None:
if not self.event_source:
return
self._remove_subscription_listeners()
for subscription, callback in self.subscriptions.items():
self.event_source.add_event_listener(subscription, callback)
def _remove_subscription_listeners(self) -> None:
if not self.event_source:
return
for subscription, callback in self.subscriptions.items():
self.event_source.remove_event_listener(subscription, callback)
def _connect_handler(self, event: Event) -> None:
self.client_id = event.id
self._submit_subscriptions()
def _connect(self) -> None:
self._disconnect()
self.event_source = SSEClient(self.client.build_url("/api/realtime"))
self.event_source.add_event_listener("PB_CONNECT", self._connect_handler)
def _disconnect(self) -> None:
self._remove_subscription_listeners()
self.client_id = ""
if not self.event_source:
return
self.event_source.remove_event_listener("PB_CONNECT", self._connect_handler)
self.event_source.close()
self.event_source = None

View File

@ -0,0 +1,275 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import List
from urllib.parse import quote, urlencode
from pocketbase.services.realtime_service import Callable, MessageData
from pocketbase.models.utils.base_model import BaseModel
from pocketbase.models.record import Record
from pocketbase.services.utils.crud_service import CrudService
from pocketbase.utils import camel_to_snake
class RecordAuthResponse:
token: str
record: Record
def __init__(self, token: str, record: Record, **kwargs) -> None:
self.token = token
self.record = record
for key, value in kwargs.items():
setattr(self, key, value)
@dataclass
class AuthProviderInfo:
name: str
state: str
code_verifier: str
code_challenge: str
code_challenge_method: str
auth_url: str
@dataclass
class AuthMethodsList:
username_password: bool
email_password: bool
auth_providers: list[AuthProviderInfo]
class RecordService(CrudService):
collection_id_or_name: str
def __init__(self, client, collection_id_or_name) -> None:
super().__init__(client)
self.collection_id_or_name = collection_id_or_name
def decode(self, data: dict) -> BaseModel:
return Record(data)
def base_crud_path(self) -> str:
return self.base_collection_path() + "/records"
def base_collection_path(self) -> str:
"""Returns the current collection service base path."""
return "/api/collections/" + quote(self.collection_id_or_name)
def get_file_url(
self, record: Record, filename: str, query_params: dict = {}
) -> str:
"""Builds and returns an absolute record file url."""
base_url = self.client.base_url
if base_url.endswith("/"):
base_url = base_url[:-1]
result = f"{base_url}/api/files/{record.collection_id}/{record.id}/{filename}"
if query_params:
result += "?" + urlencode(query_params)
return result
def subscribe(self, callback: Callable[[MessageData], None]):
"""Subscribe to realtime changes of any record from the collection."""
return self.client.realtime.subscribe(self.collection_id_or_name, callback)
def subscribeOne(self, record_id: str, callback: Callable[[MessageData], None]):
"""Subscribe to the realtime changes of a single record in the collection."""
return self.client.realtime.subscribe(
self.collection_id_or_name + "/" + record_id, callback
)
def unsubscribe(self, *record_ids: List[str]):
"""Subscribe to the realtime changes of a single record in the collection."""
if record_ids and len(record_ids) == 0:
subs = []
for id in record_ids:
subs.append(self.collection_id_or_name + "/" + id)
return self.client.realtime.unsubscribe(*subs)
return self.client.realtime.subscribe_by_prefix(self.collection_id_or_name)
def update(self, id: str, body_params: dict = {}, query_params: dict = {}):
"""
If the current `client.auth_store.model` matches with the updated id, then
on success the `client.auth_store.model` will be updated with the result.
"""
item = super().update(id, body_params) # super(Record).update
try:
if (
self.client.auth_store.model.collection_id is not None
and item.id == self.client.auth_store.model.id
):
self.client.auth_store.save(self.client.auth_store.token, item)
except:
pass
return item
def delete(self, id: str, body_params: dict = {}, query_params: dict = {}):
"""
If the current `client.auth_store.model` matches with the deleted id,
then on success the `client.auth_store` will be cleared.
"""
success = super().delete(id, body_params) # super(Record).delete
try:
if (
success
and self.client.auth_store.model.collection_id is not None
and id == self.client.auth_store.model.id
):
self.client.auth_store.clear()
except:
pass
return success
def auth_response(self, response_data: dict) -> RecordAuthResponse:
"""Prepare successful collection authorization response."""
record = self.decode(response_data.pop("record", {}))
token = response_data.pop("token", "")
if token and record:
self.client.auth_store.save(token, record)
return RecordAuthResponse(token=token, record=record, **response_data)
def list_auth_methods(self, query_params: str = {}):
"""Returns all available collection auth methods."""
response_data = self.client.send(
self.base_collection_path() + "/auth-methods",
{"method": "GET", "params": query_params},
)
username_password = response_data.pop("usernamePassword", False)
email_password = response_data.pop("emailPassword", False)
def apply_pythonic_keys(ap):
pythonic_keys_ap = {
camel_to_snake(key).replace("@", ""): value for key, value in ap.items()
}
return pythonic_keys_ap
auth_providers = [
AuthProviderInfo(**auth_provider)
for auth_provider in map(
apply_pythonic_keys, response_data.get("authProviders", [])
)
]
return AuthMethodsList(username_password, email_password, auth_providers)
def auth_with_password(
self,
username_or_email: str,
password: str,
body_params: dict = {},
query_params: dict = {},
) -> RecordAuthResponse:
"""
Authenticate a single auth collection record via its username/email and password.
On success, this method also automatically updates
the client's AuthStore data and returns:
- the authentication token
- the authenticated record model
"""
body_params.update({"identity": username_or_email, "password": password})
response_data = self.client.send(
self.base_collection_path() + "/auth-with-password",
{
"method": "POST",
"params": query_params,
"body": body_params,
"headers": {"Authorization": ""},
},
)
return self.auth_response(response_data)
def auth_with_oauth2(
self,
provider: str,
code: str,
code_verifier: str,
redirct_url: str,
create_data={},
body_params={},
query_params={},
):
"""
Authenticate a single auth collection record with OAuth2.
On success, this method also automatically updates
the client's AuthStore data and returns:
- the authentication token
- the authenticated record model
- the OAuth2 account data (eg. name, email, avatar, etc.)
"""
body_params.update(
{
"provider": provider,
"code": code,
"codeVerifier": code_verifier,
"redirectUrl": redirct_url,
"createData": create_data,
}
)
response_data = self.client.send(
self.base_collection_path() + "/auth-with-oauth2",
{
"method": "POST",
"params": query_params,
"body": body_params,
},
)
return self.auth_response(response_data)
def authRefresh(
self, body_params: dict = {}, query_params: dict = {}
) -> RecordAuthResponse:
"""
Refreshes the current authenticated record instance and
returns a new token and record data.
On success this method also automatically updates the client's AuthStore.
"""
return self.auth_response(
self.client.send(
self.base_collection_path() + "/auth-refresh",
{"method": "POST", "params": query_params, "body": body_params},
)
)
def requestPasswordReset(
self, email: str, body_params: dict = {}, query_params: dict = {}
) -> bool:
"""Sends auth record password reset request."""
body_params.update({"email": email})
self.client.send(
self.base_collection_path() + "/request-password-reset",
{
"method": "POST",
"params": query_params,
"body": body_params,
},
)
return True
def confirmPasswordReset(
self,
password_reset_token: str,
password: str,
password_confirm: str,
body_params: dict = {},
query_params: dict = {},
) -> RecordAuthResponse:
"""Confirms auth record password reset reque"""
body_params.update(
{
"token": password_reset_token,
"password": password,
"passwordConfirm": password_confirm,
}
)
return self.auth_response(
self.client.send(
self.base_collection_path() + "/confirm-password-reset",
{
"method": "POST",
"params": query_params,
"body": body_params,
},
)
)

View File

@ -1,26 +0,0 @@
from typing import Any
from urllib.parse import quote, urlencode
from pocketbase.services.utils.sub_crud_service import SubCrudService
from pocketbase.models.utils.base_model import BaseModel
from pocketbase.models.record import Record
class Records(SubCrudService):
def decode(self, data: dict[str:Any]) -> BaseModel:
return Record(data)
def base_crud_path(self, collection_id_or_name: str) -> str:
return "/api/collections/" + quote(collection_id_or_name) + "/records"
def get_file_url(
self, record: Record, filename: str, query_params: dict = {}
) -> str:
"""Builds and returns an absolute record file url."""
base_url = self.client.base_url
if base_url.endswith("/"):
base_url = base_url[:-1]
result = f"{base_url}/api/files/{record.collection_id}/{record.id}/{filename}"
if query_params:
results += "?" + urlencode(query_params)
return result

View File

@ -1,7 +1,9 @@
from __future__ import annotations
from pocketbase.services.utils.base_service import BaseService from pocketbase.services.utils.base_service import BaseService
class Settings(BaseService): class SettingsService(BaseService):
def get_all(self, query_params: dict = {}) -> dict: def get_all(self, query_params: dict = {}) -> dict:
"""Fetch all available app settings.""" """Fetch all available app settings."""
return self.client.send( return self.client.send(

View File

@ -1,280 +0,0 @@
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote
from pocketbase.services.utils.crud_service import CrudService
from pocketbase.models.utils.base_model import BaseModel
from pocketbase.models.user import User
from pocketbase.models.external_auth import ExternalAuth
class UserAuthResponse:
token: str
user: User
def __init__(self, token: str, user: User, **kwargs) -> None:
self.token = token
self.user = user
for key, value in kwargs.items():
setattr(self, key, value)
@dataclass
class AuthProviderInfo:
name: str
state: str
code_verifier: str
code_challenge: str
code_challenge_method: str
auth_url: str
@dataclass
class AuthMethodsList:
email_password: bool
auth_providers: list[AuthProviderInfo]
class Users(CrudService):
def decode(self, data: dict[str:Any]) -> BaseModel:
return User(data)
def base_crud_path(self) -> str:
return "/api/users"
def auth_response(self, response_data: Any) -> UserAuthResponse:
"""Prepare successful authorization response."""
user = self.decode(response_data.pop("user", {}))
token = response_data.pop("token", "")
if token and user:
self.client.auth_store.save(token, user)
return UserAuthResponse(token=token, user=user, **response_data)
def list_auth_methods(self, query_params: dict = {}) -> AuthMethodsList:
"""Returns all available application auth methods."""
response_data = self.client.send(
self.base_crud_path() + "/auth-methods",
{"method": "GET", "params": query_params},
)
email_password = response_data.get("emailPassword", False)
auth_providers = [
AuthProviderInfo(auth_provider)
for auth_provider in response_data.get("authProviders", [])
]
return AuthMethodsList(email_password, auth_providers)
def auth_via_email(
self, email: str, password: str, body_params: dict = {}, query_params: dict = {}
) -> UserAuthResponse:
"""
Authenticate a user via its email and password.
On success, this method also automatically updates
the client's AuthStore data and returns:
- new user authentication token
- the authenticated user model record
"""
body_params.update({"email": email, "password": password})
response_data = self.client.send(
self.base_crud_path() + "/auth-via-email",
{
"method": "POST",
"params": query_params,
"body": body_params,
"headers": {"Authorization": ""},
},
)
return self.auth_response(response_data)
def auth_via_oauth2(
self,
provider: str,
code: str,
code_verifier: str,
redirect_url: str,
body_params: dict = {},
query_params: dict = {},
) -> UserAuthResponse:
"""
Authenticate a user via OAuth2 client provider.
On success, this method also automatically updates
the client's AuthStore data and returns:
- new user authentication token
- the authenticated user model record
- the OAuth2 user profile data (eg. name, email, avatar, etc.)
"""
body_params.update(
{
"provider": provider,
"code": code,
"codeVerifier": code_verifier,
"redirectUrl": redirect_url,
}
)
response_data = self.client.send(
self.base_crud_path() + "/auth-via-oauth2",
{
"method": "POST",
"params": query_params,
"body": body_params,
"headers": {"Authorization": ""},
},
)
return self.auth_response(response_data)
def refresh(
self, body_params: dict = {}, query_params: dict = {}
) -> UserAuthResponse:
"""
Refreshes the current user authenticated instance and
returns a new token and user data.
On success this method also automatically updates the client's AuthStore data.
"""
return self.auth_response(
self.client.send(
self.base_crud_path() + "/refresh",
{"method": "POST", "params": query_params, "body": body_params},
)
)
def request_password_reset(
self, email: str, body_params: dict = {}, query_params: dict = {}
) -> bool:
"""Sends user password reset request."""
body_params.update({"email": email})
self.client.send(
self.base_crud_path() + "/request-password-reset",
{
"method": "POST",
"params": query_params,
"body": body_params,
},
)
return True
def confirm_password_reset(
self,
password_reset_token: str,
password: str,
password_confirm: str,
body_params: dict = {},
query_params: dict = {},
) -> UserAuthResponse:
"""Confirms user password reset request."""
body_params.update(
{
"token": password_reset_token,
"password": password,
"passwordConfirm": password_confirm,
}
)
return self.auth_response(
self.client.send(
self.base_crud_path() + "/confirm-password-reset",
{
"method": "POST",
"params": query_params,
"body": body_params,
},
)
)
def request_verification(
self, email: str, body_params: dict = {}, query_params: dict = {}
) -> bool:
"""Sends user verification email request."""
body_params.update({"email": email})
self.client.send(
self.base_crud_path() + "/request-verification",
{
"method": "POST",
"params": query_params,
"body": body_params,
},
)
return True
def confirm_verification(
self, verification_token: str, body_params: dict = {}, query_params: dict = {}
) -> UserAuthResponse:
"""Confirms user email verification request."""
body_params.update(
{
"token": verification_token,
}
)
return self.auth_response(
self.client.send(
self.base_crud_path() + "/confirm-verification",
{
"method": "POST",
"params": query_params,
"body": body_params,
},
)
)
def request_email_change(
self, new_email: str, body_params: dict = {}, query_params: dict = {}
) -> bool:
"""Sends an email change request to the authenticated user."""
body_params.update({"newEmail": new_email})
self.client.send(
self.base_crud_path() + "/request-email-change",
{
"method": "POST",
"params": query_params,
"body": body_params,
},
)
return True
def confirm_email_change(
self,
email_change_token: str,
password: str,
body_params: dict = {},
query_params: dict = {},
) -> UserAuthResponse:
"""Confirms user new email address."""
body_params.update(
{
"token": email_change_token,
"password": password,
}
)
return self.auth_response(
self.client.send(
self.base_crud_path() + "/confirm-email-change",
{
"method": "POST",
"params": query_params,
"body": body_params,
},
)
)
def list_external_auths(
self, user_id: str, query_params: dict = {}
) -> list[ExternalAuth]:
"""Lists all linked external auth providers for the specified user."""
response_data = self.client.send(
self.base_crud_path() + "/" + quote(user_id) + "/external-auths",
{"method": "GET", "params": query_params},
)
return [ExternalAuth(item) for item in response_data]
def unlink_external_auth(
self, user_id: str, provider: str, query_params: dict = {}
) -> bool:
"""Unlink a single external auth provider from the specified user."""
self.client.send(
self.base_crud_path()
+ "/"
+ quote(user_id)
+ "/external-auths/"
+ quote(provider),
{"method": "DELETE", "params": query_params},
)
return True

View File

@ -1,4 +1,3 @@
from .base_crud_service import BaseCrudService from .base_crud_service import BaseCrudService
from .base_service import BaseService from .base_service import BaseService
from .crud_service import CrudService from .crud_service import CrudService
from .sub_crud_service import SubCrudService

View File

@ -1,7 +1,8 @@
from __future__ import annotations
from abc import ABC from abc import ABC
from urllib.parse import quote from urllib.parse import quote
from typing import Any from pocketbase.utils import ClientResponseError
from pocketbase.models.utils.base_model import BaseModel from pocketbase.models.utils.base_model import BaseModel
from pocketbase.models.utils.list_result import ListResult from pocketbase.models.utils.list_result import ListResult
@ -9,7 +10,7 @@ from pocketbase.services.utils.base_service import BaseService
class BaseCrudService(BaseService, ABC): class BaseCrudService(BaseService, ABC):
def decode(self, data: dict[str:Any]) -> BaseModel: def decode(self, data: dict) -> BaseModel:
"""Response data decoder""" """Response data decoder"""
def _get_full_list( def _get_full_list(
@ -18,7 +19,7 @@ class BaseCrudService(BaseService, ABC):
result: list[BaseModel] = [] result: list[BaseModel] = []
def request(result: list[BaseModel], page: int) -> list[Any]: def request(result: list[BaseModel], page: int) -> list:
list = self._get_list(base_path, page, batch_size, query_params) list = self._get_list(base_path, page, batch_size, query_params)
items = list.items items = list.items
total_items = list.total_items total_items = list.total_items
@ -56,6 +57,22 @@ class BaseCrudService(BaseService, ABC):
) )
) )
def _get_first_list_item(self, base_path: str, filter: str, query_params={}):
query_params.update(
{
"filter": filter,
"$cancelKey": "one_by_filter_" + base_path + "_" + filter,
}
)
result = self._get_list(base_path, 1, 1, query_params)
try:
if len(result.items) == 0:
raise
except:
raise ClientResponseError(
"The requested resource wasn't found.", status=404
)
def _create( def _create(
self, base_path: str, body_params: dict = {}, query_params: dict = {} self, base_path: str, body_params: dict = {}, query_params: dict = {}
) -> BaseModel: ) -> BaseModel:

View File

@ -1,3 +1,5 @@
from __future__ import annotations
from abc import ABC from abc import ABC

View File

@ -1,3 +1,5 @@
from __future__ import annotations
from abc import ABC from abc import ABC
from pocketbase.models.utils.base_model import BaseModel from pocketbase.models.utils.base_model import BaseModel
@ -10,16 +12,31 @@ class CrudService(BaseCrudService, ABC):
"""Base path for the crud actions (without trailing slash, eg. '/admins').""" """Base path for the crud actions (without trailing slash, eg. '/admins')."""
def get_full_list( def get_full_list(
self, batch_size: int = 100, query_params: dict = {} self, batch: int = 200, query_params: dict = {}
) -> list[BaseModel]: ) -> list[BaseModel]:
return self._get_full_list(self.base_crud_path(), batch_size, query_params) return self._get_full_list(self.base_crud_path(), batch, query_params)
def get_list( def get_list(
self, page: int = 1, per_page: int = 30, query_params: dict = {} self, page: int = 1, per_page: int = 30, query_params: dict = {}
) -> ListResult: ) -> ListResult:
return self._get_list(self.base_crud_path(), page, per_page, query_params) return self._get_list(self.base_crud_path(), page, per_page, query_params)
def _get_first_list_item(self, base_path: str, filter: str, query_params):
"""
Returns the first found item by the specified filter.
Internally it calls `getList(1, 1, { filter })` and returns the
first found item.
For consistency with `getOne`, this method will throw a 404
ClientResponseError if no item was found.
"""
return self._get_first_list_item(base_path, filter, query_params)
def get_one(self, id: str, query_params: dict = {}) -> BaseModel: def get_one(self, id: str, query_params: dict = {}) -> BaseModel:
"""
Returns single item by its id.
"""
return self._get_one(self.base_crud_path(), id, query_params) return self._get_one(self.base_crud_path(), id, query_params)
def create(self, body_params: dict = {}, query_params: dict = {}) -> BaseModel: def create(self, body_params: dict = {}, query_params: dict = {}) -> BaseModel:

View File

@ -0,0 +1,139 @@
from __future__ import annotations
from typing import Callable
import dataclasses
import threading
import httpx
@dataclasses.dataclass
class Event:
"""Representation of an event"""
id: str = ""
event: str = "message"
data: str = ""
retry: int | None = None
class EventLoop(threading.Thread):
FIELD_SEPARATOR = ":"
def __init__(
self,
url: str,
method: str = "GET",
headers: dict | None = None,
payload: dict | None = None,
encoding="utf-8",
listeners: dict[str, Callable] | None = None,
**kwargs,
):
threading.Thread.__init__(self, **kwargs)
self.kill = False
self.client = httpx.Client()
self.url = url
self.method = method
self.headers = headers
self.payload = payload
self.encoding = encoding
self.listeners = listeners or {}
def _read(self):
"""Read the incoming event source stream and yield event chunks"""
data = b""
with self.client.stream(
self.method,
self.url,
headers=self.headers,
data=self.payload,
timeout=None,
) as r:
for chunk in r.iter_bytes():
for line in chunk.splitlines(True):
data += line
if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")):
yield data
data = b""
if data:
yield data
def _events(self):
for chunk in self._read():
event = Event()
for line in chunk.splitlines():
line = line.decode(self.encoding)
if not line.strip() or line.startswith(self.FIELD_SEPARATOR):
continue
data = line.split(self.FIELD_SEPARATOR, 1)
field = data[0]
if field not in event.__dict__:
continue
if len(data) > 1:
if data[1].startswith(" "):
value = data[1][1:]
else:
value = data[1]
else:
value = ""
if field == "data":
event.data += value + "\n"
else:
setattr(event, field, value)
if not event.data:
continue
if event.data.endswith("\n"):
event.data = event.data[0:-1]
event.event = event.event or "message"
yield event
def run(self):
for event in self._events():
if self.kill:
break
if event.event in self.listeners:
self.listeners[event.event](event)
class SSEClient:
"""Implementation of a server side event client"""
_listeners: dict = {}
_loop_thread: threading.Thread | None = None
def __init__(
self,
url: str,
method: str = "GET",
headers: dict | None = None,
payload: dict | None = None,
encoding="utf-8",
) -> None:
self._listeners = {}
self._loop_thread = EventLoop(
url=url,
method=method,
headers=headers,
payload=payload,
encoding=encoding,
listeners=self._listeners,
name="loop",
)
self._loop_thread.daemon = True
self._loop_thread.start()
def add_event_listener(self, event: str, callback: Callable[[Event], None]) -> None:
self._listeners[event] = callback
self._loop_thread.listeners = self._listeners
def remove_event_listener(
self, event: str, callback: Callable[[Event], None]
) -> None:
if event in self._listeners:
self._listeners.pop(event)
self._loop_thread.listeners = self._listeners
def close(self) -> None:
# TODO: does not work like this
self._loop_thread.kill = True

View File

@ -1,36 +0,0 @@
from abc import ABC
from pocketbase.models.utils.base_model import BaseModel
from pocketbase.models.utils.list_result import ListResult
from pocketbase.services.utils.base_crud_service import BaseCrudService
class SubCrudService(BaseCrudService, ABC):
def base_crud_path(self) -> str:
"""Base path for the crud actions (without trailing slash, eg. '/admins')."""
def get_full_list(
self, sub: str, batch_size: int = 100, query_params: dict = {}
) -> list[BaseModel]:
return self._get_full_list(self.base_crud_path(sub), batch_size, query_params)
def get_list(
self, sub: str, page: int = 1, per_page: int = 30, query_params: dict = {}
) -> ListResult:
return self._get_list(self.base_crud_path(sub), page, per_page, query_params)
def get_one(self, sub: str, id: str, query_params: dict = {}) -> BaseModel:
return self._get_one(self.base_crud_path(sub), id, query_params)
def create(
self, sub: str, body_params: dict = {}, query_params: dict = {}
) -> BaseModel:
return self._create(self.base_crud_path(sub), body_params, query_params)
def update(
self, sub: str, id: str, body_params: dict = {}, query_params: dict = {}
) -> BaseModel:
return self._update(self.base_crud_path(sub), id, body_params, query_params)
def delete(self, sub: str, id: str, query_params: dict = {}) -> bool:
return self._delete(self.base_crud_path(sub), id, query_params)

View File

@ -1,8 +1,9 @@
from __future__ import annotations
from abc import ABC from abc import ABC
from typing import Union, Optional
from pocketbase.models.admin import Admin from pocketbase.models.admin import Admin
from pocketbase.models.user import User from pocketbase.models.record import Record
class BaseAuthStore(ABC): class BaseAuthStore(ABC):
@ -12,29 +13,30 @@ class BaseAuthStore(ABC):
""" """
base_token: str base_token: str
base_model: Union[User, Admin, None] base_model: Record | Admin | None
def __init__( def __init__(
self, base_token: str = "", base_model: Optional[Union[User, Admin]] = None self, base_token: str = "", base_model: Record | Admin | None = None
) -> None: ) -> None:
super().__init__() super().__init__()
self.base_token = base_token self.base_token = base_token
self.base_model = base_model self.base_model = base_model
@property @property
def token(self) -> Union[str, None]: def token(self) -> str | None:
"""Retrieves the stored token (if any).""" """Retrieves the stored token (if any)."""
return self.base_token return self.base_token
@property @property
def model(self) -> Union[User, Admin, None]: def model(self) -> Record | Admin | None:
"""Retrieves the stored model data (if any).""" """Retrieves the stored model data (if any)."""
return self.base_model return self.base_model
def save(self, token: str = "", model: Optional[Union[User, Admin]] = None) -> None: def save(self, token: str = "", model: Record | Admin | None = None) -> None:
"""Saves the provided new token and model data in the auth store.""" """Saves the provided new token and model data in the auth store."""
self.base_token = token
self.base_model = model self.base_token = token if token else ""
self.base_model = model if model else None
def clear(self) -> None: def clear(self) -> None:
"""Removes the stored token and model data form the auth store.""" """Removes the stored token and model data form the auth store."""

View File

@ -1,9 +1,11 @@
from typing import Any, Optional, Union from __future__ import annotations
from typing import Any
import pickle import pickle
import os import os
from pocketbase.stores.base_auth_store import BaseAuthStore from pocketbase.stores.base_auth_store import BaseAuthStore
from pocketbase.models.user import User from pocketbase.models.record import Record
from pocketbase.models.admin import Admin from pocketbase.models.admin import Admin
@ -16,7 +18,7 @@ class LocalAuthStore(BaseAuthStore):
filename: str = "pocketbase_auth.data", filename: str = "pocketbase_auth.data",
filepath: str = "", filepath: str = "",
base_token: str = "", base_token: str = "",
base_model: Optional[Union[User, Admin]] = None, base_model: Record | Admin | None = None,
) -> None: ) -> None:
super().__init__(base_token, base_model) super().__init__(base_token, base_model)
self.filename = filename self.filename = filename
@ -26,18 +28,18 @@ class LocalAuthStore(BaseAuthStore):
@property @property
def token(self) -> str: def token(self) -> str:
data = self._storage_get(self.complete_filepath) data = self._storage_get(self.complete_filepath)
if not data or not "token" in data: if not data or "token" not in data:
return None return None
return data["token"] return data["token"]
@property @property
def model(self) -> Union[User, Admin, None]: def model(self) -> Record | Admin | None:
data = self._storage_get(self.complete_filepath) data = self._storage_get(self.complete_filepath)
if not data or not "model" in data: if not data or "model" not in data:
return None return None
return data["model"] return data["model"]
def save(self, token: str = "", model: Optional[Union[User, Admin]] = None) -> None: def save(self, token: str = "", model: Record | Admin | None = None) -> None:
self._storage_set(self.complete_filepath, {"token": token, "model": model}) self._storage_set(self.complete_filepath, {"token": token, "model": model})
super().save(token, model) super().save(token, model)

View File

@ -1,6 +1,8 @@
from __future__ import annotations
import re import re
import datetime import datetime
from typing import Union from typing import Any
def camel_to_snake(name: str) -> str: def camel_to_snake(name: str) -> str:
@ -10,9 +12,25 @@ def camel_to_snake(name: str) -> str:
def to_datetime( def to_datetime(
str_datetime: str, format: str = "%Y-%m-%d %H:%M:%S" str_datetime: str, format: str = "%Y-%m-%d %H:%M:%S"
) -> Union[datetime.datetime, str]: ) -> datetime.datetime | str:
str_datetime = str_datetime.split(".")[0] str_datetime = str_datetime.split(".")[0]
try: try:
return datetime.datetime.strptime(str_datetime, format) return datetime.datetime.strptime(str_datetime, format)
except Exception: except Exception:
return str_datetime return str_datetime
class ClientResponseError(Exception):
url: str = ""
status: int = 0
data: dict = {}
is_abort: bool = False
original_error: Any | None = None
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args)
self.url = kwargs.get("url", "")
self.status = kwargs.get("status", 0)
self.data = kwargs.get("data", {})
self.is_abort = kwargs.get("is_abort", False)
self.original_error = kwargs.get("original_error", None)

307
poetry.lock generated
View File

@ -1,6 +1,6 @@
[[package]] [[package]]
name = "anyio" name = "anyio"
version = "3.6.1" version = "3.6.2"
description = "High level compatibility layer for multiple asynchronous event loop implementations" description = "High level compatibility layer for multiple asynchronous event loop implementations"
category = "main" category = "main"
optional = false optional = false
@ -9,19 +9,12 @@ python-versions = ">=3.6.2"
[package.dependencies] [package.dependencies]
idna = ">=2.8" idna = ">=2.8"
sniffio = ">=1.1" sniffio = ">=1.1"
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
[package.extras] [package.extras]
doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"]
trio = ["trio (>=0.16)"] trio = ["trio (>=0.16,<0.22)"]
[[package]]
name = "atomicwrites"
version = "1.4.1"
description = "Atomic file writes."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]] [[package]]
name = "attrs" name = "attrs"
@ -35,15 +28,15 @@ python-versions = ">=3.5"
dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"]
docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"]
tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"]
tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"]
[[package]] [[package]]
name = "black" name = "black"
version = "22.8.0" version = "22.10.0"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.6.2" python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
click = ">=8.0.0" click = ">=8.0.0"
@ -51,6 +44,8 @@ mypy-extensions = ">=0.4.3"
pathspec = ">=0.9.0" pathspec = ">=0.9.0"
platformdirs = ">=2" platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""}
typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
[package.extras] [package.extras]
colorama = ["colorama (>=0.4.3)"] colorama = ["colorama (>=0.4.3)"]
@ -60,7 +55,7 @@ uvloop = ["uvloop (>=0.15.2)"]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2022.6.15.2" version = "2022.9.24"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
category = "main" category = "main"
optional = false optional = false
@ -76,14 +71,40 @@ python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""} colorama = {version = "*", markers = "platform_system == \"Windows\""}
importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.5" version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
[[package]]
name = "exceptiongroup"
version = "1.0.1"
description = "Backport of PEP 654 (exception groups)"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "flake8"
version = "5.0.4"
description = "the modular source code checker: pep8 pyflakes and co"
category = "dev"
optional = false
python-versions = ">=3.6.1"
[package.dependencies]
importlib-metadata = {version = ">=1.1.0,<4.3", markers = "python_version < \"3.8\""}
mccabe = ">=0.7.0,<0.8.0"
pycodestyle = ">=2.9.0,<2.10.0"
pyflakes = ">=2.5.0,<2.6.0"
[[package]] [[package]]
name = "h11" name = "h11"
@ -140,12 +161,36 @@ optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
[[package]] [[package]]
name = "more-itertools" name = "importlib-metadata"
version = "8.14.0" version = "4.2.0"
description = "More routines for operating on iterables, beyond itertools" description = "Read metadata from Python packages"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.6"
[package.dependencies]
typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
zipp = ">=0.5"
[package.extras]
docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"]
testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517", "pyfakefs", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"]
[[package]]
name = "iniconfig"
version = "1.1.1"
description = "iniconfig: brain-dead simple config-ini parsing"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "mccabe"
version = "0.7.0"
description = "McCabe checker, plugin for flake8"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]] [[package]]
name = "mypy-extensions" name = "mypy-extensions"
@ -176,34 +221,46 @@ python-versions = ">=3.7"
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "2.5.2" version = "2.5.3"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[package.extras] [package.extras]
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx (>=4)", "sphinx-autodoc-typehints (>=1.12)"] docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.4)"]
test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] test = ["appdirs (==1.4.4)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "0.13.1" version = "1.0.0"
description = "plugin and hook calling mechanisms for python" description = "plugin and hook calling mechanisms for python"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=3.6"
[package.dependencies]
importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
[package.extras] [package.extras]
dev = ["pre-commit", "tox"] dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]] [[package]]
name = "py" name = "pycodestyle"
version = "1.11.0" version = "2.9.1"
description = "library with cross-python path, ini-parsing, io, code, log facilities" description = "Python style guide checker"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=3.6"
[[package]]
name = "pyflakes"
version = "2.5.0"
description = "passive checker of Python programs"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
@ -218,25 +275,24 @@ diagrams = ["jinja2", "railroad-diagrams"]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "5.4.3" version = "7.2.0"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0"
attrs = ">=17.4.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""} colorama = {version = "*", markers = "sys_platform == \"win32\""}
more-itertools = ">=4.0.0" exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
iniconfig = "*"
packaging = "*" packaging = "*"
pluggy = ">=0.12,<1.0" pluggy = ">=0.12,<2.0"
py = ">=1.5.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
wcwidth = "*"
[package.extras] [package.extras]
checkqa-mypy = ["mypy (==v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
[[package]] [[package]]
name = "rfc3986" name = "rfc3986"
@ -269,66 +325,89 @@ optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[[package]] [[package]]
name = "wcwidth" name = "typed-ast"
version = "0.2.5" version = "1.5.4"
description = "Measures the displayed width of unicode strings in a terminal" description = "a fork of Python 2 and 3 ast modules with type comment support"
category = "dev" category = "dev"
optional = false optional = false
python-versions = "*" python-versions = ">=3.6"
[[package]]
name = "typing-extensions"
version = "4.4.0"
description = "Backported and Experimental Type Hints for Python 3.7+"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "zipp"
version = "3.10.0"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.7"
content-hash = "8e1b477e300709c958c814a3937ee98aa175988857e7452bc5e3e2b97f58f7ca" content-hash = "8f9534771a19adba002263f1c9c563e5d1bd7e1f134fd42aa85689b614fd6e0a"
[metadata.files] [metadata.files]
anyio = [ anyio = [
{file = "anyio-3.6.1-py3-none-any.whl", hash = "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be"}, {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"},
{file = "anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b"}, {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"},
]
atomicwrites = [
{file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"},
] ]
attrs = [ attrs = [
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
{file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
] ]
black = [ black = [
{file = "black-22.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce957f1d6b78a8a231b18e0dd2d94a33d2ba738cd88a7fe64f53f659eea49fdd"}, {file = "black-22.10.0-1fixedarch-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:5cc42ca67989e9c3cf859e84c2bf014f6633db63d1cbdf8fdb666dcd9e77e3fa"},
{file = "black-22.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5107ea36b2b61917956d018bd25129baf9ad1125e39324a9b18248d362156a27"}, {file = "black-22.10.0-1fixedarch-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:5d8f74030e67087b219b032aa33a919fae8806d49c867846bfacde57f43972ef"},
{file = "black-22.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8166b7bfe5dcb56d325385bd1d1e0f635f24aae14b3ae437102dedc0c186747"}, {file = "black-22.10.0-1fixedarch-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:197df8509263b0b8614e1df1756b1dd41be6738eed2ba9e9769f3880c2b9d7b6"},
{file = "black-22.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd82842bb272297503cbec1a2600b6bfb338dae017186f8f215c8958f8acf869"}, {file = "black-22.10.0-1fixedarch-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:2644b5d63633702bc2c5f3754b1b475378fbbfb481f62319388235d0cd104c2d"},
{file = "black-22.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d839150f61d09e7217f52917259831fe2b689f5c8e5e32611736351b89bb2a90"}, {file = "black-22.10.0-1fixedarch-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:e41a86c6c650bcecc6633ee3180d80a025db041a8e2398dcc059b3afa8382cd4"},
{file = "black-22.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a05da0430bd5ced89176db098567973be52ce175a55677436a271102d7eaa3fe"}, {file = "black-22.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2039230db3c6c639bd84efe3292ec7b06e9214a2992cd9beb293d639c6402edb"},
{file = "black-22.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a098a69a02596e1f2a58a2a1c8d5a05d5a74461af552b371e82f9fa4ada8342"}, {file = "black-22.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14ff67aec0a47c424bc99b71005202045dc09270da44a27848d534600ac64fc7"},
{file = "black-22.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5594efbdc35426e35a7defa1ea1a1cb97c7dbd34c0e49af7fb593a36bd45edab"}, {file = "black-22.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:819dc789f4498ecc91438a7de64427c73b45035e2e3680c92e18795a839ebb66"},
{file = "black-22.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a983526af1bea1e4cf6768e649990f28ee4f4137266921c2c3cee8116ae42ec3"}, {file = "black-22.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b9b29da4f564ba8787c119f37d174f2b69cdfdf9015b7d8c5c16121ddc054ae"},
{file = "black-22.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b2c25f8dea5e8444bdc6788a2f543e1fb01494e144480bc17f806178378005e"}, {file = "black-22.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8b49776299fece66bffaafe357d929ca9451450f5466e997a7285ab0fe28e3b"},
{file = "black-22.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:78dd85caaab7c3153054756b9fe8c611efa63d9e7aecfa33e533060cb14b6d16"}, {file = "black-22.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:21199526696b8f09c3997e2b4db8d0b108d801a348414264d2eb8eb2532e540d"},
{file = "black-22.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:cea1b2542d4e2c02c332e83150e41e3ca80dc0fb8de20df3c5e98e242156222c"}, {file = "black-22.10.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e464456d24e23d11fced2bc8c47ef66d471f845c7b7a42f3bd77bf3d1789650"},
{file = "black-22.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b879eb439094751185d1cfdca43023bc6786bd3c60372462b6f051efa6281a5"}, {file = "black-22.10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9311e99228ae10023300ecac05be5a296f60d2fd10fff31cf5c1fa4ca4b1988d"},
{file = "black-22.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a12e4e1353819af41df998b02c6742643cfef58282915f781d0e4dd7a200411"}, {file = "black-22.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fba8a281e570adafb79f7755ac8721b6cf1bbf691186a287e990c7929c7692ff"},
{file = "black-22.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3a73f66b6d5ba7288cd5d6dad9b4c9b43f4e8a4b789a94bf5abfb878c663eb3"}, {file = "black-22.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:915ace4ff03fdfff953962fa672d44be269deb2eaf88499a0f8805221bc68c87"},
{file = "black-22.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:e981e20ec152dfb3e77418fb616077937378b322d7b26aa1ff87717fb18b4875"}, {file = "black-22.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:444ebfb4e441254e87bad00c661fe32df9969b2bf224373a448d8aca2132b395"},
{file = "black-22.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8ce13ffed7e66dda0da3e0b2eb1bdfc83f5812f66e09aca2b0978593ed636b6c"}, {file = "black-22.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:974308c58d057a651d182208a484ce80a26dac0caef2895836a92dd6ebd725e0"},
{file = "black-22.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:32a4b17f644fc288c6ee2bafdf5e3b045f4eff84693ac069d87b1a347d861497"}, {file = "black-22.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72ef3925f30e12a184889aac03d77d031056860ccae8a1e519f6cbb742736383"},
{file = "black-22.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad827325a3a634bae88ae7747db1a395d5ee02cf05d9aa7a9bd77dfb10e940c"}, {file = "black-22.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:432247333090c8c5366e69627ccb363bc58514ae3e63f7fc75c54b1ea80fa7de"},
{file = "black-22.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53198e28a1fb865e9fe97f88220da2e44df6da82b18833b588b1883b16bb5d41"}, {file = "black-22.10.0-py3-none-any.whl", hash = "sha256:c957b2b4ea88587b46cf49d1dc17681c1e672864fd7af32fc1e9664d572b3458"},
{file = "black-22.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:bc4d4123830a2d190e9cc42a2e43570f82ace35c3aeb26a512a2102bce5af7ec"}, {file = "black-22.10.0.tar.gz", hash = "sha256:f513588da599943e0cde4e32cc9879e825d58720d6557062d1098c5ad80080e1"},
{file = "black-22.8.0-py3-none-any.whl", hash = "sha256:d2c21d439b2baf7aa80d6dd4e3659259be64c6f49dfd0f32091063db0e006db4"},
{file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"},
] ]
certifi = [ certifi = [
{file = "certifi-2022.6.15.2-py3-none-any.whl", hash = "sha256:0aa1a42fbd57645fabeb6290a7687c21755b0344ecaeaa05f4e9f6207ae2e9a8"}, {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"},
{file = "certifi-2022.6.15.2.tar.gz", hash = "sha256:aa08c101214127b9b0472ca6338315113c9487d45376fd3e669201b477c71003"}, {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"},
] ]
click = [ click = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
] ]
colorama = [ colorama = [
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
exceptiongroup = [
{file = "exceptiongroup-1.0.1-py3-none-any.whl", hash = "sha256:4d6c0aa6dd825810941c792f53d7b8d71da26f5e5f84f20f9508e8f2d33b140a"},
{file = "exceptiongroup-1.0.1.tar.gz", hash = "sha256:73866f7f842ede6cb1daa42c4af078e2035e5f7607f0e2c762cc51bb31bbe7b2"},
]
flake8 = [
{file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"},
{file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"},
] ]
h11 = [ h11 = [
{file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
@ -346,9 +425,17 @@ idna = [
{file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
{file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
] ]
more-itertools = [ importlib-metadata = [
{file = "more-itertools-8.14.0.tar.gz", hash = "sha256:c09443cd3d5438b8dafccd867a6bc1cb0894389e90cb53d227456b0b0bccb750"}, {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"},
{file = "more_itertools-8.14.0-py3-none-any.whl", hash = "sha256:1bc4f91ee5b1b31ac7ceacc17c09befe6a40a503907baf9c839c229b5095cfd2"}, {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"},
]
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
mccabe = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
] ]
mypy-extensions = [ mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
@ -363,24 +450,28 @@ pathspec = [
{file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"},
] ]
platformdirs = [ platformdirs = [
{file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, {file = "platformdirs-2.5.3-py3-none-any.whl", hash = "sha256:0cb405749187a194f444c25c82ef7225232f11564721eabffc6ec70df83b11cb"},
{file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, {file = "platformdirs-2.5.3.tar.gz", hash = "sha256:6e52c21afff35cb659c6e52d8b4d61b9bd544557180440538f255d9382c8cbe0"},
] ]
pluggy = [ pluggy = [
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
] ]
py = [ pycodestyle = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "pycodestyle-2.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"},
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, {file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"},
]
pyflakes = [
{file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"},
{file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"},
] ]
pyparsing = [ pyparsing = [
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
] ]
pytest = [ pytest = [
{file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"},
{file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"},
] ]
rfc3986 = [ rfc3986 = [
{file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
@ -394,7 +485,37 @@ tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
] ]
wcwidth = [ typed-ast = [
{file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"},
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"},
{file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"},
{file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"},
{file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"},
{file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"},
{file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"},
{file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"},
{file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"},
{file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"},
{file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"},
{file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"},
{file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"},
{file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"},
{file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"},
{file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"},
{file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"},
{file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"},
{file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"},
{file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"},
{file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"},
{file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"},
{file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"},
{file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"},
]
typing-extensions = [
{file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
{file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
]
zipp = [
{file = "zipp-3.10.0-py3-none-any.whl", hash = "sha256:4fcb6f278987a6605757302a6e40e896257570d11c51628968ccb2a47e80c6c1"},
{file = "zipp-3.10.0.tar.gz", hash = "sha256:7a7262fd930bd3e36c50b9a64897aec3fafff3dfdeec9623ae22b40e93f99bb8"},
] ]

View File

@ -1,15 +1,52 @@
[project]
name = "pocketbase"
description = "PocketBase SDK for python."
requires-python = ">=3.7"
license = "MIT"
authors = [
{ name = "Vithor Jaeger", email = "vaphes@gmail.com" },
{ name = "Max Amling", email = "max-amling@web.de" },
]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Intended Audience :: Developers",
]
keywords = "pocketbase sdk"
dependencies = ["httpx>=0.23.0"]
dynamic = ["readme", "version"]
[project.urls]
"Homepage" = "https://github.com/vaphes/pocketbase"
"Source" = "https://github.com/vaphes/pocketbase"
"Bug Tracker" = "https://github.com/vaphes/pocketbase/issues"
[tool.poetry] [tool.poetry]
name = "pocketbase" name = "pocketbase"
version = "0.1.0" version = "0.8.1"
description = "" description = "PocketBase SDK for python."
authors = ["Vithor Jaeger <vaphes@gmail.com>"] authors = ["Vithor Jaeger <vaphes@gmail.com>"]
readme = "README.md"
homepage = "https://github.com/vaphes/pocketbase"
repository = "https://github.com/vaphes/pocketbase"
keywords = ["pocketbase", "sdk"]
[tool.poetry.urls]
"Bug Tracker" = "https://github.com/vaphes/pocketbase/issues"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.7"
httpx = "^0.23.0" httpx = "^0.23.0"
[tool.poetry.dev-dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^5.2" flake8 = "^5.0.4"
pytest = "^7.1.3"
black = {version = "^22.8.0", allow-prereleases = true} black = {version = "^22.8.0", allow-prereleases = true}
[build-system] [build-system]

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
httpx >= 0.23.0

View File

@ -1,5 +0,0 @@
from pocketbase import __version__
def test_version():
assert __version__ == "0.1.0"

18
tests/test_utils.py Normal file
View File

@ -0,0 +1,18 @@
import datetime
from pocketbase import __version__
from pocketbase.utils import camel_to_snake, to_datetime
def test_version():
assert __version__ == "0.8.1"
def test_utils():
assert camel_to_snake("TestCase") == "test_case"
assert camel_to_snake("test_case") == "test_case"
assert camel_to_snake("TestBS123") == "test_bs123"
assert to_datetime("2022-01-31 12:01:05") == datetime.datetime(
2022, 1, 31, 12, 1, 5
)
assert isinstance(to_datetime("2022-01-31"), str)