Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1849,7 +1849,19 @@ These recipes can be used in the code passed to ``sqlite-utils convert`` like th
sqlite-utils convert my.db mytable mycolumn \
'r.jsonsplit(value)'

To use any of the documented parameters, do this:
You can also pass the recipe function directly without the ``(value)`` part - sqlite-utils will detect that it is a callable and use it automatically:

.. code-block:: bash

sqlite-utils convert my.db mytable mycolumn r.parsedate

This shorter syntax works for any callable, including functions from imported modules:

.. code-block:: bash

sqlite-utils convert my.db mytable mycolumn json.loads --import json

To use any of the documented parameters, use the full function call syntax:

.. code-block:: bash

Expand Down
15 changes: 13 additions & 2 deletions sqlite_utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,13 +450,26 @@ def progressbar(*args, **kwargs):

def _compile_code(code, imports, variable="value"):
globals = {"r": recipes, "recipes": recipes}
# Handle imports first so they're available for all approaches
for import_ in imports:
globals[import_.split(".")[0]] = __import__(import_)

# If user defined a convert() function, return that
try:
exec(code, globals)
return globals["convert"]
except (AttributeError, SyntaxError, NameError, KeyError, TypeError):
pass

# Check if code is a direct callable reference
# e.g. "r.parsedate" instead of "r.parsedate(value)"
try:
fn = eval(code, globals)
if callable(fn):
return fn
except Exception:
pass

# Try compiling their code as a function instead
body_variants = [code]
# If single line and no 'return', try adding the return
Expand All @@ -478,8 +491,6 @@ def _compile_code(code, imports, variable="value"):
if code_o is None:
raise SyntaxError("Could not compile code")

for import_ in imports:
globals[import_.split(".")[0]] = __import__(import_)
exec(code_o, globals)
return globals["fn"]

Expand Down
48 changes: 48 additions & 0 deletions tests/test_cli_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -645,3 +645,51 @@ def test_convert_handles_falsey_values(fresh_db_and_path):
assert result.exit_code == 0, result.output
assert db["t"].get(1)["x"] == 1
assert db["t"].get(2)["x"] == 2


@pytest.mark.parametrize(
"code",
[
# Direct callable reference (issue #686)
"r.parsedate",
"recipes.parsedate",
# Traditional call syntax still works
"r.parsedate(value)",
"recipes.parsedate(value)",
],
)
def test_convert_callable_reference(test_db_and_path, code):
"""Test that callable references like r.parsedate work without (value)"""
db, db_path = test_db_and_path
result = CliRunner().invoke(
cli.cli, ["convert", db_path, "example", "dt", code], catch_exceptions=False
)
assert result.exit_code == 0, result.output
rows = list(db["example"].rows)
assert rows[0]["dt"] == "2019-10-05"
assert rows[1]["dt"] == "2019-10-06"
assert rows[2]["dt"] == ""
assert rows[3]["dt"] is None


def test_convert_callable_reference_with_import(fresh_db_and_path):
"""Test callable reference from an imported module"""
db, db_path = fresh_db_and_path
db["example"].insert({"id": 1, "data": '{"name": "test"}'})
result = CliRunner().invoke(
cli.cli,
[
"convert",
db_path,
"example",
"data",
"json.loads",
"--import",
"json",
],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
# json.loads returns a dict, which sqlite stores as JSON string
row = db["example"].get(1)
assert row["data"] == '{"name": "test"}'
Loading