datacharter 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.
Files changed (123) hide show
  1. datacharter/__init__.py +3 -0
  2. datacharter/agent/__init__.py +5 -0
  3. datacharter/agent/llm.py +119 -0
  4. datacharter/agent/loop.py +103 -0
  5. datacharter/agent/tools.py +146 -0
  6. datacharter/cli.py +249 -0
  7. datacharter/contracts/__init__.py +5 -0
  8. datacharter/contracts/loader.py +147 -0
  9. datacharter/contracts/resolve.py +74 -0
  10. datacharter/engine/__init__.py +1 -0
  11. datacharter/engine/aggregate.py +218 -0
  12. datacharter/engine/guard.py +142 -0
  13. datacharter/engine/pushdown.py +261 -0
  14. datacharter/engine/scrub.py +19 -0
  15. datacharter/engine/session.py +413 -0
  16. datacharter/engine/snowflake.py +153 -0
  17. datacharter/engine/sources.py +140 -0
  18. datacharter/models.py +72 -0
  19. datacharter/server/__init__.py +5 -0
  20. datacharter/server/app.py +355 -0
  21. datacharter/server/static/assets/abap-DLDM7-KI.js +1 -0
  22. datacharter/server/static/assets/apex-DNDY2TF8.js +1 -0
  23. datacharter/server/static/assets/azcli-Y6nb8tq_.js +1 -0
  24. datacharter/server/static/assets/bat-BwHxbl9M.js +1 -0
  25. datacharter/server/static/assets/bicep-CFznDFnq.js +2 -0
  26. datacharter/server/static/assets/cameligo-Bf6VGUru.js +1 -0
  27. datacharter/server/static/assets/clojure-Dnu-v4kV.js +1 -0
  28. datacharter/server/static/assets/codicon-Brq4_Ui5.ttf +0 -0
  29. datacharter/server/static/assets/coffee-Bd8akH9Z.js +1 -0
  30. datacharter/server/static/assets/cpp-BbWJElDN.js +1 -0
  31. datacharter/server/static/assets/csharp-Co3qMtFm.js +1 -0
  32. datacharter/server/static/assets/csp-D-4FJmMZ.js +1 -0
  33. datacharter/server/static/assets/css-DdJfP1eB.js +3 -0
  34. datacharter/server/static/assets/css.worker-CqwWTjeG.js +97 -0
  35. datacharter/server/static/assets/cssMode-BuAVOLr4.js +1 -0
  36. datacharter/server/static/assets/cypher-cTPe9QuQ.js +1 -0
  37. datacharter/server/static/assets/dart-BOtBlQCF.js +1 -0
  38. datacharter/server/static/assets/dockerfile-BG73LgW2.js +1 -0
  39. datacharter/server/static/assets/ecl-BEgZUVRK.js +1 -0
  40. datacharter/server/static/assets/editor.worker-DCKwvLbM.js +30 -0
  41. datacharter/server/static/assets/elixir-BkW5O-1t.js +1 -0
  42. datacharter/server/static/assets/flow9-BeJ5waoc.js +1 -0
  43. datacharter/server/static/assets/freemarker2-DM0thzwF.js +3 -0
  44. datacharter/server/static/assets/fsharp-PahG7c26.js +1 -0
  45. datacharter/server/static/assets/go-acbASCJo.js +1 -0
  46. datacharter/server/static/assets/graphql-BxJiqAUM.js +1 -0
  47. datacharter/server/static/assets/handlebars-BkMf3Qj4.js +1 -0
  48. datacharter/server/static/assets/hcl-DtV1sZF8.js +1 -0
  49. datacharter/server/static/assets/html-KeNy2AFd.js +1 -0
  50. datacharter/server/static/assets/html.worker-DhJ4VZMp.js +474 -0
  51. datacharter/server/static/assets/htmlMode-CN5rSB9p.js +1 -0
  52. datacharter/server/static/assets/index-BqWIVR2x.css +1 -0
  53. datacharter/server/static/assets/index-CCTlsFBM.js +295 -0
  54. datacharter/server/static/assets/ini-Kd9XrMLS.js +1 -0
  55. datacharter/server/static/assets/java-CXBNlu9o.js +1 -0
  56. datacharter/server/static/assets/javascript-CZu1iw4q.js +1 -0
  57. datacharter/server/static/assets/json.worker-BF9eV5mS.js +62 -0
  58. datacharter/server/static/assets/jsonMode-BtMXpE2Q.js +7 -0
  59. datacharter/server/static/assets/julia-cl7-CwDS.js +1 -0
  60. datacharter/server/static/assets/kotlin-s7OhZKlX.js +1 -0
  61. datacharter/server/static/assets/less-9HpZscsL.js +2 -0
  62. datacharter/server/static/assets/lexon-OrD6JF1K.js +1 -0
  63. datacharter/server/static/assets/liquid-mhqehfbE.js +1 -0
  64. datacharter/server/static/assets/lspLanguageFeatures-D1NXFkgg.js +4 -0
  65. datacharter/server/static/assets/lua-Cyyb5UIc.js +1 -0
  66. datacharter/server/static/assets/m3-B8OfTtLu.js +1 -0
  67. datacharter/server/static/assets/markdown-BFxVWTOG.js +1 -0
  68. datacharter/server/static/assets/mdx-69yOd9f1.js +1 -0
  69. datacharter/server/static/assets/mips-CiqrrVzr.js +1 -0
  70. datacharter/server/static/assets/monaco-CB2SckMT.css +1 -0
  71. datacharter/server/static/assets/monaco-DNze5gXB.js +930 -0
  72. datacharter/server/static/assets/msdax-DmeGPVcC.js +1 -0
  73. datacharter/server/static/assets/mysql-C_tMU-Nz.js +1 -0
  74. datacharter/server/static/assets/objective-c-BDtDVThU.js +1 -0
  75. datacharter/server/static/assets/pascal-vHIfCaH5.js +1 -0
  76. datacharter/server/static/assets/pascaligo-DtZ0uQbO.js +1 -0
  77. datacharter/server/static/assets/perl-Ub6l9XKa.js +1 -0
  78. datacharter/server/static/assets/pgsql-BlNEE0v7.js +1 -0
  79. datacharter/server/static/assets/php-BBUBE1dy.js +1 -0
  80. datacharter/server/static/assets/pla-DSh2-awV.js +1 -0
  81. datacharter/server/static/assets/postiats-CocnycG-.js +1 -0
  82. datacharter/server/static/assets/powerquery-tScXyioY.js +1 -0
  83. datacharter/server/static/assets/powershell-COWaemsV.js +1 -0
  84. datacharter/server/static/assets/protobuf-Brw8urJB.js +2 -0
  85. datacharter/server/static/assets/pug-8SOpv6rk.js +1 -0
  86. datacharter/server/static/assets/python-C5qpjWC4.js +1 -0
  87. datacharter/server/static/assets/qsharp-Bw9ernYp.js +1 -0
  88. datacharter/server/static/assets/r-j7ic8hl3.js +1 -0
  89. datacharter/server/static/assets/razor-fg0l3XC9.js +1 -0
  90. datacharter/server/static/assets/redis-Bu5POkcn.js +1 -0
  91. datacharter/server/static/assets/redshift-Bs9aos_-.js +1 -0
  92. datacharter/server/static/assets/restructuredtext-CqXO7rUv.js +1 -0
  93. datacharter/server/static/assets/ruby-zBfavPgS.js +1 -0
  94. datacharter/server/static/assets/rust-BzKRNQWT.js +1 -0
  95. datacharter/server/static/assets/sb-BBc9UKZt.js +1 -0
  96. datacharter/server/static/assets/scala-D9hQfWCl.js +1 -0
  97. datacharter/server/static/assets/scheme-BPhDTwHR.js +1 -0
  98. datacharter/server/static/assets/scss-CBJaRo0y.js +3 -0
  99. datacharter/server/static/assets/shell-DiJ1NA_G.js +1 -0
  100. datacharter/server/static/assets/solidity-Db0IVjzk.js +1 -0
  101. datacharter/server/static/assets/sophia-CnS9iZB_.js +1 -0
  102. datacharter/server/static/assets/sparql-CJmd_6j2.js +1 -0
  103. datacharter/server/static/assets/sql-ClhHkBeG.js +1 -0
  104. datacharter/server/static/assets/st-CHwy0fLd.js +1 -0
  105. datacharter/server/static/assets/swift-Bqt4WxQ4.js +3 -0
  106. datacharter/server/static/assets/systemverilog-Bs9z6M-B.js +1 -0
  107. datacharter/server/static/assets/tcl-Dm6ycUr_.js +1 -0
  108. datacharter/server/static/assets/ts.worker-DTZAwq0V.js +67735 -0
  109. datacharter/server/static/assets/tsMode-CaXNdXrQ.js +11 -0
  110. datacharter/server/static/assets/twig-Csy3S7wG.js +1 -0
  111. datacharter/server/static/assets/typescript-CCbVPqil.js +1 -0
  112. datacharter/server/static/assets/typespec-Btyra-wh.js +1 -0
  113. datacharter/server/static/assets/vb-Db0cS2oM.js +1 -0
  114. datacharter/server/static/assets/wgsl-BTesnYfV.js +298 -0
  115. datacharter/server/static/assets/workers-CwEVRSMl.js +1 -0
  116. datacharter/server/static/assets/xml-CIpcyvha.js +1 -0
  117. datacharter/server/static/assets/yaml-CiikAWeE.js +1 -0
  118. datacharter/server/static/index.html +15 -0
  119. datacharter-0.1.0.dist-info/METADATA +115 -0
  120. datacharter-0.1.0.dist-info/RECORD +123 -0
  121. datacharter-0.1.0.dist-info/WHEEL +4 -0
  122. datacharter-0.1.0.dist-info/entry_points.txt +2 -0
  123. datacharter-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,3 @@
1
+ """DataCharter — contract-governed local data exploration."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Natural-language agent: 5-tool loop over any OpenAI-compatible endpoint."""
2
+
3
+ from datacharter.agent.loop import Agent, AgentConfig, AgentEvent
4
+
5
+ __all__ = ["Agent", "AgentConfig", "AgentEvent"]
@@ -0,0 +1,119 @@
1
+ """Minimal OpenAI-compatible chat client (httpx, no vendor SDK — DESIGN D2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import AsyncIterator
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ __all__ = ["LLMClient", "LLMError", "ToolCall", "Delta"]
14
+
15
+
16
+ class LLMError(Exception):
17
+ """LLM endpoint failure (connection, auth, or protocol)."""
18
+
19
+
20
+ @dataclass
21
+ class ToolCall:
22
+ id: str
23
+ name: str
24
+ arguments: str # raw JSON string as emitted by the model
25
+
26
+
27
+ @dataclass
28
+ class Delta:
29
+ """One streamed step: text fragment and/or the finished tool calls."""
30
+
31
+ text: str = ""
32
+ tool_calls: list[ToolCall] | None = None
33
+
34
+
35
+ class LLMClient:
36
+ """Talks to any /chat/completions endpoint (OpenAI, Ollama, vLLM, …)."""
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ base_url: str | None = None,
42
+ api_key: str | None = None,
43
+ model: str | None = None,
44
+ timeout_s: float = 120.0,
45
+ ) -> None:
46
+ self.base_url = (
47
+ base_url or os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
48
+ ).rstrip("/")
49
+ self.api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
50
+ self.model = model or os.environ.get("DATACHARTER_MODEL", "gpt-4o-mini")
51
+ self.timeout_s = timeout_s
52
+
53
+ async def stream(
54
+ self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]
55
+ ) -> AsyncIterator[Delta]:
56
+ """Stream a completion, yielding text deltas and assembling tool calls."""
57
+ payload = {
58
+ "model": self.model,
59
+ "messages": messages,
60
+ "tools": tools,
61
+ "stream": True,
62
+ }
63
+ headers = {"content-type": "application/json"}
64
+ if self.api_key:
65
+ headers["authorization"] = f"Bearer {self.api_key}"
66
+
67
+ pending: dict[int, dict[str, str]] = {}
68
+ try:
69
+ async with (
70
+ httpx.AsyncClient(timeout=self.timeout_s) as client,
71
+ client.stream(
72
+ "POST", f"{self.base_url}/chat/completions", json=payload, headers=headers
73
+ ) as resp,
74
+ ):
75
+ if resp.status_code >= 400:
76
+ body = (await resp.aread()).decode("utf-8", "replace")[:500]
77
+ raise LLMError(f"LLM endpoint returned {resp.status_code}: {body}")
78
+ async for line in resp.aiter_lines():
79
+ delta = _parse_sse_line(line, pending)
80
+ if delta is not None:
81
+ yield delta
82
+ except httpx.HTTPError as exc:
83
+ raise LLMError(f"Could not reach LLM at {self.base_url}: {exc}") from None
84
+
85
+ if pending:
86
+ calls = [
87
+ ToolCall(id=c["id"], name=c["name"], arguments=c["arguments"])
88
+ for c in pending.values()
89
+ if c.get("name")
90
+ ]
91
+ if calls:
92
+ yield Delta(tool_calls=calls)
93
+
94
+
95
+ def _parse_sse_line(line: str, pending: dict[int, dict[str, str]]) -> Delta | None:
96
+ if not line.startswith("data:"):
97
+ return None
98
+ data = line[len("data:") :].strip()
99
+ if not data or data == "[DONE]":
100
+ return None
101
+ try:
102
+ chunk = json.loads(data)
103
+ except json.JSONDecodeError:
104
+ return None
105
+ choices = chunk.get("choices") or [{}]
106
+ delta = choices[0].get("delta") or {}
107
+
108
+ text = delta.get("content") or ""
109
+ for tc in delta.get("tool_calls") or []:
110
+ idx = tc.get("index", 0)
111
+ slot = pending.setdefault(idx, {"id": "", "name": "", "arguments": ""})
112
+ if tc.get("id"):
113
+ slot["id"] = tc["id"]
114
+ fn = tc.get("function") or {}
115
+ if fn.get("name"):
116
+ slot["name"] = fn["name"]
117
+ if fn.get("arguments"):
118
+ slot["arguments"] += fn["arguments"]
119
+ return Delta(text=text) if text else None
@@ -0,0 +1,103 @@
1
+ """The agent loop: stream text, execute tool calls, feed results back until done."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Literal
8
+
9
+ from datacharter.agent.llm import LLMClient, LLMError
10
+ from datacharter.agent.tools import TOOL_SPECS, ToolBox
11
+
12
+ __all__ = ["Agent", "AgentConfig", "AgentEvent"]
13
+
14
+ MAX_TURNS = 8
15
+
16
+ SYSTEM_PROMPT = """\
17
+ You are DataCharter's data analyst. You answer questions about the user's data
18
+ by exploring the catalog and running read-only SQL through your tools.
19
+
20
+ Rules:
21
+ - Discover before querying: use list_tables / describe_table to learn the
22
+ schema. Never guess column names.
23
+ - Use fully-qualified relation names exactly as list_tables reports them.
24
+ - Queries are read-only. Keep exploratory queries small with LIMIT.
25
+ - Some columns are PII and come back masked as •••; never claim to reveal them.
26
+ - When a result is best seen as a chart, end your answer with a fenced
27
+ ```vega-lite block containing a valid Vega-Lite spec whose data.values you
28
+ fill from your query results.
29
+ - Be concise. Show the SQL you ran.
30
+ """
31
+
32
+
33
+ @dataclass
34
+ class AgentConfig:
35
+ llm: LLMClient = field(default_factory=LLMClient)
36
+ max_turns: int = MAX_TURNS
37
+
38
+
39
+ @dataclass
40
+ class AgentEvent:
41
+ kind: Literal["text", "tool_call", "tool_result", "error", "done"]
42
+ text: str = ""
43
+ tool: str = ""
44
+ detail: str = ""
45
+
46
+
47
+ class Agent:
48
+ """One conversational turn = many LLM/tool rounds until a final answer."""
49
+
50
+ def __init__(self, tools: ToolBox, config: AgentConfig | None = None) -> None:
51
+ self._tools = tools
52
+ self._config = config or AgentConfig()
53
+
54
+ async def run(
55
+ self, question: str, history: list[dict[str, Any]] | None = None
56
+ ) -> AsyncIterator[AgentEvent]:
57
+ messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
58
+ messages.extend(history or [])
59
+ messages.append({"role": "user", "content": question})
60
+
61
+ for _turn in range(self._config.max_turns):
62
+ assistant_text = ""
63
+ tool_calls = None
64
+ try:
65
+ async for delta in self._config.llm.stream(messages, TOOL_SPECS):
66
+ if delta.text:
67
+ assistant_text += delta.text
68
+ yield AgentEvent(kind="text", text=delta.text)
69
+ if delta.tool_calls:
70
+ tool_calls = delta.tool_calls
71
+ except LLMError as exc:
72
+ yield AgentEvent(kind="error", detail=str(exc))
73
+ return
74
+
75
+ if not tool_calls:
76
+ yield AgentEvent(kind="done")
77
+ return
78
+
79
+ messages.append(
80
+ {
81
+ "role": "assistant",
82
+ "content": assistant_text or None,
83
+ "tool_calls": [
84
+ {
85
+ "id": c.id,
86
+ "type": "function",
87
+ "function": {"name": c.name, "arguments": c.arguments},
88
+ }
89
+ for c in tool_calls
90
+ ],
91
+ }
92
+ )
93
+ for call in tool_calls:
94
+ yield AgentEvent(kind="tool_call", tool=call.name, detail=call.arguments)
95
+ result = await self._tools.run(call.name, call.arguments)
96
+ yield AgentEvent(kind="tool_result", tool=call.name, detail=result)
97
+ messages.append(
98
+ {"role": "tool", "tool_call_id": call.id, "content": result}
99
+ )
100
+
101
+ yield AgentEvent(
102
+ kind="error", detail=f"Stopped after {self._config.max_turns} tool rounds."
103
+ )
@@ -0,0 +1,146 @@
1
+ """Agent tools over the engine: catalog introspection + guarded querying with PII masking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from datacharter.engine.session import Engine
9
+ from datacharter.models import QueryResult, Source
10
+
11
+ __all__ = ["ToolBox", "TOOL_SPECS"]
12
+
13
+ TOOL_SPECS: list[dict[str, Any]] = [
14
+ {
15
+ "type": "function",
16
+ "function": {
17
+ "name": "list_sources",
18
+ "description": "List configured data sources with their types.",
19
+ "parameters": {"type": "object", "properties": {}},
20
+ },
21
+ },
22
+ {
23
+ "type": "function",
24
+ "function": {
25
+ "name": "list_tables",
26
+ "description": "List all queryable tables with their fully-qualified relation names.",
27
+ "parameters": {"type": "object", "properties": {}},
28
+ },
29
+ },
30
+ {
31
+ "type": "function",
32
+ "function": {
33
+ "name": "describe_table",
34
+ "description": "Show columns and types for one relation (e.g. 'crm.customers').",
35
+ "parameters": {
36
+ "type": "object",
37
+ "properties": {"relation": {"type": "string"}},
38
+ "required": ["relation"],
39
+ },
40
+ },
41
+ },
42
+ {
43
+ "type": "function",
44
+ "function": {
45
+ "name": "query",
46
+ "description": (
47
+ "Run a read-only SQL query and return rows. Use fully-qualified "
48
+ "relation names. Prefer LIMIT for exploration."
49
+ ),
50
+ "parameters": {
51
+ "type": "object",
52
+ "properties": {"sql": {"type": "string"}},
53
+ "required": ["sql"],
54
+ },
55
+ },
56
+ },
57
+ ]
58
+
59
+ MASKED = "•••"
60
+ _MAX_TOOL_ROWS = 50
61
+
62
+
63
+ class ToolBox:
64
+ """Executes tool calls against the engine; masks PII columns in returned data."""
65
+
66
+ def __init__(self, engine: Engine, sources: list[Source]) -> None:
67
+ self._engine = engine
68
+ # Column names flagged PII in any source's contract, matched case-insensitively.
69
+ self._pii: set[str] = set()
70
+ for src in sources:
71
+ for cols in src.pii.values():
72
+ self._pii.update(c.lower() for c in cols)
73
+
74
+ async def run(self, name: str, arguments: str) -> str:
75
+ try:
76
+ args = json.loads(arguments or "{}")
77
+ except json.JSONDecodeError:
78
+ return "Error: tool arguments were not valid JSON."
79
+ handler = {
80
+ "list_sources": self._list_sources,
81
+ "list_tables": self._list_tables,
82
+ "describe_table": self._describe_table,
83
+ "query": self._query,
84
+ }.get(name)
85
+ if handler is None:
86
+ return f"Error: unknown tool '{name}'."
87
+ try:
88
+ return await handler(args)
89
+ except Exception as exc: # engine errors already scrubbed; surface to the model
90
+ return f"Error: {exc}"
91
+
92
+ async def _list_sources(self, _args: dict) -> str:
93
+ rows = [{"name": s.name, "type": s.type.value} for s in self._engine.sources]
94
+ return json.dumps(rows)
95
+
96
+ async def _list_tables(self, _args: dict) -> str:
97
+ result = await self._engine.query("SHOW ALL TABLES", timeout_s=30)
98
+ idx = {c: i for i, c in enumerate(result.columns)}
99
+ out = []
100
+ for row in result.rows:
101
+ db = row[idx["database"]]
102
+ if db in ("system", "temp"):
103
+ continue
104
+ table = row[idx["name"]]
105
+ relation = table if db == "memory" else f"{db}.{table}"
106
+ out.append({"relation": relation, "columns": list(row[idx["column_names"]])})
107
+ return json.dumps(out)
108
+
109
+ async def _describe_table(self, args: dict) -> str:
110
+ relation = str(args.get("relation", ""))
111
+ if not _is_safe_relation(relation):
112
+ return "Error: invalid relation name."
113
+ result = await self._engine.query(f"DESCRIBE {relation}", timeout_s=30)
114
+ return self._render(result)
115
+
116
+ async def _query(self, args: dict) -> str:
117
+ sql = str(args.get("sql", ""))
118
+ result = await self._engine.query(sql, row_limit=_MAX_TOOL_ROWS)
119
+ return self._render(result)
120
+
121
+ def _render(self, result: QueryResult) -> str:
122
+ mask_idx = {
123
+ i for i, col in enumerate(result.columns) if col.lower() in self._pii
124
+ }
125
+ rows = [
126
+ [MASKED if i in mask_idx else v for i, v in enumerate(row)]
127
+ for row in result.rows
128
+ ]
129
+ payload = {
130
+ "columns": result.columns,
131
+ "rows": rows,
132
+ "row_count": result.row_count,
133
+ "truncated": result.truncated,
134
+ }
135
+ if result.warnings:
136
+ payload["warnings"] = result.warnings
137
+ if mask_idx:
138
+ payload["masked_columns"] = [result.columns[i] for i in mask_idx]
139
+ return json.dumps(payload, default=str)
140
+
141
+
142
+ def _is_safe_relation(relation: str) -> bool:
143
+ parts = relation.split(".")
144
+ return 1 <= len(parts) <= 3 and all(
145
+ p and all(ch.isalnum() or ch == "_" for ch in p) for p in parts
146
+ )
datacharter/cli.py ADDED
@@ -0,0 +1,249 @@
1
+ """Command-line entrypoint: init (workspace scaffolding), serve (M3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from datacharter import __version__
11
+
12
+ CHARTER_TEMPLATE = """\
13
+ # DataCharter workspace — https://github.com/datacharter/datacharter
14
+ # Sources are data contracts: connection shape here, secrets NEVER here.
15
+ # Credentials must be ${NAME} references resolved from your environment,
16
+ # .env, or the OS keyring (`datacharter secrets set NAME`).
17
+ version: 1
18
+
19
+ sources: {}
20
+ """
21
+
22
+ DEMO_CHARTER = """\
23
+ # DataCharter demo workspace. Run: datacharter serve
24
+ version: 1
25
+
26
+ sources:
27
+ customers:
28
+ type: csv
29
+ path: demo/customers.csv
30
+ pii:
31
+ customers: [email]
32
+
33
+ orders:
34
+ type: parquet
35
+ path: demo/orders.parquet
36
+ """
37
+
38
+ ENV_EXAMPLE = """\
39
+ # Copy to .env and fill in real values. .env is gitignored.
40
+ # EXAMPLE_DB_PASSWORD=change-me
41
+ """
42
+
43
+ GITIGNORE_BLOCK = """\
44
+ # DataCharter local state (never commit)
45
+ .env
46
+ .datacharter/
47
+ """
48
+
49
+
50
+ def _cmd_init(args: argparse.Namespace) -> int:
51
+ ws = Path(args.directory).resolve()
52
+ ws.mkdir(parents=True, exist_ok=True)
53
+ charter = ws / "charter.yaml"
54
+ if charter.exists() and not args.force:
55
+ print(f"charter.yaml already exists in {ws} (use --force to overwrite).")
56
+ return 1
57
+
58
+ charter.write_text(DEMO_CHARTER if args.demo else CHARTER_TEMPLATE)
59
+ (ws / ".env.example").write_text(ENV_EXAMPLE)
60
+ (ws / "queries").mkdir(exist_ok=True)
61
+ _ensure_gitignore(ws)
62
+ if args.demo:
63
+ _write_demo_data(ws)
64
+ print(f"Workspace initialized in {ws}.")
65
+ if args.demo:
66
+ print("Demo data in demo/ — try: datacharter serve")
67
+ return 0
68
+
69
+
70
+ def _ensure_gitignore(ws: Path) -> None:
71
+ gi = ws / ".gitignore"
72
+ existing = gi.read_text() if gi.exists() else ""
73
+ if ".datacharter/" not in existing:
74
+ joiner = "\n" if existing and not existing.endswith("\n") else ""
75
+ gi.write_text(existing + joiner + GITIGNORE_BLOCK)
76
+
77
+
78
+ def _write_demo_data(ws: Path) -> None:
79
+ import duckdb
80
+
81
+ demo = ws / "demo"
82
+ demo.mkdir(exist_ok=True)
83
+ con = duckdb.connect()
84
+ con.execute(
85
+ "COPY (SELECT * FROM (VALUES "
86
+ "(1, 'ada@example.com', 'pro'), (2, 'grace@example.com', 'free'), "
87
+ "(3, 'edsger@example.com', 'pro')) t(id, email, tier)) TO "
88
+ f"'{demo / 'customers.csv'}' (FORMAT csv, HEADER)"
89
+ )
90
+ con.execute(
91
+ "COPY (SELECT (i % 3) + 1 AS customer_id, round(random() * 200, 2) AS total, "
92
+ "DATE '2026-01-01' + INTERVAL (i) DAY AS placed_on "
93
+ f"FROM range(90) t(i)) TO '{demo / 'orders.parquet'}' (FORMAT parquet)"
94
+ )
95
+ con.close()
96
+
97
+
98
+ def _resolve_serve_workspace(directory: str) -> Path:
99
+ """Workspace for serve; ephemeral demo when no charter.yaml (D3, no surprise writes)."""
100
+ ws = Path(directory).resolve()
101
+ if (ws / "charter.yaml").exists():
102
+ return ws
103
+ import tempfile
104
+
105
+ demo_ws = Path(tempfile.mkdtemp(prefix="datacharter-demo-"))
106
+ (demo_ws / "charter.yaml").write_text(DEMO_CHARTER)
107
+ _write_demo_data(demo_ws)
108
+ print(f"No charter.yaml in {ws} — serving an ephemeral demo workspace ({demo_ws}).")
109
+ print("Run `datacharter init` here to start a real workspace.")
110
+ return demo_ws
111
+
112
+
113
+ LOCAL_BASE_URL = "http://127.0.0.1:11434/v1"
114
+ LOCAL_DEFAULT_MODEL = "qwen3:8b"
115
+
116
+
117
+ def _local_llm(model: str | None):
118
+ """Point the agent at a local Ollama; verify it's reachable, hint if not."""
119
+ import httpx
120
+
121
+ from datacharter.agent.llm import LLMClient
122
+
123
+ chosen = model or LOCAL_DEFAULT_MODEL
124
+ try:
125
+ httpx.get("http://127.0.0.1:11434/api/tags", timeout=2.0).raise_for_status()
126
+ except Exception:
127
+ print("Ollama not reachable at 127.0.0.1:11434.")
128
+ print("Install from https://ollama.com, then: ollama pull " + chosen)
129
+ print("Serving without a local agent (the UI still works; chat will be disabled).")
130
+ return None
131
+ print(f"Local agent: Ollama model '{chosen}'. Pull it with: ollama pull {chosen}")
132
+ return LLMClient(base_url=LOCAL_BASE_URL, api_key="ollama", model=chosen)
133
+
134
+
135
+ def _cmd_serve(args: argparse.Namespace) -> int:
136
+ import uvicorn
137
+
138
+ from datacharter.server import create_app
139
+
140
+ ws = _resolve_serve_workspace(args.directory)
141
+ llm = _local_llm(args.model) if args.local else None
142
+ app = create_app(ws, allow_spill=not args.no_spill, llm=llm, host=args.host)
143
+ print(f"DataCharter serving {ws} on http://{args.host}:{args.port}")
144
+ uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
145
+ return 0
146
+
147
+
148
+ _SECRETS_INDEX = "__datacharter_index__" # reserved key tracking stored names for `list`
149
+
150
+
151
+ def _secret_index(kr, service: str) -> list[str]:
152
+ try:
153
+ raw = kr.get_password(service, _SECRETS_INDEX)
154
+ except Exception:
155
+ return []
156
+ return [n for n in (raw or "").split(",") if n]
157
+
158
+
159
+ def _cmd_secrets(args: argparse.Namespace) -> int:
160
+ import getpass
161
+
162
+ try:
163
+ import keyring
164
+ except ImportError:
165
+ print("The 'keyring' package is required for secret storage.", file=sys.stderr)
166
+ return 1
167
+ from datacharter.contracts.resolve import KEYRING_SERVICE
168
+
169
+ if args.action == "set":
170
+ value = args.value
171
+ if value is None:
172
+ value = getpass.getpass(f"Value for {args.name}: ")
173
+ if not value:
174
+ print("No value provided; nothing stored.", file=sys.stderr)
175
+ return 1
176
+ try:
177
+ keyring.set_password(KEYRING_SERVICE, args.name, value)
178
+ names = sorted(set(_secret_index(keyring, KEYRING_SERVICE)) | {args.name})
179
+ keyring.set_password(KEYRING_SERVICE, _SECRETS_INDEX, ",".join(names))
180
+ except Exception as exc:
181
+ print(f"Could not store secret (no keyring backend?): {exc}", file=sys.stderr)
182
+ return 1
183
+ print(f"Stored '{args.name}' in the OS keyring.")
184
+ return 0
185
+ if args.action == "rm":
186
+ with contextlib.suppress(Exception):
187
+ keyring.delete_password(KEYRING_SERVICE, args.name)
188
+ names = [n for n in _secret_index(keyring, KEYRING_SERVICE) if n != args.name]
189
+ with contextlib.suppress(Exception):
190
+ keyring.set_password(KEYRING_SERVICE, _SECRETS_INDEX, ",".join(names))
191
+ print(f"Removed '{args.name}'.")
192
+ return 0
193
+ names = _secret_index(keyring, KEYRING_SERVICE)
194
+ if names:
195
+ print("\n".join(names))
196
+ else:
197
+ print("No secrets stored via datacharter (env and .env are not listed here).")
198
+ return 0
199
+
200
+
201
+ def main(argv: list[str] | None = None) -> int:
202
+ parser = argparse.ArgumentParser(prog="datacharter", description="Charter your data.")
203
+ parser.add_argument("--version", action="version", version=f"datacharter {__version__}")
204
+ sub = parser.add_subparsers(dest="command")
205
+
206
+ p_init = sub.add_parser(
207
+ "init", help="Scaffold a workspace (charter.yaml, queries/, .env.example)"
208
+ )
209
+ p_init.add_argument("directory", nargs="?", default=".")
210
+ p_init.add_argument("--demo", action="store_true", help="Include a generated demo dataset")
211
+ p_init.add_argument("--force", action="store_true", help="Overwrite an existing charter.yaml")
212
+ p_init.set_defaults(func=_cmd_init)
213
+
214
+ p_serve = sub.add_parser("serve", help="Start the local server")
215
+ p_serve.add_argument("directory", nargs="?", default=".")
216
+ p_serve.add_argument(
217
+ "--host", default="127.0.0.1", help="Bind address (default: localhost only)"
218
+ )
219
+ p_serve.add_argument("--port", type=int, default=8321)
220
+ p_serve.add_argument(
221
+ "--no-spill", action="store_true", help="Fail queries instead of spilling to disk"
222
+ )
223
+ p_serve.add_argument(
224
+ "--local", action="store_true", help="Use a local Ollama model for the agent"
225
+ )
226
+ p_serve.add_argument("--model", help="Model name (with --local; default qwen3:8b)")
227
+ p_serve.set_defaults(func=_cmd_serve)
228
+
229
+ p_secrets = sub.add_parser("secrets", help="Manage ${NAME} secrets in the OS keyring")
230
+ sec_sub = p_secrets.add_subparsers(dest="action")
231
+ p_set = sec_sub.add_parser("set", help="Store a secret (prompts if --value omitted)")
232
+ p_set.add_argument("name")
233
+ p_set.add_argument("--value", help="Value (omit to be prompted without echo)")
234
+ sec_sub.add_parser("rm", help="Remove a secret").add_argument("name")
235
+ sec_sub.add_parser("list", help="List secret names stored via datacharter")
236
+ p_secrets.set_defaults(func=_cmd_secrets)
237
+
238
+ args = parser.parse_args(argv)
239
+ if args.command == "secrets" and not getattr(args, "action", None):
240
+ p_secrets.print_help()
241
+ return 1
242
+ if not hasattr(args, "func"):
243
+ parser.print_help()
244
+ return 1
245
+ return args.func(args)
246
+
247
+
248
+ if __name__ == "__main__":
249
+ sys.exit(main())
@@ -0,0 +1,5 @@
1
+ """charter.yaml loading: parsing, secret resolution, validation, portability lint."""
2
+
3
+ from datacharter.contracts.loader import Charter, CharterError, load_charter
4
+
5
+ __all__ = ["Charter", "CharterError", "load_charter"]