Repository: frictionlessdata/frictionless-py (python)

Issue:
pass_row parameter should be an input to the field_update step to support functions
# Summary

In the `field_update` step, the `pass_row` parameter is set to `False` by default, except for `formula` which sets it to `True` before calling the `table.convert()` from petl. We should allow this parameter to be set to `True` for `functions` as well for the same reasons that `formula` sets it to `True`.

Pull request description:
#1728: add pass_row as configurable parameter for field_update
# Description
As described in #1728, pass_row parameter is now added as an editable parameter for the field_update step. This allows us to use row data inside custom python function.

The default value of the parameter remains False so that any existing uses of this functionality will not be affected.

# Tests
Ensured the newly added unit test ` test_step_field_update_with_function_and_pass_row_true` runs without fail and also that all the previous test cases run and pass too.

- fixes #1728 



Patch:
diff --git a/frictionless/helpers/general.py b/frictionless/helpers/general.py
index 45e27ce5..105e992c 100644
--- a/frictionless/helpers/general.py
+++ b/frictionless/helpers/general.py
@@ -17,7 +17,7 @@ from pathlib import Path
 from typing import Any, Dict, Iterator, List, Optional, Tuple, Type, TypeVar, Union
 from urllib.parse import parse_qs, urlparse
 
-import stringcase  # type: ignore
+from ..vendors import stringcase
 
 # General
 
diff --git a/frictionless/metadata/metadata.py b/frictionless/metadata/metadata.py
index dbf9e572..ac8c2e5d 100644
--- a/frictionless/metadata/metadata.py
+++ b/frictionless/metadata/metadata.py
@@ -21,12 +21,12 @@ from typing import (
     Union,
 )
 
-import stringcase  # type: ignore
 from typing_extensions import Self
 
 from .. import helpers
 from ..exception import FrictionlessException
 from ..platform import platform
+from ..vendors import stringcase
 
 if TYPE_CHECKING:
     from .. import types
@@ -136,7 +136,10 @@ class Metadata:
 
     @classmethod
     def validate_descriptor(
-        cls, descriptor: Union[types.IDescriptor, str], *, basepath: Optional[str] = None
+        cls,
+        descriptor: Union[types.IDescriptor, str],
+        *,
+        basepath: Optional[str] = None,
     ) -> Report:
         errors = []
         timer = helpers.Timer()
@@ -391,7 +394,11 @@ class Metadata:
 
     @classmethod
     def metadata_import(
-        cls, descriptor: types.IDescriptor, *, with_basepath: bool = False, **options: Any
+        cls,
+        descriptor: types.IDescriptor,
+        *,
+        with_basepath: bool = False,
+        **options: Any,
     ) -> Self:
         merged_options = {}
         profile = cls.metadata_ensure_profile()
diff --git a/frictionless/steps/field/field_update.py b/frictionless/steps/field/field_update.py
index e9309760..77420286 100644
--- a/frictionless/steps/field/field_update.py
+++ b/frictionless/steps/field/field_update.py
@@ -50,11 +50,17 @@ class field_update(Step):
     A descriptor for the field to set the metadata.
     """
 
+    pass_row: bool = False
+    """
+    Pass the entire row as a second parameter for the transformation function if set to True.
+    The first parameter (the value) remains unchanged.
+    """
+
     # Transform
 
     def transform_resource(self, resource: Resource):
         function = self.function
-        pass_row = False
+        pass_row = self.pass_row
         table = resource.to_petl()  # type: ignore
         descriptor = deepcopy(self.descriptor) or {}
         new_name = descriptor.get("name")
@@ -106,5 +112,6 @@ class field_update(Step):
             "value": {},
             "formula": {"type": "string"},
             "descriptor": {"type": "object"},
+            "pass_row": {"type": "boolean"},
         },
     }
diff --git a/frictionless/vendors/stringcase.py b/frictionless/vendors/stringcase.py
new file mode 100644
index 00000000..c41da38d
--- /dev/null
+++ b/frictionless/vendors/stringcase.py
@@ -0,0 +1,101 @@
+"""
+String convert functions
+
+The MIT License (MIT)
+
+Copyright (c) 2015 Taka Okunishi
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+
+# The "stringcase" package appears to unmaintained
+# https://github.com/okunishinishi/python-stringcase/issues/42
+# Relevant parts have been vendored here so we can fix warnings
+# that will eventually become errors
+# Code here was copied from the 1.2.0 version uploaded to PyPI
+# https://pypi.org/project/stringcase/1.2.0/
+
+import re
+
+
+def camelcase(string: str):
+    """Convert string into camel case.
+
+    Args:
+        string: String to convert.
+
+    Returns:
+        string: Camel case string.
+
+    """
+
+    string = re.sub(r"\w[\s\W]+\w", "", str(string))
+    if not string:
+        return string
+    return lowercase(string[0]) + re.sub(
+        r"[\-_\.\s]([a-z])", lambda matched: uppercase(matched.group(1)), string[1:]
+    )
+
+
+def lowercase(string: str):
+    """Convert string into lower case.
+
+    Args:
+        string: String to convert.
+
+    Returns:
+        string: Lowercase case string.
+
+    """
+
+    return str(string).lower()
+
+
+def snakecase(string: str):
+    """Convert string into snake case.
+    Join punctuation with underscore
+
+    Args:
+        string: String to convert.
+
+    Returns:
+        string: Snake cased string.
+
+    """
+
+    string = re.sub(r"[\-\.\s]", "_", str(string))
+    if not string:
+        return string
+    return lowercase(string[0]) + re.sub(
+        r"[A-Z]", lambda matched: "_" + lowercase(matched.group(0)), string[1:]
+    )
+
+
+def uppercase(string: str):
+    """Convert string into upper case.
+
+    Args:
+        string: String to convert.
+
+    Returns:
+        string: Uppercase case string.
+
+    """
+
+    return str(string).upper()
diff --git a/pyproject.toml b/pyproject.toml
index fbe068da..82b131cb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -48,7 +48,6 @@ dependencies = [
     "tabulate>=0.8.10",
     "jsonschema>=4.20",
     "simpleeval>=0.9.11",
-    "stringcase>=1.2",
     "typer>=0.12",
     "validators>=0.18",
     "python-slugify>=1.2",


Test patch:
diff --git a/frictionless/steps/field/__spec__/test_field_update.py b/frictionless/steps/field/__spec__/test_field_update.py
index 9d32b3e9..4211c554 100644
--- a/frictionless/steps/field/__spec__/test_field_update.py
+++ b/frictionless/steps/field/__spec__/test_field_update.py
@@ -144,3 +144,31 @@ def test_step_field_update_referenced_as_foreign_key():
             "reference": {"fields": ["pkey"], "resource": "resource1"},
         }
     ]
+
+
+def test_step_field_update_with_function_and_pass_row_true():
+    source = TableResource(path="data/transform.csv")
+
+    def add_country_to_id(value, row):
+        return str(value) + " " + row["name"]
+
+    pipeline = Pipeline(
+        steps=[
+            steps.field_update(
+                name="id", function=add_country_to_id,
+                descriptor={"type": "string"}, pass_row=True)
+        ],
+    )
+    target = source.transform(pipeline)
+    assert target.schema.to_descriptor() == {
+        "fields": [
+            {"name": "id", "type": "string"},
+            {"name": "name", "type": "string"},
+            {"name": "population", "type": "integer"},
+        ]
+    }
+    assert target.read_rows() == [
+        {"id": "1 germany", "name": "germany", "population": 83},
+        {"id": "2 france", "name": "france", "population": 66},
+        {"id": "3 spain", "name": "spain", "population": 47},
+    ]