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
Copyright (c) [year] [fullname]
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

View File

@ -10,10 +10,10 @@ This is in early development, and at first is just a translation of <a href="htt
## Installation
Install PocketBase using pip:
Install PocketBase using PIP:
```shell
$ pip install pocketbase
python3 -m pip install pocketbase
```
## Usage
@ -21,26 +21,66 @@ $ pip install pocketbase
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 import PocketBase # Client also works the same
from pocketbase.client import FileUpload
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
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
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...
```
> 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"
__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):

View File

@ -5,7 +5,7 @@ from urllib.parse import quote, urlencode
import httpx
from pocketbase.utils import ClientResponseError
from pocketbase.models import FileUpload
from pocketbase.models.record import Record
from pocketbase.services.admin_service import AdminService
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.settings_service import SettingsService
from pocketbase.stores.base_auth_store import BaseAuthStore
from pocketbase.utils import ClientResponseError
class Client:
@ -34,10 +35,12 @@ class Client:
base_url: str = "/",
lang: str = "en-US",
auth_store: BaseAuthStore | None = None,
timeout: float = 120,
) -> None:
self.base_url = base_url
self.lang = lang
self.auth_store = auth_store or BaseAuthStore() # LocalAuthStore()
self.timeout = timeout
# services
self.admins = AdminService(self)
self.collections = CollectionService(self)
@ -61,9 +64,7 @@ class Client:
"headers" not in config or "Authorization" not in config["headers"]
):
config["headers"] = config.get("headers", {})
config["headers"].update(
{"Authorization": self.auth_store.token}
)
config["headers"].update({"Authorization": self.auth_store.token})
# build url + path
url = self.build_url(path)
# send the request
@ -71,6 +72,21 @@ class Client:
params = config.get("params", None)
headers = config.get("headers", 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:
response = httpx.request(
method=method,
@ -78,7 +94,9 @@ class Client:
params=params,
headers=headers,
json=body,
timeout=120,
data=data,
files=files,
timeout=self.timeout,
)
except Exception as e:
raise ClientResponseError(
@ -100,16 +118,16 @@ class Client:
def get_file_url(self, record: Record, filename: str, query_params: dict):
parts = [
'api',
'files',
"api",
"files",
quote(record.collection_id or record.collection_name),
quote(record.id),
quote(filename),
]
result = self.build_url('/'.join(parts))
result = self.build_url("/".join(parts))
if len(query_params) != 0:
params: str = urlencode(query_params)
result += '&' if '?' in result else '?'
result += "&" if "?" in result else "?"
result += params
return result

View File

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

View File

@ -37,10 +37,10 @@ class Collection(BaseModel):
self.schema.append(SchemaField(**field))
def is_base(self):
return self.type == 'base'
return self.type == "base"
def is_auth(self):
return self.type == 'auth'
return self.type == "auth"
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,24 +2,21 @@ from __future__ import annotations
from pocketbase.models.utils.base_model import BaseModel
from pocketbase.utils import camel_to_snake
from dataclasses import dataclass, field
@dataclass
class Record(BaseModel):
collection_id: str
collection_name: str
expand: dict
collection_name: str = ""
expand: dict = field(default_factory=dict)
def load(self, data: dict) -> None:
super().load(data)
self.expand = {}
for key, value in data.items():
key = camel_to_snake(key).replace("@", "")
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
def parse_expanded(cls, data: dict):

View File

@ -30,7 +30,10 @@ class AdminService(CrudService):
"""
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:
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
@ -43,7 +46,10 @@ class AdminService(CrudService):
"""
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:
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

View File

@ -76,9 +76,7 @@ class RealtimeService(BaseService):
found = False
for sub in self.subscriptions:
found = True
self.event_source.remove_event_listener(
sub, self.subscriptions[sub]
)
self.event_source.remove_event_listener(sub, self.subscriptions[sub])
self.subscriptions.pop(sub)
if not found:
return
@ -141,15 +139,13 @@ class RealtimeService(BaseService):
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)
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.remove_event_listener("PB_CONNECT", self._connect_handler)
self.event_source.close()
self.event_source = None

View File

@ -74,14 +74,16 @@ class RecordService(CrudService):
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)
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)
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)
@ -92,7 +94,10 @@ class RecordService(CrudService):
"""
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:
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
@ -105,7 +110,11 @@ class RecordService(CrudService):
"""
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:
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
@ -129,18 +138,25 @@ class RecordService(CrudService):
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()}
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", []))
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 = {}
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.
@ -150,8 +166,7 @@ class RecordService(CrudService):
- the authentication token
- the authenticated record model
"""
body_params.update(
{"identity": username_or_email, "password": password})
body_params.update({"identity": username_or_email, "password": password})
response_data = self.client.send(
self.base_collection_path() + "/auth-with-password",
{
@ -182,15 +197,17 @@ class RecordService(CrudService):
- 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,
})
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-password",
self.base_collection_path() + "/auth-with-oauth2",
{
"method": "POST",
"params": query_params,
@ -211,11 +228,7 @@ class RecordService(CrudService):
return self.auth_response(
self.client.send(
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:
return self.decode(
self.client.send(
f"{base_path}/{quote(id)}",
{
"method": "GET",
"params": query_params
}
f"{base_path}/{quote(id)}", {"method": "GET", "params": query_params}
)
)
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,
})
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
"The requested resource wasn't found.", status=404
)
def _create(
@ -98,7 +95,6 @@ class BaseCrudService(BaseService, ABC):
def _delete(self, base_path: str, id: str, query_params: dict = {}) -> bool:
self.client.send(
f"{base_path}/{quote(id)}", {"method": "DELETE",
"params": query_params}
f"{base_path}/{quote(id)}", {"method": "DELETE", "params": query_params}
)
return True

View File

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

View File

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