Compare commits

..

13 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
15 changed files with 204 additions and 84 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 }}

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) [year] [fullname] Copyright (c) 2023 vaphes
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -10,10 +10,10 @@ This is in early development, and at first is just a translation of <a href="htt
## Installation ## Installation
Install PocketBase using pip: Install PocketBase using PIP:
```shell ```shell
$ pip install pocketbase python3 -m pip install pocketbase
``` ```
## Usage ## Usage
@ -22,25 +22,65 @@ The rule of thumb here is just to use it as you would <a href="https://github.co
```python ```python
from pocketbase import PocketBase # Client also works the same from pocketbase import PocketBase # Client also works the same
from pocketbase.client import FileUpload
client = PocketBase('http://127.0.0.1:8090') client = PocketBase('http://127.0.0.1:8090')
...
# list and filter "example" collection records
result = client.records.get_list(
"example", 1, 20, {"filter": 'status = true && created > "2022-08-01 10:00:00"'}
)
# authenticate as regular user # authenticate as regular user
user_data = client.users.auth_via_email("test@example.com", "123456") user_data = client.collection("users").auth_with_password(
"user@example.com", "0123456789")
# or as admin # or as admin
admin_data = client.admins.auth_via_email("test@example.com", "123456") 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... # 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' 🙃. > 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
<p align="center"><i>The PocketBase Python SDK is <a href="https://github.com/vaphes/pocketbase/blob/master/LICENCE.txt">MIT licensed</a> code.</p> 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,9 +1,9 @@
__title__ = "pocketbase" __title__ = "pocketbase"
__description__ = "PocketBase client SDK for python." __description__ = "PocketBase client SDK for python."
__version__ = "0.2.2" __version__ = "0.8.1"
from .client import Client, ClientResponseError from .client import Client
class PocketBase(Client): class PocketBase(Client):

View File

@ -5,7 +5,7 @@ from urllib.parse import quote, urlencode
import httpx import httpx
from pocketbase.utils import ClientResponseError from pocketbase.models import FileUpload
from pocketbase.models.record import Record from pocketbase.models.record import Record
from pocketbase.services.admin_service import AdminService from pocketbase.services.admin_service import AdminService
from pocketbase.services.collection_service import CollectionService from pocketbase.services.collection_service import CollectionService
@ -14,6 +14,7 @@ from pocketbase.services.realtime_service import RealtimeService
from pocketbase.services.record_service import RecordService from pocketbase.services.record_service import RecordService
from pocketbase.services.settings_service import SettingsService 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
class Client: class Client:
@ -34,10 +35,12 @@ class Client:
base_url: str = "/", base_url: str = "/",
lang: str = "en-US", lang: str = "en-US",
auth_store: BaseAuthStore | None = 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 = AdminService(self) self.admins = AdminService(self)
self.collections = CollectionService(self) self.collections = CollectionService(self)
@ -61,9 +64,7 @@ class Client:
"headers" not in config or "Authorization" not in config["headers"] "headers" not in config or "Authorization" not in config["headers"]
): ):
config["headers"] = config.get("headers", {}) config["headers"] = config.get("headers", {})
config["headers"].update( config["headers"].update({"Authorization": self.auth_store.token})
{"Authorization": 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
@ -71,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,
@ -78,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(
@ -100,16 +118,16 @@ class Client:
def get_file_url(self, record: Record, filename: str, query_params: dict): def get_file_url(self, record: Record, filename: str, query_params: dict):
parts = [ parts = [
'api', "api",
'files', "files",
quote(record.collection_id or record.collection_name), quote(record.collection_id or record.collection_name),
quote(record.id), quote(record.id),
quote(filename), quote(filename),
] ]
result = self.build_url('/'.join(parts)) result = self.build_url("/".join(parts))
if len(query_params) != 0: if len(query_params) != 0:
params: str = urlencode(query_params) params: str = urlencode(query_params)
result += '&' if '?' in result else '?' result += "&" if "?" in result else "?"
result += params result += params
return result return result

View File

@ -3,3 +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 .file_upload import FileUpload

View File

@ -37,10 +37,10 @@ class Collection(BaseModel):
self.schema.append(SchemaField(**field)) self.schema.append(SchemaField(**field))
def is_base(self): def is_base(self):
return self.type == 'base' return self.type == "base"
def is_auth(self): def is_auth(self):
return self.type == 'auth' return self.type == "auth"
def is_single(self): def is_single(self):
return self.type == 'single' return self.type == "single"

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

@ -2,23 +2,20 @@ 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 expand: dict = field(default_factory=dict)
def load(self, data: dict) -> 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.collection_name = data.get("@collectionName", "")
expand = data.get("@expand", {})
if expand:
self.expand = expand
self.load_expanded() self.load_expanded()
@classmethod @classmethod

View File

@ -30,7 +30,10 @@ class AdminService(CrudService):
""" """
item = super(AdminService).update(id, body_params) item = super(AdminService).update(id, body_params)
try: try:
if self.client.auth_store.model.collection_id is not None and item.id == self.client.auth_store.model.id: 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) self.client.auth_store.save(self.client.auth_store.token, item)
except: except:
pass pass
@ -43,7 +46,10 @@ class AdminService(CrudService):
""" """
item = super(AdminService).delete(id, body_params) item = super(AdminService).delete(id, body_params)
try: try:
if self.client.auth_store.model.collection_id is not None and item.id == self.client.auth_store.model.id: 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) self.client.auth_store.save(self.client.auth_store.token, item)
except: except:
pass pass

View File

@ -76,9 +76,7 @@ class RealtimeService(BaseService):
found = False found = False
for sub in self.subscriptions: for sub in self.subscriptions:
found = True found = True
self.event_source.remove_event_listener( self.event_source.remove_event_listener(sub, self.subscriptions[sub])
sub, self.subscriptions[sub]
)
self.subscriptions.pop(sub) self.subscriptions.pop(sub)
if not found: if not found:
return return
@ -141,15 +139,13 @@ class RealtimeService(BaseService):
def _connect(self) -> None: def _connect(self) -> None:
self._disconnect() self._disconnect()
self.event_source = SSEClient(self.client.build_url("/api/realtime")) self.event_source = SSEClient(self.client.build_url("/api/realtime"))
self.event_source.add_event_listener( self.event_source.add_event_listener("PB_CONNECT", self._connect_handler)
"PB_CONNECT", self._connect_handler)
def _disconnect(self) -> None: def _disconnect(self) -> None:
self._remove_subscription_listeners() self._remove_subscription_listeners()
self.client_id = "" self.client_id = ""
if not self.event_source: if not self.event_source:
return return
self.event_source.remove_event_listener( self.event_source.remove_event_listener("PB_CONNECT", self._connect_handler)
"PB_CONNECT", self._connect_handler)
self.event_source.close() self.event_source.close()
self.event_source = None self.event_source = None

View File

@ -74,14 +74,16 @@ class RecordService(CrudService):
def subscribeOne(self, record_id: str, callback: Callable[[MessageData], None]): def subscribeOne(self, record_id: str, callback: Callable[[MessageData], None]):
"""Subscribe to the realtime changes of a single record in the collection.""" """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) return self.client.realtime.subscribe(
self.collection_id_or_name + "/" + record_id, callback
)
def unsubscribe(self, *record_ids: List[str]): def unsubscribe(self, *record_ids: List[str]):
"""Subscribe to the realtime changes of a single record in the collection.""" """Subscribe to the realtime changes of a single record in the collection."""
if record_ids and len(record_ids) == 0: if record_ids and len(record_ids) == 0:
subs = [] subs = []
for id in record_ids: for id in record_ids:
subs.append(self.collection_id_or_name + '/' + id) subs.append(self.collection_id_or_name + "/" + id)
return self.client.realtime.unsubscribe(*subs) return self.client.realtime.unsubscribe(*subs)
return self.client.realtime.subscribe_by_prefix(self.collection_id_or_name) return self.client.realtime.subscribe_by_prefix(self.collection_id_or_name)
@ -92,7 +94,10 @@ class RecordService(CrudService):
""" """
item = super().update(id, body_params) # super(Record).update item = super().update(id, body_params) # super(Record).update
try: try:
if self.client.auth_store.model.collection_id is not None and item.id == self.client.auth_store.model.id: 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) self.client.auth_store.save(self.client.auth_store.token, item)
except: except:
pass pass
@ -105,7 +110,11 @@ class RecordService(CrudService):
""" """
success = super().delete(id, body_params) # super(Record).delete success = super().delete(id, body_params) # super(Record).delete
try: try:
if success and self.client.auth_store.model.collection_id is not None and id == self.client.auth_store.model.id: 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() self.client.auth_store.clear()
except: except:
pass pass
@ -129,18 +138,25 @@ class RecordService(CrudService):
email_password = response_data.pop("emailPassword", False) email_password = response_data.pop("emailPassword", False)
def apply_pythonic_keys(ap): def apply_pythonic_keys(ap):
pythonic_keys_ap = {camel_to_snake(key).replace( pythonic_keys_ap = {
"@", ""): value for key, value in ap.items()} camel_to_snake(key).replace("@", ""): value for key, value in ap.items()
}
return pythonic_keys_ap return pythonic_keys_ap
auth_providers = [ auth_providers = [
AuthProviderInfo(**auth_provider) AuthProviderInfo(**auth_provider)
for auth_provider in map(apply_pythonic_keys, response_data.get("authProviders", [])) for auth_provider in map(
apply_pythonic_keys, response_data.get("authProviders", [])
)
] ]
return AuthMethodsList(username_password, email_password, auth_providers) return AuthMethodsList(username_password, email_password, auth_providers)
def auth_with_password( def auth_with_password(
self, username_or_email: str, password: str, body_params: dict = {}, query_params: dict = {} self,
username_or_email: str,
password: str,
body_params: dict = {},
query_params: dict = {},
) -> RecordAuthResponse: ) -> RecordAuthResponse:
""" """
Authenticate a single auth collection record via its username/email and password. Authenticate a single auth collection record via its username/email and password.
@ -150,8 +166,7 @@ class RecordService(CrudService):
- the authentication token - the authentication token
- the authenticated record model - the authenticated record model
""" """
body_params.update( body_params.update({"identity": username_or_email, "password": password})
{"identity": username_or_email, "password": password})
response_data = self.client.send( response_data = self.client.send(
self.base_collection_path() + "/auth-with-password", self.base_collection_path() + "/auth-with-password",
{ {
@ -182,15 +197,17 @@ class RecordService(CrudService):
- the authenticated record model - the authenticated record model
- the OAuth2 account data (eg. name, email, avatar, etc.) - the OAuth2 account data (eg. name, email, avatar, etc.)
""" """
body_params.update({ body_params.update(
'provider': provider, {
'code': code, "provider": provider,
'codeVerifier': code_verifier, "code": code,
'redirectUrl': redirct_url, "codeVerifier": code_verifier,
'createData': create_data, "redirectUrl": redirct_url,
}) "createData": create_data,
}
)
response_data = self.client.send( response_data = self.client.send(
self.base_collection_path() + "/auth-with-password", self.base_collection_path() + "/auth-with-oauth2",
{ {
"method": "POST", "method": "POST",
"params": query_params, "params": query_params,
@ -211,11 +228,7 @@ class RecordService(CrudService):
return self.auth_response( return self.auth_response(
self.client.send( self.client.send(
self.base_collection_path() + "/auth-refresh", self.base_collection_path() + "/auth-refresh",
{ {"method": "POST", "params": query_params, "body": body_params},
"method": "POST",
"params": query_params,
"body": body_params
},
) )
) )

View File

@ -53,27 +53,24 @@ class BaseCrudService(BaseService, ABC):
def _get_one(self, base_path: str, id: str, query_params: dict = {}) -> BaseModel: def _get_one(self, base_path: str, id: str, query_params: dict = {}) -> BaseModel:
return self.decode( return self.decode(
self.client.send( self.client.send(
f"{base_path}/{quote(id)}", f"{base_path}/{quote(id)}", {"method": "GET", "params": query_params}
{
"method": "GET",
"params": query_params
}
) )
) )
def _get_first_list_item(self, base_path: str, filter: str, query_params={}): def _get_first_list_item(self, base_path: str, filter: str, query_params={}):
query_params.update({ query_params.update(
'filter': filter, {
'$cancelKey': 'one_by_filter_' + base_path + '_' + filter, "filter": filter,
}) "$cancelKey": "one_by_filter_" + base_path + "_" + filter,
}
)
result = self._get_list(base_path, 1, 1, query_params) result = self._get_list(base_path, 1, 1, query_params)
try: try:
if len(result.items) == 0: if len(result.items) == 0:
raise raise
except: except:
raise ClientResponseError( raise ClientResponseError(
"The requested resource wasn't found.", "The requested resource wasn't found.", status=404
status=404
) )
def _create( def _create(
@ -98,7 +95,6 @@ class BaseCrudService(BaseService, ABC):
def _delete(self, base_path: str, id: str, query_params: dict = {}) -> bool: def _delete(self, base_path: str, id: str, query_params: dict = {}) -> bool:
self.client.send( self.client.send(
f"{base_path}/{quote(id)}", {"method": "DELETE", f"{base_path}/{quote(id)}", {"method": "DELETE", "params": query_params}
"params": query_params}
) )
return True return True

View File

@ -29,7 +29,7 @@ dynamic = ["readme", "version"]
[tool.poetry] [tool.poetry]
name = "pocketbase" name = "pocketbase"
version = "0.2.2" version = "0.8.1"
description = "PocketBase SDK for python." description = "PocketBase SDK for python."
authors = ["Vithor Jaeger <vaphes@gmail.com>"] authors = ["Vithor Jaeger <vaphes@gmail.com>"]
readme = "README.md" readme = "README.md"

View File

@ -5,7 +5,7 @@ from pocketbase.utils import camel_to_snake, to_datetime
def test_version(): def test_version():
assert __version__ == "0.2.2" assert __version__ == "0.8.1"
def test_utils(): def test_utils():