Compare commits

...

16 commits

Author SHA1 Message Date
Simon Willison
2026087a72 Release 3.39
Refs #688
2025-11-24 10:38:47 -08:00
Simon Willison
3c021be968 Release notes for 3.39
Refs #659, #687, #688
2025-11-24 10:37:07 -08:00
Simon Willison
07409df236 Ran cog 2025-11-24 10:28:35 -08:00
Simon Willison
2453a29081 Run cog with --diff 2025-11-24 10:28:35 -08:00
Simon Willison
9298ea8ab5 Applied Black 2025-11-24 10:28:35 -08:00
Simon Willison
966975ff43 Update Actions, Python >=3.10, refs #688 2025-11-24 10:28:35 -08:00
fry69
41807ca7ec test: suppress PytestUnraisableExceptionWarning in dateparse_errors 2025-11-24 10:28:35 -08:00
fry69
f221c29393 test: fix Python 3.14 datetime deprecation 2025-11-24 10:28:35 -08:00
Simon Willison
83f8bc2eaa A bunch of minor fixes 2025-11-24 10:28:35 -08:00
Simon Willison
4106264983 click>=8.3.1 to avoid Sentinel issues
Refs #666, #688
2025-11-24 10:28:35 -08:00
Simon Willison
638b8ca871 --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 <noreply@anthropic.com>

* Shorter help for --functions

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-24 10:28:35 -08:00
Simon Willison
7aca26267f Add pip as a dependency to ensure plugin installation, closes #687 2025-11-24 10:28:35 -08:00
Alex Chan
bd78966f1e Add a type hint for db.close() (#663)
Closes #662
2025-11-24 10:28:35 -08:00
Simon Willison
25d88cb727 Small documentation updates to fix warnings 2025-11-24 10:28:35 -08:00
Simon Willison
eab54c7696 Link to JSON Lines instead
ndjson.org has an expired domain.
2025-11-24 10:28:35 -08:00
Simon Willison
532dc4668d Remove note from docs about 3.8 and deterministic, closes #646 2025-11-24 10:28:35 -08:00
15 changed files with 212 additions and 71 deletions

View file

@ -9,7 +9,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest, macos-latest]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -17,13 +17,8 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- uses: actions/cache@v4 cache: pip
name: Configure pip caching cache-dependency-path: setup.py
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install -e '.[test]' pip install -e '.[test]'
@ -39,13 +34,8 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.12'
- uses: actions/cache@v4 cache: pip
name: Configure pip caching cache-dependency-path: setup.py
with:
path: ~/.cache/pip
key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-publish-pip-
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install setuptools wheel twine pip install setuptools wheel twine

View file

@ -11,13 +11,8 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
- uses: actions/cache@v4 cache: pip
name: Configure pip caching cache-dependency-path: setup.py
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install -e '.[docs]' pip install -e '.[docs]'

View file

@ -17,13 +17,8 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.11" python-version: "3.11"
- uses: actions/cache@v4 cache: pip
name: Configure pip caching cache-dependency-path: setup.py
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install SpatiaLite - name: Install SpatiaLite
run: sudo apt-get install libsqlite3-mod-spatialite run: sudo apt-get install libsqlite3-mod-spatialite
- name: Install Python dependencies - name: Install Python dependencies

View file

@ -10,15 +10,10 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
numpy: [0, 1] numpy: [0, 1]
os: [ubuntu-latest, macos-latest, windows-latest, macos-14] os: [ubuntu-latest, macos-latest, windows-latest, macos-14]
# Skip 3.8 and 3.9 on macos-14 - it only has 3.10+
exclude: exclude:
- python-version: "3.8"
os: macos-14
- python-version: "3.9"
os: macos-14
- python-version: "3.13" - python-version: "3.13"
numpy: 1 numpy: 1
steps: steps:
@ -28,13 +23,8 @@ jobs:
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
allow-prereleases: true allow-prereleases: true
- uses: actions/cache@v4 cache: pip
name: Configure pip caching cache-dependency-path: setup.py
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install -e '.[test,mypy,flake8]' pip install -e '.[test,mypy,flake8]'
@ -64,4 +54,4 @@ jobs:
run: black . --check run: black . --check
- name: Check if cog needs to be run - name: Check if cog needs to be run
run: | run: |
cog --check README.md docs/*.rst cog --check --diff README.md docs/*.rst

View file

@ -4,6 +4,17 @@
Changelog Changelog
=========== ===========
.. _v3_39:
3.39 (2025-11-24)
-----------------
- Fixed a bug with ``sqlite-utils install`` when the tool had been installed using ``uv``. (:issue:`687`)
- The ```--functions``` argument now optionally accepts a path to a Python file as an alternative to a string full of code, and can be specified multiple times - see :ref:`cli_query_functions`. (:issue:`659`)
- ``sqlite-utils`` now requires on Python 3.10 or higher.
`sqlite-utils 4.0a1 <https://sqlite-utils.datasette.io/en/latest/changelog.html#a1-2025-11-23>`__ is now available as an alpha with some `minor breaking changes <https://simonwillison.net/2025/Nov/24/sqlite-utils-40a1/>`__.
.. _v3_38: .. _v3_38:
3.38 (2024-11-23) 3.38 (2024-11-23)

View file

@ -132,7 +132,7 @@ See :ref:`cli_query`.
-r, --raw Raw output, first column of first row -r, --raw Raw output, first column of first row
--raw-lines Raw output, first column of each row --raw-lines Raw output, first column of each row
-p, --param <TEXT TEXT>... Named :parameters for SQL query -p, --param <TEXT TEXT>... Named :parameters for SQL query
--functions TEXT Python code defining one or more custom SQL --functions TEXT Python code or file path defining custom SQL
functions functions
--load-extension TEXT Path to SQLite extension, with optional --load-extension TEXT Path to SQLite extension, with optional
:entrypoint :entrypoint
@ -175,7 +175,7 @@ See :ref:`cli_memory`.
sqlite-utils memory animals.csv --schema sqlite-utils memory animals.csv --schema
Options: Options:
--functions TEXT Python code defining one or more custom SQL --functions TEXT Python code or file path defining custom SQL
functions functions
--attach <TEXT FILE>... Additional databases to attach - specify alias and --attach <TEXT FILE>... Additional databases to attach - specify alias and
filepath filepath
@ -375,7 +375,7 @@ See :ref:`cli_bulk`.
Options: Options:
--batch-size INTEGER Commit every X records --batch-size INTEGER Commit every X records
--functions TEXT Python code defining one or more custom SQL functions --functions TEXT Python code or file path defining custom SQL functions
--flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes --flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes
{"a_b": 1} {"a_b": 1}
--nl Expect newline-delimited JSON --nl Expect newline-delimited JSON
@ -1497,7 +1497,7 @@ See :ref:`cli_spatialite`.
paths. To load it from a specific path, use --load-extension. paths. To load it from a specific path, use --load-extension.
Options: Options:
-t, --type [POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION|GEOMETRY] -t, --type [point|linestring|polygon|multipoint|multilinestring|multipolygon|geometrycollection|geometry]
Specify a geometry type for this column. Specify a geometry type for this column.
[default: GEOMETRY] [default: GEOMETRY]
--srid INTEGER Spatial Reference ID. See --srid INTEGER Spatial Reference ID. See

View file

@ -368,6 +368,22 @@ This example defines a function which extracts the domain from a URL:
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. 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.
You can also pass the path to a Python file containing function definitions:
.. code-block:: bash
sqlite-utils query sites.db "select url, domain(url) from urls" --functions functions.py
The ``--functions`` option can be used multiple times to load functions from multiple sources:
.. code-block:: bash
sqlite-utils query sites.db "select url, domain(url), extract_path(url) from urls" \
--functions domain_funcs.py \
--functions 'def extract_path(url):
from urllib.parse import urlparse
return urlparse(url).path'
.. _cli_query_extensions: .. _cli_query_extensions:
SQLite extensions SQLite extensions
@ -1128,7 +1144,7 @@ You can insert binary data into a BLOB column by first encoding it using base64
Inserting newline-delimited JSON Inserting newline-delimited JSON
-------------------------------- --------------------------------
You can also import `newline-delimited JSON <http://ndjson.org/>`__ using the ``--nl`` option: You can also import newline-delimited JSON (see `JSON Lines <https://jsonlines.org/>`__) using the ``--nl`` option:
.. code-block:: bash .. code-block:: bash

View file

@ -2711,7 +2711,7 @@ By default, the name of the Python function will be used as the name of the SQL
print(db.execute('select rev("hello")').fetchone()[0]) print(db.execute('select rev("hello")').fetchone()[0])
Python 3.8 added the ability to register `deterministic SQLite functions <https://sqlite.org/deterministic.html>`__, allowing you to indicate that a function will return the exact same result for any given inputs and hence allowing SQLite to apply some performance optimizations. You can mark a function as deterministic using ``deterministic=True``, like this: If a function will return the exact same result for any given inputs you can register it as a `deterministic SQLite function <https://sqlite.org/deterministic.html>`__ allowing SQLite to apply some performance optimizations:
.. code-block:: python .. code-block:: python
@ -2719,8 +2719,6 @@ Python 3.8 added the ability to register `deterministic SQLite functions <https:
def reverse_string(s): def reverse_string(s):
return "".join(reversed(list(s))) return "".join(reversed(list(s)))
If you run this on a version of Python prior to 3.8 your code will still work, but the ``deterministic=True`` parameter will be ignored.
By default registering a function with the same name and number of arguments will have no effect - the ``Database`` instance keeps track of functions that have already been registered and skips registering them if ``@db.register_function`` is called a second time. By default registering a function with the same name and number of arguments will have no effect - the ``Database`` instance keeps track of functions that have already been registered and skips registering them if ``@db.register_function`` is called a second time.
If you want to deliberately replace the registered function with a new implementation, use the ``replace=True`` argument: If you want to deliberately replace the registered function with a new implementation, use the ``replace=True`` argument:

View file

@ -2,7 +2,7 @@ from setuptools import setup, find_packages
import io import io
import os import os
VERSION = "3.38" VERSION = "3.39"
def get_long_description(): def get_long_description():
@ -25,11 +25,12 @@ setup(
package_data={"sqlite_utils": ["py.typed"]}, package_data={"sqlite_utils": ["py.typed"]},
install_requires=[ install_requires=[
"sqlite-fts4", "sqlite-fts4",
"click", "click>=8.3.1",
"click-default-group>=1.2.3", "click-default-group>=1.2.3",
"tabulate", "tabulate",
"python-dateutil", "python-dateutil",
"pluggy", "pluggy",
"pip",
], ],
extras_require={ extras_require={
"test": ["pytest", "black>=24.1.1", "hypothesis", "cogapp"], "test": ["pytest", "black>=24.1.1", "hypothesis", "cogapp"],
@ -64,20 +65,18 @@ setup(
"Issues": "https://github.com/simonw/sqlite-utils/issues", "Issues": "https://github.com/simonw/sqlite-utils/issues",
"CI": "https://github.com/simonw/sqlite-utils/actions", "CI": "https://github.com/simonw/sqlite-utils/actions",
}, },
python_requires=">=3.8", python_requires=">=3.10",
classifiers=[ classifiers=[
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Intended Audience :: Science/Research", "Intended Audience :: Science/Research",
"Intended Audience :: End Users/Desktop", "Intended Audience :: End Users/Desktop",
"Topic :: Database", "Topic :: Database",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
], ],
# Needed to bundle py.typed so mypy can see it: # Needed to bundle py.typed so mypy can see it:
zip_safe=False, zip_safe=False,

View file

@ -1,7 +1,7 @@
import base64 import base64
import click import click
from click_default_group import DefaultGroup # type: ignore from click_default_group import DefaultGroup # type: ignore
from datetime import datetime from datetime import datetime, timezone
import hashlib import hashlib
import pathlib import pathlib
from runpy import run_module from runpy import run_module
@ -962,7 +962,7 @@ def insert_upsert_implementation(
db = sqlite_utils.Database(path) db = sqlite_utils.Database(path)
_load_extensions(db, load_extension) _load_extensions(db, load_extension)
if functions: if functions:
_register_functions(db, functions) _register_functions_from_multiple(db, functions)
if (delimiter or quotechar or sniff or no_headers) and not tsv: if (delimiter or quotechar or sniff or no_headers) and not tsv:
csv = True csv = True
if (nl + csv + tsv) >= 2: if (nl + csv + tsv) >= 2:
@ -1370,7 +1370,9 @@ def upsert(
@click.argument("file", type=click.File("rb"), required=True) @click.argument("file", type=click.File("rb"), required=True)
@click.option("--batch-size", type=int, default=100, help="Commit every X records") @click.option("--batch-size", type=int, default=100, help="Commit every X records")
@click.option( @click.option(
"--functions", help="Python code defining one or more custom SQL functions" "--functions",
help="Python code or file path defining custom SQL functions",
multiple=True,
) )
@import_options @import_options
@load_extension_option @load_extension_option
@ -1759,7 +1761,9 @@ def drop_view(path, view, ignore, load_extension):
help="Named :parameters for SQL query", help="Named :parameters for SQL query",
) )
@click.option( @click.option(
"--functions", help="Python code defining one or more custom SQL functions" "--functions",
help="Python code or file path defining custom SQL functions",
multiple=True,
) )
@load_extension_option @load_extension_option
def query( def query(
@ -1796,7 +1800,7 @@ def query(
db.register_fts4_bm25() db.register_fts4_bm25()
if functions: if functions:
_register_functions(db, functions) _register_functions_from_multiple(db, functions)
_execute_query( _execute_query(
db, db,
@ -1824,7 +1828,9 @@ def query(
) )
@click.argument("sql") @click.argument("sql")
@click.option( @click.option(
"--functions", help="Python code defining one or more custom SQL functions" "--functions",
help="Python code or file path defining custom SQL functions",
multiple=True,
) )
@click.option( @click.option(
"--attach", "--attach",
@ -1996,7 +2002,7 @@ def memory(
db.register_fts4_bm25() db.register_fts4_bm25()
if functions: if functions:
_register_functions(db, functions) _register_functions_from_multiple(db, functions)
if return_db: if return_db:
return db return db
@ -3203,8 +3209,12 @@ FILE_COLUMNS = {
"ctime": lambda p: p.stat().st_ctime, "ctime": lambda p: p.stat().st_ctime,
"mtime_int": lambda p: int(p.stat().st_mtime), "mtime_int": lambda p: int(p.stat().st_mtime),
"ctime_int": lambda p: int(p.stat().st_ctime), "ctime_int": lambda p: int(p.stat().st_ctime),
"mtime_iso": lambda p: datetime.utcfromtimestamp(p.stat().st_mtime).isoformat(), "mtime_iso": lambda p: datetime.fromtimestamp(p.stat().st_mtime, timezone.utc)
"ctime_iso": lambda p: datetime.utcfromtimestamp(p.stat().st_ctime).isoformat(), .replace(tzinfo=None)
.isoformat(),
"ctime_iso": lambda p: datetime.fromtimestamp(p.stat().st_ctime, timezone.utc)
.replace(tzinfo=None)
.isoformat(),
"size": lambda p: p.stat().st_size, "size": lambda p: p.stat().st_size,
"stem": lambda p: p.stem, "stem": lambda p: p.stem,
"suffix": lambda p: p.suffix, "suffix": lambda p: p.suffix,
@ -3281,6 +3291,13 @@ def _load_extensions(db, load_extension):
def _register_functions(db, functions): def _register_functions(db, functions):
# Register any Python functions as SQL functions: # Register any Python functions as SQL functions:
# Check if this is a file path
if "\n" not in functions and functions.endswith(".py"):
try:
functions = pathlib.Path(functions).read_text()
except FileNotFoundError:
raise click.ClickException("File not found: {}".format(functions))
sqlite3.enable_callback_tracebacks(True) sqlite3.enable_callback_tracebacks(True)
globals = {} globals = {}
try: try:
@ -3291,3 +3308,12 @@ def _register_functions(db, functions):
for name, value in globals.items(): for name, value in globals.items():
if callable(value) and not name.startswith("_"): if callable(value) and not name.startswith("_"):
db.register_function(value, name=name) db.register_function(value, name=name)
def _register_functions_from_multiple(db, functions_list):
"""Register functions from multiple --functions arguments."""
if not functions_list:
return
for functions in functions_list:
if isinstance(functions, str) and functions.strip():
_register_functions(db, functions)

View file

@ -237,36 +237,43 @@ if pd:
class AlterError(Exception): class AlterError(Exception):
"Error altering table" "Error altering table"
pass pass
class NoObviousTable(Exception): class NoObviousTable(Exception):
"Could not tell which table this operation refers to" "Could not tell which table this operation refers to"
pass pass
class NoTable(Exception): class NoTable(Exception):
"Specified table does not exist" "Specified table does not exist"
pass pass
class BadPrimaryKey(Exception): class BadPrimaryKey(Exception):
"Table does not have a single obvious primary key" "Table does not have a single obvious primary key"
pass pass
class NotFoundError(Exception): class NotFoundError(Exception):
"Record not found" "Record not found"
pass pass
class PrimaryKeyRequired(Exception): class PrimaryKeyRequired(Exception):
"Primary key needs to be specified" "Primary key needs to be specified"
pass pass
class InvalidColumns(Exception): class InvalidColumns(Exception):
"Specified columns do not exist" "Specified columns do not exist"
pass pass
@ -368,7 +375,7 @@ class Database:
pm.hook.prepare_connection(conn=self.conn) pm.hook.prepare_connection(conn=self.conn)
self.strict = strict self.strict = strict
def close(self): def close(self) -> None:
"Close the SQLite connection, and the underlying database file" "Close the SQLite connection, and the underlying database file"
self.conn.close() self.conn.close()
@ -3203,7 +3210,7 @@ class Table(Queryable):
:param not_null: Set of strings specifying columns that should be ``NOT NULL``. :param not_null: Set of strings specifying columns that should be ``NOT NULL``.
:param defaults: Dictionary specifying default values for specific columns. :param defaults: Dictionary specifying default values for specific columns.
:param hash_id: Name of a column to create and use as a primary key, where the :param hash_id: Name of a column to create and use as a primary key, where the
value of thet primary key will be derived as a SHA1 hash of the other column values value of that primary key will be derived as a SHA1 hash of the other column values
in the record. ``hash_id="id"`` is a common column name used for this. in the record. ``hash_id="id"`` is a common column name used for this.
:param alter: Boolean, should any missing columns be added automatically? :param alter: Boolean, should any missing columns be added automatically?
:param ignore: Boolean, if a record already exists with this primary key, ignore this insert. :param ignore: Boolean, if a record already exists with this primary key, ignore this insert.
@ -3852,7 +3859,7 @@ def jsonify_if_needed(value):
def resolve_extracts( def resolve_extracts(
extracts: Optional[Union[Dict[str, str], List[str], Tuple[str]]] extracts: Optional[Union[Dict[str, str], List[str], Tuple[str]]],
) -> dict: ) -> dict:
if extracts is None: if extracts is None:
extracts = {} extracts = {}

View file

@ -806,6 +806,77 @@ def test_hidden_functions_are_hidden(db_path):
assert "_two" not in functions assert "_two" not in functions
def test_query_functions_from_file(db_path, tmp_path):
# Create a temporary file with function definitions
functions_file = tmp_path / "my_functions.py"
functions_file.write_text(TEST_FUNCTIONS)
result = CliRunner().invoke(
cli.cli,
[
db_path,
"select zero(), one(1), two(1, 2)",
"--functions",
str(functions_file),
],
)
assert result.exit_code == 0
assert json.loads(result.output.strip()) == [
{"zero()": 0, "one(1)": 1, "two(1, 2)": 3}
]
def test_query_functions_file_not_found(db_path):
result = CliRunner().invoke(
cli.cli,
[
db_path,
"select zero()",
"--functions",
"nonexistent.py",
],
)
assert result.exit_code == 1
assert "File not found: nonexistent.py" in result.output
def test_query_functions_multiple_invocations(db_path):
# Test using --functions multiple times
result = CliRunner().invoke(
cli.cli,
[
db_path,
"select triple(2), quadruple(2)",
"--functions",
"def triple(x):\n return x * 3",
"--functions",
"def quadruple(x):\n return x * 4",
],
)
assert result.exit_code == 0
assert json.loads(result.output.strip()) == [{"triple(2)": 6, "quadruple(2)": 8}]
def test_query_functions_file_and_inline(db_path, tmp_path):
# Test combining file and inline code
functions_file = tmp_path / "file_funcs.py"
functions_file.write_text("def triple(x):\n return x * 3")
result = CliRunner().invoke(
cli.cli,
[
db_path,
"select triple(2), quadruple(2)",
"--functions",
str(functions_file),
"--functions",
"def quadruple(x):\n return x * 4",
],
)
assert result.exit_code == 0
assert json.loads(result.output.strip()) == [{"triple(2)": 6, "quadruple(2)": 8}]
LOREM_IPSUM_COMPRESSED = ( LOREM_IPSUM_COMPRESSED = (
b"x\x9c\xed\xd1\xcdq\x03!\x0c\x05\xe0\xbb\xabP\x01\x1eW\x91\xdc|M\x01\n\xc8\x8e" b"x\x9c\xed\xd1\xcdq\x03!\x0c\x05\xe0\xbb\xabP\x01\x1eW\x91\xdc|M\x01\n\xc8\x8e"
b"f\xf83H\x1e\x97\x1f\x91M\x8e\xe9\xe0\xdd\x96\x05\x84\xf4\xbek\x9fRI\xc7\xf2J" b"f\xf83H\x1e\x97\x1f\x91M\x8e\xe9\xe0\xdd\x96\x05\x84\xf4\xbek\x9fRI\xc7\xf2J"
@ -916,7 +987,7 @@ def test_query_json_with_json_cols(db_path):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"content,is_binary", "content,is_binary",
[(b"\x00\x0Fbinary", True), ("this is text", False), (1, False), (1.5, False)], [(b"\x00\x0fbinary", True), ("this is text", False), (1, False), (1.5, False)],
) )
def test_query_raw(db_path, content, is_binary): def test_query_raw(db_path, content, is_binary):
Database(db_path)["files"].insert({"content": content}) Database(db_path)["files"].insert({"content": content})
@ -931,7 +1002,7 @@ def test_query_raw(db_path, content, is_binary):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"content,is_binary", "content,is_binary",
[(b"\x00\x0Fbinary", True), ("this is text", False), (1, False), (1.5, False)], [(b"\x00\x0fbinary", True), ("this is text", False), (1, False), (1.5, False)],
) )
def test_query_raw_lines(db_path, content, is_binary): def test_query_raw_lines(db_path, content, is_binary):
Database(db_path)["files"].insert_all({"content": content} for _ in range(3)) Database(db_path)["files"].insert_all({"content": content} for _ in range(3))

View file

@ -45,6 +45,32 @@ def test_cli_bulk(test_db_and_path):
] == list(db["example"].rows) ] == list(db["example"].rows)
def test_cli_bulk_multiple_functions(test_db_and_path):
db, db_path = test_db_and_path
result = CliRunner().invoke(
cli.cli,
[
"bulk",
db_path,
"insert into example (id, name) values (:id, myupper(mylower(:name)))",
"-",
"--nl",
"--functions",
"myupper = lambda s: s.upper()",
"--functions",
"mylower = lambda s: s.lower()",
],
input='{"id": 3, "name": "ThReE"}\n{"id": 4, "name": "FoUr"}\n',
)
assert result.exit_code == 0, result.output
assert [
{"id": 1, "name": "One"},
{"id": 2, "name": "Two"},
{"id": 3, "name": "THREE"},
{"id": 4, "name": "FOUR"},
] == list(db["example"].rows)
def test_cli_bulk_batch_size(test_db_and_path): def test_cli_bulk_batch_size(test_db_and_path):
db, db_path = test_db_and_path db, db_path = test_db_and_path
proc = subprocess.Popen( proc = subprocess.Popen(

View file

@ -307,6 +307,22 @@ def test_memory_functions():
assert result.output.strip() == '[{"hello()": "Hello"}]' assert result.output.strip() == '[{"hello()": "Hello"}]'
def test_memory_functions_multiple():
result = CliRunner().invoke(
cli.cli,
[
"memory",
"select triple(2), quadruple(2)",
"--functions",
"def triple(x):\n return x * 3",
"--functions",
"def quadruple(x):\n return x * 4",
],
)
assert result.exit_code == 0
assert result.output.strip() == '[{"triple(2)": 6, "quadruple(2)": 8}]'
def test_memory_return_db(tmpdir): def test_memory_return_db(tmpdir):
# https://github.com/simonw/sqlite-utils/issues/643 # https://github.com/simonw/sqlite-utils/issues/643
from sqlite_utils.cli import cli from sqlite_utils.cli import cli

View file

@ -64,6 +64,7 @@ def test_dayfirst_yearfirst(fresh_db, recipe, kwargs, expected):
@pytest.mark.parametrize("fn", ("parsedate", "parsedatetime")) @pytest.mark.parametrize("fn", ("parsedate", "parsedatetime"))
@pytest.mark.parametrize("errors", (None, recipes.SET_NULL, recipes.IGNORE)) @pytest.mark.parametrize("errors", (None, recipes.SET_NULL, recipes.IGNORE))
@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
def test_dateparse_errors(fresh_db, fn, errors): def test_dateparse_errors(fresh_db, fn, errors):
fresh_db["example"].insert_all( fresh_db["example"].insert_all(
[ [