Skip to content

Commit 95f720b

Browse files
simonwclaude
andcommitted
--functions can take filenames, can be used multiple times (#681)
Closes #659 The --functions option now accepts: - File paths ending in .py (e.g., --functions my_funcs.py) - Multiple invocations (e.g., --functions foo.py --functions 'def bar(): ...') - Inline Python code (existing behavior) Implementation follows the same pattern as llm's --functions flag (simonw/llm@a880c123). Changes: - Added multiple=True to --functions Click option in query, bulk, and memory commands - Modified _register_functions() to detect and read .py files - Updated _maybe_register_functions() to iterate over multiple function sources - Removed unused bytes/bytearray handling - Added comprehensive tests for file paths and multiple invocations - Updated documentation with examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Shorter help for --functions --------- Co-authored-by: Claude <[email protected]>
1 parent 151a349 commit 95f720b

File tree

6 files changed

+166
-9
lines changed

6 files changed

+166
-9
lines changed

docs/cli-reference.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ See :ref:`cli_query`.
131131
-r, --raw Raw output, first column of first row
132132
--raw-lines Raw output, first column of each row
133133
-p, --param <TEXT TEXT>... Named :parameters for SQL query
134-
--functions TEXT Python code defining one or more custom SQL
134+
--functions TEXT Python code or file path defining custom SQL
135135
functions
136136
--load-extension TEXT Path to SQLite extension, with optional
137137
:entrypoint
@@ -174,7 +174,7 @@ See :ref:`cli_memory`.
174174
sqlite-utils memory animals.csv --schema
175175

176176
Options:
177-
--functions TEXT Python code defining one or more custom SQL
177+
--functions TEXT Python code or file path defining custom SQL
178178
functions
179179
--attach <TEXT FILE>... Additional databases to attach - specify alias and
180180
filepath
@@ -374,7 +374,7 @@ See :ref:`cli_bulk`.
374374

375375
Options:
376376
--batch-size INTEGER Commit every X records
377-
--functions TEXT Python code defining one or more custom SQL functions
377+
--functions TEXT Python code or file path defining custom SQL functions
378378
--flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes
379379
{"a_b": 1}
380380
--nl Expect newline-delimited JSON

docs/cli.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,22 @@ This example defines a function which extracts the domain from a URL:
368368
369369
Every callable object defined in the block will be registered as a SQL function with the same name, with the exception of functions with names that begin with an underscore.
370370

371+
You can also pass the path to a Python file containing function definitions:
372+
373+
.. code-block:: bash
374+
375+
sqlite-utils query sites.db "select url, domain(url) from urls" --functions functions.py
376+
377+
The ``--functions`` option can be used multiple times to load functions from multiple sources:
378+
379+
.. code-block:: bash
380+
381+
sqlite-utils query sites.db "select url, domain(url), extract_path(url) from urls" \
382+
--functions domain_funcs.py \
383+
--functions 'def extract_path(url):
384+
from urllib.parse import urlparse
385+
return urlparse(url).path'
386+
371387
.. _cli_query_extensions:
372388

373389
SQLite extensions

sqlite_utils/cli.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -962,7 +962,7 @@ def insert_upsert_implementation(
962962
db = sqlite_utils.Database(path)
963963
_load_extensions(db, load_extension)
964964
if functions:
965-
_register_functions(db, functions)
965+
_maybe_register_functions(db, functions)
966966
if (delimiter or quotechar or sniff or no_headers) and not tsv:
967967
csv = True
968968
if (nl + csv + tsv) >= 2:
@@ -1370,7 +1370,9 @@ def upsert(
13701370
@click.argument("file", type=click.File("rb"), required=True)
13711371
@click.option("--batch-size", type=int, default=100, help="Commit every X records")
13721372
@click.option(
1373-
"--functions", help="Python code defining one or more custom SQL functions"
1373+
"--functions",
1374+
help="Python code or file path defining custom SQL functions",
1375+
multiple=True,
13741376
)
13751377
@import_options
13761378
@load_extension_option
@@ -1759,7 +1761,9 @@ def drop_view(path, view, ignore, load_extension):
17591761
help="Named :parameters for SQL query",
17601762
)
17611763
@click.option(
1762-
"--functions", help="Python code defining one or more custom SQL functions"
1764+
"--functions",
1765+
help="Python code or file path defining custom SQL functions",
1766+
multiple=True,
17631767
)
17641768
@load_extension_option
17651769
def query(
@@ -1796,7 +1800,7 @@ def query(
17961800
db.register_fts4_bm25()
17971801

17981802
if functions:
1799-
_register_functions(db, functions)
1803+
_maybe_register_functions(db, functions)
18001804

18011805
_execute_query(
18021806
db,
@@ -1824,7 +1828,9 @@ def query(
18241828
)
18251829
@click.argument("sql")
18261830
@click.option(
1827-
"--functions", help="Python code defining one or more custom SQL functions"
1831+
"--functions",
1832+
help="Python code or file path defining custom SQL functions",
1833+
multiple=True,
18281834
)
18291835
@click.option(
18301836
"--attach",
@@ -1996,7 +2002,7 @@ def memory(
19962002
db.register_fts4_bm25()
19972003

19982004
if functions:
1999-
_register_functions(db, functions)
2005+
_maybe_register_functions(db, functions)
20002006

20012007
if return_db:
20022008
return db
@@ -3281,6 +3287,13 @@ def _load_extensions(db, load_extension):
32813287

32823288
def _register_functions(db, functions):
32833289
# Register any Python functions as SQL functions:
3290+
# Check if this is a file path
3291+
if "\n" not in functions and functions.endswith(".py"):
3292+
try:
3293+
functions = pathlib.Path(functions).read_text()
3294+
except FileNotFoundError:
3295+
raise click.ClickException("File not found: {}".format(functions))
3296+
32843297
sqlite3.enable_callback_tracebacks(True)
32853298
globals = {}
32863299
try:
@@ -3291,3 +3304,18 @@ def _register_functions(db, functions):
32913304
for name, value in globals.items():
32923305
if callable(value) and not name.startswith("_"):
32933306
db.register_function(value, name=name)
3307+
3308+
3309+
def _value_or_none(value):
3310+
if getattr(value, "__class__", None).__name__ == "Sentinel":
3311+
return None
3312+
return value
3313+
3314+
3315+
def _maybe_register_functions(db, functions_list):
3316+
if not functions_list:
3317+
return
3318+
for functions in functions_list:
3319+
functions = _value_or_none(functions)
3320+
if isinstance(functions, str) and functions.strip():
3321+
_register_functions(db, functions)

tests/test_cli.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,77 @@ def test_hidden_functions_are_hidden(db_path):
806806
assert "_two" not in functions
807807

808808

809+
def test_query_functions_from_file(db_path, tmp_path):
810+
# Create a temporary file with function definitions
811+
functions_file = tmp_path / "my_functions.py"
812+
functions_file.write_text(TEST_FUNCTIONS)
813+
814+
result = CliRunner().invoke(
815+
cli.cli,
816+
[
817+
db_path,
818+
"select zero(), one(1), two(1, 2)",
819+
"--functions",
820+
str(functions_file),
821+
],
822+
)
823+
assert result.exit_code == 0
824+
assert json.loads(result.output.strip()) == [
825+
{"zero()": 0, "one(1)": 1, "two(1, 2)": 3}
826+
]
827+
828+
829+
def test_query_functions_file_not_found(db_path):
830+
result = CliRunner().invoke(
831+
cli.cli,
832+
[
833+
db_path,
834+
"select zero()",
835+
"--functions",
836+
"nonexistent.py",
837+
],
838+
)
839+
assert result.exit_code == 1
840+
assert "File not found: nonexistent.py" in result.output
841+
842+
843+
def test_query_functions_multiple_invocations(db_path):
844+
# Test using --functions multiple times
845+
result = CliRunner().invoke(
846+
cli.cli,
847+
[
848+
db_path,
849+
"select triple(2), quadruple(2)",
850+
"--functions",
851+
"def triple(x):\n return x * 3",
852+
"--functions",
853+
"def quadruple(x):\n return x * 4",
854+
],
855+
)
856+
assert result.exit_code == 0
857+
assert json.loads(result.output.strip()) == [{"triple(2)": 6, "quadruple(2)": 8}]
858+
859+
860+
def test_query_functions_file_and_inline(db_path, tmp_path):
861+
# Test combining file and inline code
862+
functions_file = tmp_path / "file_funcs.py"
863+
functions_file.write_text("def triple(x):\n return x * 3")
864+
865+
result = CliRunner().invoke(
866+
cli.cli,
867+
[
868+
db_path,
869+
"select triple(2), quadruple(2)",
870+
"--functions",
871+
str(functions_file),
872+
"--functions",
873+
"def quadruple(x):\n return x * 4",
874+
],
875+
)
876+
assert result.exit_code == 0
877+
assert json.loads(result.output.strip()) == [{"triple(2)": 6, "quadruple(2)": 8}]
878+
879+
809880
LOREM_IPSUM_COMPRESSED = (
810881
b"x\x9c\xed\xd1\xcdq\x03!\x0c\x05\xe0\xbb\xabP\x01\x1eW\x91\xdc|M\x01\n\xc8\x8e"
811882
b"f\xf83H\x1e\x97\x1f\x91M\x8e\xe9\xe0\xdd\x96\x05\x84\xf4\xbek\x9fRI\xc7\xf2J"

tests/test_cli_bulk.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,32 @@ def test_cli_bulk(test_db_and_path):
4545
] == list(db["example"].rows)
4646

4747

48+
def test_cli_bulk_multiple_functions(test_db_and_path):
49+
db, db_path = test_db_and_path
50+
result = CliRunner().invoke(
51+
cli.cli,
52+
[
53+
"bulk",
54+
db_path,
55+
"insert into example (id, name) values (:id, myupper(mylower(:name)))",
56+
"-",
57+
"--nl",
58+
"--functions",
59+
"myupper = lambda s: s.upper()",
60+
"--functions",
61+
"mylower = lambda s: s.lower()",
62+
],
63+
input='{"id": 3, "name": "ThReE"}\n{"id": 4, "name": "FoUr"}\n',
64+
)
65+
assert result.exit_code == 0, result.output
66+
assert [
67+
{"id": 1, "name": "One"},
68+
{"id": 2, "name": "Two"},
69+
{"id": 3, "name": "THREE"},
70+
{"id": 4, "name": "FOUR"},
71+
] == list(db["example"].rows)
72+
73+
4874
def test_cli_bulk_batch_size(test_db_and_path):
4975
db, db_path = test_db_and_path
5076
proc = subprocess.Popen(

tests/test_cli_memory.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,22 @@ def test_memory_functions():
307307
assert result.output.strip() == '[{"hello()": "Hello"}]'
308308

309309

310+
def test_memory_functions_multiple():
311+
result = CliRunner().invoke(
312+
cli.cli,
313+
[
314+
"memory",
315+
"select triple(2), quadruple(2)",
316+
"--functions",
317+
"def triple(x):\n return x * 3",
318+
"--functions",
319+
"def quadruple(x):\n return x * 4",
320+
],
321+
)
322+
assert result.exit_code == 0
323+
assert result.output.strip() == '[{"triple(2)": 6, "quadruple(2)": 8}]'
324+
325+
310326
def test_memory_return_db(tmpdir):
311327
# https://github.com/simonw/sqlite-utils/issues/643
312328
from sqlite_utils.cli import cli

0 commit comments

Comments
 (0)