pyronites 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyronites/__init__.py +14 -0
- pyronites/auth.py +45 -0
- pyronites/client.py +121 -0
- pyronites/config.py +80 -0
- pyronites/errors.py +40 -0
- pyronites/http.py +124 -0
- pyronites/local.py +128 -0
- pyronites/storage.py +140 -0
- pyronites/table.py +217 -0
- pyronites-0.1.0.dist-info/METADATA +109 -0
- pyronites-0.1.0.dist-info/RECORD +12 -0
- pyronites-0.1.0.dist-info/WHEEL +4 -0
pyronites/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""pyronites — official Python client for the Pyronites backend."""
|
|
2
|
+
|
|
3
|
+
from pyronites.client import create_client
|
|
4
|
+
from pyronites.errors import ApiError, AuthError, NotFoundError
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"create_client",
|
|
10
|
+
"ApiError",
|
|
11
|
+
"AuthError",
|
|
12
|
+
"NotFoundError",
|
|
13
|
+
"__version__",
|
|
14
|
+
]
|
pyronites/auth.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Auth methods for the pyronites client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from pyronites.errors import AuthError
|
|
8
|
+
from pyronites.http import HttpTransport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthClient:
|
|
12
|
+
def __init__(self, transport: HttpTransport) -> None:
|
|
13
|
+
self._http = transport
|
|
14
|
+
self._user: Optional[Dict[str, Any]] = None
|
|
15
|
+
|
|
16
|
+
def sign_up(self, email: str, password: str) -> Dict[str, Any]:
|
|
17
|
+
data = self._http.request(
|
|
18
|
+
"POST", "/auth/signup",
|
|
19
|
+
json={"email": email, "password": password},
|
|
20
|
+
)
|
|
21
|
+
self._user = data
|
|
22
|
+
return data # type: ignore[return-value]
|
|
23
|
+
|
|
24
|
+
def sign_in(self, email: str, password: str) -> Dict[str, Any]:
|
|
25
|
+
data = self._http.request(
|
|
26
|
+
"POST", "/auth/login",
|
|
27
|
+
json={"email": email, "password": password},
|
|
28
|
+
)
|
|
29
|
+
self._user = data
|
|
30
|
+
return data # type: ignore[return-value]
|
|
31
|
+
|
|
32
|
+
def sign_out(self) -> None:
|
|
33
|
+
try:
|
|
34
|
+
self._http.request("POST", "/auth/logout")
|
|
35
|
+
finally:
|
|
36
|
+
self._user = None
|
|
37
|
+
|
|
38
|
+
def user(self) -> Optional[Dict[str, Any]]:
|
|
39
|
+
try:
|
|
40
|
+
data = self._http.request("GET", "/auth/me")
|
|
41
|
+
self._user = data
|
|
42
|
+
return data # type: ignore[return-value]
|
|
43
|
+
except AuthError:
|
|
44
|
+
self._user = None
|
|
45
|
+
return None
|
pyronites/client.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Main façade for the pyronites client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Sequence
|
|
6
|
+
|
|
7
|
+
from pyronites.auth import AuthClient
|
|
8
|
+
from pyronites.config import ClientConfig, load_config
|
|
9
|
+
from pyronites.errors import ApiError
|
|
10
|
+
from pyronites.http import HttpTransport
|
|
11
|
+
from pyronites.local import LocalStore
|
|
12
|
+
from pyronites.storage import StorageClient
|
|
13
|
+
from pyronites.table import TableQuery
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PyronitesClient:
|
|
17
|
+
"""High-level client. Prefer ``create_client()`` over constructing this directly."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
20
|
+
self._config = config
|
|
21
|
+
self._http = HttpTransport(config)
|
|
22
|
+
self.auth = AuthClient(self._http)
|
|
23
|
+
self.storage = StorageClient(self._http)
|
|
24
|
+
self._local: Optional[LocalStore] = None
|
|
25
|
+
if config.local_db_path or config.local_tables or config.cache_tables:
|
|
26
|
+
path = config.local_db_path or "./.pyronites_local.db"
|
|
27
|
+
self._local = LocalStore(path)
|
|
28
|
+
|
|
29
|
+
def table(self, name: str) -> TableQuery:
|
|
30
|
+
"""Return a fluent table handle for the given table name."""
|
|
31
|
+
if not name or not isinstance(name, str):
|
|
32
|
+
raise ApiError("table name must be a non-empty string")
|
|
33
|
+
policy = self._config.table_policy(name)
|
|
34
|
+
return TableQuery(self._http, name, policy=policy, local=self._local)
|
|
35
|
+
|
|
36
|
+
def tables(self) -> list:
|
|
37
|
+
"""List user tables with row counts. Maps to ``GET /tables``."""
|
|
38
|
+
result = self._http.request("GET", "/tables")
|
|
39
|
+
return result if isinstance(result, list) else []
|
|
40
|
+
|
|
41
|
+
def sql(self, query: str) -> dict:
|
|
42
|
+
"""Execute raw SQL (admin-scoped key required)."""
|
|
43
|
+
return self._http.request( # type: ignore[return-value]
|
|
44
|
+
"POST", "/sql/execute", json={"sql": query},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def pull(self, table: str) -> int:
|
|
48
|
+
"""Fetch all remote rows and replace the local copy. Remote wins."""
|
|
49
|
+
if self._local is None:
|
|
50
|
+
raise ApiError(
|
|
51
|
+
"Local store is not configured. Pass local_db_path=... "
|
|
52
|
+
"or list local_tables/cache_tables on create_client()."
|
|
53
|
+
)
|
|
54
|
+
rows = self._http.request("GET", f"/tables/{table}", params={"limit": 200})
|
|
55
|
+
if not isinstance(rows, list):
|
|
56
|
+
rows = []
|
|
57
|
+
return self._local.replace_all(table, rows)
|
|
58
|
+
|
|
59
|
+
def push(self, table: str) -> int:
|
|
60
|
+
"""Push local rows to remote. Local-only tables cannot be pushed."""
|
|
61
|
+
if self._local is None:
|
|
62
|
+
raise ApiError("Local store is not configured.")
|
|
63
|
+
if self._config.table_policy(table) == "local":
|
|
64
|
+
raise ApiError(
|
|
65
|
+
f"Table {table!r} is local-only and cannot be pushed to the backend."
|
|
66
|
+
)
|
|
67
|
+
rows = self._local.all_rows(table)
|
|
68
|
+
count = 0
|
|
69
|
+
for row in rows:
|
|
70
|
+
row_id = row.get("id")
|
|
71
|
+
if not row_id:
|
|
72
|
+
self._http.request("POST", f"/tables/{table}", json=row)
|
|
73
|
+
count += 1
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
self._http.request(
|
|
77
|
+
"PATCH", f"/tables/{table}/{row_id}",
|
|
78
|
+
json={k: v for k, v in row.items() if k != "id"},
|
|
79
|
+
)
|
|
80
|
+
count += 1
|
|
81
|
+
except ApiError as e:
|
|
82
|
+
if e.status_code == 404 or e.code == "not_found":
|
|
83
|
+
self._http.request("POST", f"/tables/{table}", json=row)
|
|
84
|
+
count += 1
|
|
85
|
+
else:
|
|
86
|
+
raise
|
|
87
|
+
return count
|
|
88
|
+
|
|
89
|
+
def sync(self, table: str) -> int:
|
|
90
|
+
"""v1 policy: remote wins → equivalent to pull."""
|
|
91
|
+
return self.pull(table)
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
94
|
+
self._http.close()
|
|
95
|
+
if self._local is not None:
|
|
96
|
+
self._local.close()
|
|
97
|
+
|
|
98
|
+
def __enter__(self) -> "PyronitesClient":
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def __exit__(self, *args: object) -> None:
|
|
102
|
+
self.close()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def create_client(
|
|
106
|
+
url: Optional[str] = None,
|
|
107
|
+
key: Optional[str] = None,
|
|
108
|
+
timeout: float = 30.0,
|
|
109
|
+
local_tables: Optional[Sequence[str]] = None,
|
|
110
|
+
remote_tables: Optional[Sequence[str]] = None,
|
|
111
|
+
cache_tables: Optional[Sequence[str]] = None,
|
|
112
|
+
local_db_path: Optional[str] = None,
|
|
113
|
+
) -> PyronitesClient:
|
|
114
|
+
config = load_config(
|
|
115
|
+
url=url, key=key, timeout=timeout,
|
|
116
|
+
local_tables=local_tables,
|
|
117
|
+
remote_tables=remote_tables,
|
|
118
|
+
cache_tables=cache_tables,
|
|
119
|
+
local_db_path=local_db_path,
|
|
120
|
+
)
|
|
121
|
+
return PyronitesClient(config)
|
pyronites/config.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Configuration resolution for the pyronites client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import FrozenSet, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from pyronites.errors import ApiError
|
|
10
|
+
|
|
11
|
+
POLICY_REMOTE = "remote"
|
|
12
|
+
POLICY_LOCAL = "local"
|
|
13
|
+
POLICY_CACHE = "cache"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ClientConfig:
|
|
18
|
+
"""Resolved client configuration."""
|
|
19
|
+
|
|
20
|
+
url: str
|
|
21
|
+
key: Optional[str]
|
|
22
|
+
timeout: float = 30.0
|
|
23
|
+
local_tables: FrozenSet[str] = field(default_factory=frozenset)
|
|
24
|
+
remote_tables: FrozenSet[str] = field(default_factory=frozenset)
|
|
25
|
+
cache_tables: FrozenSet[str] = field(default_factory=frozenset)
|
|
26
|
+
local_db_path: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
def table_policy(self, name: str) -> str:
|
|
29
|
+
"""Return policy for a table: local, cache, or remote (default).
|
|
30
|
+
|
|
31
|
+
Priority: local_tables > cache_tables > remote (default).
|
|
32
|
+
"""
|
|
33
|
+
if name in self.local_tables:
|
|
34
|
+
return POLICY_LOCAL
|
|
35
|
+
if name in self.cache_tables:
|
|
36
|
+
return POLICY_CACHE
|
|
37
|
+
return POLICY_REMOTE
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_url(url: str) -> str:
|
|
41
|
+
return url.rstrip("/")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _to_frozenset(names: Optional[Sequence[str]]) -> FrozenSet[str]:
|
|
45
|
+
if not names:
|
|
46
|
+
return frozenset()
|
|
47
|
+
return frozenset(str(n) for n in names)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_config(
|
|
51
|
+
url: Optional[str] = None,
|
|
52
|
+
key: Optional[str] = None,
|
|
53
|
+
timeout: float = 30.0,
|
|
54
|
+
local_tables: Optional[Sequence[str]] = None,
|
|
55
|
+
remote_tables: Optional[Sequence[str]] = None,
|
|
56
|
+
cache_tables: Optional[Sequence[str]] = None,
|
|
57
|
+
local_db_path: Optional[str] = None,
|
|
58
|
+
) -> ClientConfig:
|
|
59
|
+
resolved_url = url if url is not None else os.environ.get("PYRONITES_URL")
|
|
60
|
+
resolved_key = key if key is not None else os.environ.get("PYRONITES_KEY")
|
|
61
|
+
|
|
62
|
+
if not resolved_url:
|
|
63
|
+
raise ApiError(
|
|
64
|
+
"Missing backend URL. Pass url=... to create_client() "
|
|
65
|
+
"or set the PYRONITES_URL environment variable."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
path = local_db_path
|
|
69
|
+
if path is None:
|
|
70
|
+
path = os.environ.get("PYRONITES_LOCAL_DB")
|
|
71
|
+
|
|
72
|
+
return ClientConfig(
|
|
73
|
+
url=_normalize_url(resolved_url),
|
|
74
|
+
key=resolved_key or None,
|
|
75
|
+
timeout=timeout,
|
|
76
|
+
local_tables=_to_frozenset(local_tables),
|
|
77
|
+
remote_tables=_to_frozenset(remote_tables),
|
|
78
|
+
cache_tables=_to_frozenset(cache_tables),
|
|
79
|
+
local_db_path=path,
|
|
80
|
+
)
|
pyronites/errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Typed exceptions raised by the pyronites client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApiError(Exception):
|
|
9
|
+
"""Base error for non-2xx API responses and client-side validation failures."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
status_code: Optional[int] = None,
|
|
16
|
+
code: Optional[str] = None,
|
|
17
|
+
body: Any = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.message = message
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
self.code = code
|
|
23
|
+
self.body = body
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
parts = []
|
|
27
|
+
if self.status_code is not None:
|
|
28
|
+
parts.append(str(self.status_code))
|
|
29
|
+
if self.code:
|
|
30
|
+
parts.append(self.code)
|
|
31
|
+
parts.append(self.message)
|
|
32
|
+
return " ".join(parts)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthError(ApiError):
|
|
36
|
+
"""Raised for 401 / 403 responses."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NotFoundError(ApiError):
|
|
40
|
+
"""Raised for 404 responses."""
|
pyronites/http.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""HTTP transport layer for the pyronites client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, Optional, Set
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from pyronites.config import ClientConfig
|
|
11
|
+
from pyronites.errors import ApiError, AuthError, NotFoundError
|
|
12
|
+
|
|
13
|
+
_RETRYABLE_STATUS: Set[int] = {502, 503, 504}
|
|
14
|
+
_DEFAULT_MAX_RETRIES = 2
|
|
15
|
+
_DEFAULT_BACKOFF_BASE = 0.3
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HttpTransport:
|
|
19
|
+
"""Thin wrapper around ``httpx.Client`` with auth headers, retries, and error mapping."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
config: ClientConfig,
|
|
24
|
+
*,
|
|
25
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
26
|
+
backoff_base: float = _DEFAULT_BACKOFF_BASE,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._config = config
|
|
29
|
+
self._max_retries = max(0, max_retries)
|
|
30
|
+
self._backoff_base = backoff_base
|
|
31
|
+
headers: Dict[str, str] = {}
|
|
32
|
+
if config.key:
|
|
33
|
+
headers["Authorization"] = f"Bearer {config.key}"
|
|
34
|
+
self._client = httpx.Client(
|
|
35
|
+
base_url=config.url,
|
|
36
|
+
headers=headers,
|
|
37
|
+
timeout=config.timeout,
|
|
38
|
+
follow_redirects=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def config(self) -> ClientConfig:
|
|
43
|
+
return self._config
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
self._client.close()
|
|
47
|
+
|
|
48
|
+
def request(
|
|
49
|
+
self,
|
|
50
|
+
method: str,
|
|
51
|
+
path: str,
|
|
52
|
+
*,
|
|
53
|
+
json: Any = None,
|
|
54
|
+
params: Optional[Dict[str, Any]] = None,
|
|
55
|
+
files: Any = None,
|
|
56
|
+
data: Any = None,
|
|
57
|
+
) -> Any:
|
|
58
|
+
"""Perform a request with conservative retries on transient failures."""
|
|
59
|
+
if not path.startswith("/"):
|
|
60
|
+
path = "/" + path
|
|
61
|
+
|
|
62
|
+
last_exc: Optional[Exception] = None
|
|
63
|
+
attempts = self._max_retries + 1
|
|
64
|
+
|
|
65
|
+
for attempt in range(attempts):
|
|
66
|
+
try:
|
|
67
|
+
response = self._client.request(
|
|
68
|
+
method, path, json=json, params=params, files=files, data=data,
|
|
69
|
+
)
|
|
70
|
+
except httpx.TimeoutException as exc:
|
|
71
|
+
last_exc = ApiError(f"Request timed out after {self._config.timeout}s")
|
|
72
|
+
last_exc.__cause__ = exc
|
|
73
|
+
if attempt < self._max_retries:
|
|
74
|
+
time.sleep(self._backoff_base * (2 ** attempt))
|
|
75
|
+
continue
|
|
76
|
+
raise last_exc from exc
|
|
77
|
+
except httpx.RequestError as exc:
|
|
78
|
+
last_exc = ApiError(f"Network error: {exc}")
|
|
79
|
+
last_exc.__cause__ = exc
|
|
80
|
+
if attempt < self._max_retries:
|
|
81
|
+
time.sleep(self._backoff_base * (2 ** attempt))
|
|
82
|
+
continue
|
|
83
|
+
raise last_exc from exc
|
|
84
|
+
|
|
85
|
+
if response.status_code in _RETRYABLE_STATUS and attempt < self._max_retries:
|
|
86
|
+
time.sleep(self._backoff_base * (2 ** attempt))
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
return self._handle_response(response)
|
|
90
|
+
|
|
91
|
+
if last_exc is not None:
|
|
92
|
+
raise last_exc
|
|
93
|
+
raise ApiError("Request failed after retries")
|
|
94
|
+
|
|
95
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
96
|
+
if 200 <= response.status_code < 300:
|
|
97
|
+
if not response.content:
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
return response.json()
|
|
101
|
+
except ValueError:
|
|
102
|
+
return response.content
|
|
103
|
+
|
|
104
|
+
code: Optional[str] = None
|
|
105
|
+
message = response.text or f"HTTP {response.status_code}"
|
|
106
|
+
body: Any = None
|
|
107
|
+
try:
|
|
108
|
+
body = response.json()
|
|
109
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
110
|
+
if isinstance(detail, dict):
|
|
111
|
+
code = detail.get("code")
|
|
112
|
+
message = detail.get("message") or message
|
|
113
|
+
elif isinstance(body, dict):
|
|
114
|
+
code = body.get("code")
|
|
115
|
+
message = body.get("message") or message
|
|
116
|
+
except ValueError:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
status = response.status_code
|
|
120
|
+
if status in (401, 403):
|
|
121
|
+
raise AuthError(message, status_code=status, code=code, body=body)
|
|
122
|
+
if status == 404:
|
|
123
|
+
raise NotFoundError(message, status_code=status, code=code, body=body)
|
|
124
|
+
raise ApiError(message, status_code=status, code=code, body=body)
|
pyronites/local.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Local SQLite store for local-only and cache tables (Phase P3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from pyronites.errors import ApiError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LocalStore:
|
|
15
|
+
"""Minimal JSON-row store backed by SQLite."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, path: str) -> None:
|
|
18
|
+
self._path = path
|
|
19
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
self._conn = sqlite3.connect(path)
|
|
21
|
+
self._conn.row_factory = sqlite3.Row
|
|
22
|
+
|
|
23
|
+
def close(self) -> None:
|
|
24
|
+
self._conn.close()
|
|
25
|
+
|
|
26
|
+
def _ensure_table(self, name: str) -> None:
|
|
27
|
+
if not name.replace("_", "").isalnum():
|
|
28
|
+
raise ApiError(f"Invalid local table name: {name!r}")
|
|
29
|
+
self._conn.execute(
|
|
30
|
+
f'CREATE TABLE IF NOT EXISTS "{name}" ('
|
|
31
|
+
"id TEXT PRIMARY KEY, "
|
|
32
|
+
"data TEXT NOT NULL"
|
|
33
|
+
")"
|
|
34
|
+
)
|
|
35
|
+
self._conn.commit()
|
|
36
|
+
|
|
37
|
+
def select(
|
|
38
|
+
self,
|
|
39
|
+
table: str,
|
|
40
|
+
*,
|
|
41
|
+
filter_column: Optional[str] = None,
|
|
42
|
+
filter_value: Optional[str] = None,
|
|
43
|
+
limit: Optional[int] = None,
|
|
44
|
+
offset: Optional[int] = None,
|
|
45
|
+
) -> List[Dict[str, Any]]:
|
|
46
|
+
self._ensure_table(table)
|
|
47
|
+
cur = self._conn.execute(f'SELECT id, data FROM "{table}"')
|
|
48
|
+
rows: List[Dict[str, Any]] = []
|
|
49
|
+
for r in cur.fetchall():
|
|
50
|
+
row = json.loads(r["data"])
|
|
51
|
+
if "id" not in row:
|
|
52
|
+
row["id"] = r["id"]
|
|
53
|
+
if filter_column is not None and filter_value is not None:
|
|
54
|
+
if str(row.get(filter_column)) != str(filter_value):
|
|
55
|
+
continue
|
|
56
|
+
rows.append(row)
|
|
57
|
+
if offset:
|
|
58
|
+
rows = rows[offset:]
|
|
59
|
+
if limit is not None:
|
|
60
|
+
rows = rows[:limit]
|
|
61
|
+
return rows
|
|
62
|
+
|
|
63
|
+
def get(self, table: str, row_id: str) -> Optional[Dict[str, Any]]:
|
|
64
|
+
self._ensure_table(table)
|
|
65
|
+
cur = self._conn.execute(
|
|
66
|
+
f'SELECT data FROM "{table}" WHERE id = ?', (row_id,)
|
|
67
|
+
)
|
|
68
|
+
r = cur.fetchone()
|
|
69
|
+
if not r:
|
|
70
|
+
return None
|
|
71
|
+
row = json.loads(r["data"])
|
|
72
|
+
if "id" not in row:
|
|
73
|
+
row["id"] = row_id
|
|
74
|
+
return row
|
|
75
|
+
|
|
76
|
+
def insert(self, table: str, row: Dict[str, Any]) -> Dict[str, Any]:
|
|
77
|
+
self._ensure_table(table)
|
|
78
|
+
data = dict(row)
|
|
79
|
+
row_id = data.get("id")
|
|
80
|
+
if not row_id:
|
|
81
|
+
row_id = str(uuid.uuid4())
|
|
82
|
+
data["id"] = row_id
|
|
83
|
+
else:
|
|
84
|
+
row_id = str(row_id)
|
|
85
|
+
data["id"] = row_id
|
|
86
|
+
self._conn.execute(
|
|
87
|
+
f'INSERT OR REPLACE INTO "{table}" (id, data) VALUES (?, ?)',
|
|
88
|
+
(row_id, json.dumps(data)),
|
|
89
|
+
)
|
|
90
|
+
self._conn.commit()
|
|
91
|
+
return data
|
|
92
|
+
|
|
93
|
+
def update(self, table: str, row_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
94
|
+
existing = self.get(table, row_id)
|
|
95
|
+
if existing is None:
|
|
96
|
+
raise ApiError("Row not found", status_code=404, code="not_found")
|
|
97
|
+
merged = {**existing, **data, "id": row_id}
|
|
98
|
+
self._conn.execute(
|
|
99
|
+
f'UPDATE "{table}" SET data = ? WHERE id = ?',
|
|
100
|
+
(json.dumps(merged), row_id),
|
|
101
|
+
)
|
|
102
|
+
self._conn.commit()
|
|
103
|
+
return merged
|
|
104
|
+
|
|
105
|
+
def delete(self, table: str, row_id: str) -> Dict[str, Any]:
|
|
106
|
+
existing = self.get(table, row_id)
|
|
107
|
+
if existing is None:
|
|
108
|
+
raise ApiError("Row not found", status_code=404, code="not_found")
|
|
109
|
+
self._conn.execute(f'DELETE FROM "{table}" WHERE id = ?', (row_id,))
|
|
110
|
+
self._conn.commit()
|
|
111
|
+
return {"message": "Row deleted"}
|
|
112
|
+
|
|
113
|
+
def replace_all(self, table: str, rows: List[Dict[str, Any]]) -> int:
|
|
114
|
+
self._ensure_table(table)
|
|
115
|
+
self._conn.execute(f'DELETE FROM "{table}"')
|
|
116
|
+
for row in rows:
|
|
117
|
+
data = dict(row)
|
|
118
|
+
row_id = str(data.get("id") or uuid.uuid4())
|
|
119
|
+
data["id"] = row_id
|
|
120
|
+
self._conn.execute(
|
|
121
|
+
f'INSERT INTO "{table}" (id, data) VALUES (?, ?)',
|
|
122
|
+
(row_id, json.dumps(data)),
|
|
123
|
+
)
|
|
124
|
+
self._conn.commit()
|
|
125
|
+
return len(rows)
|
|
126
|
+
|
|
127
|
+
def all_rows(self, table: str) -> List[Dict[str, Any]]:
|
|
128
|
+
return self.select(table)
|
pyronites/storage.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Storage methods for the pyronites client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, BinaryIO, Dict, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
from pyronites.errors import ApiError
|
|
10
|
+
from pyronites.http import HttpTransport
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StorageClient:
|
|
14
|
+
"""Client for ``/storage/*`` routes."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, transport: HttpTransport) -> None:
|
|
17
|
+
self._http = transport
|
|
18
|
+
|
|
19
|
+
def upload(
|
|
20
|
+
self,
|
|
21
|
+
source: Union[str, Path, BinaryIO, bytes],
|
|
22
|
+
*,
|
|
23
|
+
filename: Optional[str] = None,
|
|
24
|
+
content_type: Optional[str] = None,
|
|
25
|
+
) -> Dict[str, Any]:
|
|
26
|
+
"""Upload a file.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
source:
|
|
31
|
+
Path string, ``Path``, open binary file, or raw ``bytes``.
|
|
32
|
+
filename:
|
|
33
|
+
Required when ``source`` is bytes or a file object without a name.
|
|
34
|
+
content_type:
|
|
35
|
+
Optional MIME type; guessed from filename when omitted.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
dict
|
|
40
|
+
Metadata matching backend shape:
|
|
41
|
+
``id``, ``original_filename``, ``content_type``, ``size_bytes``,
|
|
42
|
+
``uploaded_at``, ``project_id``.
|
|
43
|
+
"""
|
|
44
|
+
name, data, ctype = self._resolve_upload(source, filename, content_type)
|
|
45
|
+
files = {"file": (name, data, ctype)}
|
|
46
|
+
result = self._http.request("POST", "/storage/upload", files=files)
|
|
47
|
+
if not isinstance(result, dict):
|
|
48
|
+
raise ApiError("Unexpected response from storage upload")
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
def list(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
prefix: Optional[str] = None,
|
|
55
|
+
limit: int = 50,
|
|
56
|
+
offset: int = 0,
|
|
57
|
+
) -> List[Dict[str, Any]]:
|
|
58
|
+
"""List stored files (newest first).
|
|
59
|
+
|
|
60
|
+
Maps to ``GET /storage``.
|
|
61
|
+
"""
|
|
62
|
+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
63
|
+
if prefix is not None:
|
|
64
|
+
params["prefix"] = prefix
|
|
65
|
+
result = self._http.request("GET", "/storage", params=params)
|
|
66
|
+
return result if isinstance(result, list) else []
|
|
67
|
+
|
|
68
|
+
def get(self, file_id: str) -> Dict[str, Any]:
|
|
69
|
+
"""Return metadata for a file.
|
|
70
|
+
|
|
71
|
+
Maps to ``GET /storage/{id}``.
|
|
72
|
+
"""
|
|
73
|
+
result = self._http.request("GET", f"/storage/{file_id}")
|
|
74
|
+
if not isinstance(result, dict):
|
|
75
|
+
raise ApiError("Unexpected response from storage get")
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
def download(
|
|
79
|
+
self,
|
|
80
|
+
file_id: str,
|
|
81
|
+
*,
|
|
82
|
+
path: Optional[Union[str, Path]] = None,
|
|
83
|
+
) -> bytes:
|
|
84
|
+
"""Download file bytes.
|
|
85
|
+
|
|
86
|
+
Maps to ``GET /storage/{id}/download``.
|
|
87
|
+
|
|
88
|
+
If ``path`` is given, also writes the bytes to that filesystem path.
|
|
89
|
+
"""
|
|
90
|
+
data = self._http.request("GET", f"/storage/{file_id}/download")
|
|
91
|
+
if isinstance(data, dict):
|
|
92
|
+
raise ApiError("Unexpected JSON response from storage download")
|
|
93
|
+
if not isinstance(data, (bytes, bytearray)):
|
|
94
|
+
data = bytes(data) if data is not None else b""
|
|
95
|
+
else:
|
|
96
|
+
data = bytes(data)
|
|
97
|
+
|
|
98
|
+
if path is not None:
|
|
99
|
+
Path(path).write_bytes(data)
|
|
100
|
+
return data
|
|
101
|
+
|
|
102
|
+
def remove(self, file_id: str) -> Dict[str, Any]:
|
|
103
|
+
"""Delete a file.
|
|
104
|
+
|
|
105
|
+
Maps to ``DELETE /storage/{id}``.
|
|
106
|
+
"""
|
|
107
|
+
result = self._http.request("DELETE", f"/storage/{file_id}")
|
|
108
|
+
return result if isinstance(result, dict) else {"message": "File deleted successfully"}
|
|
109
|
+
|
|
110
|
+
def _resolve_upload(
|
|
111
|
+
self,
|
|
112
|
+
source: Union[str, Path, BinaryIO, bytes],
|
|
113
|
+
filename: Optional[str],
|
|
114
|
+
content_type: Optional[str],
|
|
115
|
+
) -> tuple[str, bytes, str]:
|
|
116
|
+
if isinstance(source, (str, Path)):
|
|
117
|
+
p = Path(source)
|
|
118
|
+
if not p.is_file():
|
|
119
|
+
raise ApiError(f"File not found: {p}")
|
|
120
|
+
name = filename or p.name
|
|
121
|
+
data = p.read_bytes()
|
|
122
|
+
elif isinstance(source, (bytes, bytearray)):
|
|
123
|
+
if not filename:
|
|
124
|
+
raise ApiError("filename is required when uploading raw bytes")
|
|
125
|
+
name = filename
|
|
126
|
+
data = bytes(source)
|
|
127
|
+
else:
|
|
128
|
+
name = filename
|
|
129
|
+
if not name:
|
|
130
|
+
name = getattr(source, "name", None)
|
|
131
|
+
if name:
|
|
132
|
+
name = Path(name).name
|
|
133
|
+
if not name:
|
|
134
|
+
raise ApiError("filename is required when uploading a file object without a name")
|
|
135
|
+
data = source.read()
|
|
136
|
+
if isinstance(data, str):
|
|
137
|
+
data = data.encode("utf-8")
|
|
138
|
+
|
|
139
|
+
ctype = content_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
140
|
+
return name, data, ctype
|
pyronites/table.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Table (database) methods for the pyronites client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from pyronites.config import POLICY_CACHE, POLICY_LOCAL, POLICY_REMOTE
|
|
8
|
+
from pyronites.errors import ApiError, NotFoundError
|
|
9
|
+
from pyronites.http import HttpTransport
|
|
10
|
+
from pyronites.local import LocalStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TableQuery:
|
|
14
|
+
"""Fluent builder for table operations on a single table."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
transport: HttpTransport,
|
|
19
|
+
name: str,
|
|
20
|
+
*,
|
|
21
|
+
policy: str = POLICY_REMOTE,
|
|
22
|
+
local: Optional[LocalStore] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._http = transport
|
|
25
|
+
self._name = name
|
|
26
|
+
self._policy = policy
|
|
27
|
+
self._local = local
|
|
28
|
+
self._filter_column: Optional[str] = None
|
|
29
|
+
self._filter_value: Optional[str] = None
|
|
30
|
+
self._limit: Optional[int] = None
|
|
31
|
+
self._offset: Optional[int] = None
|
|
32
|
+
self._update_data: Optional[Dict[str, Any]] = None
|
|
33
|
+
self._op: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
def eq(self, column: str, value: Any) -> Any:
|
|
36
|
+
"""Add a single equality filter. On update/delete, executes immediately."""
|
|
37
|
+
self._filter_column = column
|
|
38
|
+
self._filter_value = str(value) if value is not None else None
|
|
39
|
+
if self._op in ("update", "delete"):
|
|
40
|
+
return self._dispatch()
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def limit(self, n: int) -> "TableQuery":
|
|
44
|
+
self._limit = n
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def offset(self, n: int) -> "TableQuery":
|
|
48
|
+
self._offset = n
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def select(self) -> "TableQuery":
|
|
52
|
+
self._op = "select"
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def insert(
|
|
56
|
+
self, row: Union[Dict[str, Any], List[Dict[str, Any]]]
|
|
57
|
+
) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
|
58
|
+
if isinstance(row, list):
|
|
59
|
+
return [self._insert_one(r) for r in row]
|
|
60
|
+
return self._insert_one(row)
|
|
61
|
+
|
|
62
|
+
def update(self, data: Dict[str, Any]) -> "TableQuery":
|
|
63
|
+
if not data:
|
|
64
|
+
raise ApiError("update() requires a non-empty data dict")
|
|
65
|
+
self._op = "update"
|
|
66
|
+
self._update_data = data
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def delete(self) -> "TableQuery":
|
|
70
|
+
self._op = "delete"
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def single(self) -> Dict[str, Any]:
|
|
74
|
+
rows = self._execute_select()
|
|
75
|
+
if not rows:
|
|
76
|
+
raise ApiError("No rows returned for .single()", code="not_found")
|
|
77
|
+
if len(rows) > 1:
|
|
78
|
+
raise ApiError("Multiple rows returned for .single()")
|
|
79
|
+
return rows[0]
|
|
80
|
+
|
|
81
|
+
def get(self, row_id: str) -> Dict[str, Any]:
|
|
82
|
+
if self._policy == POLICY_LOCAL:
|
|
83
|
+
assert self._local is not None
|
|
84
|
+
row = self._local.get(self._name, row_id)
|
|
85
|
+
if row is None:
|
|
86
|
+
raise NotFoundError("Row not found", status_code=404, code="not_found")
|
|
87
|
+
return row
|
|
88
|
+
|
|
89
|
+
if self._policy == POLICY_CACHE and self._local is not None:
|
|
90
|
+
row = self._local.get(self._name, row_id)
|
|
91
|
+
if row is not None:
|
|
92
|
+
return row
|
|
93
|
+
|
|
94
|
+
result = self._http.request("GET", f"/tables/{self._name}/{row_id}")
|
|
95
|
+
if self._policy == POLICY_CACHE and self._local is not None and isinstance(result, dict):
|
|
96
|
+
self._local.insert(self._name, result)
|
|
97
|
+
return result # type: ignore[return-value]
|
|
98
|
+
|
|
99
|
+
def schema(self) -> List[Dict[str, Any]]:
|
|
100
|
+
"""Return column definitions. Maps to ``GET /tables/{name}/schema``."""
|
|
101
|
+
result = self._http.request("GET", f"/tables/{self._name}/schema")
|
|
102
|
+
return result if isinstance(result, list) else []
|
|
103
|
+
|
|
104
|
+
def execute(self) -> Any:
|
|
105
|
+
return self._dispatch()
|
|
106
|
+
|
|
107
|
+
def __iter__(self):
|
|
108
|
+
return iter(self._execute_select())
|
|
109
|
+
|
|
110
|
+
def __call__(self) -> Any:
|
|
111
|
+
return self._dispatch()
|
|
112
|
+
|
|
113
|
+
def _require_local(self) -> LocalStore:
|
|
114
|
+
if self._local is None:
|
|
115
|
+
raise ApiError(
|
|
116
|
+
"Local store is not configured. Pass local_db_path=... to create_client()."
|
|
117
|
+
)
|
|
118
|
+
return self._local
|
|
119
|
+
|
|
120
|
+
def _insert_one(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
|
121
|
+
if self._policy == POLICY_LOCAL:
|
|
122
|
+
return self._require_local().insert(self._name, row)
|
|
123
|
+
|
|
124
|
+
result = self._http.request(
|
|
125
|
+
"POST", f"/tables/{self._name}", json=row,
|
|
126
|
+
) # type: ignore[return-value]
|
|
127
|
+
if self._policy == POLICY_CACHE and self._local is not None and isinstance(result, dict):
|
|
128
|
+
self._local.insert(self._name, result)
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
def _execute_select(self) -> List[Dict[str, Any]]:
|
|
132
|
+
if self._policy == POLICY_LOCAL:
|
|
133
|
+
return self._require_local().select(
|
|
134
|
+
self._name,
|
|
135
|
+
filter_column=self._filter_column,
|
|
136
|
+
filter_value=self._filter_value,
|
|
137
|
+
limit=self._limit,
|
|
138
|
+
offset=self._offset,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if self._policy == POLICY_CACHE and self._local is not None:
|
|
142
|
+
local_rows = self._local.select(
|
|
143
|
+
self._name,
|
|
144
|
+
filter_column=self._filter_column,
|
|
145
|
+
filter_value=self._filter_value,
|
|
146
|
+
limit=self._limit,
|
|
147
|
+
offset=self._offset,
|
|
148
|
+
)
|
|
149
|
+
if local_rows:
|
|
150
|
+
return local_rows
|
|
151
|
+
remote_rows = self._select_remote()
|
|
152
|
+
if remote_rows:
|
|
153
|
+
self._local.replace_all(self._name, remote_rows)
|
|
154
|
+
return remote_rows
|
|
155
|
+
|
|
156
|
+
return self._select_remote()
|
|
157
|
+
|
|
158
|
+
def _select_remote(self) -> List[Dict[str, Any]]:
|
|
159
|
+
params: Dict[str, Any] = {}
|
|
160
|
+
if self._limit is not None:
|
|
161
|
+
params["limit"] = self._limit
|
|
162
|
+
if self._offset is not None:
|
|
163
|
+
params["offset"] = self._offset
|
|
164
|
+
if self._filter_column is not None and self._filter_value is not None:
|
|
165
|
+
params["filter_column"] = self._filter_column
|
|
166
|
+
params["filter_value"] = self._filter_value
|
|
167
|
+
result = self._http.request(
|
|
168
|
+
"GET", f"/tables/{self._name}", params=params or None,
|
|
169
|
+
)
|
|
170
|
+
return result if isinstance(result, list) else []
|
|
171
|
+
|
|
172
|
+
def _execute_update(self) -> Dict[str, Any]:
|
|
173
|
+
if self._filter_column != "id" or self._filter_value is None:
|
|
174
|
+
raise ApiError(
|
|
175
|
+
'update() currently requires .eq("id", value) '
|
|
176
|
+
"because the backend only supports update-by-id."
|
|
177
|
+
)
|
|
178
|
+
assert self._update_data is not None
|
|
179
|
+
row_id = self._filter_value
|
|
180
|
+
|
|
181
|
+
if self._policy == POLICY_LOCAL:
|
|
182
|
+
return self._require_local().update(self._name, row_id, self._update_data)
|
|
183
|
+
|
|
184
|
+
result = self._http.request(
|
|
185
|
+
"PATCH", f"/tables/{self._name}/{row_id}", json=self._update_data,
|
|
186
|
+
) # type: ignore[return-value]
|
|
187
|
+
if self._policy == POLICY_CACHE and self._local is not None and isinstance(result, dict):
|
|
188
|
+
self._local.insert(self._name, result)
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
def _execute_delete(self) -> Any:
|
|
192
|
+
if self._filter_column != "id" or self._filter_value is None:
|
|
193
|
+
raise ApiError(
|
|
194
|
+
'delete() currently requires .eq("id", value) '
|
|
195
|
+
"because the backend only supports delete-by-id."
|
|
196
|
+
)
|
|
197
|
+
row_id = self._filter_value
|
|
198
|
+
|
|
199
|
+
if self._policy == POLICY_LOCAL:
|
|
200
|
+
return self._require_local().delete(self._name, row_id)
|
|
201
|
+
|
|
202
|
+
result = self._http.request("DELETE", f"/tables/{self._name}/{row_id}")
|
|
203
|
+
if self._policy == POLICY_CACHE and self._local is not None:
|
|
204
|
+
try:
|
|
205
|
+
self._local.delete(self._name, row_id)
|
|
206
|
+
except ApiError:
|
|
207
|
+
pass
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
def _dispatch(self) -> Any:
|
|
211
|
+
if self._op == "select":
|
|
212
|
+
return self._execute_select()
|
|
213
|
+
if self._op == "update":
|
|
214
|
+
return self._execute_update()
|
|
215
|
+
if self._op == "delete":
|
|
216
|
+
return self._execute_delete()
|
|
217
|
+
raise ApiError("No operation specified. Call select(), update(), or delete() first.")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyronites
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the Pyronites (PyroCore) backend
|
|
5
|
+
Project-URL: Homepage, https://github.com/Ashutosh3021/Pyronites
|
|
6
|
+
Project-URL: Repository, https://github.com/Ashutosh3021/Pyronites
|
|
7
|
+
Project-URL: Documentation, https://github.com/Ashutosh3021/Pyronites/tree/main/pyronites/docs
|
|
8
|
+
Author: Ashutosh
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: backend,client,pyrocore,pyronites,sqlite,supabase
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx>=0.27.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# pyronites
|
|
29
|
+
|
|
30
|
+
Official Python client for the [Pyronites](https://github.com/Ashutosh3021/Pyronites) backend.
|
|
31
|
+
|
|
32
|
+
Supabase-style API, zero mandatory paid services — point it at your own backend URL + API key.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Development (from this repo)
|
|
38
|
+
pip install -e ./pyronites
|
|
39
|
+
|
|
40
|
+
# From PyPI (after publish)
|
|
41
|
+
pip install pyronites
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Requires **Python 3.10+**.
|
|
45
|
+
|
|
46
|
+
## 5-minute setup
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export PYRONITES_URL="https://your-backend.example.com"
|
|
50
|
+
export PYRONITES_KEY="pyro_live_..."
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from pyronites import create_client
|
|
55
|
+
|
|
56
|
+
client = create_client()
|
|
57
|
+
|
|
58
|
+
# Auth (optional)
|
|
59
|
+
client.auth.sign_in("user@example.com", "password")
|
|
60
|
+
print(client.auth.user())
|
|
61
|
+
|
|
62
|
+
# Tables
|
|
63
|
+
note = client.table("notes").insert({"title": "Hello", "body": "World"})
|
|
64
|
+
for row in client.table("notes").select().limit(10):
|
|
65
|
+
print(row)
|
|
66
|
+
client.table("notes").update({"title": "Updated"}).eq("id", note["id"])
|
|
67
|
+
client.table("notes").delete().eq("id", note["id"])
|
|
68
|
+
|
|
69
|
+
# Storage
|
|
70
|
+
meta = client.storage.upload("photo.jpg")
|
|
71
|
+
data = client.storage.download(meta["id"])
|
|
72
|
+
client.storage.remove(meta["id"])
|
|
73
|
+
|
|
74
|
+
# Local-only / cache tables (optional)
|
|
75
|
+
client = create_client(
|
|
76
|
+
local_tables=["settings"],
|
|
77
|
+
cache_tables=["catalog"],
|
|
78
|
+
local_db_path="./.pyronites_local.db",
|
|
79
|
+
)
|
|
80
|
+
client.table("settings").insert({"theme": "dark"})
|
|
81
|
+
client.pull("catalog")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Config
|
|
85
|
+
|
|
86
|
+
| Source | Variables / args |
|
|
87
|
+
|--------|------------------|
|
|
88
|
+
| Env | `PYRONITES_URL`, `PYRONITES_KEY`, optional `PYRONITES_LOCAL_DB` |
|
|
89
|
+
| Constructor | `create_client(url=..., key=..., timeout=30, local_tables=..., cache_tables=..., remote_tables=..., local_db_path=...)` |
|
|
90
|
+
|
|
91
|
+
Args override env. Missing URL raises a clear error at create time.
|
|
92
|
+
|
|
93
|
+
**Security:** never put a powerful service key in a public frontend.
|
|
94
|
+
|
|
95
|
+
## Public API
|
|
96
|
+
|
|
97
|
+
See [`docs/syntax.md`](docs/syntax.md).
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
cd pyronites
|
|
103
|
+
pip install -e ".[dev]"
|
|
104
|
+
pytest
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyronites/__init__.py,sha256=t03obFUXOOqA7js4Bjca17CWxUNvXriE_ke_jrPVfFw,324
|
|
2
|
+
pyronites/auth.py,sha256=fnXWWWhMv4BKjTIzEaSk9p7S2H-J0-mltfDlDTUD_mo,1404
|
|
3
|
+
pyronites/client.py,sha256=m6vBrURSOWAXfHtc1kqAHOO4Nsqqr87PW8dCiUWaKfo,4591
|
|
4
|
+
pyronites/config.py,sha256=GwjIU_xQW8sloNqWjg93LHR81O-4bQdJgIri0sDhkR0,2452
|
|
5
|
+
pyronites/errors.py,sha256=1k1gEU79ctDSxDTrt4qoq3kkAQ0omAkjDZTD0RKaLhg,1021
|
|
6
|
+
pyronites/http.py,sha256=PyiwwC_LqRiTQerefCQfvgcXgpfOLcggvw1KW5wl9CY,4288
|
|
7
|
+
pyronites/local.py,sha256=imp5zykRzDwlhjQgyJt36YnP4CGgtdju_69lsbDhXBE,4360
|
|
8
|
+
pyronites/storage.py,sha256=smd0bdkR2gBa2lgXHwRneVf5vN-qMlrit_B-OXqJeHY,4762
|
|
9
|
+
pyronites/table.py,sha256=zSAU8tBEzuugHQhHJZZRZwFZyiEm1qK-tWuop_o50F8,8169
|
|
10
|
+
pyronites-0.1.0.dist-info/METADATA,sha256=CfNkoUUf3hVpq8J2DW3RcKB9JF1SR4P53mbUQMISRVo,2982
|
|
11
|
+
pyronites-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
pyronites-0.1.0.dist-info/RECORD,,
|