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

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

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

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

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