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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any