mdb-cli 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.
mdb/puller.py ADDED
@@ -0,0 +1,212 @@
1
+ """SQLite pull-phase operations: query execution and feed ingestion."""
2
+
3
+ import hashlib
4
+ import os
5
+ import sqlite3
6
+
7
+ from mdb.models import FeedResult, TapMarker, TapResult
8
+
9
+
10
+ def execute_tap_query(db_path: str, query: str) -> TapResult:
11
+ """Execute a SELECT query against a SQLite database in read-only mode.
12
+
13
+ Opens the database via file: URI with mode=ro.
14
+ Returns TapResult with columns, rows, and success/error status.
15
+ Always closes the connection.
16
+ """
17
+ # Create a dummy marker for the result
18
+ marker = TapMarker(line_number=0, scope=db_path, raw_query=query)
19
+ conn = None
20
+ try:
21
+ uri = f"file:{db_path}?mode=ro"
22
+ conn = sqlite3.connect(uri, uri=True)
23
+ cursor = conn.execute(query)
24
+ columns = [desc[0] for desc in cursor.description]
25
+ raw_rows = cursor.fetchall()
26
+ rows = [
27
+ [str(cell) if cell is not None else "" for cell in row]
28
+ for row in raw_rows
29
+ ]
30
+ return TapResult(
31
+ marker=marker,
32
+ success=True,
33
+ columns=columns,
34
+ rows=rows,
35
+ )
36
+ except Exception as e:
37
+ return TapResult(
38
+ marker=marker,
39
+ success=False,
40
+ error=str(e),
41
+ )
42
+ finally:
43
+ if conn:
44
+ conn.close()
45
+
46
+
47
+ def compute_content_hash(
48
+ columns: list[str],
49
+ column_types: list[str],
50
+ rows: list[list[str]],
51
+ ) -> str:
52
+ """Compute a deterministic SHA-256 hash from column names, types, and row values.
53
+
54
+ Returns a 64-character lowercase hexadecimal string.
55
+ """
56
+ parts = []
57
+ # Column definitions: name:TYPE joined by \0
58
+ col_defs = "\0".join(f"{name}:{typ}" for name, typ in zip(columns, column_types))
59
+ parts.append(col_defs)
60
+ # Section separator
61
+ parts.append("\0\0")
62
+ # Row data: cells joined by \0, each row terminated by \n
63
+ for row in rows:
64
+ parts.append("\0".join(row))
65
+ parts.append("\n")
66
+ serialized = "".join(parts)
67
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
68
+
69
+
70
+ def ingest_feed_table(db_path: str, table_name: str, columns: list[str], rows: list[list[str]], column_types: list[str] | None = None) -> FeedResult:
71
+ """Drop and recreate a table in the SQLite database, inserting all rows.
72
+
73
+ Creates the database file if it does not exist.
74
+ Wraps DROP + CREATE + INSERT in a single transaction.
75
+ When column_types is provided, uses specified types; otherwise defaults to TEXT.
76
+ """
77
+ try:
78
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
79
+ conn = sqlite3.connect(db_path)
80
+ try:
81
+ # Quote column names to handle spaces and reserved words
82
+ quoted_cols = [f'"{col}"' for col in columns]
83
+ types_for_hash = column_types if column_types is not None else ["TEXT"] * len(columns)
84
+ if column_types is not None:
85
+ col_defs = ", ".join(
86
+ f'{qc} {ct}' for qc, ct in zip(quoted_cols, column_types)
87
+ )
88
+ else:
89
+ col_defs = ", ".join(f'{qc} TEXT' for qc in quoted_cols)
90
+ col_list = ", ".join(quoted_cols)
91
+ placeholders = ", ".join("?" for _ in columns)
92
+
93
+ conn.execute("BEGIN TRANSACTION")
94
+
95
+ # Ensure metadata table exists
96
+ conn.execute(
97
+ "CREATE TABLE IF NOT EXISTS _mdb_meta "
98
+ "(table_name TEXT PRIMARY KEY, content_hash TEXT NOT NULL)"
99
+ )
100
+
101
+ # Compute content hash
102
+ computed_hash = compute_content_hash(columns, types_for_hash, rows)
103
+
104
+ # Look up stored hash
105
+ cursor = conn.execute(
106
+ "SELECT content_hash FROM _mdb_meta WHERE table_name = ?",
107
+ (table_name,),
108
+ )
109
+ row_result = cursor.fetchone()
110
+
111
+ # Skip if hash matches
112
+ if row_result is not None and row_result[0] == computed_hash:
113
+ return FeedResult(
114
+ task=None,
115
+ success=True,
116
+ skipped=True,
117
+ rows_written=0,
118
+ )
119
+
120
+ # Hash mismatch or no stored hash: full write
121
+ conn.execute(f'DROP TABLE IF EXISTS "{table_name}"')
122
+ conn.execute(f'CREATE TABLE "{table_name}" ({col_defs})')
123
+
124
+ if rows:
125
+ conn.executemany(
126
+ f'INSERT INTO "{table_name}" ({col_list}) VALUES ({placeholders})',
127
+ rows,
128
+ )
129
+
130
+ # Store hash
131
+ conn.execute(
132
+ "INSERT OR REPLACE INTO _mdb_meta (table_name, content_hash) VALUES (?, ?)",
133
+ (table_name, computed_hash),
134
+ )
135
+
136
+ conn.commit()
137
+
138
+ return FeedResult(
139
+ task=None,
140
+ success=True,
141
+ rows_written=len(rows),
142
+ )
143
+ except Exception as e:
144
+ conn.rollback()
145
+ return FeedResult(
146
+ task=None,
147
+ success=False,
148
+ error=str(e),
149
+ rows_written=0,
150
+ )
151
+ finally:
152
+ conn.close()
153
+ except Exception as e:
154
+ return FeedResult(
155
+ task=None,
156
+ success=False,
157
+ error=str(e),
158
+ rows_written=0,
159
+ )
160
+
161
+
162
+ def cleanup_stale_tables(db_path: str, discovered_tables: set[str]) -> list[str]:
163
+ """Remove orphaned tables and their metadata from a database.
164
+
165
+ Compares tracked tables in _mdb_meta against the set of table names
166
+ declared by markers in the known universe. Tables present in _mdb_meta
167
+ but not in discovered_tables are dropped and their metadata removed.
168
+
169
+ Returns sorted list of removed table names, or [] if no cleanup needed.
170
+ Never raises exceptions to caller.
171
+ """
172
+ if not os.path.exists(db_path):
173
+ return []
174
+
175
+ try:
176
+ conn = sqlite3.connect(db_path)
177
+ try:
178
+ # Check if _mdb_meta exists
179
+ cursor = conn.execute(
180
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='_mdb_meta'"
181
+ )
182
+ if cursor.fetchone() is None:
183
+ return []
184
+
185
+ # Get tracked table names
186
+ cursor = conn.execute("SELECT table_name FROM _mdb_meta")
187
+ tracked = set(row[0] for row in cursor.fetchall())
188
+
189
+ # Compute orphans
190
+ orphaned = tracked - discovered_tables
191
+ if not orphaned:
192
+ return []
193
+
194
+ # Remove orphans in a single transaction
195
+ removed = sorted(orphaned)
196
+ conn.execute("BEGIN TRANSACTION")
197
+ for name in removed:
198
+ conn.execute(f'DROP TABLE IF EXISTS "{name}"')
199
+ conn.execute("DELETE FROM _mdb_meta WHERE table_name = ?", (name,))
200
+ conn.commit()
201
+
202
+ return removed
203
+ except Exception:
204
+ try:
205
+ conn.rollback()
206
+ except Exception:
207
+ pass
208
+ return []
209
+ finally:
210
+ conn.close()
211
+ except Exception:
212
+ return []
mdb/validators.py ADDED
@@ -0,0 +1,46 @@
1
+ """SQL query validators for mdb."""
2
+
3
+ import re
4
+
5
+ # Write keywords to reject (case-insensitive, word boundary match)
6
+ _WRITE_KEYWORDS_RE = re.compile(
7
+ r'\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b',
8
+ re.IGNORECASE,
9
+ )
10
+
11
+
12
+ def validate_dql(query: str) -> str | None:
13
+ """Validate that a SQL query contains only read-only (DQL) operations.
14
+
15
+ Returns None if valid (read-only), error message string if invalid.
16
+ """
17
+ match = _WRITE_KEYWORDS_RE.search(query)
18
+ if match:
19
+ keyword = match.group(1).upper()
20
+ return f"Query contains write operation: {keyword}"
21
+ return None
22
+
23
+
24
+ def validate_dml(query: str) -> str | None:
25
+ """Validate that all statements in a query are DML.
26
+
27
+ Returns None if all statements are valid DML.
28
+ Error message string if any statement is invalid.
29
+ """
30
+ _DML_KEYWORDS = {"INSERT", "UPDATE", "DELETE"}
31
+ _SELECT_KEYWORDS = {"SELECT"}
32
+ _DDL_KEYWORDS = {"CREATE", "DROP", "ALTER"}
33
+
34
+ statements = query.split(";")
35
+ for stmt in statements:
36
+ stripped = stmt.strip()
37
+ if not stripped:
38
+ continue
39
+ first_word = stripped.split()[0].upper()
40
+ if first_word in _SELECT_KEYWORDS:
41
+ return "Only mutative queries (INSERT, UPDATE, DELETE) are permitted with push. For SELECT queries, use mdb pull."
42
+ if first_word in _DDL_KEYWORDS:
43
+ return "Only DML statements are permitted. DDL (CREATE, DROP, ALTER) is not supported."
44
+ if first_word not in _DML_KEYWORDS:
45
+ return f"Unrecognized statement keyword: {first_word}. Only INSERT, UPDATE, DELETE are permitted."
46
+ return None
@@ -0,0 +1,220 @@
1
+ Metadata-Version: 2.4
2
+ Name: mdb-cli
3
+ Version: 0.1.0
4
+ Summary: Markdown table workflows w/ SQLite powers ✨
5
+ Project-URL: Homepage, https://atomanoid.github.io/mdb/
6
+ Project-URL: Repository, https://github.com/atomanoid/mdb
7
+ Project-URL: Documentation, https://atomanoid.github.io/mdb/
8
+ Author: atomanoid
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Database
20
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Provides-Extra: docs
24
+ Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
25
+ Requires-Dist: mkdocs>=1.6; extra == 'docs'
26
+ Requires-Dist: mkdocstrings[python]>=0.25; extra == 'docs'
27
+ Description-Content-Type: text/markdown
28
+
29
+ <p align="center">
30
+ <!-- <img src="https://raw.githubusercontent.com/atomanoid/mdb/main/docs/assets/floppy.svg" alt="floppy disk" width="128"> -->
31
+ <img src="https://raw.githubusercontent.com/gist/atomanoid/2e8135956d498969842907b1b149c72f/raw/f9d3aa40f4ebf7b347de617263ac1c7492bd6203/mdb-floppy.svg" alt="floppy disk" width="128">
32
+
33
+ </p>
34
+
35
+ <h1 align="center"><code>mdb-cli</code></h1>
36
+
37
+ <h3 align="center">Markdown table workflows w/ SQLite powers ✨</h3>
38
+
39
+ `mdb-cli` provides the `mdb` command-line tool to make markdown tables data-driven and queryable via SQL, using specialized co-located markers `💾 ...` with some embedded inline SQL queries to **define and dynamically view** our project-level data.
40
+
41
+ - **Zero runtime dependencies** -- standard library only
42
+ - **Simple integration** -- add markers and get running
43
+ - **Python 3.11+** required
44
+
45
+ ## Installation
46
+
47
+ Install from [PyPI](https://pypi.org/project/mdb-cli/):
48
+
49
+ ```bash
50
+ # Using pipx (recommended)
51
+ pipx install mdb-cli
52
+
53
+ # Using uv (recommended)
54
+ uv tool install mdb-cli
55
+
56
+ # Using pip
57
+ pip install mdb-cli
58
+ ```
59
+
60
+ After installation, verify the tool is available:
61
+
62
+ ```bash
63
+ mdb --help
64
+ ```
65
+
66
+ For development installation, see [CONTRIBUTING.md](https://github.com/atomanoid/mdb/blob/main/CONTRIBUTING.md).
67
+
68
+ ## Basic Usage
69
+
70
+ ### Marker Syntax
71
+
72
+ Place inline query markers above tables in your markdown file using the following format:
73
+
74
+ ```
75
+ `💾 [scope] <directive> <sql-query>`
76
+ ```
77
+
78
+ | Component | Description |
79
+ | ------------- | ------------------------------------------------------- |
80
+ | `💾` | Marker prefix -- identifies the line as an `mdb` marker |
81
+ | `[scope]` | Optional named scope identifier of the dataset |
82
+ | `<directive>` | Directive: `🌀` (feed) or `💎` (tap) |
83
+ | `<sql-query>` | SQL query to execute against the dataset |
84
+
85
+ ## Directives
86
+
87
+ | Directive | Name | Instruction |
88
+ | --------- | ---- | ----------------------------------------------------------------------------------------------------------------- |
89
+ | `🌀` | feed | Include the table below me as part of the dataset |
90
+ | `💎` | tap | Execute my SQL query on the dataset and render the results as the table below me (table auto-generated if absent) |
91
+
92
+ > Under the hood, datasets are ingested into SQLite backing databases on which SQL queries are actually run.
93
+
94
+ ### Subcommands
95
+
96
+ | Subcommand | Behavior |
97
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------- |
98
+ | `mdb pull <sql-query>` | process all `🌀` markers first (pulls), then outputs SQL query results in CSV format |
99
+ | `mdb push` | process all `🌀` markers first (pulls), then all `💎` markers after (pushes) |
100
+ | `mdb push <sql-mutation>` | process all `🌀` markers first (pulls), execute the mutation, then all `🌀` and `💎` markers after (pushes) |
101
+
102
+ ### Example
103
+
104
+ Given a project file `snacks.md` with the following content:
105
+
106
+ ```markdown
107
+ # Snacks
108
+
109
+ ## Inventory
110
+
111
+ `💾 🌀 SELECT * FROM snacks`
112
+
113
+ | name | vibe | qty |
114
+ | ------------------ | ------------------ | --- |
115
+ | Hot Cheese Popcorn | chaos energy | 3 |
116
+ | Pocky | elegant simplicity | 12 |
117
+ | Takis | rolled-up danger | 7 |
118
+ | Goldfish | the people's snack | 41 |
119
+
120
+ ## Low stock
121
+
122
+ `💾 💎 SELECT name, qty FROM snacks WHERE qty < 10 ORDER BY qty`
123
+ ```
124
+
125
+ After running:
126
+
127
+ ```bash
128
+ mdb push
129
+ ```
130
+
131
+ We'll have the `snacks.md` updated to the following content:
132
+
133
+ ```markdown
134
+ # Snacks
135
+
136
+ ## Inventory
137
+
138
+ `💾 🌀 SELECT * FROM snacks`
139
+
140
+ | name | vibe | qty |
141
+ | ------------------ | ------------------ | --- |
142
+ | Hot Cheese Popcorn | chaos energy | 3 |
143
+ | Pocky | elegant simplicity | 12 |
144
+ | Takis | rolled-up danger | 7 |
145
+ | Goldfish | the people's snack | 41 |
146
+
147
+ ## Low stock
148
+
149
+ `💾 💎 SELECT name, qty FROM snacks WHERE qty < 10 ORDER BY qty`
150
+
151
+ | name | qty |
152
+ | ------------------ | --- |
153
+ | Hot Cheese Popcorn | 3 |
154
+ | Takis | 7 |
155
+ ```
156
+
157
+ The 🌀 (feed) marker ingests the snacks data into the (unscoped) dataset, then the 💎 (tap) marker executes the query and surfaces only the snacks running low in stock.
158
+
159
+ ---
160
+
161
+ Additionally, after running:
162
+
163
+ ```bash
164
+ mdb pull "SELECT SUM(qty) as total_snacks FROM snacks"
165
+ ```
166
+
167
+ We'll get the output:
168
+
169
+ ```
170
+ total_snacks
171
+ 63
172
+ ```
173
+
174
+ The 🌀 (feed) marker again ingests the snacks data into the dataset, but the 💎 (tap) marker is not processed.
175
+
176
+ ---
177
+
178
+ We can also mutate data directly. Running:
179
+
180
+ ```bash
181
+ mdb push "UPDATE snacks SET qty = qty + 20 WHERE name = 'Takis'"
182
+ ```
183
+
184
+ The 🌀 (feed) marker again ingests the snacks data into the dataset, applies the UPDATE, then both 🌀 (feed) and 💎 (tap) markers push fresh results back:
185
+
186
+ ```markdown
187
+ ## Inventory
188
+
189
+ `💾 🌀 SELECT * FROM snacks`
190
+
191
+ | name | vibe | qty |
192
+ | ------------------ | ------------------ | --- |
193
+ | Hot Cheese Popcorn | chaos energy | 3 |
194
+ | Pocky | elegant simplicity | 12 |
195
+ | Takis | rolled-up danger | 27 |
196
+ | Goldfish | the people's snack | 41 |
197
+
198
+ ## Low stock
199
+
200
+ `💾 💎 SELECT name, qty FROM snacks WHERE qty < 10 ORDER BY qty`
201
+
202
+ | name | qty |
203
+ | ------------------ | --- |
204
+ | Hot Cheese Popcorn | 3 |
205
+ ```
206
+
207
+ Takis got restocked to 27 and dropped off the low-stock list — all from a single command.
208
+
209
+ ## Agent Support
210
+
211
+ `mdb-cli` includes a built-in agent skill. Install it:
212
+
213
+ ```
214
+ mdb init # default: .mdb/skills/mdb/SKILL.md
215
+ mdb init .claude/skills # Claude Code: .claude/skills/mdb/SKILL.md
216
+ mdb init .opencode/skills # OpenCode: .opencode/skills/mdb/SKILL.md
217
+ mdb init path/to/agent/skills # any agent: path/to/agent/skills/mdb/SKILL.md
218
+ ```
219
+
220
+ Within a session, invoke the `/mdb` slash command to access a comprehensive reference covering marker syntax, subcommand workflows, templates for common operations, and an error reference guide. The skill provides everything an AI coding assistant needs to construct and debug mdb markers without leaving the editor.
@@ -0,0 +1,18 @@
1
+ mdb/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ mdb/atomic.py,sha256=xnmxwGPWdFGZQIMLZ9uuOS6tZCZb6YayaVLj1dzCcqo,719
3
+ mdb/discovery.py,sha256=Y7sD9Xvcga6RGsOKhptLOjM2Gsb3vRPVbWXD2LYvKhw,2139
4
+ mdb/filelock.py,sha256=Lp4WYbA6Vt_-7SUffSAXhNGeuALDCXMQPkAeiVpEpYQ,2159
5
+ mdb/formatter.py,sha256=pWpllujgZQy4JAkjbLP7EJJtVl2c9cKlye6lJEJc-70,3486
6
+ mdb/init.py,sha256=CqeF8pqjmZ363sgU6Cymmmd6jMqkb8mnpHzZuirQYiw,5352
7
+ mdb/mdb.py,sha256=Zd_W64ZP3FVHD0SIYMw0SkIRu5zFrNKeKx73QMzgsGU,46314
8
+ mdb/models.py,sha256=TBPVvA5IcWLWriX5klQdgaLlIZgZR2K1Jhc2qcOtKoo,1871
9
+ mdb/parser.py,sha256=jgwIYTCoBgDAkeBxLj7UyTm75U1K5aNzuULU71U90gI,21661
10
+ mdb/puller.py,sha256=AiW0vjl5Mm4YRGNhPZbD24M4IjikJQO1-48r_ZnJ9Ds,7058
11
+ mdb/validators.py,sha256=quvEieq1_LGbFzV4K4zxLrLLgEFeYLTHqDEwHHUF6dU,1619
12
+ mdb/data/SKILL.md,sha256=0R5afekElV7QDdS9AETKqjz1MzVzNkbFJ3sDKM8DsUA,4776
13
+ mdb/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ mdb_cli-0.1.0.dist-info/METADATA,sha256=Q6M7nrayK9j0aX1bPP9SJcOffUFv5-6SaPCLn69lOrk,7657
15
+ mdb_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
16
+ mdb_cli-0.1.0.dist-info/entry_points.txt,sha256=jpkNA_RSyT0mb_bbnqohVCrcm_ymAkFMRA6q2YqTkbA,37
17
+ mdb_cli-0.1.0.dist-info/licenses/LICENSE,sha256=0tfi6n8q7wQbUQ47en8pj6Kq4SLQwZ5qfNRbTg-mUyo,1077
18
+ mdb_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mdb = mdb.mdb:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mdb-cli contributors
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.