diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index f925c40d7..5d4cb16cd 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -114,14 +114,18 @@ jobs: # Update this key to force a new cache (sync with packaging.yml) prefix-key: "python-v3" - - name: Install + - name: Install sedonadb-expr + run: | + pip install -e "python/sedonadb-expr" -vv + + - name: Install sedonadb run: | # Keep this export in sync with the export in dev/release/verify-release-candidate.sh export MATURIN_PEP517_ARGS="--features s2geography" pip install -e "python/sedonadb/[test]" -vv - # Unset so `--features s2geography` (sedonadb-only) doesn't - # carry into the plugin install. - unset MATURIN_PEP517_ARGS + + - name: Install sedonadb-zarr + run: | pip install -e "python/sedonadb-zarr/[test]" -vv - name: Download minimal geoarrow-data assets @@ -132,18 +136,28 @@ jobs: run: | docker compose up --wait --detach postgis - - name: Run tests + - name: Run tests (sedonadb) env: # Ensure that we don't skip tests that we didn't intend to SEDONADB_PYTHON_NO_SKIP_TESTS: "true" run: | - cd python + cd python/sedonadb + python -m pytest -vv + + - name: Run doctests (sedonadb) + run: | + cd python/sedonadb + python -m pytest --doctest-modules python/ + + - name: Run tests (sedonadb-expr) + run: | + cd python/sedonadb-expr python -m pytest -vv - - name: Run doctests + - name: Run doctests (sedonadb-expr) run: | - cd python - python -m pytest --doctest-modules + cd python/sedonadb-expr + python -m pytest --doctest-modules python/ - name: Shutdown docker compose services if: always() diff --git a/python/sedonadb-expr/.gitignore b/python/sedonadb-expr/.gitignore new file mode 100644 index 000000000..71528ae4b --- /dev/null +++ b/python/sedonadb-expr/.gitignore @@ -0,0 +1,76 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Generated files +python/sedonadb_expr/_version.py +python/sedonadb_expr/_generated/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +*.egg + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/python/sedonadb-expr/README.md b/python/sedonadb-expr/README.md new file mode 100644 index 000000000..f11574805 --- /dev/null +++ b/python/sedonadb-expr/README.md @@ -0,0 +1,36 @@ + + +# SedonaDB Expr + +A standalone Python package for SedonaDB expressions. + +## Installation + +```shell +pip install sedonadb-expr +``` + +## Example + +```python +import sedonadb_expr + +print(sedonadb_expr.__version__) +``` diff --git a/python/sedonadb-expr/_version.py b/python/sedonadb-expr/_version.py new file mode 100644 index 000000000..ff985f4c5 --- /dev/null +++ b/python/sedonadb-expr/_version.py @@ -0,0 +1,47 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Version source for hatchling - reads from workspace Cargo.toml. + +This file is used by hatchling at build time to determine the version. +The build hook then generates a static _version.py inside the package. +""" + +import re +from pathlib import Path + + +def get_version() -> str: + """Read version from the workspace root Cargo.toml.""" + here = Path(__file__).parent + cargo_toml = here.parent.parent / "Cargo.toml" + + if not cargo_toml.exists(): + raise FileNotFoundError(f"Could not find workspace Cargo.toml at {cargo_toml}") + + content = cargo_toml.read_text() + + match = re.search( + r'\[workspace\.package\].*?version\s*=\s*"([^"]+)"', + content, + re.DOTALL, + ) + if match: + return match.group(1) + + raise ValueError("Could not find workspace.package.version in Cargo.toml") diff --git a/python/sedonadb-expr/hatch_build.py b/python/sedonadb-expr/hatch_build.py new file mode 100644 index 000000000..083d72588 --- /dev/null +++ b/python/sedonadb-expr/hatch_build.py @@ -0,0 +1,644 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Hatch build hook for sedonadb-expr. + +This hook runs during sdist and wheel builds to generate Python source +files from the docs/reference/sql documentation files. +""" + +from __future__ import annotations + +import re +import textwrap +from pathlib import Path +from typing import Any + +import yaml +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +# Type to parameter name mapping (matches R version) +TYPE_TO_PARAM: dict[str, str] = { + "geometry": "geom", + "geography": "geom", + "raster": "rast", + "float64": "x", + "double": "x", + "integer": "n", + "int64": "n", + "string": "s", + "boolean": "b", + "crs": "crs", +} + +# Types that qualify for geo methods (first arg piped in) +GEO_TYPES = {"geometry", "geography"} + +DOCS_BASE_URL = "https://sedona.apache.org/sedonadb/latest/reference/sql" + +LICENSE_HEADER = """\ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" + + +class ArgInfo: + """Information about a kernel argument.""" + + def __init__( + self, + type: str, + name: str | None = None, + description: str | None = None, + ): + self.type = type + self.name = name + self.description = description + + +class KernelInfo: + """Parsed kernel information.""" + + def __init__( + self, + args: list[ArgInfo] | None = None, + returns: str = "unknown", + variadic: bool = False, + kernel_signatures: list[str] | None = None, + ): + self.args = args if args is not None else [] + self.returns = returns + self.variadic = variadic + self.kernel_signatures = ( + kernel_signatures if kernel_signatures is not None else [] + ) + + +class FunctionInfo: + """Parsed function information from a .qmd file.""" + + def __init__( + self, + name: str, + title: str, + description: str, + kernels: list[dict[str, Any]], + is_geo_method: bool = False, + kernel_info: KernelInfo | None = None, + ): + self.name = name + self.title = title + self.description = description + self.kernels = kernels + self.is_geo_method = is_geo_method + self.kernel_info = kernel_info + + +def extract_frontmatter(file_path: Path) -> dict[str, Any]: + """Extract YAML frontmatter from a .qmd file.""" + content = file_path.read_text() + lines = content.split("\n") + + # Find YAML delimiters + delimiters = [i for i, line in enumerate(lines) if line.strip() == "---"] + if len(delimiters) < 2: + raise ValueError(f"Could not find YAML frontmatter in {file_path}") + + yaml_text = "\n".join(lines[delimiters[0] + 1 : delimiters[1]]) + return yaml.safe_load(yaml_text) + + +def extract_description_section(file_path: Path) -> str | None: + """Extract the ## Description section from the .qmd file body.""" + content = file_path.read_text() + lines = content.split("\n") + + # Find end of frontmatter + delimiters = [i for i, line in enumerate(lines) if line.strip() == "---"] + if len(delimiters) < 2: + return None + + body_lines = lines[delimiters[1] + 1 :] + + # Find ## Description section + desc_start = None + for i, line in enumerate(body_lines): + if line.startswith("## Description"): + desc_start = i + break + + if desc_start is None: + return None + + # Find next section or end + remaining = body_lines[desc_start + 1 :] + next_section = None + for i, line in enumerate(remaining): + if line.startswith("## "): + next_section = i + break + + if next_section is None: + desc_lines = remaining + else: + desc_lines = remaining[:next_section] + + # Process lines: preserve markdown lists, join paragraphs + result_lines: list[str] = [] + current_paragraph: list[str] = [] + + for line in desc_lines: + stripped = line.strip() + # Check if this is a list item (-, *, or numbered) + is_list_item = bool(re.match(r"^[-*]|\d+\.", stripped)) + + if not stripped: + # Empty line: flush current paragraph and add blank line for separation + if current_paragraph: + result_lines.append(" ".join(current_paragraph)) + current_paragraph = [] + result_lines.append("") # Preserve paragraph break + elif is_list_item: + # List item: flush paragraph first, then add list item + if current_paragraph: + result_lines.append(" ".join(current_paragraph)) + current_paragraph = [] + result_lines.append(stripped) + else: + # Regular text: accumulate into paragraph + current_paragraph.append(stripped) + + # Flush any remaining paragraph + if current_paragraph: + result_lines.append(" ".join(current_paragraph)) + + desc_text = "\n".join(result_lines).strip() + return desc_text if desc_text else None + + +def type_to_param_name( + arg_type: str, index: int = 0, needs_suffix: bool = False +) -> str: + """Generate parameter name from type.""" + base_name = TYPE_TO_PARAM.get(arg_type, "arg") + if needs_suffix: + suffix = chr(ord("a") + index) # 0=a, 1=b, 2=c, ... + return f"{base_name}_{suffix}" + return base_name + + +def parse_kernel_args(kernel_args: list) -> list[ArgInfo]: + """Parse kernel arguments into ArgInfo objects.""" + result = [] + for arg in kernel_args: + if isinstance(arg, str): + result.append(ArgInfo(type=arg)) + elif isinstance(arg, dict): + result.append( + ArgInfo( + type=arg.get("type", "unknown"), + name=arg.get("name"), + description=arg.get("description"), + ) + ) + else: + result.append(ArgInfo(type="unknown")) + return result + + +def generate_arg_names(arg_info_list: list[ArgInfo]) -> list[str]: + """Generate argument names for a kernel's args.""" + types = [info.type for info in arg_info_list] + type_counts: dict[str, int] = {} + type_totals: dict[str, int] = {} + + # Count total occurrences of each type + for t in types: + type_totals[t] = type_totals.get(t, 0) + 1 + + arg_names = [] + for info in arg_info_list: + arg_type = info.type + arg_name = info.name + + if arg_name is None: + type_counts[arg_type] = type_counts.get(arg_type, 0) + 1 + needs_suffix = type_totals.get(arg_type, 0) > 1 + arg_name = type_to_param_name( + arg_type, type_counts[arg_type] - 1, needs_suffix + ) + + arg_names.append(arg_name) + + return arg_names + + +def parse_kernel_params(kernels: list[dict], fn_name: str = "unknown") -> KernelInfo: + """Parse kernel arguments and generate parameter info.""" + if not kernels: + return KernelInfo() + + # Process all kernels + all_kernel_info = [parse_kernel_args(k.get("args", [])) for k in kernels] + all_kernel_args = [generate_arg_names(info) for info in all_kernel_info] + + # Find max args + kernel_lengths = [len(args) for args in all_kernel_args] + max_args = max(kernel_lengths) if kernel_lengths else 0 + + # Check for argument name conflicts + has_conflict = False + for pos in range(max_args): + names_at_pos = set() + for args in all_kernel_args: + if pos < len(args): + names_at_pos.add(args[pos]) + if len(names_at_pos) > 1: + has_conflict = True + break + + returns = kernels[0].get("returns", "unknown") + + if has_conflict: + # Build signature strings for documentation + kernel_signatures = [] + for i, args in enumerate(all_kernel_args): + types = [info.type for info in all_kernel_info[i]] + sig = ", ".join(f"{arg} ({t})" for arg, t in zip(args, types)) + kernel_signatures.append(sig) + + return KernelInfo( + args=[], + returns=returns, + variadic=True, + kernel_signatures=kernel_signatures, + ) + + # Use kernel with most arguments as reference + ref_idx = kernel_lengths.index(max(kernel_lengths)) if kernel_lengths else 0 + arg_info = all_kernel_info[ref_idx] if all_kernel_info else [] + arg_names = all_kernel_args[ref_idx] if all_kernel_args else [] + + # Update ArgInfo with generated names + for i, info in enumerate(arg_info): + if info.name is None: + info.name = arg_names[i] + + return KernelInfo(args=arg_info, returns=returns, variadic=False) + + +def parse_qmd_file(qmd_path: Path) -> FunctionInfo | None: + """Parse a .qmd file and return FunctionInfo.""" + fn_name = qmd_path.stem # e.g., "st_envelope" + + try: + frontmatter = extract_frontmatter(qmd_path) + except Exception: + return None + + kernels = frontmatter.get("kernels", []) + if not kernels: + return None + + # Check if first argument of any kernel is geometry/geography + is_geo_method = False + for kernel in kernels: + args = kernel.get("args", []) + if args: + first_arg = args[0] + first_type = ( + first_arg if isinstance(first_arg, str) else first_arg.get("type", "") + ) + if first_type in GEO_TYPES: + is_geo_method = True + break + + title = frontmatter.get("description", frontmatter.get("title", fn_name)) + description = extract_description_section(qmd_path) or "" + + kernel_info = parse_kernel_params(kernels, fn_name) + + return FunctionInfo( + name=fn_name, + title=title, + description=description, + kernels=kernels, + is_geo_method=is_geo_method, + kernel_info=kernel_info, + ) + + +def wrap_docstring(text: str, width: int = 88, indent: str = " ") -> str: + """Wrap text for docstrings, preserving markdown lists.""" + if not text: + return "" + + result_lines: list[str] = [] + for i, line in enumerate(text.split("\n")): + if not line.strip(): + result_lines.append("") + continue + + # Wrap each line separately + wrapped = textwrap.fill(line, width=width - len(indent)) + for j, wrapped_line in enumerate(wrapped.split("\n")): + if i == 0 and j == 0: + # First line of first paragraph - no indent + result_lines.append(wrapped_line) + else: + result_lines.append(indent + wrapped_line) + + return "\n".join(result_lines) + + +def generate_method_docstring(func: FunctionInfo) -> str: + """Generate docstring for a method.""" + title = func.title.strip() + parts = [f'"""{title}'] + + if func.description and func.description.strip() != title: + parts.append("") + parts.append(wrap_docstring(func.description, indent=" ")) + + kernel_info = func.kernel_info + if kernel_info: + if kernel_info.variadic and kernel_info.kernel_signatures: + # Variadic mode: document with bulleted list of supported combinations + # Skip the first arg (piped in via self._expr) from each signature + parts.append("") + parts.append("Variants:") + for sig in kernel_info.kernel_signatures: + # Split signature, skip first arg, rejoin + arg_parts = [p.strip() for p in sig.split(",")] + remaining = ", ".join(arg_parts[1:]) if len(arg_parts) > 1 else "" + if remaining: + parts.append(f" - {remaining}") + else: + parts.append(" - (no additional arguments)") + elif kernel_info.args: + # Skip first arg (piped in via self._expr) + remaining_args = kernel_info.args[1:] if len(kernel_info.args) > 1 else [] + if remaining_args: + parts.append("") + parts.append("Args:") + for arg in remaining_args: + desc = arg.description or f"Input {arg.type}" + parts.append(f" {arg.name}: {desc}") + + parts.append("") + parts.append("See Also:") + parts.append(f" {DOCS_BASE_URL}/{func.name}/") + parts.append('"""') + + return "\n ".join(parts) + + +def generate_function_docstring(func: FunctionInfo) -> str: + """Generate docstring for a standalone function property.""" + title = func.title.strip() + parts = [f'"""{title}'] + + if func.description and func.description.strip() != title: + parts.append("") + parts.append(wrap_docstring(func.description, indent=" ")) + + kernel_info = func.kernel_info + if kernel_info: + if kernel_info.variadic and kernel_info.kernel_signatures: + # Variadic mode: document with bulleted list of supported combinations + parts.append("") + parts.append("Variants:") + for sig in kernel_info.kernel_signatures: + parts.append(f" - {sig}") + elif kernel_info.args: + parts.append("") + parts.append("Args:") + for arg in kernel_info.args: + desc = arg.description or f"Input {arg.type}" + parts.append(f" {arg.name}: {desc}") + + parts.append("") + parts.append("See Also:") + parts.append(f" {DOCS_BASE_URL}/{func.name}/") + parts.append('"""') + + return "\n ".join(parts) + + +def generate_geo_methods_py(functions: list[FunctionInfo]) -> str: + """Generate geo_methods.py content.""" + # Filter to only geo methods (first arg is geometry/geography) + geo_funcs = [f for f in functions if f.is_geo_method] + + lines = [ + LICENSE_HEADER, + "", + '"""Auto-generated geometry/geography methods - do not edit."""', + "", + "from typing import Generic, TypeVar", + "", + 'ExprT = TypeVar("ExprT")', + "", + "", + "class GeoMethods(Generic[ExprT]):", + ' """Geometry and geography methods accessible via expr.geo."""', + "", + " def __init__(self, expr: ExprT) -> None:", + " self._expr = expr", + ] + + for func in sorted(geo_funcs, key=lambda f: f.name): + # Method name: strip st_ prefix + method_name = func.name + if method_name.startswith("st_"): + method_name = method_name[3:] + + kernel_info = func.kernel_info + if not kernel_info: + continue + + # Build method signature - skip first arg (piped in) + remaining_args = kernel_info.args[1:] if len(kernel_info.args) > 1 else [] + + if kernel_info.variadic: + params = "self, *args" + call_args = "*args" + elif remaining_args: + param_strs = [f"{arg.name}" for arg in remaining_args] + params = "self, " + ", ".join(param_strs) + call_args = ", ".join(arg.name for arg in remaining_args) + else: + params = "self" + call_args = "" + + docstring = generate_method_docstring(func) + + lines.extend( + [ + "", + f" def {method_name}({params}) -> ExprT:", + f" {docstring}", + ] + ) + + if call_args: + lines.append( + f' return self._expr._call("{method_name}", {call_args})' + ) + else: + lines.append(f' return self._expr._call("{method_name}")') + + lines.append("") + return "\n".join(lines) + + +def generate_geo_functions_py(functions: list[FunctionInfo]) -> str: + """Generate geo_functions.py content.""" + # Filter to only geo methods (these become callable properties) + geo_funcs = [f for f in functions if f.is_geo_method] + + lines = [ + LICENSE_HEADER, + "", + '"""Auto-generated geometry/geography functions - do not edit."""', + "", + "from typing import Callable, Generic, TypeVar", + "", + 'ExprT = TypeVar("ExprT")', + "", + "", + "class GeoFunctions(Generic[ExprT]):", + ' """Geometry and geography functions accessible via a factory."""', + "", + " def __init__(self, factory) -> None:", + " self._factory = factory", + ] + + for func in sorted(geo_funcs, key=lambda f: f.name): + # Property name: strip st_ prefix + prop_name = func.name + if prop_name.startswith("st_"): + prop_name = prop_name[3:] + + docstring = generate_function_docstring(func) + + lines.extend( + [ + "", + " @property", + f" def {prop_name}(self) -> Callable[..., ExprT]:", + f" {docstring}", + f' return self._factory["{prop_name}"]', + ] + ) + + lines.append("") + return "\n".join(lines) + + +class CustomBuildHook(BuildHookInterface): + """Custom build hook that generates Python sources from SQL docs.""" + + PLUGIN_NAME = "custom" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + """ + Called before the build process starts. + + Args: + version: The version being built + build_data: Mutable dict to modify build behavior + """ + self._generate_version(version) + self._generate_sources() + + def _generate_version(self, version: str) -> None: + """Generate _version.py with the static version string.""" + here = Path(__file__).parent + version_file = here / "python" / "sedonadb_expr" / "_version.py" + + content = f'''# Auto-generated at build time - do not edit +__version__ = "{version}" +''' + version_file.write_text(content) + self.app.display_info(f"Generated _version.py with version {version}") + + def _generate_sources(self) -> None: + """Generate Python source files from docs/reference/sql.""" + here = Path(__file__).parent + docs_sql = here.parent.parent / "docs" / "reference" / "sql" + output_dir = here / "python" / "sedonadb_expr" / "_generated" + + # Ensure output directory exists + output_dir.mkdir(parents=True, exist_ok=True) + + # Create __init__.py for the generated module + init_file = output_dir / "__init__.py" + init_file.write_text( + "# Auto-generated module - do not edit\n" + "# Generated from docs/reference/sql\n" + ) + + if not docs_sql.exists(): + self.app.display_warning( + f"docs/reference/sql not found at {docs_sql}, skipping generation" + ) + return + + # Find all .qmd files + qmd_files = sorted(docs_sql.glob("st_*.qmd")) + + # Parse all function definitions + functions: list[FunctionInfo] = [] + for qmd_file in qmd_files: + func = parse_qmd_file(qmd_file) + if func: + functions.append(func) + + # Generate geo_methods.py + geo_methods_content = generate_geo_methods_py(functions) + geo_methods_file = output_dir / "geo_methods.py" + geo_methods_file.write_text(geo_methods_content) + + # Generate geo_functions.py + geo_functions_content = generate_geo_functions_py(functions) + geo_functions_file = output_dir / "geo_functions.py" + geo_functions_file.write_text(geo_functions_content) + + # Count stats + geo_method_count = sum(1 for f in functions if f.is_geo_method) + + self.app.display_info( + f"Generated {len(functions)} functions total, " + f"{geo_method_count} geo methods" + ) diff --git a/python/sedonadb-expr/pyproject.toml b/python/sedonadb-expr/pyproject.toml new file mode 100644 index 000000000..8ff2153ca --- /dev/null +++ b/python/sedonadb-expr/pyproject.toml @@ -0,0 +1,55 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[build-system] +requires = ["hatchling", "pyyaml"] +build-backend = "hatchling.build" + +[project] +name = "sedonadb-expr" +readme = "README.md" +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", +] +dynamic = ["version"] + +[project.optional-dependencies] +test = [ + "pytest", +] + +[tool.hatch.version] +source = "code" +path = "_version.py" +expression = "get_version()" + +[tool.hatch.build.targets.wheel] +packages = ["python/sedonadb_expr"] + +[tool.hatch.build.targets.wheel.hooks.custom] +path = "hatch_build.py" + +[tool.hatch.build.targets.sdist.hooks.custom] +path = "hatch_build.py" diff --git a/python/sedonadb-expr/python/sedonadb_expr/__init__.py b/python/sedonadb-expr/python/sedonadb_expr/__init__.py new file mode 100644 index 000000000..19a470021 --- /dev/null +++ b/python/sedonadb-expr/python/sedonadb_expr/__init__.py @@ -0,0 +1,22 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from sedonadb_expr._version import __version__ +from sedonadb_expr._generated.geo_functions import GeoFunctions +from sedonadb_expr._generated.geo_methods import GeoMethods + +__all__ = ["__version__", "GeoFunctions", "GeoMethods"] diff --git a/python/sedonadb-expr/tests/__init__.py b/python/sedonadb-expr/tests/__init__.py new file mode 100644 index 000000000..13a83393a --- /dev/null +++ b/python/sedonadb-expr/tests/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/python/sedonadb-expr/tests/test_sedonadb_expr.py b/python/sedonadb-expr/tests/test_sedonadb_expr.py new file mode 100644 index 000000000..e69329211 --- /dev/null +++ b/python/sedonadb-expr/tests/test_sedonadb_expr.py @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import sedonadb_expr + + +def test_version(): + # Version should match workspace Cargo.toml + assert sedonadb_expr.__version__ diff --git a/python/sedonadb/python/sedonadb/expr/expression.py b/python/sedonadb/python/sedonadb/expr/expression.py index 1859fdc67..e94504ff9 100644 --- a/python/sedonadb/python/sedonadb/expr/expression.py +++ b/python/sedonadb/python/sedonadb/expr/expression.py @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -from typing import Any, Iterable, Optional +from typing import Any, Iterable, Optional, TYPE_CHECKING from sedonadb._lib import ( InternalExpr as _InternalExpr, @@ -29,6 +29,10 @@ from sedonadb.expr.literal import Literal +if TYPE_CHECKING: + from sedonadb_expr._generated.geo_methods import GeoMethods + + class Expr: """A column expression. @@ -189,6 +193,12 @@ def desc(self, nulls_first: bool = False) -> "SortExpr": """ return SortExpr(self._impl.desc(nulls_first)) + @property + def geo(self) -> "GeoMethods[Expr]": + from sedonadb_expr import GeoMethods + + return GeoMethods(self) + # Arithmetic operators ------------------------------------------------- # # Each binary dunder routes through the shared `_binary` helper, which diff --git a/python/sedonadb/python/sedonadb/functions/__init__.py b/python/sedonadb/python/sedonadb/functions/__init__.py index 914545662..b27747acd 100644 --- a/python/sedonadb/python/sedonadb/functions/__init__.py +++ b/python/sedonadb/python/sedonadb/functions/__init__.py @@ -23,6 +23,8 @@ if TYPE_CHECKING: from sedonadb.functions.table import TableFunctions + from sedonadb.expr.expression import Expr + from sedonadb_expr import GeoFunctions class Functions: @@ -42,6 +44,12 @@ def table(self) -> "TableFunctions": return TableFunctions(self._ctx) + @property + def geo(self) -> "GeoFunctions[Expr]": + from sedonadb_expr import GeoFunctions + + return GeoFunctions(self) + def __getattr__(self, name) -> Union["ScalarUdf", "AggregateUdf"]: try: return ScalarUdf(self._ctx._impl.scalar_udf(name))