Skip to content

Commit 7203112

Browse files
authored
refactor(native): delete the Python discovery implementation (#1007)
* refactor(native): read bounded discovery entry points through native discovery Scope inspection, selected contract discovery, model description inputs and SQL test fixture formatting now read model, SQL test, scenario and source files and the declaration layout through the native discovery instead of the Python discovery helpers. Native discovery gains model name and test path selection, per-file fault reporting, in-memory SQL test and scenario parsing, and declaration scans isolated to one kind, so these callers keep their exact outcomes. The Python engine's discovery stage is unchanged and remains the differential oracle. * feat(native): replace native discovery deferrals with native errors Model, SQL test, scenario, source and schema files and the declaration layout are now discovered natively under both compiler engines, with no Python fallback: - Unreadable files raise Python's own read errors natively: the exact UnicodeDecodeError for invalid UTF-8 and OSError for refused reads, with Windows error codes mapped the way Python maps them. - Invalid YAML in sources files (D006) and seed declarations or schema.yml (D005) names the file, line and column. YAML outside the supported plain subset (tabs, directives, Python-only tags, recursive aliases and similar) fails with the same codes and a hint. - A model, SQL test, scenario, source or schema file whose path is not UTF-8 fails with D016, showing invalid bytes as \xNN escapes; callers that tolerate bad files report it per file. Other files and directories with such names are ignored and declaration files are read by their real names, as before; every name keeps its own identity even when names share a lossy spelling. A non-UTF-8 project directory fails. - Native discovery runs on Windows: paths print with backslashes, sort case-insensitively as Python's Windows paths do, and junctions are walked like directories. A Windows CI job covers it. - An unknown Python or Unicode version fails with D017 naming the supported Python versions. The discovery fact cache, which only served the Python fallbacks, is removed. * refactor(native): delete the Python discovery implementation Model, SQL test, scenario, source and schema file discovery and the declaration layout scan now exist only in native discovery, for both compiler engines. Delete the Python walkers, parsers, YAML loaders and the discovery fact cache they used, keep the Python semantic layers that build discovered objects from native results, and point the remaining behaviour tests at native discovery. Parity oracles that compared the two implementations are removed with the Python side. * build: support Python 3.12 to 3.14 only Native discovery ships Unicode and text tables for Python 3.12, 3.13 and 3.14 and refuses other versions, so declare that range in requires-python and the lockfile, and say "Python 3.12–3.14" in the quickstart and contributing guide. * fix(native): show Windows lone surrogates as units and test Windows raw names A raw-segment test built its names from POSIX byte hex, which Windows decodes as UTF-16 units, so it failed there. The product round-trips Windows names correctly; only the display was off: a lone surrogate in U+DC80-U+DCFF was shown as a \xNN byte. Windows now always shows lone surrogates as \uXXXX, in Rust and in Python's own messages. Decoding and display take the platform as a parameter so Linux tests cover both the POSIX and the Windows branches, and a Windows-only test walks real files whose names hold lone surrogates. * fix(cli): write CLI output as UTF-8 under legacy code pages When stdout or stderr was opened with a non-UTF-8 encoding, such as the Windows cp1252 code page used for pipes and redirects, sqb crashed with E001 on the first non-ASCII character of its summary output. The CLI entry point now reconfigures any non-UTF-8 standard stream to UTF-8 with backslashreplace, so output never fails to encode and JSON output stays valid. Library use and the rule-engine host process are unaffected.
1 parent e166844 commit 7203112

146 files changed

Lines changed: 3989 additions & 2996 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/verify.yml‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,14 +335,61 @@ jobs:
335335
uv run --no-sync pytest
336336
tests/integration/src/sqlbuild/compiler/test_native_python_compat_oracle.py
337337
-n auto --dist loadfile
338-
- name: Compare python with the shipped native default on fixtures, examples, the failure corpus and a few seeds
338+
- name: Compare python with native-preview on fixtures, examples, the failure corpus and a few seeds
339339
env:
340340
PYTHONUNBUFFERED: "1"
341341
run: >-
342342
uv run --no-sync python -m scripts.run_compiler_differential
343-
--engines python native
343+
--engines python native-preview
344344
--corpus fixtures examples failures seeds --seeds 4 --jobs 4
345345
346+
native_discovery_windows:
347+
name: Native discovery on Windows
348+
needs: [changes, lockfile]
349+
if: needs.changes.outputs.compiler_differential == 'true'
350+
runs-on: windows-2025
351+
timeout-minutes: 40
352+
defaults:
353+
run:
354+
shell: bash
355+
steps:
356+
- uses: actions/checkout@v4
357+
- uses: astral-sh/setup-uv@v5
358+
with:
359+
version: "0.12.3"
360+
enable-cache: true
361+
cache-dependency-glob: |
362+
uv.lock
363+
Cargo.lock
364+
- uses: dtolnay/rust-toolchain@master
365+
with:
366+
toolchain: stable
367+
- name: Test the native discovery crate (walk, junctions, ordering, read errors)
368+
run: cargo test --locked -p sqlbuild-discovery
369+
- name: Install Python
370+
run: uv python install 3.12
371+
- name: Install SQLBuild and pytest
372+
run: |
373+
uv sync --locked --no-dev --python 3.12
374+
uv pip install "pytest>=8.4.2" "pytest-xdist>=3.8.0"
375+
- name: Compare native text, YAML and TOML readers with this Python
376+
run: >-
377+
uv run --no-sync pytest
378+
tests/integration/src/sqlbuild/compiler/test_native_python_compat_oracle.py
379+
tests/integration/src/sqlbuild/compiler/discovery/test_native_yaml_oracle.py
380+
tests/integration/src/sqlbuild/compiler/discovery/test_native_toml_oracle.py
381+
-n auto --dist loadfile
382+
- name: Compile and build playground projects
383+
run: |
384+
project="${RUNNER_TEMP}/waffle-shop"
385+
uv run --no-sync sqb playground "${project}"
386+
uv run --no-sync sqb --project-dir "${project}" --no-color compile
387+
SQLBUILD_COMPILER_ENGINE=python uv run --no-sync sqb --project-dir "${project}" --no-color compile
388+
uv run --no-sync sqb --project-dir "${project}" --no-color build
389+
nodes="${RUNNER_TEMP}/python-nodes"
390+
uv run --no-sync sqb playground --template python_nodes "${nodes}"
391+
uv run --no-sync sqb --project-dir "${nodes}" --no-color compile
392+
346393
format_performance:
347394
name: Formatter performance guards
348395
needs: [changes, lockfile]
@@ -762,6 +809,7 @@ jobs:
762809
e2e_shards,
763810
compiler_differential,
764811
compiler_differential_python_versions,
812+
native_discovery_windows,
765813
format_performance,
766814
performance,
767815
cold_compile_performance,
@@ -783,6 +831,7 @@ jobs:
783831
E2E_SHARDS_RESULT: ${{ needs.e2e_shards.result }}
784832
COMPILER_DIFFERENTIAL_RESULT: ${{ needs.compiler_differential.result }}
785833
COMPILER_DIFFERENTIAL_PYTHON_VERSIONS_RESULT: ${{ needs.compiler_differential_python_versions.result }}
834+
NATIVE_DISCOVERY_WINDOWS_RESULT: ${{ needs.native_discovery_windows.result }}
786835
FORMAT_PERFORMANCE_RESULT: ${{ needs.format_performance.result }}
787836
PERFORMANCE_RESULT: ${{ needs.performance.result }}
788837
COLD_COMPILE_PERFORMANCE_RESULT: ${{ needs.cold_compile_performance.result }}
@@ -806,6 +855,7 @@ jobs:
806855
"${E2E_SHARDS_RESULT}" \
807856
"${COMPILER_DIFFERENTIAL_RESULT}" \
808857
"${COMPILER_DIFFERENTIAL_PYTHON_VERSIONS_RESULT}" \
858+
"${NATIVE_DISCOVERY_WINDOWS_RESULT}" \
809859
"${FORMAT_PERFORMANCE_RESULT}" \
810860
"${PERFORMANCE_RESULT}" \
811861
"${COLD_COMPILE_PERFORMANCE_RESULT}" \

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Thanks for helping improve SQLBuild. Keep changes focused, tested, and easy to r
44

55
## Setup
66

7-
SQLBuild requires Python 3.12 or newer.
7+
SQLBuild supports Python 3.12, 3.13 and 3.14.
88

99
Install dependencies with:
1010

‎Cargo.lock‎

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ rust-version = "1.88"
4040
[workspace.dependencies]
4141
fensu-policy = "=0.22.0"
4242
globset = "0.4"
43+
# Windows only: tells NTFS junctions, which Python walks as directories, from symbolic links.
44+
junction = { version = "2.1.0", default-features = false }
4345
# Python loads the extension with dlopen, so mimalloc must not use initial-exec TLS.
4446
mimalloc = { version = "0.1", default-features = false, features = ["local_dynamic_tls"] }
4547
proc-macro2 = { version = "1", features = ["span-locations"] }

‎crates/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ that can be tested and benchmarked with `cargo test` alone.
88
|---|---|
99
| `sqlbuild-core` | Constants shared by several crates, the panic boundary that turns an unwinding native failure into a named compiler error, passive identity, diagnostic and SQL value types, text positions, and a JSON emitter whose output matches Python's `json.dumps` and `orjson` byte for byte. |
1010
| `sqlbuild-sqltext` | Lexical SQL text without polyglot: comment, quote and parenthesis scanning, the rules quote policy, model header tokenization and matching, static variable substitution and static reference extraction. |
11-
| `sqlbuild-config` | Configuration file reading without Python: `tomllib`-compatible TOML, PyYAML `safe_load`-compatible YAML 1.1, and typed project and local config readers for the fields discovery needs. Any error means "defer to Python", which re-parses for exact messages. |
12-
| `sqlbuild-discovery` | Native project discovery: the shared directory walk with Python's glob and sort semantics, file reading with Python's newline handling, and parsing of authored files with Python's exact discovery messages. Results are plain data the Python discovery facade materialises; anything it cannot reproduce exactly defers to Python. |
11+
| `sqlbuild-config` | Configuration file reading without Python: `tomllib`-compatible TOML, PyYAML `safe_load`-compatible YAML 1.1, and typed project and local config readers for the fields discovery needs. Errors carry their kind and position; YAML outside the supported subset is an `Unsupported` error. |
12+
| `sqlbuild-discovery` | Native project discovery and the only implementation of the model, SQL test, scenario, source and schema file collections and the declaration layout: the shared directory walk with Python's glob and sort semantics, file reading with Python's newline handling and read errors, and parsing of authored files with Python's exact discovery messages. Results are plain data the Python discovery facade materialises. |
1313
| `sqlbuild-scopes` | Declaration scopes: the scope index with Python's record orders and diagnostics, declaration visibility, relationship grants, the scope lookup groups, and the dialect-aware scan of SQL tests and scenarios for their expected models. Anything it cannot reproduce exactly defers to Python. |
1414
| `sqlbuild-model-config` | Model configuration: MODEL header columns and audits, `${...}` template expansion with its environment and context reads, and the template and macro presence scans over authored config values, all read through a trait over the caller's values. Anything it cannot reproduce exactly defers to Python, which also raises every model config error. |
1515
| `sqlbuild-analysis` | SQL analysis over polyglot: SQL tokens, query analysis, the binding catalog, semantic validation and usage, column references, and SQL-test extraction, planning and rendering. |

‎crates/sqlbuild-config/src/errors.rs‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub enum ConfigErrorKind {
1515
InvalidField,
1616
}
1717

18-
/// A configuration failure; the Python reader re-parses the file to report its exact message.
18+
/// A configuration failure, at its one-based line and column where known.
1919
#[derive(Clone, Debug, PartialEq, Eq)]
2020
pub struct ConfigError {
2121
pub kind: ConfigErrorKind,
@@ -34,4 +34,16 @@ impl ConfigError {
3434
column: None,
3535
}
3636
}
37+
38+
/// This error at a one-based line and column, unless it already has a position.
39+
pub fn at(self, line: usize, column: usize) -> Self {
40+
if self.line.is_some() {
41+
return self;
42+
}
43+
Self {
44+
line: Some(line),
45+
column: Some(column),
46+
..self
47+
}
48+
}
3749
}

‎crates/sqlbuild-config/src/yaml/_helpers/composer.rs‎

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,15 @@ fn syntax_error(error: &ScanError) -> ConfigError {
6464
kind: ConfigErrorKind::Syntax,
6565
message: error.info().to_owned(),
6666
line: Some(error.marker().line()),
67-
column: Some(error.marker().col()),
67+
column: Some(error.marker().col() + 1),
6868
}
6969
}
7070

71+
/// The one-based line and column where `span` starts.
72+
fn start_position(span: Span) -> (usize, usize) {
73+
(span.start.line(), span.start.col() + 1)
74+
}
75+
7176
fn full_tag(tag: Option<&Cow<'_, Tag>>) -> Option<String> {
7277
tag.map(|tag| format!("{}{}", tag.handle, tag.suffix))
7378
}
@@ -92,8 +97,12 @@ impl<'input> Composer<'input> {
9297
}
9398
}
9499

95-
fn push(&mut self, tag: String, content: NodeContent) -> usize {
96-
self.document.nodes.push(Node { tag, content });
100+
fn push(&mut self, tag: String, content: NodeContent, span: Span) -> usize {
101+
self.document.nodes.push(Node {
102+
tag,
103+
content,
104+
position: start_position(span),
105+
});
97106
self.document.nodes.len() - 1
98107
}
99108

@@ -103,7 +112,7 @@ impl<'input> Composer<'input> {
103112
return Ok(());
104113
}
105114
let name = anchor_name(&self.chars, &self.code_ends, span.start.index())
106-
.ok_or_else(|| unsupported("anchor name could not be recovered"))?;
115+
.ok_or_else(|| unsupported("this anchor"))?;
107116
if !is_python_anchor_name(&name) {
108117
return Err(ConfigError::new(
109118
ConfigErrorKind::Syntax,
@@ -130,7 +139,7 @@ impl<'input> Composer<'input> {
130139

131140
fn alias(&self, anchor_id: usize) -> Result<usize, ConfigError> {
132141
if self.open_anchors.contains(&anchor_id) {
133-
return Err(unsupported("recursive aliases are left to Python"));
142+
return Err(unsupported("recursive aliases"));
134143
}
135144
self.anchors
136145
.get(&anchor_id)
@@ -212,7 +221,7 @@ impl<'input> Composer<'input> {
212221
&& matches!(self.char_at(colon + 1), Some(',' | ']' | '}'));
213222
if placement.flow && adjacent_value {
214223
return Err(unsupported(
215-
"LibYAML rejects ':' directly before a flow indicator",
224+
"':' directly before ',', ']' or '}' in a flow collection",
216225
));
217226
}
218227
let explicit_key = explicit_key_before(&self.chars, &self.code_ends, start);
@@ -281,7 +290,7 @@ impl<'input> Composer<'input> {
281290
span: Span,
282291
placement: Placement,
283292
) -> Result<String, ConfigError> {
284-
let deferred = || unsupported("this block scalar is left to Python");
293+
let deferred = || unsupported("this literal or folded block scalar layout");
285294
if placement.flow || placement.key {
286295
return Err(deferred());
287296
}
@@ -304,14 +313,26 @@ impl<'input> Composer<'input> {
304313
.ok_or_else(deferred)
305314
}
306315

316+
/// Compose one node; an error without a position is placed at the node's start.
307317
fn compose_node(
308318
&mut self,
309319
event: Event<'input>,
310320
span: Span,
311321
placement: Placement,
322+
) -> Result<usize, ConfigError> {
323+
let (line, column) = start_position(span);
324+
self.compose_node_at(event, span, placement)
325+
.map_err(|error| error.at(line, column))
326+
}
327+
328+
fn compose_node_at(
329+
&mut self,
330+
event: Event<'input>,
331+
span: Span,
332+
placement: Placement,
312333
) -> Result<usize, ConfigError> {
313334
if placement.depth > MAX_NESTING_DEPTH {
314-
return Err(unsupported("nesting is deeper than the native limit"));
335+
return Err(unsupported("nesting deeper than 256 levels"));
315336
}
316337
match event {
317338
Event::Alias(anchor_id) => self.alias(anchor_id),
@@ -321,7 +342,7 @@ impl<'input> Composer<'input> {
321342
&& value.contains(FLOW_KEY_INDICATOR)
322343
{
323344
return Err(unsupported(
324-
"PyYAML ends a flow scalar at '?' and LibYAML does not",
345+
"'?' inside a plain scalar in a flow collection",
325346
));
326347
}
327348
if style == ScalarStyle::Plain
@@ -340,9 +361,7 @@ impl<'input> Composer<'input> {
340361
));
341362
}
342363
if placement.key && self.is_long_implicit_key(span) {
343-
return Err(unsupported(
344-
"implicit keys near PyYAML's 1024-character limit are left to Python",
345-
));
364+
return Err(unsupported("implicit keys longer than 1000 characters"));
346365
}
347366
if placement.key && placement.flow && self.spans_lines(span) {
348367
return Err(ConfigError::new(
@@ -361,13 +380,11 @@ impl<'input> Composer<'input> {
361380
};
362381
let non_specific = full_tag(tag.as_ref()).as_deref() == Some(NON_SPECIFIC_TAG);
363382
if non_specific && value.is_empty() && style == ScalarStyle::Plain {
364-
return Err(unsupported(
365-
"LibYAML and PyYAML differ on an empty '!' scalar",
366-
));
383+
return Err(unsupported("an empty scalar tagged '!'"));
367384
}
368385
self.open_anchor(anchor_id, span)?;
369386
let tag = scalar_tag(&value, style, full_tag(tag.as_ref()));
370-
let node = self.push(tag, NodeContent::Scalar(value.into_owned()));
387+
let node = self.push(tag, NodeContent::Scalar(value.into_owned()), span);
371388
Ok(self.close_anchor(anchor_id, node))
372389
}
373390
Event::SequenceStart(anchor_id, tag) => {
@@ -398,7 +415,7 @@ impl<'input> Composer<'input> {
398415
items.push(self.compose_node(event, item_span, item_placement)?);
399416
}
400417
let tag = collection_tag(full_tag(tag.as_ref()), SEQ_TAG);
401-
let node = self.push(tag, NodeContent::Sequence(items));
418+
let node = self.push(tag, NodeContent::Sequence(items), span);
402419
Ok(self.close_anchor(anchor_id, node))
403420
}
404421

@@ -426,7 +443,7 @@ impl<'input> Composer<'input> {
426443
entries.push((key, value));
427444
}
428445
let tag = collection_tag(full_tag(tag.as_ref()), MAP_TAG);
429-
let node = self.push(tag, NodeContent::Mapping(entries));
446+
let node = self.push(tag, NodeContent::Mapping(entries), span);
430447
Ok(self.close_anchor(anchor_id, node))
431448
}
432449

0 commit comments

Comments
 (0)