rootmemory 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.
- rootmemory/__init__.py +84 -0
- rootmemory/api/__init__.py +25 -0
- rootmemory/api/agents.py +38 -0
- rootmemory/api/beliefs.py +66 -0
- rootmemory/api/claims.py +87 -0
- rootmemory/api/deps.py +21 -0
- rootmemory/api/errors.py +36 -0
- rootmemory/api/graph.py +32 -0
- rootmemory/api/invalidation.py +47 -0
- rootmemory/api/observations.py +46 -0
- rootmemory/api/promotion.py +37 -0
- rootmemory/api/provenance.py +88 -0
- rootmemory/api/security.py +53 -0
- rootmemory/cli.py +96 -0
- rootmemory/client.py +219 -0
- rootmemory/config.py +64 -0
- rootmemory/db/__init__.py +0 -0
- rootmemory/db/base.py +42 -0
- rootmemory/db/migrations/env.py +59 -0
- rootmemory/db/migrations/script.py.mako +26 -0
- rootmemory/db/migrations/versions/d336d5cf7fb9_initial_schema.py +260 -0
- rootmemory/db/session.py +138 -0
- rootmemory/errors.py +27 -0
- rootmemory/integrations/__init__.py +16 -0
- rootmemory/integrations/async_client.py +268 -0
- rootmemory/integrations/langchain_tools.py +237 -0
- rootmemory/integrations/langgraph_memory.py +179 -0
- rootmemory/integrations/langgraph_store.py +402 -0
- rootmemory/logging_config.py +30 -0
- rootmemory/main.py +133 -0
- rootmemory/models/__init__.py +22 -0
- rootmemory/models/agent.py +30 -0
- rootmemory/models/audit.py +51 -0
- rootmemory/models/belief.py +39 -0
- rootmemory/models/claim.py +52 -0
- rootmemory/models/decision.py +38 -0
- rootmemory/models/edge.py +43 -0
- rootmemory/models/enums.py +104 -0
- rootmemory/models/node_ref.py +42 -0
- rootmemory/models/observation.py +51 -0
- rootmemory/py.typed +0 -0
- rootmemory/repositories/__init__.py +19 -0
- rootmemory/repositories/agent_repo.py +44 -0
- rootmemory/repositories/audit_repo.py +43 -0
- rootmemory/repositories/belief_repo.py +63 -0
- rootmemory/repositories/claim_repo.py +102 -0
- rootmemory/repositories/decision_repo.py +76 -0
- rootmemory/repositories/edge_repo.py +77 -0
- rootmemory/repositories/observation_repo.py +76 -0
- rootmemory/schemas/__init__.py +52 -0
- rootmemory/schemas/agent.py +26 -0
- rootmemory/schemas/belief.py +49 -0
- rootmemory/schemas/claim.py +54 -0
- rootmemory/schemas/common.py +27 -0
- rootmemory/schemas/observation.py +69 -0
- rootmemory/schemas/promotion.py +50 -0
- rootmemory/schemas/provenance.py +51 -0
- rootmemory/services/__init__.py +35 -0
- rootmemory/services/belief_service.py +161 -0
- rootmemory/services/contradiction_service.py +148 -0
- rootmemory/services/decision_service.py +65 -0
- rootmemory/services/graph_service.py +316 -0
- rootmemory/services/independence_service.py +159 -0
- rootmemory/services/invalidation_service.py +164 -0
- rootmemory/services/normalization_service.py +234 -0
- rootmemory/services/promotion_service.py +318 -0
- rootmemory/services/provenance_service.py +282 -0
- rootmemory/services/scoring_service.py +53 -0
- rootmemory/static/index.html +866 -0
- rootmemory-0.1.0.dist-info/METADATA +266 -0
- rootmemory-0.1.0.dist-info/RECORD +75 -0
- rootmemory-0.1.0.dist-info/WHEEL +5 -0
- rootmemory-0.1.0.dist-info/entry_points.txt +2 -0
- rootmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
- rootmemory-0.1.0.dist-info/top_level.txt +1 -0
rootmemory/cli.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The ``rootmemory`` command.
|
|
2
|
+
|
|
3
|
+
rootmemory version
|
|
4
|
+
rootmemory demo run the four scenarios against a naive baseline
|
|
5
|
+
rootmemory serve start the API and the portal
|
|
6
|
+
rootmemory policy show the promotion policy in force
|
|
7
|
+
|
|
8
|
+
Kept deliberately thin: the API and the demo are optional extras, so this
|
|
9
|
+
imports them lazily and explains what to install if they are missing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _missing(extra: str, what: str) -> int:
|
|
19
|
+
print(f"{what} needs the optional '{extra}' extra:", file=sys.stderr)
|
|
20
|
+
print(f' pip install "rootmemory[{extra}]"', file=sys.stderr)
|
|
21
|
+
return 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmd_version() -> int:
|
|
25
|
+
from rootmemory import __version__
|
|
26
|
+
|
|
27
|
+
print(f"rootmemory {__version__}")
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_policy() -> int:
|
|
32
|
+
import json
|
|
33
|
+
|
|
34
|
+
from rootmemory.services.promotion_service import PromotionPolicy
|
|
35
|
+
|
|
36
|
+
print(json.dumps(PromotionPolicy.from_settings().as_dict(), indent=2))
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_serve(host: str, port: int, reload: bool) -> int:
|
|
41
|
+
try:
|
|
42
|
+
import uvicorn
|
|
43
|
+
except ModuleNotFoundError:
|
|
44
|
+
return _missing("api", "serve")
|
|
45
|
+
|
|
46
|
+
uvicorn.run("rootmemory.main:app", host=host, port=port, reload=reload)
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_demo() -> int:
|
|
51
|
+
try:
|
|
52
|
+
from experiments.run_demo import main as run_demo
|
|
53
|
+
except ModuleNotFoundError:
|
|
54
|
+
print(
|
|
55
|
+
"The demo scenarios ship with the source repository, not the wheel.\n"
|
|
56
|
+
"Clone it to run them:\n"
|
|
57
|
+
" git clone https://github.com/Shubs5758/RootMemory",
|
|
58
|
+
file=sys.stderr,
|
|
59
|
+
)
|
|
60
|
+
return 1
|
|
61
|
+
return int(run_demo([]))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv: list[str] | None = None) -> int:
|
|
65
|
+
parser = argparse.ArgumentParser(
|
|
66
|
+
prog="rootmemory",
|
|
67
|
+
description="Provenance-aware memory for multi-agent AI systems.",
|
|
68
|
+
)
|
|
69
|
+
sub = parser.add_subparsers(dest="command")
|
|
70
|
+
|
|
71
|
+
sub.add_parser("version", help="print the installed version")
|
|
72
|
+
sub.add_parser("policy", help="print the promotion policy in force")
|
|
73
|
+
sub.add_parser("demo", help="run the demo scenarios (source checkout only)")
|
|
74
|
+
|
|
75
|
+
serve = sub.add_parser("serve", help="start the API and the portal at /ui")
|
|
76
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
77
|
+
serve.add_argument("--port", type=int, default=8000)
|
|
78
|
+
serve.add_argument("--reload", action="store_true")
|
|
79
|
+
|
|
80
|
+
args = parser.parse_args(argv)
|
|
81
|
+
|
|
82
|
+
if args.command == "version":
|
|
83
|
+
return cmd_version()
|
|
84
|
+
if args.command == "policy":
|
|
85
|
+
return cmd_policy()
|
|
86
|
+
if args.command == "demo":
|
|
87
|
+
return cmd_demo()
|
|
88
|
+
if args.command == "serve":
|
|
89
|
+
return cmd_serve(args.host, args.port, args.reload)
|
|
90
|
+
|
|
91
|
+
parser.print_help()
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
raise SystemExit(main())
|
rootmemory/client.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""RootMemory: the interface agents use (spec section 48).
|
|
2
|
+
|
|
3
|
+
Agents never touch the database or the ORM. They go through this façade, which
|
|
4
|
+
keeps the memory layer framework-agnostic: the same client works for a plain
|
|
5
|
+
Python agent, a LangGraph node or a CrewAI worker.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from sqlalchemy.orm import Session
|
|
14
|
+
|
|
15
|
+
from rootmemory.errors import NodeNotFoundError
|
|
16
|
+
from rootmemory.models.agent import Agent
|
|
17
|
+
from rootmemory.models.belief import Belief
|
|
18
|
+
from rootmemory.models.claim import Claim
|
|
19
|
+
from rootmemory.models.decision import Decision
|
|
20
|
+
from rootmemory.models.enums import EpistemicStatus, NodeType, ValidityStatus
|
|
21
|
+
from rootmemory.models.node_ref import NodeRef
|
|
22
|
+
from rootmemory.models.observation import Observation
|
|
23
|
+
from rootmemory.repositories.agent_repo import AgentRepository
|
|
24
|
+
from rootmemory.repositories.claim_repo import ClaimRepository
|
|
25
|
+
from rootmemory.repositories.decision_repo import DecisionRepository
|
|
26
|
+
from rootmemory.repositories.observation_repo import ObservationRepository
|
|
27
|
+
from rootmemory.services.belief_service import BeliefService, ParentSpec
|
|
28
|
+
from rootmemory.services.contradiction_service import ClaimContext, ContradictionService
|
|
29
|
+
from rootmemory.services.decision_service import DecisionService
|
|
30
|
+
from rootmemory.services.invalidation_service import InvalidationService, RepairSummary
|
|
31
|
+
from rootmemory.services.promotion_service import (
|
|
32
|
+
PromotionEvaluation,
|
|
33
|
+
PromotionPolicy,
|
|
34
|
+
PromotionService,
|
|
35
|
+
)
|
|
36
|
+
from rootmemory.services.provenance_service import ProvenanceService
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RootMemory:
|
|
40
|
+
"""A thin, typed API over the memory services."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, session: Session, policy: PromotionPolicy | None = None) -> None:
|
|
43
|
+
self.session = session
|
|
44
|
+
self.agents = AgentRepository(session)
|
|
45
|
+
self.observations = ObservationRepository(session)
|
|
46
|
+
self.claims = ClaimRepository(session)
|
|
47
|
+
self.decisions = DecisionRepository(session)
|
|
48
|
+
self.decision_service = DecisionService(session)
|
|
49
|
+
self.beliefs = BeliefService(session)
|
|
50
|
+
self.provenance = ProvenanceService(session)
|
|
51
|
+
self.contradictions = ContradictionService(session)
|
|
52
|
+
self.promotion = PromotionService(session, policy)
|
|
53
|
+
self.invalidation = InvalidationService(session, policy)
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# agents
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def register_agent(
|
|
60
|
+
self, name: str, role: str = "", description: str = "", trust_score: float = 0.5
|
|
61
|
+
) -> Agent:
|
|
62
|
+
existing = self.agents.get_by_name(name)
|
|
63
|
+
if existing is not None:
|
|
64
|
+
return existing
|
|
65
|
+
return self.agents.create(
|
|
66
|
+
name=name, role=role, description=description, trust_score=trust_score
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# evidence
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def create_observation(
|
|
74
|
+
self,
|
|
75
|
+
source_type: str,
|
|
76
|
+
content: str,
|
|
77
|
+
source_uri: str | None = None,
|
|
78
|
+
source_actor: str | None = None,
|
|
79
|
+
created_by_agent_id: uuid.UUID | None = None,
|
|
80
|
+
reliability_score: float = 1.0,
|
|
81
|
+
source_family_id: str | None = None,
|
|
82
|
+
meta: dict | None = None,
|
|
83
|
+
) -> Observation:
|
|
84
|
+
"""Record a piece of external evidence."""
|
|
85
|
+
return self.observations.create(
|
|
86
|
+
source_type=source_type,
|
|
87
|
+
content=content,
|
|
88
|
+
source_uri=source_uri,
|
|
89
|
+
source_actor=source_actor,
|
|
90
|
+
created_by_agent_id=created_by_agent_id,
|
|
91
|
+
reliability_score=reliability_score,
|
|
92
|
+
source_family_id=source_family_id,
|
|
93
|
+
meta=meta or {},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def invalidate_observation(self, observation_id: uuid.UUID, reason: str) -> RepairSummary:
|
|
97
|
+
"""Retract a source and repair everything downstream."""
|
|
98
|
+
return self.invalidation.invalidate_observation(observation_id, reason)
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# beliefs
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def create_belief(
|
|
105
|
+
self,
|
|
106
|
+
agent_id: uuid.UUID,
|
|
107
|
+
claim_text: str,
|
|
108
|
+
claim_key: str | None,
|
|
109
|
+
confidence: float,
|
|
110
|
+
derived_from: Sequence[Observation | Belief],
|
|
111
|
+
epistemic_status: EpistemicStatus = EpistemicStatus.INFERRED,
|
|
112
|
+
llm_generated: bool = False,
|
|
113
|
+
meta: dict | None = None,
|
|
114
|
+
) -> Belief:
|
|
115
|
+
"""Form a belief from observations and/or other beliefs.
|
|
116
|
+
|
|
117
|
+
``derived_from`` takes model instances so callers cannot forget to say
|
|
118
|
+
which type each parent is.
|
|
119
|
+
|
|
120
|
+
``claim_key`` may be None, in which case the configured normalizer
|
|
121
|
+
works out which proposition this belief is about.
|
|
122
|
+
"""
|
|
123
|
+
parents = [ParentSpec(id=node.id, type=_node_type_of(node)) for node in derived_from]
|
|
124
|
+
return self.beliefs.create_belief(
|
|
125
|
+
agent_id=agent_id,
|
|
126
|
+
claim_text=claim_text,
|
|
127
|
+
normalized_claim_key=claim_key,
|
|
128
|
+
confidence=confidence,
|
|
129
|
+
parents=parents,
|
|
130
|
+
epistemic_status=epistemic_status,
|
|
131
|
+
llm_generated=llm_generated,
|
|
132
|
+
meta=meta,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def get_belief(self, belief_id: uuid.UUID) -> Belief:
|
|
136
|
+
belief = self.beliefs.beliefs.get(belief_id)
|
|
137
|
+
if belief is None:
|
|
138
|
+
raise NodeNotFoundError(f"belief {belief_id} does not exist")
|
|
139
|
+
return belief
|
|
140
|
+
|
|
141
|
+
def find_belief(self, belief_id: uuid.UUID) -> Belief | None:
|
|
142
|
+
"""Look a belief up without raising when it is absent."""
|
|
143
|
+
return self.beliefs.beliefs.get(belief_id)
|
|
144
|
+
|
|
145
|
+
def list_beliefs(self) -> Sequence[Belief]:
|
|
146
|
+
"""Every belief, oldest first."""
|
|
147
|
+
return self.beliefs.beliefs.list()
|
|
148
|
+
|
|
149
|
+
def propose_shared_belief(self, belief_id: uuid.UUID) -> Belief:
|
|
150
|
+
"""Submit a private belief as a candidate for shared memory."""
|
|
151
|
+
return self.beliefs.submit_for_promotion(belief_id)
|
|
152
|
+
|
|
153
|
+
def evidence_roots(self, belief: Belief) -> list[Observation]:
|
|
154
|
+
"""The observations a belief ultimately rests on."""
|
|
155
|
+
return self.provenance.find_evidence_root_observations(NodeRef.belief(belief.id))
|
|
156
|
+
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
# claims
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def get_or_create_claim(self, claim_key: str, canonical_text: str) -> Claim:
|
|
162
|
+
return self.claims.get_or_create(claim_key, canonical_text)
|
|
163
|
+
|
|
164
|
+
def support_claim(self, claim_id: uuid.UUID, belief_id: uuid.UUID) -> None:
|
|
165
|
+
self.contradictions.register_support(claim_id, belief_id)
|
|
166
|
+
|
|
167
|
+
def contradict_claim(self, claim_id: uuid.UUID, belief_id: uuid.UUID) -> None:
|
|
168
|
+
self.contradictions.register_contradiction(claim_id, belief_id)
|
|
169
|
+
|
|
170
|
+
def get_claim_context(self, claim_id: uuid.UUID) -> ClaimContext:
|
|
171
|
+
"""Read a claim the honest way: both sides, with independence counts."""
|
|
172
|
+
return self.contradictions.build_context(claim_id)
|
|
173
|
+
|
|
174
|
+
def evaluate_claim(self, claim_id: uuid.UUID) -> PromotionEvaluation:
|
|
175
|
+
"""Run the promotion gate without changing the claim."""
|
|
176
|
+
return self.promotion.evaluate(claim_id)
|
|
177
|
+
|
|
178
|
+
def promote_claim(self, claim_id: uuid.UUID) -> PromotionEvaluation:
|
|
179
|
+
"""Attempt promotion into shared memory. Always re-evaluates."""
|
|
180
|
+
return self.promotion.promote(claim_id)
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# decisions
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def create_decision(
|
|
187
|
+
self,
|
|
188
|
+
agent_id: uuid.UUID,
|
|
189
|
+
decision_type: str,
|
|
190
|
+
content: str,
|
|
191
|
+
depends_on_claims: Sequence[Claim] = (),
|
|
192
|
+
meta: dict | None = None,
|
|
193
|
+
) -> Decision:
|
|
194
|
+
"""Record a decision and the claims it rests on."""
|
|
195
|
+
return self.decision_service.create_decision(
|
|
196
|
+
agent_id=agent_id,
|
|
197
|
+
decision_type=decision_type,
|
|
198
|
+
content=content,
|
|
199
|
+
claim_ids=[claim.id for claim in depends_on_claims],
|
|
200
|
+
meta=meta,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
# misc
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def observation_status(self, observation_id: uuid.UUID) -> ValidityStatus:
|
|
208
|
+
observation = self.observations.get(observation_id)
|
|
209
|
+
if observation is None:
|
|
210
|
+
raise NodeNotFoundError(f"observation {observation_id} does not exist")
|
|
211
|
+
return ValidityStatus(observation.validity_status)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _node_type_of(node: Observation | Belief) -> NodeType:
|
|
215
|
+
if isinstance(node, Observation):
|
|
216
|
+
return NodeType.OBSERVATION
|
|
217
|
+
if isinstance(node, Belief):
|
|
218
|
+
return NodeType.BELIEF
|
|
219
|
+
raise TypeError(f"a belief cannot be derived from {type(node).__name__}")
|
rootmemory/config.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Application configuration.
|
|
2
|
+
|
|
3
|
+
Every knob that changes epistemic behaviour (the promotion policy) is exposed
|
|
4
|
+
here so experiments can vary it without touching service code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Settings(BaseSettings):
|
|
17
|
+
"""Runtime settings, read from the environment and an optional .env file."""
|
|
18
|
+
|
|
19
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
20
|
+
|
|
21
|
+
# Default is SQLite so the demo, the tests and a fresh clone all run with no
|
|
22
|
+
# infrastructure. docker-compose / .env point this at PostgreSQL.
|
|
23
|
+
database_url: str = "sqlite+pysqlite:///./rootmemory.db"
|
|
24
|
+
sql_echo: bool = False
|
|
25
|
+
log_level: str = "INFO"
|
|
26
|
+
|
|
27
|
+
promotion_min_independent_support: int = Field(default=2, ge=1)
|
|
28
|
+
promotion_min_confidence: float = Field(default=0.75, ge=0.0, le=1.0)
|
|
29
|
+
promotion_max_high_confidence_contradictions: int = Field(default=0, ge=0)
|
|
30
|
+
promotion_contradiction_confidence_threshold: float = Field(default=0.75, ge=0.0, le=1.0)
|
|
31
|
+
promotion_policy_version: str = "v1"
|
|
32
|
+
|
|
33
|
+
# Comma-separated. Empty means authentication is disabled, which is the
|
|
34
|
+
# right default for local development and the wrong one on a network.
|
|
35
|
+
api_keys: str = ""
|
|
36
|
+
|
|
37
|
+
# Claim normalization: how free-text beliefs are mapped onto a claim key.
|
|
38
|
+
# "manual" - the caller always supplies claim_key (V1 behaviour)
|
|
39
|
+
# "deterministic" - derive a slug from the text, no LLM
|
|
40
|
+
# "llm" - ask a model to match an existing claim, with the
|
|
41
|
+
# deterministic slug as the fallback
|
|
42
|
+
claim_normalization: Literal["manual", "deterministic", "llm"] = "manual"
|
|
43
|
+
|
|
44
|
+
openai_api_key: str | None = None
|
|
45
|
+
openai_model: str | None = None
|
|
46
|
+
openai_base_url: str | None = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_sqlite(self) -> bool:
|
|
50
|
+
return self.database_url.startswith("sqlite")
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def api_key_list(self) -> list[str]:
|
|
54
|
+
return [key.strip() for key in self.api_keys.split(",") if key.strip()]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def auth_enabled(self) -> bool:
|
|
58
|
+
"""Authentication is on exactly when at least one key is configured."""
|
|
59
|
+
return bool(self.api_key_list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@lru_cache
|
|
63
|
+
def get_settings() -> Settings:
|
|
64
|
+
return Settings()
|
|
File without changes
|
rootmemory/db/base.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Declarative base and shared column helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import JSON, DateTime, MetaData, Uuid
|
|
9
|
+
from sqlalchemy.orm import DeclarativeBase, mapped_column
|
|
10
|
+
|
|
11
|
+
NAMING_CONVENTION = {
|
|
12
|
+
"ix": "ix_%(column_0_label)s",
|
|
13
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
14
|
+
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
15
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
16
|
+
"pk": "pk_%(table_name)s",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Base(DeclarativeBase):
|
|
21
|
+
"""Base class for every ORM model."""
|
|
22
|
+
|
|
23
|
+
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def utcnow() -> datetime:
|
|
27
|
+
"""Timezone-aware 'now'. Used as a Python-side default so behaviour is
|
|
28
|
+
identical on SQLite and PostgreSQL."""
|
|
29
|
+
return datetime.now(UTC)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pk_column(): # type: ignore[no-untyped-def]
|
|
33
|
+
"""UUID primary key, generated client-side so callers know the id up front."""
|
|
34
|
+
return mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def timestamp_column(**kwargs): # type: ignore[no-untyped-def]
|
|
38
|
+
return mapped_column(DateTime(timezone=True), default=utcnow, **kwargs)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def json_column(**kwargs): # type: ignore[no-untyped-def]
|
|
42
|
+
return mapped_column(JSON, default=dict, **kwargs)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Alembic environment.
|
|
2
|
+
|
|
3
|
+
The database URL comes from the application settings, so migrations and the
|
|
4
|
+
running app can never drift apart.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from logging.config import fileConfig
|
|
10
|
+
|
|
11
|
+
from alembic import context
|
|
12
|
+
from sqlalchemy import engine_from_config, pool
|
|
13
|
+
|
|
14
|
+
import rootmemory.models # noqa: F401 (register every mapper before autogenerate)
|
|
15
|
+
from rootmemory.config import get_settings
|
|
16
|
+
from rootmemory.db.base import Base
|
|
17
|
+
|
|
18
|
+
config = context.config
|
|
19
|
+
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
|
20
|
+
|
|
21
|
+
if config.config_file_name is not None:
|
|
22
|
+
fileConfig(config.config_file_name)
|
|
23
|
+
|
|
24
|
+
target_metadata = Base.metadata
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_migrations_offline() -> None:
|
|
28
|
+
context.configure(
|
|
29
|
+
url=config.get_main_option("sqlalchemy.url"),
|
|
30
|
+
target_metadata=target_metadata,
|
|
31
|
+
literal_binds=True,
|
|
32
|
+
dialect_opts={"paramstyle": "named"},
|
|
33
|
+
render_as_batch=True,
|
|
34
|
+
)
|
|
35
|
+
with context.begin_transaction():
|
|
36
|
+
context.run_migrations()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_migrations_online() -> None:
|
|
40
|
+
connectable = engine_from_config(
|
|
41
|
+
config.get_section(config.config_ini_section, {}),
|
|
42
|
+
prefix="sqlalchemy.",
|
|
43
|
+
poolclass=pool.NullPool,
|
|
44
|
+
)
|
|
45
|
+
with connectable.connect() as connection:
|
|
46
|
+
context.configure(
|
|
47
|
+
connection=connection,
|
|
48
|
+
target_metadata=target_metadata,
|
|
49
|
+
# Needed for SQLite, harmless on PostgreSQL.
|
|
50
|
+
render_as_batch=True,
|
|
51
|
+
)
|
|
52
|
+
with context.begin_transaction():
|
|
53
|
+
context.run_migrations()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if context.is_offline_mode():
|
|
57
|
+
run_migrations_offline()
|
|
58
|
+
else:
|
|
59
|
+
run_migrations_online()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
from alembic import op
|
|
12
|
+
import sqlalchemy as sa
|
|
13
|
+
${imports if imports else ""}
|
|
14
|
+
|
|
15
|
+
revision: str = ${repr(up_revision)}
|
|
16
|
+
down_revision: str | None = ${repr(down_revision)}
|
|
17
|
+
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
|
18
|
+
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
${upgrades if upgrades else "pass"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def downgrade() -> None:
|
|
26
|
+
${downgrades if downgrades else "pass"}
|