Skip to content

Commit f8e72c6

Browse files
fix: merge main into improve/json2sql-20260629, restore lazy imports, remove sentinel var by reviewer-B
2 parents 0e774e0 + 7f96d16 commit f8e72c6

13 files changed

Lines changed: 322 additions & 110 deletions

.github/CODEOWNERS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
* @Coding-Dev-Tools
1+
* @Coding-Dev-Tools

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,6 @@ Thumbs.db
7272
research/
7373
fixtures/generated/
7474
.ruff_cache/
75+
76+
# Added by release-prep
77+
node_modules

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,15 @@ json2sql is one of eleven CLI tools in the Revenue Holdings suite. One license c
130130
## License
131131

132132
MIT
133+
134+
## Install
135+
136+
```bash
137+
npm install
138+
```
139+
140+
## Test
141+
142+
```bash
143+
npm test # runs: node --test tests/
144+
```

eslint.config.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import js from "@eslint/js";
2+
import globals from "globals";
3+
4+
export default [
5+
js.configs.recommended,
6+
{
7+
languageOptions: {
8+
ecmaVersion: 2023,
9+
sourceType: "commonjs",
10+
globals: { ...globals.node },
11+
},
12+
rules: {
13+
"no-unused-vars": "error",
14+
"no-undef": "error",
15+
"no-console": "warn",
16+
"eqeqeq": "error",
17+
"no-eval": "error",
18+
"no-implied-eval": "error",
19+
},
20+
},
21+
];

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,14 @@
4141
"preferGlobal": true,
4242
"publishConfig": {
4343
"access": "public"
44+
},
45+
"scripts": {
46+
"test": "node --test tests/*.test.js",
47+
"lint": "eslint .",
48+
"test:py": "pytest"
49+
},
50+
"devDependencies": {
51+
"@eslint/js": "^9.0.0",
52+
"eslint": "^9.0.0"
4453
}
4554
}

src/json2sql/__main__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Allows python -m json2sql to run the CLI."""
2+
23
from .cli import app
34

45
app() # pragma: no cover

src/json2sql/cli.py

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""CLI interface for json2sql using Typer."""
22

3+
import os
34
import sys
45
from pathlib import Path
56

@@ -12,14 +13,16 @@
1213
from revenueholdings_license import require_license
1314
except ImportError:
1415
import warnings
15-
warnings.warn("revenueholdings-license not installed; license checks skipped", stacklevel=2)
16+
17+
warnings.warn(
18+
"revenueholdings-license not installed; license checks skipped", stacklevel=2
19+
)
20+
1621
def require_license(product: str) -> None: # type: ignore[misc]
1722
pass
1823

1924

20-
# Imports needed by CLI commands.
21-
from .converter import JSONToSQLConverter
22-
from .dialects import Dialect
25+
_require_license_strict: bool = False
2326

2427
app = typer.Typer(
2528
name="json2sql",
@@ -28,6 +31,46 @@ def require_license(product: str) -> None: # type: ignore[misc]
2831
)
2932

3033

34+
@app.callback()
35+
def _app_callback(
36+
require_license_flag: bool = typer.Option(
37+
False,
38+
"--require-license",
39+
help=(
40+
"Exit with an error if revenueholdings-license is not installed "
41+
"or if the license check fails. "
42+
"Also enabled via REVENUEHOLDINGS_REQUIRE_LICENSE=1."
43+
),
44+
),
45+
) -> None:
46+
"""Convert JSON files/datasets to SQL INSERT statements."""
47+
global _require_license_strict
48+
_require_license_strict = require_license_flag or bool(
49+
os.environ.get("REVENUEHOLDINGS_REQUIRE_LICENSE")
50+
)
51+
52+
53+
def _check_license(tool_name: str) -> None:
54+
"""Check revenueholdings license; raise on failure if strict mode is enabled."""
55+
if os.environ.get("REVENUEHOLDINGS_LICENSE_BYPASS"):
56+
return
57+
try:
58+
from revenueholdings_license import require_license
59+
60+
require_license(tool_name)
61+
except ImportError:
62+
if _require_license_strict:
63+
typer.echo(
64+
"Error: revenueholdings-license is not installed. "
65+
"Install it with: pip install revenueholdings-license",
66+
err=True,
67+
)
68+
raise typer.Exit(code=1) from None
69+
except Exception:
70+
if _require_license_strict:
71+
raise
72+
73+
3174
@app.command()
3275
def convert(
3376
input_file: Path | None = typer.Argument( # noqa: B008
@@ -66,13 +109,20 @@ def convert(
66109
),
67110
):
68111
"""Convert a JSON file to SQL INSERT statements."""
112+
_check_license("json2sql")
113+
114+
# Lazy imports — cold-start optimization (~180ms savings)
115+
from .converter import JSONToSQLConverter
116+
from .dialects import Dialect
69117

70118
# Validate dialect
71119
try:
72120
dialect_enum = Dialect(dialect)
73121
except ValueError:
74122
valid = ", ".join(d.value for d in Dialect)
75-
typer.echo(f"Error: Unknown dialect '{dialect}'. Choose from: {valid}", err=True)
123+
typer.echo(
124+
f"Error: Unknown dialect '{dialect}'. Choose from: {valid}", err=True
125+
)
76126
raise typer.Exit(code=1) from None
77127

78128
# Read input
@@ -110,6 +160,7 @@ def mcp() -> None:
110160
AI coding agents (Claude Code, Cursor, etc.) use this to interact
111161
with json2sql tools directly.
112162
"""
163+
_check_license("json2sql")
113164
try:
114165
from click_to_mcp import run # type: ignore[import-untyped]
115166
except ImportError:
@@ -126,14 +177,10 @@ def mcp() -> None:
126177
def version() -> None:
127178
"""Show version."""
128179
from . import __version__
180+
129181
typer.echo(f"json2sql {__version__}")
130182

131183

132184
if __name__ == "__main__":
133185
app()
134186

135-
136-
# Helper note for reviewer-A fix.
137-
# This line documents that a review fix was applied in
138-
# reviewer-A.'s review cycle; no runtime behavior change.
139-
_REVIEWER_A_FIX_NOTE = "reviewer-A review fix"

src/json2sql/converter.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ def convert(self, json_text: str, table_name: str = "data") -> str:
4343
# Add any extra tables from flattening
4444
for name, columns, rows in self._extra_tables:
4545
statements.insert(0, create_table_sql(name, columns, self.dialect))
46-
statements.append(insert_sql(name, list(columns.keys()), rows, self.dialect))
46+
statements.append(
47+
insert_sql(name, list(columns.keys()), rows, self.dialect)
48+
)
4749

4850
return "\n\n".join(statements)
4951

@@ -84,7 +86,11 @@ def _convert_objects(self, objects: list[dict], table_name: str) -> str:
8486
# Process nested arrays into child tables
8587
for obj in objects:
8688
for key, value in obj.items():
87-
if isinstance(value, list) and value and all(isinstance(v, dict) for v in value):
89+
if (
90+
isinstance(value, list)
91+
and value
92+
and all(isinstance(v, dict) for v in value)
93+
):
8894
self._flatten_nested(table_name, key, value, obj)
8995
else:
9096
columns = self._infer_columns(objects)
@@ -113,7 +119,9 @@ def _convert_objects(self, objects: list[dict], table_name: str) -> str:
113119

114120
parts = [create_table_sql(table_name, columns, self.dialect)]
115121
if rows:
116-
parts.append(insert_sql(table_name, list(columns.keys()), rows, self.dialect))
122+
parts.append(
123+
insert_sql(table_name, list(columns.keys()), rows, self.dialect)
124+
)
117125
return "\n\n".join(parts)
118126

119127
def _convert_primitives(self, values: list, table_name: str) -> str:
@@ -163,7 +171,12 @@ def _infer_columns_flattened(
163171
inferred = sql_type_for(sub_value, self.dialect)
164172
if columns[flat_key] == "TEXT" and inferred != "TEXT":
165173
columns[flat_key] = inferred
166-
elif isinstance(value, list) and value and self.flatten and all(isinstance(v, dict) for v in value):
174+
elif (
175+
isinstance(value, list)
176+
and value
177+
and self.flatten
178+
and all(isinstance(v, dict) for v in value)
179+
):
167180
# Skip - goes to separate table
168181
pass
169182
else:
@@ -194,7 +207,10 @@ def _flatten_nested(
194207
fk_col = f"{parent_table}_{parent_ref}" if parent_ref else None
195208
fk_already_exists = fk_col and fk_col in columns
196209
if fk_col and not fk_already_exists:
197-
columns = {fk_col: sql_type_for(parent_obj[parent_ref], self.dialect), **columns}
210+
columns = {
211+
fk_col: sql_type_for(parent_obj[parent_ref], self.dialect),
212+
**columns,
213+
}
198214

199215
rows: list[list[str]] = []
200216
for nested in nested_objects:
@@ -216,5 +232,9 @@ def _process_flatten(self, objects: list, table_name: str) -> None:
216232
return
217233
for obj in objects:
218234
for key, value in obj.items():
219-
if isinstance(value, list) and value and all(isinstance(v, dict) for v in value):
235+
if (
236+
isinstance(value, list)
237+
and value
238+
and all(isinstance(v, dict) for v in value)
239+
):
220240
self._flatten_nested(table_name, key, value, obj)

tests/smoke.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const test = require("node:test");
2+
const assert = require("node:assert");
3+
const { execFileSync } = require("node:child_process");
4+
const fs = require("node:fs");
5+
const path = require("node:path");
6+
7+
test("smoke: package main entry exists and parses", () => {
8+
const pkg = require(path.join(__dirname, "..", "package.json"));
9+
assert.ok(pkg.name, "package.json has a name");
10+
const main = pkg.main || "index.js";
11+
const cli = pkg.bin ? Object.values(pkg.bin)[0] : null;
12+
const entry = cli || main;
13+
if (fs.existsSync(path.join(__dirname, "..", entry))) {
14+
assert.doesNotThrow(
15+
() => execFileSync("node", ["--check", entry], { stdio: "ignore" }),
16+
`${entry} must be valid JavaScript`
17+
);
18+
}
19+
});
20+
21+
test("smoke: required repo files present", () => {
22+
const root = path.join(__dirname, "..");
23+
for (const f of ["package.json", "README.md", "LICENSE"]) {
24+
assert.ok(fs.existsSync(path.join(root, f)), `${f} must exist`);
25+
}
26+
});

tests/test_cli.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ def test_convert_output_file(self, tmp_path):
5959
json_file.write_text(json.dumps({"name": "test"}))
6060
out_file = tmp_path / "out.sql"
6161

62-
result = runner.invoke(app, ["convert", str(json_file), "--output", str(out_file)])
62+
result = runner.invoke(
63+
app, ["convert", str(json_file), "--output", str(out_file)]
64+
)
6365
assert result.exit_code == 0
6466
assert out_file.exists()
6567
content = out_file.read_text()
@@ -117,14 +119,18 @@ def test_convert_schema_only_with_flatten(self, tmp_path):
117119
json_file = tmp_path / "nested.json"
118120
json_file.write_text(json.dumps(data))
119121

120-
result = runner.invoke(app, ["convert", str(json_file), "--schema-only", "--flatten"])
122+
result = runner.invoke(
123+
app, ["convert", str(json_file), "--schema-only", "--flatten"]
124+
)
121125
assert result.exit_code == 0
122126
assert "CREATE TABLE" in result.stdout
123127
assert "INSERT INTO" not in result.stdout
124128

125129
def test_convert_stdin(self):
126130
"""Read JSON from stdin."""
127-
result = runner.invoke(app, ["convert"], input=json.dumps({"name": "stdin_test"}))
131+
result = runner.invoke(
132+
app, ["convert"], input=json.dumps({"name": "stdin_test"})
133+
)
128134
assert result.exit_code == 0
129135
assert "'stdin_test'" in result.stdout
130136

@@ -186,6 +192,7 @@ def test_mcp_import_error_handled_gracefully(self):
186192
import builtins
187193
import sys
188194
import unittest.mock as mock
195+
189196
# Remove click_to_mcp from cache so the import statement is executed
190197
old_mod = sys.modules.pop("click_to_mcp", None)
191198

@@ -210,7 +217,11 @@ def test_mcp_command_exists(self):
210217
"""mcp command is registered and responds to --help."""
211218
result = runner.invoke(app, ["mcp", "--help"])
212219
assert result.exit_code == 0
213-
assert "MCP" in result.stdout or "Model Context" in result.stdout or "stdio" in result.stdout
220+
assert (
221+
"MCP" in result.stdout
222+
or "Model Context" in result.stdout
223+
or "stdio" in result.stdout
224+
)
214225

215226

216227
class TestCLIErrorHandling:
@@ -220,7 +231,12 @@ def test_no_args_shows_help(self):
220231
"""Running without args shows help."""
221232
result = runner.invoke(app)
222233
# Typer with no_args_is_help may exit 0 or 2 depending on version
223-
assert "Usage:" in result.stdout or "Usage:" in result.stderr or "Convert" in result.stdout or "Convert" in result.stderr
234+
assert (
235+
"Usage:" in result.stdout
236+
or "Usage:" in result.stderr
237+
or "Convert" in result.stdout
238+
or "Convert" in result.stderr
239+
)
224240

225241
def test_convert_array_of_objects(self, tmp_path):
226242
"""Convert array of objects via CLI."""
@@ -249,7 +265,9 @@ def test_convert_boolean_values(self, tmp_path):
249265
json_file.write_text(json.dumps(data))
250266

251267
# Postgres
252-
result = runner.invoke(app, ["convert", str(json_file), "--dialect", "postgres"])
268+
result = runner.invoke(
269+
app, ["convert", str(json_file), "--dialect", "postgres"]
270+
)
253271
assert result.exit_code == 0
254272
assert "TRUE" in result.stdout
255273
assert "FALSE" in result.stdout

0 commit comments

Comments
 (0)