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
14 changed files with 173 additions and 86 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

@ -1,13 +1,11 @@
from __future__ import annotations from __future__ import annotations
from pocketbase.stores.base_auth_store import BaseAuthStore
from pocketbase.models import FileUpload
from typing import Any, Dict from typing import Any, Dict
from urllib.parse import quote, urlencode 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
@ -16,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:
@ -36,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)
@ -63,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
@ -97,7 +96,7 @@ class Client:
json=body, json=body,
data=data, data=data,
files=files, files=files,
timeout=120, timeout=self.timeout,
) )
except Exception as e: except Exception as e:
raise ClientResponseError( raise ClientResponseError(
@ -119,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

@ -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

@ -1,5 +1,6 @@
from httpx._types import FileTypes from httpx._types import FileTypes
from typing import Sequence, Union from typing import Sequence, Union
FileUploadTypes = Union[FileTypes, Sequence[FileTypes]] FileUploadTypes = Union[FileTypes, Sequence[FileTypes]]

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():