finai-snowflake-transform 0.1.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.
@@ -0,0 +1,7 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ *.py[cod]
6
+ .pytest_cache/
7
+ .venv/
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.5
2
+ Name: finai-snowflake-transform
3
+ Version: 0.1.0
4
+ Summary: YAML-driven demo transformation for Snowpark and pandas on Snowflake
5
+ Author: finai-data-analytics
6
+ License-Expression: MIT
7
+ Keywords: pandas,snowflake,snowpark,transform
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: numpy>=1.24
13
+ Requires-Dist: pandas>=2.0
14
+ Requires-Dist: pyyaml>=6.0
15
+ Provides-Extra: snowflake
16
+ Requires-Dist: snowflake-snowpark-python[modin]>=1.55.0; extra == 'snowflake'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # finai-snowflake-transform
20
+
21
+ A small public package used to verify that a PyPI release, including YAML files
22
+ bundled inside it, can be installed and executed inside Snowflake through both
23
+ Snowpark and pandas on Snowflake.
24
+
25
+ ## Transformation
26
+
27
+ Parameters are read from a YAML file bundled in the package, so nothing depends
28
+ on the working directory:
29
+
30
+ ```text
31
+ TRANSFORMED_VALUE = fill_null(VALUE, null_fill) * scale + offset
32
+ ```
33
+
34
+ | Config | scale | offset | null_fill |
35
+ | --- | --- | --- | --- |
36
+ | `defaults.yaml` | 2.0 | 1.0 | 0.0 |
37
+ | `alternate.yaml` | 3.0 | -1.0 | 2.0 |
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install finai-snowflake-transform
43
+ # with the Snowflake runtime extra
44
+ pip install "finai-snowflake-transform[snowflake]"
45
+ ```
46
+
47
+ ## Use
48
+
49
+ `ID` and `VALUE` are preserved and `TRANSFORMED_VALUE` is appended. Arithmetic
50
+ stays native to the engine you pass in; the caller reads and writes tables.
51
+
52
+ ```python
53
+ from finai_snowflake_transform import (
54
+ load_config,
55
+ transform_pandas_on_snowflake,
56
+ transform_snowpark,
57
+ )
58
+
59
+ load_config("defaults.yaml")
60
+ # {'scale': 2.0, 'offset': 1.0, 'null_fill': 0.0}
61
+
62
+ # pandas on Snowflake (also works with plain pandas)
63
+ import modin.pandas as pd
64
+ import snowflake.snowpark.modin.plugin # noqa: F401
65
+
66
+ out = transform_pandas_on_snowflake(pd.read_snowflake("MY_INPUT"), "defaults.yaml")
67
+
68
+ # Snowpark
69
+ out = transform_snowpark(session.table("MY_INPUT"), "alternate.yaml")
70
+ ```
71
+
72
+ Invalid configuration names, non-mapping YAML, missing parameters, booleans and
73
+ non-finite numbers all raise immediately with a message naming the config file.
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,59 @@
1
+ # finai-snowflake-transform
2
+
3
+ A small public package used to verify that a PyPI release, including YAML files
4
+ bundled inside it, can be installed and executed inside Snowflake through both
5
+ Snowpark and pandas on Snowflake.
6
+
7
+ ## Transformation
8
+
9
+ Parameters are read from a YAML file bundled in the package, so nothing depends
10
+ on the working directory:
11
+
12
+ ```text
13
+ TRANSFORMED_VALUE = fill_null(VALUE, null_fill) * scale + offset
14
+ ```
15
+
16
+ | Config | scale | offset | null_fill |
17
+ | --- | --- | --- | --- |
18
+ | `defaults.yaml` | 2.0 | 1.0 | 0.0 |
19
+ | `alternate.yaml` | 3.0 | -1.0 | 2.0 |
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install finai-snowflake-transform
25
+ # with the Snowflake runtime extra
26
+ pip install "finai-snowflake-transform[snowflake]"
27
+ ```
28
+
29
+ ## Use
30
+
31
+ `ID` and `VALUE` are preserved and `TRANSFORMED_VALUE` is appended. Arithmetic
32
+ stays native to the engine you pass in; the caller reads and writes tables.
33
+
34
+ ```python
35
+ from finai_snowflake_transform import (
36
+ load_config,
37
+ transform_pandas_on_snowflake,
38
+ transform_snowpark,
39
+ )
40
+
41
+ load_config("defaults.yaml")
42
+ # {'scale': 2.0, 'offset': 1.0, 'null_fill': 0.0}
43
+
44
+ # pandas on Snowflake (also works with plain pandas)
45
+ import modin.pandas as pd
46
+ import snowflake.snowpark.modin.plugin # noqa: F401
47
+
48
+ out = transform_pandas_on_snowflake(pd.read_snowflake("MY_INPUT"), "defaults.yaml")
49
+
50
+ # Snowpark
51
+ out = transform_snowpark(session.table("MY_INPUT"), "alternate.yaml")
52
+ ```
53
+
54
+ Invalid configuration names, non-mapping YAML, missing parameters, booleans and
55
+ non-finite numbers all raise immediately with a message naming the config file.
56
+
57
+ ## License
58
+
59
+ MIT
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "finai-snowflake-transform"
3
+ version = "0.1.0"
4
+ description = "YAML-driven demo transformation for Snowpark and pandas on Snowflake"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "finai-data-analytics" }]
9
+ keywords = ["snowflake", "snowpark", "pandas", "transform"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Operating System :: OS Independent",
14
+ ]
15
+ dependencies = [
16
+ "pandas>=2.0",
17
+ "numpy>=1.24",
18
+ "PyYAML>=6.0",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ snowflake = ["snowflake-snowpark-python[modin]>=1.55.0"]
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/finai_snowflake_transform"]
30
+
31
+ [tool.hatch.build.targets.sdist]
32
+ # Explicit allowlist so nothing from the surrounding repository can leak in.
33
+ include = [
34
+ "src/finai_snowflake_transform",
35
+ "tests",
36
+ "README.md",
37
+ "pyproject.toml",
38
+ ]
39
+ # Hatchling otherwise pulls the surrounding repository's ignore file into the sdist.
40
+ exclude = [".gitignore"]
@@ -0,0 +1,24 @@
1
+ """Public API for ``finai-snowflake-transform``."""
2
+
3
+ from finai_snowflake_transform.transforms import (
4
+ DEFAULT_CONFIG,
5
+ OUTPUT_COLUMN,
6
+ REQUIRED_KEYS,
7
+ VALUE_COLUMN,
8
+ load_config,
9
+ transform_pandas_on_snowflake,
10
+ transform_snowpark,
11
+ )
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "DEFAULT_CONFIG",
17
+ "OUTPUT_COLUMN",
18
+ "REQUIRED_KEYS",
19
+ "VALUE_COLUMN",
20
+ "__version__",
21
+ "load_config",
22
+ "transform_pandas_on_snowflake",
23
+ "transform_snowpark",
24
+ ]
@@ -0,0 +1,5 @@
1
+ # Alternate transformation parameters, used to prove the YAML drives the result.
2
+ # TRANSFORMED_VALUE = fill_null(VALUE, null_fill) * scale + offset
3
+ scale: 3.0
4
+ offset: -1.0
5
+ null_fill: 2.0
@@ -0,0 +1,5 @@
1
+ # Default transformation parameters.
2
+ # TRANSFORMED_VALUE = fill_null(VALUE, null_fill) * scale + offset
3
+ scale: 2.0
4
+ offset: 1.0
5
+ null_fill: 0.0
@@ -0,0 +1,98 @@
1
+ """YAML-driven transformations for Snowpark and pandas on Snowflake.
2
+
3
+ The transformation is ``TRANSFORMED_VALUE = fill_null(VALUE, null_fill) * scale
4
+ + offset``. Parameters come from a YAML file bundled inside this package, so the
5
+ same code works locally, in a stored procedure, or in a UDF without depending on
6
+ the working directory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib import resources
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+ import yaml
16
+
17
+ __all__ = [
18
+ "DEFAULT_CONFIG",
19
+ "OUTPUT_COLUMN",
20
+ "REQUIRED_KEYS",
21
+ "VALUE_COLUMN",
22
+ "load_config",
23
+ "transform_pandas_on_snowflake",
24
+ "transform_snowpark",
25
+ ]
26
+
27
+ DEFAULT_CONFIG = "defaults.yaml"
28
+ REQUIRED_KEYS = ("scale", "offset", "null_fill")
29
+ VALUE_COLUMN = "VALUE"
30
+ OUTPUT_COLUMN = "TRANSFORMED_VALUE"
31
+
32
+
33
+ def _read_resource(config_name: str) -> str:
34
+ """Read a YAML file bundled in this package, rejecting unsafe names."""
35
+ if not isinstance(config_name, str) or not config_name.strip():
36
+ raise ValueError("config_name must be a non-empty string")
37
+ if config_name != config_name.strip():
38
+ raise ValueError(f"config_name must not be padded with whitespace: {config_name!r}")
39
+ if config_name.startswith(".") or "/" in config_name or "\\" in config_name:
40
+ raise ValueError(f"config_name must be a bare bundled file name: {config_name!r}")
41
+ if not config_name.endswith((".yaml", ".yml")):
42
+ raise ValueError(f"config_name must end with .yaml or .yml: {config_name!r}")
43
+
44
+ resource = resources.files(__package__).joinpath(config_name)
45
+ if not resource.is_file():
46
+ raise FileNotFoundError(f"bundled config not found in {__package__}: {config_name!r}")
47
+ return resource.read_text(encoding="utf-8")
48
+
49
+
50
+ def _validate_config(raw: Any, source: str = "<mapping>") -> dict[str, float]:
51
+ """Validate a parsed config mapping and coerce its values to finite floats."""
52
+ if not isinstance(raw, dict):
53
+ raise ValueError(f"{source}: config must be a YAML mapping, got {type(raw).__name__}")
54
+
55
+ missing = [key for key in REQUIRED_KEYS if key not in raw]
56
+ if missing:
57
+ raise ValueError(f"{source}: missing required parameter(s): {', '.join(missing)}")
58
+
59
+ config: dict[str, float] = {}
60
+ for key in REQUIRED_KEYS:
61
+ value = raw[key]
62
+ # bool is a subclass of int, so it has to be rejected before the numeric check.
63
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
64
+ raise TypeError(f"{source}: {key} must be a real number, got {type(value).__name__}")
65
+ number = float(value)
66
+ if not np.isfinite(number):
67
+ raise ValueError(f"{source}: {key} must be finite, got {value!r}")
68
+ config[key] = number
69
+ return config
70
+
71
+
72
+ def load_config(config_name: str = DEFAULT_CONFIG) -> dict[str, float]:
73
+ """Load and validate a bundled YAML config into ``{scale, offset, null_fill}``."""
74
+ text = _read_resource(config_name)
75
+ try:
76
+ raw = yaml.safe_load(text)
77
+ except yaml.YAMLError as exc: # pragma: no cover - bundled configs are valid
78
+ raise ValueError(f"{config_name}: invalid YAML: {exc}") from exc
79
+ return _validate_config(raw, source=config_name)
80
+
81
+
82
+ def transform_pandas_on_snowflake(df, config_name: str = DEFAULT_CONFIG):
83
+ """Add ``TRANSFORMED_VALUE`` to a Snowpark pandas (or pandas) DataFrame."""
84
+ config = load_config(config_name)
85
+ transformed = df[VALUE_COLUMN].fillna(config["null_fill"]) * config["scale"] + config["offset"]
86
+ return df.assign(**{OUTPUT_COLUMN: transformed})
87
+
88
+
89
+ def transform_snowpark(df, config_name: str = DEFAULT_CONFIG):
90
+ """Add ``TRANSFORMED_VALUE`` to a Snowpark DataFrame using Snowpark expressions."""
91
+ from snowflake.snowpark.functions import coalesce, col, lit
92
+
93
+ config = load_config(config_name)
94
+ return df.with_column(
95
+ OUTPUT_COLUMN,
96
+ coalesce(col(VALUE_COLUMN), lit(config["null_fill"])) * lit(config["scale"])
97
+ + lit(config["offset"]),
98
+ )
@@ -0,0 +1,104 @@
1
+ """Local checks for the bundled-YAML transformation.
2
+
3
+ Expected values are written out by hand from the MVP spec, not recomputed with
4
+ the package, so a change in the transformation is caught rather than mirrored.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ import pandas as pd
12
+ import pytest
13
+
14
+ from finai_snowflake_transform import (
15
+ __version__,
16
+ load_config,
17
+ transform_pandas_on_snowflake,
18
+ )
19
+ from finai_snowflake_transform.transforms import _validate_config
20
+
21
+ IDS = [1, 2, 3, 4]
22
+ VALUES = [-2.0, 0.0, 3.5, None]
23
+ EXPECTED = {
24
+ "defaults.yaml": [-3.0, 1.0, 8.0, 1.0],
25
+ "alternate.yaml": [-7.0, -1.0, 9.5, 5.0],
26
+ }
27
+
28
+
29
+ def make_input() -> pd.DataFrame:
30
+ return pd.DataFrame({"ID": IDS, "VALUE": VALUES})
31
+
32
+
33
+ def test_version() -> None:
34
+ assert __version__ == "0.1.0"
35
+
36
+
37
+ def test_bundled_configs_load_from_the_installed_package() -> None:
38
+ assert load_config("defaults.yaml") == {"scale": 2.0, "offset": 1.0, "null_fill": 0.0}
39
+ assert load_config("alternate.yaml") == {"scale": 3.0, "offset": -1.0, "null_fill": 2.0}
40
+ assert load_config() == load_config("defaults.yaml")
41
+
42
+
43
+ @pytest.mark.parametrize("config_name", sorted(EXPECTED))
44
+ def test_transform_matches_independent_expectations(config_name: str) -> None:
45
+ result = transform_pandas_on_snowflake(make_input(), config_name)
46
+
47
+ assert list(result.columns) == ["ID", "VALUE", "TRANSFORMED_VALUE"]
48
+ assert list(result["ID"]) == IDS
49
+ assert result["VALUE"].isna().tolist() == [False, False, False, True]
50
+ assert result["TRANSFORMED_VALUE"].tolist() == pytest.approx(EXPECTED[config_name])
51
+
52
+
53
+ def test_input_frame_is_not_mutated() -> None:
54
+ df = make_input()
55
+ transform_pandas_on_snowflake(df)
56
+ assert list(df.columns) == ["ID", "VALUE"]
57
+
58
+
59
+ @pytest.mark.parametrize(
60
+ "config_name",
61
+ ["missing.yaml", "defaults.json", "", " ", "../defaults.yaml", "sub/defaults.yaml", ".defaults.yaml", None, 3],
62
+ )
63
+ def test_invalid_config_names_are_rejected(config_name: object) -> None:
64
+ with pytest.raises((ValueError, FileNotFoundError)):
65
+ load_config(config_name) # type: ignore[arg-type]
66
+
67
+
68
+ @pytest.mark.parametrize(
69
+ "raw",
70
+ [
71
+ None,
72
+ [1, 2, 3],
73
+ "scale: 2.0",
74
+ {"scale": 2.0, "offset": 1.0},
75
+ {"scale": True, "offset": 1.0, "null_fill": 0.0},
76
+ {"scale": "2.0", "offset": 1.0, "null_fill": 0.0},
77
+ {"scale": None, "offset": 1.0, "null_fill": 0.0},
78
+ {"scale": math.nan, "offset": 1.0, "null_fill": 0.0},
79
+ {"scale": math.inf, "offset": 1.0, "null_fill": 0.0},
80
+ ],
81
+ )
82
+ def test_invalid_config_payloads_are_rejected(raw: object) -> None:
83
+ with pytest.raises((ValueError, TypeError)):
84
+ _validate_config(raw, source="unit")
85
+
86
+
87
+ def demo() -> None:
88
+ """Runnable self-check: ``python tests/test_transform.py``."""
89
+ for config_name, expected in EXPECTED.items():
90
+ got = transform_pandas_on_snowflake(make_input(), config_name)["TRANSFORMED_VALUE"].tolist()
91
+ assert all(math.isclose(a, b) for a, b in zip(got, expected, strict=True)), (config_name, got)
92
+ print(f"{config_name}: {got} == {expected}")
93
+ for bad in ("missing.yaml", "../defaults.yaml"):
94
+ try:
95
+ load_config(bad)
96
+ except (ValueError, FileNotFoundError) as exc:
97
+ print(f"rejected {bad!r}: {exc}")
98
+ else: # pragma: no cover
99
+ raise AssertionError(f"{bad!r} should have been rejected")
100
+ print("OK")
101
+
102
+
103
+ if __name__ == "__main__":
104
+ demo()