7 Commits

Author SHA1 Message Date
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 157 additions and 79 deletions

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.0"
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:
@@ -61,9 +62,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 +70,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,6 +92,8 @@ class Client:
params=params, params=params,
headers=headers, headers=headers,
json=body, json=body,
data=data,
files=files,
timeout=120, timeout=120,
) )
except Exception as e: except Exception as e:
@@ -100,16 +116,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

@@ -11,14 +11,10 @@ class Record(BaseModel):
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,13 +197,15 @@ 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-password",
{ {
@@ -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.0"
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.0"
def test_utils(): def test_utils():