awaredb 0.1.1__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.
- awaredb/__init__.py +20 -0
- awaredb/api.py +81 -0
- awaredb/exceptions.py +64 -0
- awaredb/mixins/__init__.py +15 -0
- awaredb/mixins/analyses.py +117 -0
- awaredb/mixins/auth.py +48 -0
- awaredb/mixins/data.py +83 -0
- awaredb/mixins/history.py +42 -0
- awaredb/mixins/http.py +122 -0
- awaredb/mixins/io.py +59 -0
- awaredb/py.typed +0 -0
- awaredb/typing.py +47 -0
- awaredb-0.1.1.dist-info/METADATA +320 -0
- awaredb-0.1.1.dist-info/RECORD +17 -0
- awaredb-0.1.1.dist-info/WHEEL +5 -0
- awaredb-0.1.1.dist-info/licenses/LICENSE +21 -0
- awaredb-0.1.1.dist-info/top_level.txt +1 -0
awaredb/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .api import AwareDBClient
|
|
2
|
+
from .exceptions import (
|
|
3
|
+
AuthError,
|
|
4
|
+
AwareDBError,
|
|
5
|
+
InvalidCommandError,
|
|
6
|
+
TransportError,
|
|
7
|
+
UnexpectedError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.1"
|
|
11
|
+
|
|
12
|
+
__all__ = (
|
|
13
|
+
"AwareDBClient",
|
|
14
|
+
"AwareDBError",
|
|
15
|
+
"AuthError",
|
|
16
|
+
"InvalidCommandError",
|
|
17
|
+
"TransportError",
|
|
18
|
+
"UnexpectedError",
|
|
19
|
+
"__version__",
|
|
20
|
+
)
|
awaredb/api.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""AwareDBClient — composes the mixins into the user-facing class."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import AuthError
|
|
4
|
+
from .mixins import (
|
|
5
|
+
AnalysesMixin,
|
|
6
|
+
AuthMixin,
|
|
7
|
+
DataMixin,
|
|
8
|
+
HistoryMixin,
|
|
9
|
+
HTTPClientMixin,
|
|
10
|
+
IOMixin,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = ("AwareDBClient",)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AwareDBClient(
|
|
17
|
+
HTTPClientMixin,
|
|
18
|
+
AuthMixin,
|
|
19
|
+
DataMixin,
|
|
20
|
+
AnalysesMixin,
|
|
21
|
+
HistoryMixin,
|
|
22
|
+
IOMixin,
|
|
23
|
+
):
|
|
24
|
+
"""Python client for an AwareDB instance.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
|
|
28
|
+
client = AwareDBClient(db="my_db", token="…")
|
|
29
|
+
client.check()
|
|
30
|
+
client.calculate(formula="car.power")
|
|
31
|
+
|
|
32
|
+
Or with username/password:
|
|
33
|
+
|
|
34
|
+
client = AwareDBClient(db="my_db", user="alice", password="…")
|
|
35
|
+
|
|
36
|
+
The client keeps a persistent HTTP connection pool. Call `close()` (or use
|
|
37
|
+
it as a context manager) when finished to release the socket.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
DEFAULT_HOST = "https://djina.com"
|
|
41
|
+
DEFAULT_TIMEOUT = 180.0
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
db: str,
|
|
46
|
+
token: str | None = None,
|
|
47
|
+
user: str | None = None,
|
|
48
|
+
password: str | None = None,
|
|
49
|
+
host: str | None = None,
|
|
50
|
+
timeout: float | None = None,
|
|
51
|
+
check_connection: bool = True,
|
|
52
|
+
):
|
|
53
|
+
"""Initialise the client.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
db: Database name or UUID.
|
|
57
|
+
token: API token. Required unless `user`/`password` are provided.
|
|
58
|
+
user / password: Used to fetch a fresh token at construction time.
|
|
59
|
+
host: AwareDB base URL. Defaults to ``https://djina.com``.
|
|
60
|
+
timeout: Request timeout in seconds. Defaults to 180.
|
|
61
|
+
check_connection: Probe `/check/` on init to fail fast on bad creds.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: Neither token nor user/password were provided.
|
|
65
|
+
AuthError: Server rejected the credentials, or the database is unreachable.
|
|
66
|
+
"""
|
|
67
|
+
if not token and not (user and password):
|
|
68
|
+
raise ValueError("Either `token` or both `user` and `password` are required.")
|
|
69
|
+
|
|
70
|
+
self.host = (host or self.DEFAULT_HOST).rstrip("/")
|
|
71
|
+
self.db = db
|
|
72
|
+
self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
|
|
73
|
+
self.token = token
|
|
74
|
+
|
|
75
|
+
if not self.token:
|
|
76
|
+
assert user
|
|
77
|
+
assert password
|
|
78
|
+
self.token = self._fetch_token(user=user, password=password)
|
|
79
|
+
|
|
80
|
+
if check_connection and not self.check():
|
|
81
|
+
raise AuthError(f"Unable to connect to AwareDB database `{db}` at {self.host}.")
|
awaredb/exceptions.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""AwareDB client exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AwareDBError(Exception):
|
|
5
|
+
"""Base exception for all AwareDB client errors."""
|
|
6
|
+
|
|
7
|
+
code = "awaredb_error"
|
|
8
|
+
default_message = "An error occurred while talking to AwareDB."
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str | None = None, extra_info: str | None = None):
|
|
11
|
+
super().__init__(message or self.default_message)
|
|
12
|
+
self.extra_info = extra_info
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UnexpectedError(AwareDBError):
|
|
16
|
+
"""Raised when something unanticipated breaks (e.g. a 2xx body that isn't JSON)."""
|
|
17
|
+
|
|
18
|
+
code = "unexpected_error"
|
|
19
|
+
default_message = "An unexpected error occurred."
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Transport
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TransportError(AwareDBError):
|
|
28
|
+
"""Raised when an HTTP request fails (non-2xx status, timeout, network error)."""
|
|
29
|
+
|
|
30
|
+
code = "transport_error"
|
|
31
|
+
default_message = "Transport error: HTTP request to AwareDB failed."
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
message: str | None = None,
|
|
36
|
+
extra_info: str | None = None,
|
|
37
|
+
status_code: int | None = None,
|
|
38
|
+
):
|
|
39
|
+
super().__init__(message, extra_info)
|
|
40
|
+
self.status_code = status_code
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Auth
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AuthError(AwareDBError):
|
|
49
|
+
"""Raised when authentication or authorisation fails."""
|
|
50
|
+
|
|
51
|
+
code = "auth_error"
|
|
52
|
+
default_message = "Authentication failed."
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Validation
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class InvalidCommandError(AwareDBError):
|
|
61
|
+
"""Raised when a request is rejected by the server with a 400 (bad payload, bad command)."""
|
|
62
|
+
|
|
63
|
+
code = "invalid_command"
|
|
64
|
+
default_message = "AwareDB rejected the request as invalid."
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .analyses import AnalysesMixin
|
|
2
|
+
from .auth import AuthMixin
|
|
3
|
+
from .data import DataMixin
|
|
4
|
+
from .history import HistoryMixin
|
|
5
|
+
from .http import HTTPClientMixin
|
|
6
|
+
from .io import IOMixin
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"AnalysesMixin",
|
|
10
|
+
"AuthMixin",
|
|
11
|
+
"DataMixin",
|
|
12
|
+
"HistoryMixin",
|
|
13
|
+
"HTTPClientMixin",
|
|
14
|
+
"IOMixin",
|
|
15
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Wrappers around the read-only analysis endpoints.
|
|
2
|
+
|
|
3
|
+
The server runs each analysis against a temporarily recalculated context
|
|
4
|
+
(`db.scenarios`, `db.impact`, …). The client just packages settings and
|
|
5
|
+
forwards them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..typing import Edit
|
|
11
|
+
|
|
12
|
+
__all__ = ("AnalysesMixin",)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AnalysesMixin:
|
|
16
|
+
"""Mirrors the server-side analysis commands."""
|
|
17
|
+
|
|
18
|
+
def scenarios(
|
|
19
|
+
self,
|
|
20
|
+
edits: list[Edit] | dict[str, Any],
|
|
21
|
+
states: list[str] | None = None,
|
|
22
|
+
) -> Any:
|
|
23
|
+
"""Apply path/value overrides, recalculate, return per-scenario results."""
|
|
24
|
+
return self._request("scenarios", {"edits": edits, "states": states or []})
|
|
25
|
+
|
|
26
|
+
def impact(
|
|
27
|
+
self,
|
|
28
|
+
edit: Edit | dict[str, Any] | None = None,
|
|
29
|
+
states: list[str] | None = None,
|
|
30
|
+
**extra: Any,
|
|
31
|
+
) -> Any:
|
|
32
|
+
"""Run impact analysis — vary an input across N steps and observe outputs."""
|
|
33
|
+
payload: dict[str, Any] = {"states": states or [], **extra}
|
|
34
|
+
if edit is not None:
|
|
35
|
+
payload["edit"] = edit
|
|
36
|
+
return self._request("impact", payload)
|
|
37
|
+
|
|
38
|
+
def projection(
|
|
39
|
+
self,
|
|
40
|
+
start_from: str | None = None,
|
|
41
|
+
unit: str = "day",
|
|
42
|
+
step: int = 1,
|
|
43
|
+
points: int = 10,
|
|
44
|
+
edits: list[Edit] | None = None,
|
|
45
|
+
states: list[str] | None = None,
|
|
46
|
+
) -> Any:
|
|
47
|
+
"""Walk forward in time. `edits` apply per-step changes."""
|
|
48
|
+
payload: dict[str, Any] = {
|
|
49
|
+
"unit": unit,
|
|
50
|
+
"step": step,
|
|
51
|
+
"points": points,
|
|
52
|
+
"edits": edits or [],
|
|
53
|
+
"states": states or [],
|
|
54
|
+
}
|
|
55
|
+
if start_from is not None:
|
|
56
|
+
payload["start_from"] = start_from
|
|
57
|
+
return self._request("projection", payload)
|
|
58
|
+
|
|
59
|
+
def goal_seek(
|
|
60
|
+
self,
|
|
61
|
+
target: dict[str, Any],
|
|
62
|
+
decision: dict[str, Any],
|
|
63
|
+
states: list[str] | None = None,
|
|
64
|
+
**extra: Any,
|
|
65
|
+
) -> Any:
|
|
66
|
+
"""Find the input value that drives `target` to a desired value."""
|
|
67
|
+
return self._request(
|
|
68
|
+
"goal_seek",
|
|
69
|
+
{"target": target, "decision": decision, "states": states or [], **extra},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def optimize(
|
|
73
|
+
self,
|
|
74
|
+
objectives: list[dict[str, Any]],
|
|
75
|
+
decisions: list[dict[str, Any]],
|
|
76
|
+
constraints: list[dict[str, Any]] | None = None,
|
|
77
|
+
states: list[str] | None = None,
|
|
78
|
+
**extra: Any,
|
|
79
|
+
) -> Any:
|
|
80
|
+
"""Vary decision variables to min/max objectives subject to constraints."""
|
|
81
|
+
return self._request(
|
|
82
|
+
"optimize",
|
|
83
|
+
{
|
|
84
|
+
"objectives": objectives,
|
|
85
|
+
"decisions": decisions,
|
|
86
|
+
"constraints": constraints or [],
|
|
87
|
+
"states": states or [],
|
|
88
|
+
**extra,
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def sensitivity(
|
|
93
|
+
self,
|
|
94
|
+
target: dict[str, Any],
|
|
95
|
+
inputs: list[dict[str, Any]],
|
|
96
|
+
states: list[str] | None = None,
|
|
97
|
+
**extra: Any,
|
|
98
|
+
) -> Any:
|
|
99
|
+
"""Perturb each input by ±delta and report the response per variant."""
|
|
100
|
+
return self._request(
|
|
101
|
+
"sensitivity",
|
|
102
|
+
{"target": target, "inputs": inputs, "states": states or [], **extra},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def trace(
|
|
106
|
+
self,
|
|
107
|
+
target: dict[str, Any] | str,
|
|
108
|
+
depth: int = 5,
|
|
109
|
+
states: list[str] | None = None,
|
|
110
|
+
) -> Any:
|
|
111
|
+
"""Walk the upstream dependency tree for `target`."""
|
|
112
|
+
if isinstance(target, str):
|
|
113
|
+
target = {"path": target}
|
|
114
|
+
return self._request(
|
|
115
|
+
"trace",
|
|
116
|
+
{"target": target, "depth": depth, "states": states or []},
|
|
117
|
+
)
|
awaredb/mixins/auth.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Authentication + connection-check for the AwareDB client."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from ..exceptions import AuthError, TransportError
|
|
6
|
+
|
|
7
|
+
__all__ = ("AuthMixin",)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthMixin:
|
|
11
|
+
"""Token fetch (username/password → token) and connection probe."""
|
|
12
|
+
|
|
13
|
+
host: str
|
|
14
|
+
token: str | None
|
|
15
|
+
timeout: float
|
|
16
|
+
|
|
17
|
+
def _fetch_token(self, user: str, password: str) -> str:
|
|
18
|
+
"""Exchange username/password for an AwareDB API token.
|
|
19
|
+
|
|
20
|
+
Hits the standard DRF auth endpoint `/rest/auth/token/login/`.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
response = httpx.post(
|
|
24
|
+
f"{self.host}/rest/auth/token/login/",
|
|
25
|
+
json={"username": user, "password": password},
|
|
26
|
+
timeout=self.timeout,
|
|
27
|
+
)
|
|
28
|
+
except httpx.HTTPError as exc:
|
|
29
|
+
raise TransportError(
|
|
30
|
+
f"login: HTTP request to {self.host} failed ({type(exc).__name__})",
|
|
31
|
+
extra_info=str(exc)[:500],
|
|
32
|
+
) from exc
|
|
33
|
+
|
|
34
|
+
if not response.is_success:
|
|
35
|
+
raise AuthError(
|
|
36
|
+
f"login: AwareDB returned HTTP {response.status_code}",
|
|
37
|
+
extra_info=(response.text[:500] if response.text else ""),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
token = response.json().get("token")
|
|
41
|
+
if not token:
|
|
42
|
+
raise AuthError("login: AwareDB returned no token in the response")
|
|
43
|
+
return token
|
|
44
|
+
|
|
45
|
+
def check(self) -> bool:
|
|
46
|
+
"""Return True if the token is valid and the database exists."""
|
|
47
|
+
# late-bound — provided by HTTPClientMixin
|
|
48
|
+
return self._request("check") == {"connected": True}
|
awaredb/mixins/data.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Read + write commands against an AwareDB."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..typing import Node
|
|
6
|
+
|
|
7
|
+
__all__ = ("DataMixin",)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DataMixin:
|
|
11
|
+
"""Mirrors the server-side data commands declared in `AwareDBCommandView.COMMANDS`."""
|
|
12
|
+
|
|
13
|
+
# Reads
|
|
14
|
+
# ------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
def get(self, path: str, states: list[str] | None = None) -> Any:
|
|
17
|
+
"""Return the value at `path`.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
path: Dotted path into a node, e.g. ``"car.engine.power"``.
|
|
21
|
+
states: Optional list of active states to apply during calculation.
|
|
22
|
+
"""
|
|
23
|
+
return self._request("get", {"path": path, "states": states or []})
|
|
24
|
+
|
|
25
|
+
def query(
|
|
26
|
+
self,
|
|
27
|
+
nodes: list[str] | None = None,
|
|
28
|
+
conditions: list[str] | None = None,
|
|
29
|
+
properties: list[str] | None = None,
|
|
30
|
+
states: list[str] | None = None,
|
|
31
|
+
show_abstract: bool = False,
|
|
32
|
+
) -> list[Node]:
|
|
33
|
+
"""Return nodes matching the filters.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
nodes: Identifiers (id, uid, or name). Use ``["*"]`` for all.
|
|
37
|
+
conditions: Formula-language predicates evaluated per node.
|
|
38
|
+
properties: Project only these properties. Default: all.
|
|
39
|
+
states: Active states.
|
|
40
|
+
show_abstract: Include abstract (template) nodes. Default ``False``.
|
|
41
|
+
"""
|
|
42
|
+
return self._request(
|
|
43
|
+
"query",
|
|
44
|
+
{
|
|
45
|
+
"nodes": nodes or ["*"],
|
|
46
|
+
"conditions": conditions or [],
|
|
47
|
+
"properties": properties or [],
|
|
48
|
+
"states": states or [],
|
|
49
|
+
"show_abstract": show_abstract,
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def calculate(
|
|
54
|
+
self,
|
|
55
|
+
formula: str | list[str],
|
|
56
|
+
states: list[str] | None = None,
|
|
57
|
+
) -> Any:
|
|
58
|
+
"""Evaluate one or more formulas against the database."""
|
|
59
|
+
return self._request("calculate", {"formula": formula, "states": states or []})
|
|
60
|
+
|
|
61
|
+
# Writes
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def update(
|
|
65
|
+
self,
|
|
66
|
+
data: dict[str, Any] | list[dict[str, Any]],
|
|
67
|
+
partial: bool = False,
|
|
68
|
+
) -> list[dict[str, Any]]:
|
|
69
|
+
"""Create or update nodes, relations, or relation types.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
data: Item or list of items to upsert.
|
|
73
|
+
partial: If ``True``, only the fields provided are updated.
|
|
74
|
+
"""
|
|
75
|
+
return self._request("update", {"data": data, "partial": partial})
|
|
76
|
+
|
|
77
|
+
def remove(self, ids: list[str]) -> None:
|
|
78
|
+
"""Delete nodes / relations / relation types by id."""
|
|
79
|
+
return self._request("remove", {"ids": ids})
|
|
80
|
+
|
|
81
|
+
def flush(self) -> None:
|
|
82
|
+
"""Drop everything in the database."""
|
|
83
|
+
return self._request("flush")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""History endpoint wrapper."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..typing import HistoryChange
|
|
7
|
+
|
|
8
|
+
__all__ = ("HistoryMixin",)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HistoryMixin:
|
|
12
|
+
"""Mirrors the server-side `history` command."""
|
|
13
|
+
|
|
14
|
+
def history(
|
|
15
|
+
self,
|
|
16
|
+
change_id: str | None = None,
|
|
17
|
+
ids: list[str] | None = None,
|
|
18
|
+
start: int | None = None,
|
|
19
|
+
end: int | None = None,
|
|
20
|
+
from_date: datetime | None = None,
|
|
21
|
+
to_date: datetime | None = None,
|
|
22
|
+
) -> list[HistoryChange]:
|
|
23
|
+
"""List changes recorded in the history database.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
change_id: If given, return only the matching change.
|
|
27
|
+
ids: Filter to changes that touched these node/relation ids.
|
|
28
|
+
start: Pagination start index (default 0).
|
|
29
|
+
end: Pagination end index (default 1000).
|
|
30
|
+
from_date: Restrict to changes on or after this datetime.
|
|
31
|
+
to_date: Restrict to changes on or before this datetime.
|
|
32
|
+
"""
|
|
33
|
+
payload: dict[str, Any] = {
|
|
34
|
+
"change_id": change_id,
|
|
35
|
+
"ids": ids,
|
|
36
|
+
"start": start,
|
|
37
|
+
"end": end,
|
|
38
|
+
"from_date": from_date.isoformat() if from_date else None,
|
|
39
|
+
"to_date": to_date.isoformat() if to_date else None,
|
|
40
|
+
}
|
|
41
|
+
payload = {key: value for key, value in payload.items() if value is not None}
|
|
42
|
+
return self._request("history", payload)
|
awaredb/mixins/http.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Low-level signed HTTP transport for the AwareDB client.
|
|
2
|
+
|
|
3
|
+
One mixin to own every HTTP concern: building the URL, attaching the auth
|
|
4
|
+
header, parsing the response, mapping non-2xx into typed exceptions. Higher
|
|
5
|
+
mixins call `_request(...)` and stay command-shaped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ..exceptions import (
|
|
13
|
+
AuthError,
|
|
14
|
+
InvalidCommandError,
|
|
15
|
+
TransportError,
|
|
16
|
+
UnexpectedError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = ("HTTPClientMixin",)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HTTPClientMixin:
|
|
23
|
+
"""Provides a persistent `httpx.Client` and a thin `_request()` wrapper."""
|
|
24
|
+
|
|
25
|
+
# These attributes are set by AwareDBClient.__init__.
|
|
26
|
+
host: str
|
|
27
|
+
db: str
|
|
28
|
+
token: str | None
|
|
29
|
+
timeout: float
|
|
30
|
+
|
|
31
|
+
_client: httpx.Client | None = None
|
|
32
|
+
|
|
33
|
+
# ------------------------------------------------------------------
|
|
34
|
+
# Lifecycle
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def close(self) -> None:
|
|
38
|
+
"""Close the underlying httpx client. Safe to call repeatedly."""
|
|
39
|
+
if self._client is not None:
|
|
40
|
+
self._client.close()
|
|
41
|
+
self._client = None
|
|
42
|
+
|
|
43
|
+
def __enter__(self):
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __exit__(self, exc_type, exc, tb):
|
|
47
|
+
self.close()
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
# Request
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def _http_client(self) -> httpx.Client:
|
|
54
|
+
"""Return (and lazily build) the persistent HTTP client.
|
|
55
|
+
|
|
56
|
+
Keeping a single `httpx.Client` per `AwareDBClient` instance reuses
|
|
57
|
+
the underlying connection pool, which matters when a caller issues
|
|
58
|
+
many calculate/query requests in a row.
|
|
59
|
+
"""
|
|
60
|
+
if self._client is None:
|
|
61
|
+
transport = httpx.HTTPTransport(retries=3)
|
|
62
|
+
self._client = httpx.Client(
|
|
63
|
+
base_url=self.host,
|
|
64
|
+
timeout=self.timeout,
|
|
65
|
+
transport=transport,
|
|
66
|
+
)
|
|
67
|
+
return self._client
|
|
68
|
+
|
|
69
|
+
def _headers(self) -> dict[str, str]:
|
|
70
|
+
headers = {"Accept": "application/json"}
|
|
71
|
+
if self.token:
|
|
72
|
+
headers["Authorization"] = f"Token {self.token}"
|
|
73
|
+
return headers
|
|
74
|
+
|
|
75
|
+
def _request(self, command: str, data: dict[str, Any] | None = None) -> Any:
|
|
76
|
+
"""Send a command request and unwrap the `{"data": ...}` envelope.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
command: The server command name, e.g. ``"query"``, ``"scenarios"``.
|
|
80
|
+
data: JSON-serialisable payload.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
AuthError: 401/403 from the server.
|
|
84
|
+
InvalidCommandError: 400 from the server (bad payload, unknown command).
|
|
85
|
+
TransportError: Network failure or other non-2xx status.
|
|
86
|
+
UnexpectedError: 2xx response that isn't JSON.
|
|
87
|
+
"""
|
|
88
|
+
url = f"/rest/db/{self.db}/{command}/"
|
|
89
|
+
try:
|
|
90
|
+
response = self._http_client().post(url, json=data or {}, headers=self._headers())
|
|
91
|
+
except httpx.HTTPError as exc:
|
|
92
|
+
raise TransportError(
|
|
93
|
+
f"{command}: HTTP request to {self.host}{url} failed ({type(exc).__name__})",
|
|
94
|
+
extra_info=str(exc)[:500],
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
if response.status_code in (401, 403):
|
|
98
|
+
raise AuthError(
|
|
99
|
+
f"{command}: AwareDB returned HTTP {response.status_code}",
|
|
100
|
+
extra_info=(response.text[:500] if response.text else ""),
|
|
101
|
+
)
|
|
102
|
+
if response.status_code == 400:
|
|
103
|
+
raise InvalidCommandError(
|
|
104
|
+
f"{command}: AwareDB rejected the request",
|
|
105
|
+
extra_info=(response.text[:500] if response.text else ""),
|
|
106
|
+
)
|
|
107
|
+
if not response.is_success:
|
|
108
|
+
raise TransportError(
|
|
109
|
+
f"{command}: AwareDB returned HTTP {response.status_code}",
|
|
110
|
+
extra_info=(response.text[:500] if response.text else ""),
|
|
111
|
+
status_code=response.status_code,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
body = response.json()
|
|
116
|
+
except ValueError as exc:
|
|
117
|
+
raise UnexpectedError(
|
|
118
|
+
f"{command}: AwareDB returned a non-JSON 2xx body",
|
|
119
|
+
extra_info=response.text[:500],
|
|
120
|
+
) from exc
|
|
121
|
+
|
|
122
|
+
return body.get("data") if isinstance(body, dict) else body
|
awaredb/mixins/io.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Bulk-load JSON files / folders into an AwareDB."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = ("IOMixin",)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IOMixin:
|
|
11
|
+
"""`load(...)` reads JSON from disk and pushes it through `update`."""
|
|
12
|
+
|
|
13
|
+
def load(self, filepath: str | Path, recursive: bool = False, flush: bool = False) -> None:
|
|
14
|
+
"""Push the contents of a JSON file (or folder of JSON files) to the database.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
filepath: File or directory path.
|
|
18
|
+
recursive: When `filepath` is a directory, descend into subfolders.
|
|
19
|
+
flush: If ``True``, drop the database before loading.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
FileNotFoundError: If `filepath` doesn't exist.
|
|
23
|
+
"""
|
|
24
|
+
path = Path(filepath)
|
|
25
|
+
if not path.exists():
|
|
26
|
+
raise FileNotFoundError(f"Path {path} does not exist.")
|
|
27
|
+
|
|
28
|
+
if flush:
|
|
29
|
+
self.flush()
|
|
30
|
+
|
|
31
|
+
data: list[dict[str, Any]] = []
|
|
32
|
+
if path.is_dir():
|
|
33
|
+
self._load_folder(data, path, recursive=recursive)
|
|
34
|
+
else:
|
|
35
|
+
self._load_file(data, path)
|
|
36
|
+
|
|
37
|
+
if data:
|
|
38
|
+
self.update(data)
|
|
39
|
+
|
|
40
|
+
def _load_folder(
|
|
41
|
+
self,
|
|
42
|
+
data: list[dict[str, Any]],
|
|
43
|
+
path: Path,
|
|
44
|
+
recursive: bool = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
for entry in path.iterdir():
|
|
47
|
+
if entry.is_dir():
|
|
48
|
+
if recursive:
|
|
49
|
+
self._load_folder(data, entry, recursive=recursive)
|
|
50
|
+
elif entry.suffix.lower() == ".json":
|
|
51
|
+
self._load_file(data, entry)
|
|
52
|
+
|
|
53
|
+
def _load_file(self, data: list[dict[str, Any]], path: Path) -> None:
|
|
54
|
+
with path.open("r", encoding="utf-8") as f:
|
|
55
|
+
content = json.load(f)
|
|
56
|
+
if isinstance(content, list):
|
|
57
|
+
data.extend(content)
|
|
58
|
+
else:
|
|
59
|
+
data.append(content)
|
awaredb/py.typed
ADDED
|
File without changes
|
awaredb/typing.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Typed response shapes returned by AwareDB.
|
|
2
|
+
|
|
3
|
+
These are intentionally loose `TypedDict`s — AwareDB returns nested,
|
|
4
|
+
dynamically-shaped JSON for most calls. The types document the *known*
|
|
5
|
+
fields so callers get autocomplete; unknown fields are still allowed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, NotRequired, TypedDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Node(TypedDict, total=False):
|
|
12
|
+
"""A node in an AwareDB graph."""
|
|
13
|
+
|
|
14
|
+
id: str
|
|
15
|
+
uid: NotRequired[str]
|
|
16
|
+
name: NotRequired[str]
|
|
17
|
+
adb_type: NotRequired[str]
|
|
18
|
+
abstract: NotRequired[bool]
|
|
19
|
+
properties: NotRequired[dict[str, Any]]
|
|
20
|
+
value: NotRequired[Any]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Relation(TypedDict, total=False):
|
|
24
|
+
"""An edge in an AwareDB graph."""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
from_node: NotRequired[str]
|
|
28
|
+
to_node: NotRequired[str]
|
|
29
|
+
relation_type: NotRequired[str]
|
|
30
|
+
properties: NotRequired[dict[str, Any]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Edit(TypedDict):
|
|
34
|
+
"""A path/value override used by scenarios and other analyses."""
|
|
35
|
+
|
|
36
|
+
path: str
|
|
37
|
+
value: Any
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HistoryChange(TypedDict, total=False):
|
|
41
|
+
"""A single change record from the history database."""
|
|
42
|
+
|
|
43
|
+
id: str
|
|
44
|
+
date: str
|
|
45
|
+
user: NotRequired[str]
|
|
46
|
+
data: NotRequired[dict[str, Any]]
|
|
47
|
+
info: NotRequired[dict[str, Any]]
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: awaredb
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python client for AwareDB — a data-modeling and calculation platform with unit-aware formulas, scenarios, optimization, and reversible history.
|
|
5
|
+
Author-email: AwareDB Team <support@awaredb.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/djinalab/awaredb-python
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/djinalab/awaredb-python/issues
|
|
9
|
+
Project-URL: Documentation, https://dev.djina.com/
|
|
10
|
+
Project-URL: Source Code, https://github.com/djinalab/awaredb-python
|
|
11
|
+
Keywords: awaredb,calculations,formulas,units,scenarios,optimization,graph,api-client,modeling
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: httpx>=0.28.1
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# awaredb
|
|
29
|
+
|
|
30
|
+
Python client for [AwareDB](https://dev.djina.com/) — a data-modeling and calculation
|
|
31
|
+
platform with unit-aware formulas, reversible history, and a rich analyses toolbox
|
|
32
|
+
(scenarios, optimisation, sensitivity, projections, traces).
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install awaredb
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from awaredb import AwareDBClient
|
|
40
|
+
|
|
41
|
+
with AwareDBClient(db="my_db", token="…") as client:
|
|
42
|
+
client.calculate(formula="car.power * 2")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
- Connect by token or by username/password.
|
|
50
|
+
- Read commands: `get`, `query`, `calculate`.
|
|
51
|
+
- Write commands: `update`, `remove`, `flush`, `load` (bulk-load JSON from disk).
|
|
52
|
+
- Analyses: `scenarios`, `impact`, `projection`, `goal_seek`, `optimize`,
|
|
53
|
+
`sensitivity`, `trace`.
|
|
54
|
+
- History: `history` against the reversible audit database.
|
|
55
|
+
- Typed exceptions: `AwareDBError`, `TransportError`, `AuthError`,
|
|
56
|
+
`InvalidCommandError`, `UnexpectedError`.
|
|
57
|
+
- Persistent `httpx`-backed transport with built-in retries and connection pooling.
|
|
58
|
+
- Ships a `py.typed` marker — works with `mypy` / `pyright` out of the box.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Requirements
|
|
63
|
+
|
|
64
|
+
- Python 3.12+
|
|
65
|
+
- `httpx`
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Quick start
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from awaredb import AwareDBClient
|
|
73
|
+
|
|
74
|
+
# Token auth
|
|
75
|
+
client = AwareDBClient(db="my_db", token="…")
|
|
76
|
+
|
|
77
|
+
# Username / password — fetches a token at construction time
|
|
78
|
+
client = AwareDBClient(db="my_db", user="alice", password="…")
|
|
79
|
+
|
|
80
|
+
# Custom host + timeout, no upfront connection check
|
|
81
|
+
client = AwareDBClient(
|
|
82
|
+
db="my_db",
|
|
83
|
+
token="…",
|
|
84
|
+
host="https://aware.example.com",
|
|
85
|
+
timeout=30,
|
|
86
|
+
check_connection=False,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Close the underlying HTTP client when finished
|
|
90
|
+
client.close()
|
|
91
|
+
|
|
92
|
+
# Or use the client as a context manager
|
|
93
|
+
with AwareDBClient(db="my_db", token="…") as client:
|
|
94
|
+
client.flush()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Read commands
|
|
100
|
+
|
|
101
|
+
### `get(path, states=None)`
|
|
102
|
+
|
|
103
|
+
Return the value at a path.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
client.get(path="car.power")
|
|
107
|
+
# "250 hp"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### `query(nodes=None, conditions=None, properties=None, states=None, show_abstract=False)`
|
|
111
|
+
|
|
112
|
+
Return nodes matching the filters. `nodes` defaults to `["*"]` (all).
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
client.query(
|
|
116
|
+
nodes=["employee"],
|
|
117
|
+
conditions=["node.salary.gross > 60000"],
|
|
118
|
+
properties=["salary"],
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### `calculate(formula, states=None)`
|
|
123
|
+
|
|
124
|
+
Evaluate one or more formulas against the live database.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
client.calculate(formula="car.power * 2")
|
|
128
|
+
# "500 hp"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Write commands
|
|
134
|
+
|
|
135
|
+
### `update(data, partial=False)`
|
|
136
|
+
|
|
137
|
+
Create or update nodes / relations / relation types. `data` accepts a single item
|
|
138
|
+
or a list.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
client.update([
|
|
142
|
+
{"uid": "fan", "power": "=sum(this.children.power)"},
|
|
143
|
+
])
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### `remove(ids)`
|
|
147
|
+
|
|
148
|
+
Delete by id.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
client.remove(ids=["7037a8a5-…", "7037a8a5-…"])
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### `flush()`
|
|
155
|
+
|
|
156
|
+
Drop every node and relation in the database.
|
|
157
|
+
|
|
158
|
+
### `load(filepath, recursive=False, flush=False)`
|
|
159
|
+
|
|
160
|
+
Push the contents of a JSON file (or folder of JSON files) through `update`.
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
client.load("./seed", recursive=True, flush=True)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Analyses
|
|
169
|
+
|
|
170
|
+
All analyses are read-only — they recalculate the graph against a temporary
|
|
171
|
+
context and return per-variant results. None mutate the database.
|
|
172
|
+
|
|
173
|
+
### `scenarios(edits, states=None)`
|
|
174
|
+
|
|
175
|
+
Apply path/value overrides, recalculate, return per-scenario results.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
client.scenarios(edits=[{"path": "motor.power", "value": "30 kW"}])
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### `impact(edit, states=None, **extra)`
|
|
182
|
+
|
|
183
|
+
Sweep an input across N steps and observe outputs.
|
|
184
|
+
|
|
185
|
+
### `projection(start_from, unit="day", step=1, points=10, edits=None, states=None)`
|
|
186
|
+
|
|
187
|
+
Walk forward in time. Per-step `edits` apply between points.
|
|
188
|
+
|
|
189
|
+
### `goal_seek(target, decision, states=None, **extra)`
|
|
190
|
+
|
|
191
|
+
Find the input value that drives `target` to a desired value.
|
|
192
|
+
|
|
193
|
+
### `optimize(objectives, decisions, constraints=None, states=None, **extra)`
|
|
194
|
+
|
|
195
|
+
Vary decision variables to minimise / maximise objectives subject to constraints.
|
|
196
|
+
|
|
197
|
+
### `sensitivity(target, inputs, states=None, **extra)`
|
|
198
|
+
|
|
199
|
+
Perturb each input by ±delta and report the response per variant.
|
|
200
|
+
|
|
201
|
+
### `trace(target, depth=5, states=None)`
|
|
202
|
+
|
|
203
|
+
Walk the upstream dependency tree of a target path.
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
client.trace(target="car.range", depth=10)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## History
|
|
212
|
+
|
|
213
|
+
### `history(change_id=None, ids=None, start=None, end=None, from_date=None, to_date=None)`
|
|
214
|
+
|
|
215
|
+
List changes recorded in the reversible history database.
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
from datetime import datetime
|
|
219
|
+
|
|
220
|
+
client.history(from_date=datetime(2026, 1, 1))
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Errors
|
|
226
|
+
|
|
227
|
+
Everything funnels through `AwareDBError`. Subclasses indicate the failure mode:
|
|
228
|
+
|
|
229
|
+
| Exception | When it fires |
|
|
230
|
+
|------------------------|----------------------------------------------------------|
|
|
231
|
+
| `AuthError` | 401 / 403, or username/password rejected. |
|
|
232
|
+
| `InvalidCommandError` | 400 — bad payload or unknown command. |
|
|
233
|
+
| `TransportError` | Network failure or other non-2xx HTTP status. |
|
|
234
|
+
| `UnexpectedError` | 2xx body that isn't JSON. |
|
|
235
|
+
| `AwareDBError` | Base class. Catch this to handle everything above. |
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Development
|
|
240
|
+
|
|
241
|
+
[`uv`](https://github.com/astral-sh/uv) drives dependency management. The
|
|
242
|
+
lockfile (`uv.lock`) is committed and pins exact versions.
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
# One-time: install uv
|
|
246
|
+
brew install uv # macOS / Linuxbrew
|
|
247
|
+
# or:
|
|
248
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
249
|
+
|
|
250
|
+
# Provision the project env (creates .venv from uv.lock — runtime + dev group)
|
|
251
|
+
uv sync
|
|
252
|
+
|
|
253
|
+
# Runtime-only (excludes dev group: pytest, ruff, mypy, …)
|
|
254
|
+
uv sync --no-dev
|
|
255
|
+
|
|
256
|
+
# Tests, lint, format, type check
|
|
257
|
+
uv run pytest
|
|
258
|
+
uv run ruff check awaredb/ tests/
|
|
259
|
+
uv run ruff format --check awaredb/ tests/
|
|
260
|
+
uv run mypy awaredb/
|
|
261
|
+
|
|
262
|
+
# Add / remove deps (auto-updates pyproject.toml + uv.lock)
|
|
263
|
+
uv add some-package
|
|
264
|
+
uv add --group dev some-package
|
|
265
|
+
uv remove some-package
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
> **Note:** `python -m build` is the pip-era invocation and requires the `build`
|
|
269
|
+
> package installed in the env. With `uv`, use `uv build` instead — it uses
|
|
270
|
+
> `uv`'s own PEP 517 frontend, no extra dependency needed.
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Releasing
|
|
275
|
+
|
|
276
|
+
Releases are cut by pushing a `v*` git tag. The `.github/workflows/release.yml`
|
|
277
|
+
workflow builds the sdist + wheel with `uv build` and uploads them to PyPI via
|
|
278
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC, no token
|
|
279
|
+
in CI).
|
|
280
|
+
|
|
281
|
+
### One-time PyPI setup
|
|
282
|
+
|
|
283
|
+
1. Create the project on PyPI: <https://pypi.org/manage/account/publishing/>.
|
|
284
|
+
2. Add a **pending trusted publisher** with:
|
|
285
|
+
- PyPI Project Name: `awaredb`
|
|
286
|
+
- Owner: `djinalab`
|
|
287
|
+
- Repository name: `awaredb-python`
|
|
288
|
+
- Workflow name: `release.yml`
|
|
289
|
+
- Environment name: `pypi`
|
|
290
|
+
3. In GitHub repo settings → Environments, create an environment named `pypi`
|
|
291
|
+
(optionally with required reviewers for an approval gate).
|
|
292
|
+
|
|
293
|
+
### Cutting a release
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
# 1. Bump version in awaredb/__init__.py (e.g. 0.1.0 → 0.1.1)
|
|
297
|
+
# 2. Move the [Unreleased] block in CHANGELOG.md under a new [X.Y.Z] - YYYY-MM-DD heading
|
|
298
|
+
# 3. Commit, push to main
|
|
299
|
+
git commit -am "Release 0.1.1"
|
|
300
|
+
git push
|
|
301
|
+
|
|
302
|
+
# 4. Tag and push — release.yml fires automatically
|
|
303
|
+
git tag v0.1.1
|
|
304
|
+
git push origin v0.1.1
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Manual publish (fallback)
|
|
308
|
+
|
|
309
|
+
If you need to publish from a workstation:
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
uv build # writes sdist + wheel to dist/
|
|
313
|
+
uv publish # uses UV_PUBLISH_TOKEN env var
|
|
314
|
+
# or:
|
|
315
|
+
uv publish --token "$PYPI_API_TOKEN"
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
The `dist/` folder is gitignored — never commit build artifacts.
|
|
319
|
+
|
|
320
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) and [CHANGELOG.md](./CHANGELOG.md).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
awaredb/__init__.py,sha256=WS1XhmOCUFO3tYUNJxk8INse2QM-VD1gM507xWRBRlY,345
|
|
2
|
+
awaredb/api.py,sha256=iO7DGQpORr9v00YYPeEH5-1-XxOLvjEHNMPajNPSNQQ,2450
|
|
3
|
+
awaredb/exceptions.py,sha256=FTD8cE6OZX6NCgnuwdpSDng_VuBgnSSLaSZJ05Cub-g,1983
|
|
4
|
+
awaredb/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
awaredb/typing.py,sha256=VmXCTHFx9Jcjm1fCAYF8WshlZZudFwdSHuAS9v196IA,1186
|
|
6
|
+
awaredb/mixins/__init__.py,sha256=R1sckB16TE68qfdqyYcJEGN0WKwAmrnp1k5gh1Dr1Xw,312
|
|
7
|
+
awaredb/mixins/analyses.py,sha256=S-1MtP-z0lqOuJCKpbc5uU-XQsI31xiUJYWzG6LYuvc,3610
|
|
8
|
+
awaredb/mixins/auth.py,sha256=BldS2xxQ8XsyaeJtkovPnAK6KgZ9PBvjjOsxz947X-o,1570
|
|
9
|
+
awaredb/mixins/data.py,sha256=CuQ9PY1a7BcuLvPgDxBkEFI5x4OezZY9-Yj33Y2rQg0,2742
|
|
10
|
+
awaredb/mixins/history.py,sha256=QqRGRqrCEZpDyeMH8-X2eAepLFT0ztTOWzLV8ZtRmk4,1415
|
|
11
|
+
awaredb/mixins/http.py,sha256=JCjYXSYJmwFScaAaUGi7sZU165mUaL0nVY_FCJ6Uss0,4221
|
|
12
|
+
awaredb/mixins/io.py,sha256=i4XJVGM2B0VWKqc48W7hfuYEivyZlMkgi5_cj3fLWeg,1801
|
|
13
|
+
awaredb-0.1.1.dist-info/licenses/LICENSE,sha256=4ONJY9Y7UcYVWtQNV7ltTooadZYWXzlV3wnUdV1L-q8,1072
|
|
14
|
+
awaredb-0.1.1.dist-info/METADATA,sha256=eNsmSfDHMckMFkoxi2htBfHzB52BvQZd65wwwn9mtao,8776
|
|
15
|
+
awaredb-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
awaredb-0.1.1.dist-info/top_level.txt,sha256=V8cxa4yPWZoTfLt-TLzvQ4w1zWSzFwc-q7ywFBRqyWM,8
|
|
17
|
+
awaredb-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Nelson Monteiro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
awaredb
|