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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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