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.
- finai_snowflake_transform-0.1.0/.gitignore +7 -0
- finai_snowflake_transform-0.1.0/PKG-INFO +77 -0
- finai_snowflake_transform-0.1.0/README.md +59 -0
- finai_snowflake_transform-0.1.0/pyproject.toml +40 -0
- finai_snowflake_transform-0.1.0/src/finai_snowflake_transform/__init__.py +24 -0
- finai_snowflake_transform-0.1.0/src/finai_snowflake_transform/alternate.yaml +5 -0
- finai_snowflake_transform-0.1.0/src/finai_snowflake_transform/defaults.yaml +5 -0
- finai_snowflake_transform-0.1.0/src/finai_snowflake_transform/transforms.py +98 -0
- finai_snowflake_transform-0.1.0/tests/test_transform.py +104 -0
|
@@ -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,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()
|