You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by pa...@apache.org on 2026/06/15 19:08:47 UTC

(sedona-db) branch main updated: feat(python/sedonadb-expr): Add Python documentation sources based on SQL documentation (#862)

This is an automated email from the ASF dual-hosted git repository.

paleolimbot pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/sedona-db.git


The following commit(s) were added to refs/heads/main by this push:
     new f6b289da7 feat(python/sedonadb-expr): Add Python documentation sources based on SQL documentation (#862)
f6b289da7 is described below

commit f6b289da74cea876b2a0c394f1ec6370dde3cc50
Author: Dewey Dunnington <de...@dunnington.ca>
AuthorDate: Mon Jun 15 14:08:41 2026 -0500

    feat(python/sedonadb-expr): Add Python documentation sources based on SQL documentation (#862)
---
 .github/workflows/python.yml                       |  27 +-
 python/sedonadb-expr/.gitignore                    |  76 +++
 python/sedonadb-expr/README.md                     |  38 ++
 python/sedonadb-expr/_version.py                   |  47 ++
 python/sedonadb-expr/hatch_build.py                |  89 +++
 python/sedonadb-expr/pyproject.toml                |  56 ++
 .../sedonadb-expr/python/sedonadb_expr/__init__.py |  26 +
 .../sedonadb-expr/python/sedonadb_expr/_codegen.py | 730 +++++++++++++++++++++
 python/sedonadb-expr/python/sedonadb_expr/utils.py |  73 +++
 python/sedonadb-expr/tests/__init__.py             |  16 +
 python/sedonadb-expr/tests/test_codegen.py         | 250 +++++++
 python/sedonadb-expr/tests/test_utils.py           | 112 ++++
 python/sedonadb/python/sedonadb/expr/expression.py |  13 +
 python/sedonadb/python/sedonadb/expr/literal.py    |  12 +
 .../sedonadb/python/sedonadb/functions/__init__.py |   8 +
 .../tests/expr/test_function_expression.py         |  35 +
 16 files changed, 1599 insertions(+), 9 deletions(-)

diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
index f925c40d7..d42989042 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,23 @@ 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
+      - name: Run doctests (sedonadb)
         run: |
-          cd python
-          python -m pytest --doctest-modules
+          cd python/sedonadb
+          python -m pytest --doctest-modules python/
+
+      - name: Run tests (sedonadb-expr)
+        run: |
+          cd python/sedonadb-expr
+          python -m pytest -vv
 
       - 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..83296cf9a
--- /dev/null
+++ b/python/sedonadb-expr/README.md
@@ -0,0 +1,38 @@
+<!---
+  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.
+-->
+
+# SedonaDB Expr
+
+A standalone Python package for SedonaDB expressions. This is an optional
+dependency of the `sedonadb` package that powers the type-specific accessors
+without bloating the core package for non-interactive usage.
+
+## 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..277a82289
--- /dev/null
+++ b/python/sedonadb-expr/hatch_build.py
@@ -0,0 +1,89 @@
+# 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
+
+from pathlib import Path
+from typing import Any
+
+from hatchling.builders.hooks.plugin.interface import BuildHookInterface
+
+
+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
+        """
+        # Import the _codegen module directly to avoid triggering __init__.py,
+        # which imports from _generated (which doesn't exist yet).
+        import importlib.util
+
+        here = Path(__file__).parent
+        codegen_path = here / "python" / "sedonadb_expr" / "_codegen.py"
+        spec = importlib.util.spec_from_file_location("_codegen", codegen_path)
+        codegen_module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(codegen_module)
+
+        self._generate_version(version, codegen_module.LICENSE_HEADER)
+        self._generate_sources(codegen_module, here)
+
+    def _generate_version(self, version: str, license_header: str) -> None:
+        """Generate _version.py with the static version string."""
+        here = Path(__file__).parent
+        version_file = here / "python" / "sedonadb_expr" / "_version.py"
+
+        content = f'''{license_header}
+# 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, codegen_module: Any, here: Path) -> None:
+        """Generate Python source files from docs/reference/sql."""
+        generate_sources = codegen_module.generate_sources
+
+        docs_sql = here.parent.parent / "docs" / "reference" / "sql"
+        output_dir = here / "python" / "sedonadb_expr" / "_generated"
+
+        result = generate_sources(docs_sql, output_dir)
+
+        if result.total_functions == 0 and not docs_sql.exists():
+            self.app.display_warning(
+                f"docs/reference/sql not found at {docs_sql}, skipping generation"
+            )
+            return
+
+        self.app.display_info(
+            f"Generated {result.total_functions} functions total, "
+            f"{result.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..e36243aa6
--- /dev/null
+++ b/python/sedonadb-expr/pyproject.toml
@@ -0,0 +1,56 @@
+# 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",
+    "pyyaml",
+]
+
+[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..8536d706f
--- /dev/null
+++ b/python/sedonadb-expr/python/sedonadb_expr/__init__.py
@@ -0,0 +1,26 @@
+# 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/python/sedonadb_expr/_codegen.py b/python/sedonadb-expr/python/sedonadb_expr/_codegen.py
new file mode 100644
index 000000000..d1edc1c4d
--- /dev/null
+++ b/python/sedonadb-expr/python/sedonadb_expr/_codegen.py
@@ -0,0 +1,730 @@
+# 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.
+
+"""
+Code generation for sedonadb-expr.
+
+This module generates Python source files from docs/reference/sql documentation files.
+It can be invoked during the build process or run as a standalone script.
+"""
+
+from __future__ import annotations
+
+import re
+import textwrap
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+
+# 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"
+
+
+def camel_to_snake(name: str) -> str:
+    """Convert CamelCase/PascalCase to snake_case.
+
+    Examples:
+        AsBinary -> as_binary
+        GeomFromWKB -> geom_from_wkb
+        AsEWKT -> as_ewkt
+        LineInterpolatePoint -> line_interpolate_point
+    """
+    # Insert underscore before uppercase letters that follow lowercase letters
+    # or before uppercase letters that are followed by lowercase letters
+    result = re.sub(r"(?<=[a-z])(?=[A-Z])", "_", name)
+    result = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", "_", result)
+    return result.lower()
+
+
+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,
+        optional: bool = False,
+    ):
+        self.type = type
+        self.name = name
+        self.description = description
+        self.optional = optional
+
+
+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 []
+        )
+
+    @property
+    def has_optional_args(self) -> bool:
+        """Return True if any argument is optional."""
+        return any(arg.optional for arg in self.args)
+
+
+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,
+        sql_name: str | 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
+        self.sql_name = sql_name or name  # e.g., "ST_AsBinary"
+
+    @property
+    def method_name(self) -> str:
+        """Return the snake_case method name derived from the SQL function name.
+
+        e.g., ST_AsBinary -> as_binary, ST_GeomFromWKB -> geom_from_wkb
+        """
+        sql = self.sql_name
+        # Strip prefix (ST_, RS_, S2_, SD_)
+        for prefix in ("ST_", "RS_", "S2_", "SD_"):
+            if sql.upper().startswith(prefix):
+                sql = sql[len(prefix) :]
+                break
+        return camel_to_snake(sql)
+
+
+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 []
+
+    # Determine minimum args (args present in all kernels)
+    min_args = min(kernel_lengths) if kernel_lengths else 0
+
+    # Update ArgInfo with generated names and optional flag
+    for i, info in enumerate(arg_info):
+        if info.name is None:
+            info.name = arg_names[i]
+        # Args beyond min_args are optional (not present in all kernels)
+        info.optional = i >= min_args
+
+    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
+
+    # Get properly-cased SQL function name from title field
+    sql_name = frontmatter.get("title", fn_name)
+    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,
+        sql_name=sql_name,
+    )
+
+
+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('"""')
+
+    joined = "\n        ".join(parts)
+    return "\n".join(line.rstrip() for line in joined.split("\n"))
+
+
+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('"""')
+
+    joined = "\n        ".join(parts)
+    return "\n".join(line.rstrip() for line in joined.split("\n"))
+
+
+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",
+        "",
+        "from sedonadb_expr.utils import MISSING, filter_missing_args",
+        "",
+        '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: derived from SQL function name (e.g., ST_AsBinary -> as_binary)
+        method_name = func.method_name
+
+        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 []
+        # Check if any remaining args are optional
+        has_optional = any(arg.optional for arg in remaining_args)
+
+        if kernel_info.variadic:
+            params = "self, *args"
+            call_args = "*args"
+            use_filter = False
+        elif remaining_args:
+            # Build param strings with MISSING default for optional args
+            param_strs = []
+            for arg in remaining_args:
+                if arg.optional:
+                    param_strs.append(f"{arg.name}=MISSING")
+                else:
+                    param_strs.append(arg.name)
+            params = "self, " + ", ".join(param_strs)
+            call_args = ", ".join(arg.name for arg in remaining_args)
+            use_filter = has_optional
+        else:
+            params = "self"
+            call_args = ""
+            use_filter = False
+
+        docstring = generate_method_docstring(func)
+
+        lines.extend(
+            [
+                "",
+                f"    def {method_name}({params}) -> ExprT:",
+                f"        {docstring}",
+            ]
+        )
+
+        if call_args:
+            if use_filter:
+                lines.append(
+                    f'        return self._expr._call("{func.name}", *filter_missing_args({call_args}))'
+                )
+            else:
+                lines.append(
+                    f'        return self._expr._call("{func.name}", {call_args})'
+                )
+        else:
+            lines.append(f'        return self._expr._call("{func.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: derived from SQL function name (e.g., ST_AsBinary -> as_binary)
+        prop_name = func.method_name
+
+        docstring = generate_function_docstring(func)
+
+        lines.extend(
+            [
+                "",
+                "    @property",
+                f"    def {prop_name}(self) -> Callable[..., ExprT]:",
+                f"        {docstring}",
+                f'        return self._factory["{func.name}"]',
+            ]
+        )
+
+    lines.append("")
+    return "\n".join(lines)
+
+
+class GenerationResult:
+    """Result of code generation."""
+
+    def __init__(
+        self,
+        total_functions: int,
+        geo_method_count: int,
+        generated_files: list[Path],
+    ):
+        self.total_functions = total_functions
+        self.geo_method_count = geo_method_count
+        self.generated_files = generated_files
+
+
+def parse_qmd_files(docs_sql: Path, pattern: str) -> list[FunctionInfo]:
+    """Parse all .qmd files in a directory and return function definitions.
+
+    Args:
+        docs_sql: Path to directory containing .qmd files.
+
+    Returns:
+        List of parsed FunctionInfo objects.
+    """
+    qmd_files = sorted(docs_sql.glob(pattern))
+    functions: list[FunctionInfo] = []
+    for qmd_file in qmd_files:
+        func = parse_qmd_file(qmd_file)
+        if func:
+            functions.append(func)
+    return functions
+
+
+def generate_sources(docs_sql: Path, output_dir: Path) -> GenerationResult:
+    """Generate Python source files from docs/reference/sql.
+
+    Args:
+        docs_sql: Path to docs/reference/sql directory containing .qmd files.
+        output_dir: Path to output directory for generated files.
+
+    Returns:
+        GenerationResult with statistics about generated code.
+    """
+    # 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(
+        f"{LICENSE_HEADER}\n"
+        "# Auto-generated module - do not edit\n"
+        "# Generated from docs/reference/sql\n"
+    )
+
+    generated_files: list[Path] = [init_file]
+
+    if not docs_sql.exists():
+        return GenerationResult(
+            total_functions=0,
+            geo_method_count=0,
+            generated_files=generated_files,
+        )
+
+    functions = parse_qmd_files(docs_sql, "st_*.qmd")
+
+    # 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)
+    generated_files.append(geo_methods_file)
+
+    # 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)
+    generated_files.append(geo_functions_file)
+
+    # Count stats
+    geo_method_count = sum(1 for f in functions if f.is_geo_method)
+
+    return GenerationResult(
+        total_functions=len(functions),
+        geo_method_count=geo_method_count,
+        generated_files=generated_files,
+    )
+
+
+if __name__ == "__main__":
+    # Allow running as a standalone script for development/debugging
+    here = Path(__file__).parent
+    docs_sql = here.parent.parent.parent.parent / "docs" / "reference" / "sql"
+    output_dir = here / "_generated"
+
+    result = generate_sources(docs_sql, output_dir)
+
+    print(f"Generated {result.total_functions} functions total")
+    print(f"Generated {result.geo_method_count} geo methods")
+    print("Output files:")
+    for f in result.generated_files:
+        print(f"  - {f}")
diff --git a/python/sedonadb-expr/python/sedonadb_expr/utils.py b/python/sedonadb-expr/python/sedonadb_expr/utils.py
new file mode 100644
index 000000000..c81ef8a5c
--- /dev/null
+++ b/python/sedonadb-expr/python/sedonadb_expr/utils.py
@@ -0,0 +1,73 @@
+# 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.
+
+"""Utility classes and functions for sedonadb-expr."""
+
+from typing import Any
+
+
+class _MissingType:
+    """Sentinel type for missing/omitted arguments.
+
+    This is distinct from None, which represents a valid NULL value.
+    Use the MISSING singleton instance rather than creating new instances.
+    """
+
+
+MISSING = _MissingType()
+"""Sentinel value for missing/omitted arguments.
+
+Use this as the default value for optional parameters.
+"""
+
+
+def filter_missing_args(*args: Any):
+    """Filter out trailing MISSING arguments, validating ordering.
+
+    Args:
+        *args: Arguments to filter
+
+    Returns:
+        Tuple of non-MISSING arguments
+
+    Raises:
+        ValueError: If MISSING arguments appear before non-MISSING arguments
+    """
+    if not args:
+        return ()
+
+    # Find indices of missing args
+    is_missing = [arg is MISSING for arg in args]
+
+    if not any(is_missing):
+        return args
+
+    # Find last non-missing arg
+    last_non_missing = -1
+    for i in range(len(args) - 1, -1, -1):
+        if not is_missing[i]:
+            last_non_missing = i
+            break
+
+    # Check no missing args before non-missing args
+    if last_non_missing >= 0 and any(is_missing[: last_non_missing + 1]):
+        raise ValueError("Missing arguments must be at the end of the argument list")
+
+    # Return args up to and including last non-missing
+    if last_non_missing < 0:
+        return ()
+    return args[: last_non_missing + 1]
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_codegen.py b/python/sedonadb-expr/tests/test_codegen.py
new file mode 100644
index 000000000..d007a7a22
--- /dev/null
+++ b/python/sedonadb-expr/tests/test_codegen.py
@@ -0,0 +1,250 @@
+# 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 pathlib import Path
+from tempfile import TemporaryDirectory
+
+import pytest
+
+from sedonadb_expr import _codegen
+
+SAMPLE_QMD = """\
+---
+title: ST_Buffer
+description: Computes a buffered geometry.
+kernels:
+  - returns: geometry
+    args:
+    - geometry
+    - name: distance
+      type: float64
+      description: Radius of the buffer
+---
+
+## Description
+
+Returns a geometry covering all points within a given distance.
+This paragraph could have more than one line.
+
+This is the second paragraph.
+
+- Followed by a list!
+- Second bullet point
+
+## The Next Section
+
+...if there is one
+"""
+
+
+@pytest.fixture
+def sample_qmd_path():
+    with TemporaryDirectory() as tmpdir:
+        path = Path(tmpdir) / "st_buffer.qmd"
+        path.write_text(SAMPLE_QMD)
+        yield path
+
+
+def test_camel_to_snake():
+    assert _codegen.camel_to_snake("AsBinary") == "as_binary"
+    assert _codegen.camel_to_snake("GeomFromWKB") == "geom_from_wkb"
+    assert _codegen.camel_to_snake("AsEWKT") == "as_ewkt"
+    assert _codegen.camel_to_snake("LineInterpolatePoint") == "line_interpolate_point"
+
+
+def test_extract_frontmatter(sample_qmd_path: Path):
+    fm = _codegen.extract_frontmatter(sample_qmd_path)
+    assert fm["title"] == "ST_Buffer"
+
+
+def test_extract_description_section(sample_qmd_path: Path):
+    desc = _codegen.extract_description_section(sample_qmd_path)
+    expected = """\
+Returns a geometry covering all points within a given distance. This paragraph could have more than one line.
+
+This is the second paragraph.
+
+- Followed by a list!
+- Second bullet point"""
+    assert desc == expected
+
+
+def test_generate_method_docstring_with_args():
+    # Test case: method with description and args (first arg skipped)
+    func = _codegen.FunctionInfo(
+        name="st_buffer",
+        title="ST_Buffer",
+        description="Returns a buffered geometry.",
+        kernels=[],
+        kernel_info=_codegen.KernelInfo(
+            args=[
+                _codegen.ArgInfo(
+                    type="geometry", name="geom", description="Input geometry"
+                ),
+                _codegen.ArgInfo(
+                    type="float64", name="distance", description="Buffer distance"
+                ),
+            ],
+            returns="geometry",
+        ),
+    )
+    docstring = _codegen.generate_method_docstring(func)
+    expected = '''\
+"""ST_Buffer
+
+        Returns a buffered geometry.
+
+        Args:
+            distance: Buffer distance
+
+        See Also:
+            https://sedona.apache.org/sedonadb/latest/reference/sql/st_buffer/
+        """'''
+    assert docstring == expected
+
+
+def test_generate_method_docstring_variadic():
+    # Variadic mode is triggered when parameter names conflict across kernels
+    # e.g., ST_Buffer has (geom, distance, params) vs (geog, distance, num_quad_segs)
+    # where position 3 conflicts: "params" vs "num_quad_segs"
+    func = _codegen.FunctionInfo(
+        name="st_buffer",
+        title="ST_Buffer",
+        description="Creates a buffer.",
+        kernels=[],
+        kernel_info=_codegen.KernelInfo(
+            args=[],
+            returns="geometry",
+            variadic=True,
+            kernel_signatures=[
+                "geom (geometry), distance (float64)",
+                "geom (geometry), distance (float64), params (string)",
+                "geog (geography), distance (float64)",
+                "geog (geography), distance (float64), num_quad_segs (integer)",
+                "geog (geography), distance (float64), params (string)",
+            ],
+        ),
+    )
+    docstring = _codegen.generate_method_docstring(func)
+    # Method docstring skips the first arg (piped in via self._expr)
+    expected = '''\
+"""ST_Buffer
+
+        Creates a buffer.
+
+        Variants:
+            - distance (float64)
+            - distance (float64), params (string)
+            - distance (float64)
+            - distance (float64), num_quad_segs (integer)
+            - distance (float64), params (string)
+
+        See Also:
+            https://sedona.apache.org/sedonadb/latest/reference/sql/st_buffer/
+        """'''
+    assert docstring == expected
+
+
+def test_generate_function_docstring_with_args():
+    # Test case: function with description and args (all args included)
+    func = _codegen.FunctionInfo(
+        name="st_buffer",
+        title="ST_Buffer",
+        description="Returns a buffered geometry.",
+        kernels=[],
+        kernel_info=_codegen.KernelInfo(
+            args=[
+                _codegen.ArgInfo(
+                    type="geometry", name="geom", description="Input geometry"
+                ),
+                _codegen.ArgInfo(
+                    type="float64", name="distance", description="Buffer distance"
+                ),
+            ],
+            returns="geometry",
+        ),
+    )
+    docstring = _codegen.generate_function_docstring(func)
+    expected = '''\
+"""ST_Buffer
+
+        Returns a buffered geometry.
+
+        Args:
+            geom: Input geometry
+            distance: Buffer distance
+
+        See Also:
+            https://sedona.apache.org/sedonadb/latest/reference/sql/st_buffer/
+        """'''
+    assert docstring == expected
+
+
+def test_generate_function_docstring_variadic():
+    # Variadic mode is triggered when parameter names conflict across kernels
+    func = _codegen.FunctionInfo(
+        name="st_buffer",
+        title="ST_Buffer",
+        description="Creates a buffer.",
+        kernels=[],
+        kernel_info=_codegen.KernelInfo(
+            args=[],
+            returns="geometry",
+            variadic=True,
+            kernel_signatures=[
+                "geom (geometry), distance (float64)",
+                "geom (geometry), distance (float64), params (string)",
+                "geom (geography), distance (float64)",
+                "geom (geography), distance (float64), num_quad_segs (integer)",
+                "geom (geography), distance (float64), params (string)",
+            ],
+        ),
+    )
+    docstring = _codegen.generate_function_docstring(func)
+    expected = '''\
+"""ST_Buffer
+
+        Creates a buffer.
+
+        Variants:
+            - geom (geometry), distance (float64)
+            - geom (geometry), distance (float64), params (string)
+            - geom (geography), distance (float64)
+            - geom (geography), distance (float64), num_quad_segs (integer)
+            - geom (geography), distance (float64), params (string)
+
+        See Also:
+            https://sedona.apache.org/sedonadb/latest/reference/sql/st_buffer/
+        """'''
+    assert docstring == expected
+
+
+def test_generate_sources(sample_qmd_path: Path):
+    docs_sql = sample_qmd_path.parent
+    with TemporaryDirectory() as tmpdir:
+        output_dir = Path(tmpdir) / "output"
+
+        result = _codegen.generate_sources(docs_sql, output_dir)
+
+        assert result.total_functions == 1
+        assert result.geo_method_count == 1
+        assert len(result.generated_files) == 3
+
+        # Verify generated files compile as valid Python
+        for file_path in result.generated_files:
+            code = file_path.read_text()
+            compile(code, str(file_path), "exec")
diff --git a/python/sedonadb-expr/tests/test_utils.py b/python/sedonadb-expr/tests/test_utils.py
new file mode 100644
index 000000000..103a5ecc8
--- /dev/null
+++ b/python/sedonadb-expr/tests/test_utils.py
@@ -0,0 +1,112 @@
+# 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 pytest
+from sedonadb_expr import GeoFunctions, GeoMethods
+from sedonadb_expr.utils import MISSING, filter_missing_args
+
+
+class MockExpr:
+    """Mock expression that records _call invocations."""
+
+    def __init__(self):
+        self.calls = []
+
+    def _call(self, name, *args):
+        self.calls.append((name, args))
+        return self
+
+
+class MockFunctions:
+    """Mock function mapping that records _call invocations."""
+
+    def __init__(self):
+        self.calls = []
+
+    def __getitem__(self, name, *args):
+        def fn(*args):
+            self.calls.append((name, args))
+
+        return fn
+
+
+def test_filter_missing_args():
+    """Tests for filter_missing_args utility."""
+    # Passthrough when no missing
+    assert filter_missing_args(1, 2, 3) == (1, 2, 3)
+
+    # All missing returns empty
+    assert filter_missing_args(MISSING, MISSING) == ()
+
+    # Trailing missing are filtered
+    assert filter_missing_args(1, 2, MISSING, MISSING) == (1, 2)
+    assert filter_missing_args(1, MISSING) == (1,)
+
+    # Empty args
+    assert filter_missing_args() == ()
+
+    # Missing before non-missing raises
+    with pytest.raises(ValueError, match="Missing arguments must be at the end"):
+        filter_missing_args(MISSING, 1)
+
+    # Missing in middle raises
+    with pytest.raises(ValueError, match="Missing arguments must be at the end"):
+        filter_missing_args(1, MISSING, 2)
+
+
+def test_geo_methods_missing_args():
+    """Tests for MISSING argument handling in generated GeoMethods."""
+    mock = MockExpr()
+    geo = GeoMethods(mock)
+
+    # force4d with no args passes no extra arguments
+    geo.force4d()
+    assert mock.calls[-1] == ("st_force4d", ())
+
+    # force4d with z only passes just z
+    geo.force4d(z=1.0)
+    assert mock.calls[-1] == ("st_force4d", (1.0,))
+
+    # force4d with both z and m passes both
+    geo.force4d(z=1.0, m=2.0)
+    assert mock.calls[-1] == ("st_force4d", (1.0, 2.0))
+
+    # translate with partial missing filters correctly
+    geo.translate(deltaX=1.0, deltaY=2.0)
+    assert mock.calls[-1] == ("st_translate", (1.0, 2.0))
+
+    # force4d with MISSING z but non-MISSING m should raise
+    with pytest.raises(ValueError, match="Missing arguments must be at the end"):
+        geo.force4d(z=MISSING, m=2.0)
+
+    # translate with MISSING in middle should raise
+    with pytest.raises(ValueError, match="Missing arguments must be at the end"):
+        geo.translate(deltaX=1.0, deltaY=MISSING, deltaZ=3.0)
+
+
+def test_geo_functions():
+    """Tests for GeoFunctions property access."""
+    factory = MockFunctions()
+    geo_fns = GeoFunctions(factory)
+
+    # Properties return callables
+    assert callable(geo_fns.affine)
+    assert callable(geo_fns.buffer)
+
+    # Calling returned function invokes factory
+    geo_fns.envelope("geom_arg")
+    assert factory.calls[-1] == ("st_envelope", ("geom_arg",))
diff --git a/python/sedonadb/python/sedonadb/expr/expression.py b/python/sedonadb/python/sedonadb/expr/expression.py
index 0ce6c57a3..497b9b753 100644
--- a/python/sedonadb/python/sedonadb/expr/expression.py
+++ b/python/sedonadb/python/sedonadb/expr/expression.py
@@ -34,6 +34,10 @@ if TYPE_CHECKING:
     from sedonadb.functions import Functions
 
 
+if TYPE_CHECKING:
+    from sedonadb_expr import GeoMethods
+
+
 class Expr:
     """A column expression.
 
@@ -212,6 +216,15 @@ class Expr:
         """
         return SortExpr(self._impl.desc(nulls_first))
 
+    @property
+    def geo(self) -> "GeoMethods[Expr]":
+        from sedonadb_expr import GeoMethods
+
+        return GeoMethods(self)
+
+    def _call(self, name, *args) -> "Expr":
+        return self.funcs[name](*args)
+
     # Arithmetic operators -------------------------------------------------
     #
     # Each binary dunder routes through the shared `_binary` helper, which
diff --git a/python/sedonadb/python/sedonadb/expr/literal.py b/python/sedonadb/python/sedonadb/expr/literal.py
index a0c21828c..5fbbfcb04 100644
--- a/python/sedonadb/python/sedonadb/expr/literal.py
+++ b/python/sedonadb/python/sedonadb/expr/literal.py
@@ -20,6 +20,9 @@ from typing import TYPE_CHECKING, Any
 from sedonadb.utility import sedona  # noqa: F401
 
 if TYPE_CHECKING:
+    from sedonadb_expr import GeoMethods
+
+    from sedonadb.expr import Expr
     from sedonadb.functions import Functions
 
 
@@ -71,6 +74,15 @@ class Literal:
 
         return Functions(self._ctx, self)
 
+    @property
+    def geo(self) -> "GeoMethods[Expr]":
+        from sedonadb_expr import GeoMethods
+
+        return GeoMethods(self)
+
+    def _call(self, name, *args) -> "Expr":
+        return self.funcs[name](*args)
+
     def alias(self, name: str):
         """Give this literal a column name.
 
diff --git a/python/sedonadb/python/sedonadb/functions/__init__.py b/python/sedonadb/python/sedonadb/functions/__init__.py
index 2469becfc..ac3e4591a 100644
--- a/python/sedonadb/python/sedonadb/functions/__init__.py
+++ b/python/sedonadb/python/sedonadb/functions/__init__.py
@@ -23,6 +23,8 @@ from sedonadb.expr.expression import ScalarUdf, AggregateUdf
 
 if TYPE_CHECKING:
     from sedonadb.functions.table import TableFunctions
+    from sedonadb.expr.expression import Expr
+    from sedonadb_expr import GeoFunctions
 
 
 class Functions:
@@ -46,6 +48,12 @@ class Functions:
 
         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), self._ctx, self._expr)
diff --git a/python/sedonadb/tests/expr/test_function_expression.py b/python/sedonadb/tests/expr/test_function_expression.py
index add20624f..0675a684a 100644
--- a/python/sedonadb/tests/expr/test_function_expression.py
+++ b/python/sedonadb/tests/expr/test_function_expression.py
@@ -18,6 +18,9 @@
 from sedonadb.expr import Expr
 from sedonadb.expr.expression import ScalarUdf, AggregateUdf
 
+import shapely
+import pytest
+
 
 def test_scalar_st_function_returns_expr(con):
     st_geomfromwkt = con.funcs.st_geomfromwkt
@@ -27,6 +30,10 @@ def test_scalar_st_function_returns_expr(con):
     assert isinstance(e, Expr)
     assert repr(e) == 'Expr(st_geomfromwkt(Utf8("POINT (0 1)")))'
 
+    # Also check piped function from literal
+    e = con.lit("POINT (0 1)").funcs.st_geomfromwkt()
+    assert repr(e) == 'Expr(st_geomfromwkt(Utf8("POINT (0 1)")))'
+
 
 def test_scalar_st_function_alias_returns_expr(con):
     st_geomfromtext = con.funcs.st_geomfromtext
@@ -46,6 +53,10 @@ def test_scalar_st_function_with_column(con):
     assert isinstance(e, Expr)
     assert repr(e) == "Expr(st_area(geom))"
 
+    # Also check piped function from column
+    e = con.col("geom").funcs.st_geomfromwkt()
+    assert repr(e) == "Expr(st_geomfromwkt(geom))"
+
 
 def test_scalar_st_function_with_multiple_args(con):
     st_buffer = con.funcs.st_buffer
@@ -108,3 +119,27 @@ def test_function_expression_composed(con):
         repr(e)
         == 'Expr(st_area(st_geomfromwkt(Utf8("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"))))'
     )
+
+
+def test_geo_functions_accessor(con):
+    pytest.importorskip("sedonadb_expr")
+
+    # Check function as resolved from the geo accessor
+    e = con.funcs.geo.as_text(con.col("foofy"))
+    assert isinstance(e, Expr)
+    assert repr(e) == "Expr(st_astext(foofy))"
+
+
+def test_geo_methods_accessor(con):
+    pytest.importorskip("sedonadb_expr")
+
+    # Check piped function from literal via .geo accessor
+    e = con.lit(shapely.Point(0, 1)).geo.as_text()
+    assert (
+        repr(e)
+        == """Expr(st_astext(Binary("1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,240,63") FieldMetadata { inner: {"ARROW:extension:metadata": "{}", "ARROW:extension:name": "geoarrow.wkb"} }))"""
+    )
+
+    # Check piped function from Expr via .geo accessor
+    e = con.col("foofy").geo.as_text()
+    assert repr(e) == "Expr(st_astext(foofy))"