semolina 0.3.0__tar.gz
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.
- semolina-0.3.0/PKG-INFO +29 -0
- semolina-0.3.0/README.md +3 -0
- semolina-0.3.0/pyproject.toml +125 -0
- semolina-0.3.0/src/semolina/__init__.py +41 -0
- semolina-0.3.0/src/semolina/__main__.py +6 -0
- semolina-0.3.0/src/semolina/cli/__init__.py +51 -0
- semolina-0.3.0/src/semolina/cli/codegen.py +119 -0
- semolina-0.3.0/src/semolina/cli/utils.py +98 -0
- semolina-0.3.0/src/semolina/codegen/__init__.py +1 -0
- semolina-0.3.0/src/semolina/codegen/introspector.py +65 -0
- semolina-0.3.0/src/semolina/codegen/python_renderer.py +289 -0
- semolina-0.3.0/src/semolina/codegen/templates/python_model.py.jinja2 +25 -0
- semolina-0.3.0/src/semolina/codegen/type_map.py +134 -0
- semolina-0.3.0/src/semolina/config.py +87 -0
- semolina-0.3.0/src/semolina/conftest.py +81 -0
- semolina-0.3.0/src/semolina/cursor.py +179 -0
- semolina-0.3.0/src/semolina/dialect.py +69 -0
- semolina-0.3.0/src/semolina/engines/__init__.py +26 -0
- semolina-0.3.0/src/semolina/engines/base.py +159 -0
- semolina-0.3.0/src/semolina/engines/databricks.py +367 -0
- semolina-0.3.0/src/semolina/engines/mock.py +371 -0
- semolina-0.3.0/src/semolina/engines/snowflake.py +374 -0
- semolina-0.3.0/src/semolina/engines/sql.py +906 -0
- semolina-0.3.0/src/semolina/fields.py +733 -0
- semolina-0.3.0/src/semolina/filters.py +172 -0
- semolina-0.3.0/src/semolina/models.py +240 -0
- semolina-0.3.0/src/semolina/pool.py +243 -0
- semolina-0.3.0/src/semolina/py.typed +0 -0
- semolina-0.3.0/src/semolina/query.py +490 -0
- semolina-0.3.0/src/semolina/registry.py +161 -0
- semolina-0.3.0/src/semolina/results.py +171 -0
- semolina-0.3.0/src/semolina/testing/__init__.py +17 -0
- semolina-0.3.0/src/semolina/testing/credentials.py +222 -0
semolina-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: semolina
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A Pythonic ORM for querying data warehouse semantic views (Snowflake, Databricks)
|
|
5
|
+
Author: Anentropic
|
|
6
|
+
Author-email: Anentropic <ego@anentropic.com>
|
|
7
|
+
Requires-Dist: adbc-poolhouse>=1.2.0
|
|
8
|
+
Requires-Dist: typer>=0.12.0
|
|
9
|
+
Requires-Dist: rich>=13.0.0
|
|
10
|
+
Requires-Dist: jinja2>=3.1.0
|
|
11
|
+
Requires-Dist: sphinx-autobuild[dev]>=2025.8.25
|
|
12
|
+
Requires-Dist: semolina[snowflake,databricks] ; extra == 'all'
|
|
13
|
+
Requires-Dist: databricks-sql-connector[pyarrow]>=4.2.5 ; extra == 'databricks'
|
|
14
|
+
Requires-Dist: adbc-poolhouse[snowflake] ; extra == 'snowflake'
|
|
15
|
+
Requires-Dist: snowflake-connector-python>=4.3.0 ; extra == 'snowflake'
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Project-URL: Homepage, https://github.com/anentropic/semolina
|
|
18
|
+
Project-URL: Documentation, https://anentropic.github.io/semolina/
|
|
19
|
+
Project-URL: Repository, https://github.com/anentropic/semolina
|
|
20
|
+
Project-URL: Issues, https://github.com/anentropic/semolina/issues
|
|
21
|
+
Project-URL: Changelog, https://anentropic.github.io/semolina/changelog/
|
|
22
|
+
Provides-Extra: all
|
|
23
|
+
Provides-Extra: databricks
|
|
24
|
+
Provides-Extra: snowflake
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Semolina
|
|
28
|
+
|
|
29
|
+
A Python "OAM" (Object Analytics Model) for data warehouse Semantic Layer backends.
|
semolina-0.3.0/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "semolina"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "A Pythonic ORM for querying data warehouse semantic views (Snowflake, Databricks)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Anentropic", email = "ego@anentropic.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"adbc-poolhouse>=1.2.0",
|
|
12
|
+
"typer>=0.12.0",
|
|
13
|
+
"rich>=13.0.0",
|
|
14
|
+
"jinja2>=3.1.0",
|
|
15
|
+
"sphinx-autobuild[dev]>=2025.8.25",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
semolina = "semolina.cli:app"
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/anentropic/semolina"
|
|
23
|
+
Documentation = "https://anentropic.github.io/semolina/"
|
|
24
|
+
Repository = "https://github.com/anentropic/semolina"
|
|
25
|
+
Issues = "https://github.com/anentropic/semolina/issues"
|
|
26
|
+
Changelog = "https://anentropic.github.io/semolina/changelog/"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
databricks = [
|
|
30
|
+
# Databricks ADBC driver is Foundry-distributed (not on PyPI).
|
|
31
|
+
# Keep databricks-sql-connector as fallback for non-ADBC usage.
|
|
32
|
+
"databricks-sql-connector[pyarrow]>=4.2.5",
|
|
33
|
+
]
|
|
34
|
+
snowflake = [
|
|
35
|
+
"adbc-poolhouse[snowflake]",
|
|
36
|
+
# SnowflakeEngine still uses snowflake-connector-python directly.
|
|
37
|
+
"snowflake-connector-python>=4.3.0",
|
|
38
|
+
]
|
|
39
|
+
all = [
|
|
40
|
+
"semolina[snowflake,databricks]",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.9.18,<0.11.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[dependency-groups]
|
|
48
|
+
dev = [
|
|
49
|
+
"basedpyright>=1.38.0",
|
|
50
|
+
"ipython>=9.10.0",
|
|
51
|
+
"pdbpp>=0.12.0.post1",
|
|
52
|
+
"pydantic-settings>=2.7.0",
|
|
53
|
+
"pytest>=8.0.0",
|
|
54
|
+
"pytest-cov>=6.0.0",
|
|
55
|
+
"pytest-xdist>=3.6.0",
|
|
56
|
+
"ruff>=0.15.1",
|
|
57
|
+
"syrupy>=5.1.0",
|
|
58
|
+
]
|
|
59
|
+
docs = [
|
|
60
|
+
"sphinx>=8.0",
|
|
61
|
+
"shibuya>=2025.1.1",
|
|
62
|
+
"sphinx-autoapi>=3.6.0",
|
|
63
|
+
"sphinx-design>=0.6.0",
|
|
64
|
+
"sphinx-copybutton>=0.5.2",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.basedpyright]
|
|
68
|
+
pythonVersion = "3.11"
|
|
69
|
+
typeCheckingMode = "strict"
|
|
70
|
+
include = ["src", "tests"]
|
|
71
|
+
# Tests legitimately access private members (_metrics, _fields, etc.) for validation
|
|
72
|
+
reportPrivateUsage = false
|
|
73
|
+
# Field.__eq__ and __ne__ intentionally return Q objects instead of bool for query DSL
|
|
74
|
+
reportIncompatibleMethodOverride = false
|
|
75
|
+
# Field, Metric, Dimension, Fact are Generic[T] descriptors. Unsubscripted usage (e.g.
|
|
76
|
+
# revenue = Metric()) is valid when the type parameter is irrelevant to the usage context
|
|
77
|
+
# (tests, doctests, example models). Python 3.13 TypeVar defaults (PEP 696) would resolve
|
|
78
|
+
# this at the language level; for 3.11/3.12 we suppress the cascade of Unknown errors.
|
|
79
|
+
reportUnknownMemberType = false
|
|
80
|
+
reportUnknownVariableType = false
|
|
81
|
+
reportUnknownArgumentType = false
|
|
82
|
+
# Lambda parameters in MagicMock.side_effect can't be annotated without wrapper function
|
|
83
|
+
reportUnknownLambdaType = false
|
|
84
|
+
|
|
85
|
+
[tool.ruff]
|
|
86
|
+
target-version = "py311"
|
|
87
|
+
line-length = 100
|
|
88
|
+
src = ["src"]
|
|
89
|
+
|
|
90
|
+
[tool.ruff.lint]
|
|
91
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "TCH", "D"]
|
|
92
|
+
ignore = [
|
|
93
|
+
"D1", # don't require docstrings everywhere
|
|
94
|
+
"D202", # allow blank line after docstring (common in tests)
|
|
95
|
+
"D203", # conflicts with D211 (no blank line before class docstring)
|
|
96
|
+
"D212", # conflicts with D213 (multiline summary on second line)
|
|
97
|
+
"D401", # imperative mood is awkward for dunder methods
|
|
98
|
+
"D413", # blank line after last section not required
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
[tool.uv.workspace]
|
|
102
|
+
members = ["semolina-jaffle-shop"]
|
|
103
|
+
|
|
104
|
+
[tool.uv.sources]
|
|
105
|
+
semolina = { workspace = true }
|
|
106
|
+
|
|
107
|
+
[tool.pytest.ini_options]
|
|
108
|
+
addopts = [
|
|
109
|
+
"-v",
|
|
110
|
+
"--doctest-modules",
|
|
111
|
+
"--doctest-continue-on-failure",
|
|
112
|
+
]
|
|
113
|
+
doctest_optionflags = ["ELLIPSIS", "NORMALIZE_WHITESPACE"]
|
|
114
|
+
testpaths = ["tests", "src"]
|
|
115
|
+
filterwarnings = [
|
|
116
|
+
"ignore::DeprecationWarning:syrupy.*",
|
|
117
|
+
"ignore:Field name .schema. in.*shadows an attribute in parent:UserWarning",
|
|
118
|
+
]
|
|
119
|
+
markers = [
|
|
120
|
+
"mock: Fast tests using MockEngine (no warehouse required)",
|
|
121
|
+
"unit: Pure unit tests with no external dependencies",
|
|
122
|
+
"warehouse: Integration tests against real warehouse (requires credentials)",
|
|
123
|
+
"snowflake: Snowflake-specific warehouse tests",
|
|
124
|
+
"databricks: Databricks-specific warehouse tests",
|
|
125
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Semolina - A Pythonic ORM for querying data warehouse semantic views.
|
|
3
|
+
|
|
4
|
+
Semolina provides typed model definitions and a fluent query API for
|
|
5
|
+
Snowflake and Databricks semantic views.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .config import pool_from_config
|
|
9
|
+
from .cursor import SemolinaCursor
|
|
10
|
+
from .dialect import Dialect
|
|
11
|
+
from .engines.base import SemolinaConnectionError, SemolinaViewNotFoundError
|
|
12
|
+
from .fields import Dimension, Fact, Metric, NullsOrdering, OrderTerm
|
|
13
|
+
from .filters import Predicate
|
|
14
|
+
from .models import SemanticView
|
|
15
|
+
from .pool import MockPool
|
|
16
|
+
from .registry import get_engine, get_pool, register, unregister
|
|
17
|
+
from .results import Row
|
|
18
|
+
|
|
19
|
+
__version__ = __import__("importlib.metadata").metadata.version("semolina")
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__",
|
|
23
|
+
"Dialect",
|
|
24
|
+
"Dimension",
|
|
25
|
+
"Fact",
|
|
26
|
+
"Metric",
|
|
27
|
+
"MockPool",
|
|
28
|
+
"NullsOrdering",
|
|
29
|
+
"OrderTerm",
|
|
30
|
+
"Predicate",
|
|
31
|
+
"Row",
|
|
32
|
+
"SemolinaCursor",
|
|
33
|
+
"SemolinaConnectionError",
|
|
34
|
+
"SemolinaViewNotFoundError",
|
|
35
|
+
"SemanticView",
|
|
36
|
+
"get_engine",
|
|
37
|
+
"get_pool",
|
|
38
|
+
"pool_from_config",
|
|
39
|
+
"register",
|
|
40
|
+
"unregister",
|
|
41
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Semolina command-line interface.
|
|
3
|
+
|
|
4
|
+
Entry point for the `semolina` CLI command. Registers subcommands
|
|
5
|
+
for code generation and future tooling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .codegen import codegen
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="semolina",
|
|
14
|
+
help="Semolina — warehouse-native semantic view tooling.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
add_completion=False,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
app.command(
|
|
20
|
+
"codegen",
|
|
21
|
+
epilog=(
|
|
22
|
+
"[bold]Exit codes[/bold]\n\n"
|
|
23
|
+
" [green]0[/green] Success\n\n"
|
|
24
|
+
" [yellow]1[/yellow] Unexpected error\n\n"
|
|
25
|
+
" [yellow]2[/yellow] Invalid [bold]--backend[/bold] value (or omitted)\n\n"
|
|
26
|
+
" [red]3[/red] View not found in the warehouse\n\n"
|
|
27
|
+
" [red]4[/red] Connection or authentication failure"
|
|
28
|
+
),
|
|
29
|
+
)(codegen)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def version_callback(value: bool) -> None:
|
|
33
|
+
"""Print version and exit."""
|
|
34
|
+
if value:
|
|
35
|
+
from semolina import __version__
|
|
36
|
+
|
|
37
|
+
typer.echo(f"semolina {__version__}")
|
|
38
|
+
raise typer.Exit()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.callback()
|
|
42
|
+
def main(
|
|
43
|
+
version: bool = typer.Option(
|
|
44
|
+
False,
|
|
45
|
+
"--version",
|
|
46
|
+
callback=version_callback,
|
|
47
|
+
is_eager=True,
|
|
48
|
+
help="Show version and exit.",
|
|
49
|
+
),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Semolina — warehouse-native semantic view tooling."""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Codegen subcommand: introspect warehouse semantic views and generate Python model classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import TYPE_CHECKING, Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from semolina.codegen.introspector import IntrospectedView
|
|
13
|
+
from semolina.engines.base import Engine
|
|
14
|
+
|
|
15
|
+
# Diagnostics-only console: writes to stderr
|
|
16
|
+
# NOTE: _stderr is module-level for error messages outside the command function.
|
|
17
|
+
# Python source output uses typer.echo() so CliRunner captures it correctly.
|
|
18
|
+
_stderr = Console(file=sys.stderr, stderr=True)
|
|
19
|
+
|
|
20
|
+
# Exit code constants for scripted callers.
|
|
21
|
+
# Note: Typer also uses exit code 2 for missing required arguments (fires earlier).
|
|
22
|
+
# EXIT_INVALID_BACKEND=2 fires when --backend value is provided but unrecognized.
|
|
23
|
+
EXIT_INVALID_BACKEND = 2
|
|
24
|
+
EXIT_VIEW_NOT_FOUND = 3
|
|
25
|
+
EXIT_CONNECTION_ERROR = 4
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_backend(backend_spec: str) -> Engine:
|
|
29
|
+
"""
|
|
30
|
+
Resolve a backend specifier string to an Engine instance.
|
|
31
|
+
|
|
32
|
+
Recognises the shorthand aliases ``'snowflake'`` and ``'databricks'``, and
|
|
33
|
+
also accepts any fully-qualified ``dotted.path.ClassName`` string which is
|
|
34
|
+
dynamically imported and instantiated with no arguments.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
backend_spec: One of ``'snowflake'``, ``'databricks'``, or a dotted
|
|
38
|
+
import path such as ``'mypackage.backends.CustomEngine'``.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Engine: An instantiated engine ready for introspection calls.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
typer.BadParameter: If the specifier is not recognised or cannot be
|
|
45
|
+
imported.
|
|
46
|
+
"""
|
|
47
|
+
if backend_spec == "snowflake":
|
|
48
|
+
from semolina.engines.snowflake import SnowflakeEngine
|
|
49
|
+
from semolina.testing.credentials import SnowflakeCredentials
|
|
50
|
+
|
|
51
|
+
creds = SnowflakeCredentials.load()
|
|
52
|
+
params = creds.model_dump(by_alias=True)
|
|
53
|
+
params["password"] = creds.password.get_secret_value()
|
|
54
|
+
return SnowflakeEngine(**params)
|
|
55
|
+
elif backend_spec == "databricks":
|
|
56
|
+
from semolina.engines.databricks import DatabricksEngine
|
|
57
|
+
from semolina.testing.credentials import DatabricksCredentials
|
|
58
|
+
|
|
59
|
+
creds = DatabricksCredentials.load()
|
|
60
|
+
params = creds.model_dump(by_alias=True)
|
|
61
|
+
params["access_token"] = creds.access_token.get_secret_value()
|
|
62
|
+
return DatabricksEngine(**params)
|
|
63
|
+
else:
|
|
64
|
+
import importlib
|
|
65
|
+
|
|
66
|
+
module_path, _, class_name = backend_spec.rpartition(".")
|
|
67
|
+
if not module_path:
|
|
68
|
+
raise typer.BadParameter(
|
|
69
|
+
f"Unknown backend {backend_spec!r}. "
|
|
70
|
+
"Use 'snowflake', 'databricks', or a dotted import path."
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
module = importlib.import_module(module_path)
|
|
74
|
+
cls = getattr(module, class_name)
|
|
75
|
+
return cls() # type: ignore[no-any-return]
|
|
76
|
+
except (ImportError, AttributeError) as e:
|
|
77
|
+
raise typer.BadParameter(f"Cannot import backend {backend_spec!r}: {e}") from e
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def codegen(
|
|
81
|
+
views: Annotated[
|
|
82
|
+
list[str],
|
|
83
|
+
typer.Argument(help="Schema-qualified view names (e.g. my_schema.my_view)"),
|
|
84
|
+
],
|
|
85
|
+
backend: Annotated[
|
|
86
|
+
str,
|
|
87
|
+
typer.Option(
|
|
88
|
+
"--backend", "-b", help="Backend: snowflake, databricks, or dotted.path.ClassName"
|
|
89
|
+
),
|
|
90
|
+
],
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Introspect warehouse semantic views and generate Semolina model classes."""
|
|
93
|
+
from semolina.engines.base import SemolinaConnectionError, SemolinaViewNotFoundError
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
engine = _resolve_backend(backend)
|
|
97
|
+
except typer.BadParameter as e:
|
|
98
|
+
_stderr.print(f"[bold red]Error:[/bold red] {e}")
|
|
99
|
+
raise typer.Exit(code=EXIT_INVALID_BACKEND) from e
|
|
100
|
+
|
|
101
|
+
introspected_views: list[IntrospectedView] = []
|
|
102
|
+
for view_name in views:
|
|
103
|
+
try:
|
|
104
|
+
introspected = engine.introspect(view_name)
|
|
105
|
+
introspected_views.append(introspected)
|
|
106
|
+
except SemolinaViewNotFoundError as e:
|
|
107
|
+
_stderr.print(f"[bold red]Error:[/bold red] {e}")
|
|
108
|
+
raise typer.Exit(code=EXIT_VIEW_NOT_FOUND) from e
|
|
109
|
+
except SemolinaConnectionError as e:
|
|
110
|
+
_stderr.print(f"[bold red]Error:[/bold red] {e}")
|
|
111
|
+
raise typer.Exit(code=EXIT_CONNECTION_ERROR) from e
|
|
112
|
+
except RuntimeError as e:
|
|
113
|
+
_stderr.print(f"[bold red]Error:[/bold red] {e}")
|
|
114
|
+
raise typer.Exit(code=1) from e
|
|
115
|
+
|
|
116
|
+
from semolina.codegen.python_renderer import render_and_format
|
|
117
|
+
|
|
118
|
+
source = render_and_format(introspected_views)
|
|
119
|
+
typer.echo(source)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI utility functions for Semolina codegen commands.
|
|
3
|
+
|
|
4
|
+
Provides path resolution, stderr console creation, and progress helpers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import glob as _glob
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def make_stderr_console(verbose: bool = False) -> Console:
|
|
18
|
+
"""
|
|
19
|
+
Create a Rich Console writing to stderr for diagnostic output.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
verbose: If True, suppress no output (show everything). If False, only
|
|
23
|
+
show warnings and errors.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Configured Rich Console for stderr.
|
|
27
|
+
"""
|
|
28
|
+
return Console(file=sys.stderr, stderr=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_input_paths(input_spec: str, stderr: Console) -> list[Path]:
|
|
32
|
+
"""
|
|
33
|
+
Resolve an input specification to a list of Python file paths.
|
|
34
|
+
|
|
35
|
+
Handles three forms:
|
|
36
|
+
- Explicit file path: must exist, returned as single-element list
|
|
37
|
+
- Directory path: scanned recursively for *.py files
|
|
38
|
+
- Glob pattern: expanded via pathlib from cwd
|
|
39
|
+
|
|
40
|
+
Fails immediately (raises SystemExit via typer.Exit) if an explicit
|
|
41
|
+
file path doesn't exist or can't be read.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
input_spec: File path, directory path, or glob pattern string.
|
|
45
|
+
stderr: Rich Console for diagnostic messages.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Sorted list of resolved .py file paths.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
typer.Exit: If an explicit path doesn't exist or is unreadable.
|
|
52
|
+
"""
|
|
53
|
+
path = Path(input_spec)
|
|
54
|
+
|
|
55
|
+
# If input contains glob special characters, treat as glob regardless of suffix.
|
|
56
|
+
# This handles patterns like '/path/to/*.py' which have suffix '.py' but aren't
|
|
57
|
+
# explicit file paths.
|
|
58
|
+
_GLOB_CHARS = {"*", "?", "["}
|
|
59
|
+
is_glob = any(ch in input_spec for ch in _GLOB_CHARS)
|
|
60
|
+
|
|
61
|
+
if not is_glob:
|
|
62
|
+
# Explicit file: must exist
|
|
63
|
+
if path.suffix == ".py":
|
|
64
|
+
if not path.exists():
|
|
65
|
+
stderr.print(
|
|
66
|
+
f"[bold red]Error:[/bold red] File not found: [cyan]{path}[/cyan]\n"
|
|
67
|
+
f" Check the path and try again."
|
|
68
|
+
)
|
|
69
|
+
raise typer.Exit(code=1)
|
|
70
|
+
if not path.is_file():
|
|
71
|
+
stderr.print(f"[bold red]Error:[/bold red] Not a file: [cyan]{path}[/cyan]")
|
|
72
|
+
raise typer.Exit(code=1)
|
|
73
|
+
return [path.resolve()]
|
|
74
|
+
|
|
75
|
+
# Directory: scan recursively for *.py files
|
|
76
|
+
if path.is_dir():
|
|
77
|
+
files = sorted(path.rglob("*.py"))
|
|
78
|
+
# Exclude __pycache__ and hidden directories
|
|
79
|
+
files = [
|
|
80
|
+
f
|
|
81
|
+
for f in files
|
|
82
|
+
if "__pycache__" not in f.parts and not any(p.startswith(".") for p in f.parts)
|
|
83
|
+
]
|
|
84
|
+
return files
|
|
85
|
+
|
|
86
|
+
# Explicit directory path that doesn't exist: fail immediately
|
|
87
|
+
if not path.suffix and not path.exists():
|
|
88
|
+
stderr.print(
|
|
89
|
+
f"[bold red]Error:[/bold red] Directory not found: [cyan]{path}[/cyan]\n"
|
|
90
|
+
f" Check the path and try again."
|
|
91
|
+
)
|
|
92
|
+
raise typer.Exit(code=1)
|
|
93
|
+
|
|
94
|
+
# Glob pattern: expand via glob.glob which handles both relative and absolute patterns.
|
|
95
|
+
# Path.cwd().glob() raises NotImplementedError for absolute patterns in Python 3.14+.
|
|
96
|
+
matched = sorted(Path(m) for m in _glob.glob(input_spec, recursive=True))
|
|
97
|
+
py_files = [f for f in matched if f.suffix == ".py" and f.is_file()]
|
|
98
|
+
return py_files
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Semolina code generation engine."""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Intermediate representation dataclasses for reverse codegen introspection.
|
|
3
|
+
|
|
4
|
+
Defines the frozen dataclasses that form the contract between warehouse
|
|
5
|
+
introspection engines and the Python code renderer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class IntrospectedField:
|
|
16
|
+
"""
|
|
17
|
+
Intermediate representation of a single field from an introspected semantic view.
|
|
18
|
+
|
|
19
|
+
Produced by warehouse introspection engines and consumed by the Python code
|
|
20
|
+
renderer to generate Metric, Dimension, or Fact field declarations.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
name: Python attribute name for the field (e.g., 'revenue', 'country').
|
|
24
|
+
Always lowercase (Pythonic). For Snowflake views, this is the
|
|
25
|
+
warehouse column name lowercased.
|
|
26
|
+
field_type: Semantic role of the field: 'metric', 'dimension', or 'fact'.
|
|
27
|
+
data_type: Python annotation string (e.g., 'int', 'str', 'datetime.date'),
|
|
28
|
+
or None if the SQL type has no clean Python equivalent (triggers a
|
|
29
|
+
TODO comment in the generated output).
|
|
30
|
+
description: Human-readable description of the field. Defaults to empty
|
|
31
|
+
string if the warehouse provides no description metadata.
|
|
32
|
+
source_name: Original warehouse column name when it differs from what
|
|
33
|
+
``normalize_identifier`` would produce. Set only when the column
|
|
34
|
+
was created with a quoted identifier (e.g., ``"order_id"`` in
|
|
35
|
+
Snowflake, stored as lowercase). None for standard columns that
|
|
36
|
+
round-trip correctly through normalize_identifier.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
field_type: Literal["metric", "dimension", "fact"]
|
|
41
|
+
data_type: str | None
|
|
42
|
+
description: str = ""
|
|
43
|
+
source_name: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class IntrospectedView:
|
|
48
|
+
"""
|
|
49
|
+
Intermediate representation of a semantic view returned by engine introspection.
|
|
50
|
+
|
|
51
|
+
Produced by Engine.introspect() and consumed by the Python code renderer to
|
|
52
|
+
generate a complete SemanticView subclass.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
view_name: Warehouse identifier for the view (e.g., 'sales_view'). Used
|
|
56
|
+
as the ``view=`` parameter in the generated SemanticView subclass.
|
|
57
|
+
class_name: Python class name to use in the generated code (e.g., 'Sales').
|
|
58
|
+
Derived from view_name by the introspection layer (snake_case → PascalCase).
|
|
59
|
+
fields: All fields discovered in the view, in the order returned by the
|
|
60
|
+
warehouse API. Includes metrics, dimensions, and facts.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
view_name: str
|
|
64
|
+
class_name: str
|
|
65
|
+
fields: list[IntrospectedField] = field(default_factory=list)
|