Skip to content

Commit b47feae

Browse files
authored
Add an inject command (#115)
2 parents 4e01af0 + 1165126 commit b47feae

File tree

5 files changed

+301
-1
lines changed

5 files changed

+301
-1
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Added
2+
-----
3+
4+
* Add an ``inject`` command.
5+
6+
This allows a database of dependencies to be injected into a target file.
7+
This can be used to make a Python file a standalone executable.
8+
9+
This feature requires Python 3.11+ due to Python 3.11+ sqlite3 API usage.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ source = [
8383

8484
[tool.coverage.report]
8585
skip_covered = true
86-
fail_under = 60
86+
fail_under = 54
8787

8888

8989
# flake8

src/sqliteimport/cli.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from . import bundler
1212
from . import compiler
13+
from . import injector
1314
from .accessor import Accessor
1415
from .util import get_magic_number
1516

@@ -156,3 +157,106 @@ def describe(database: pathlib.Path) -> None:
156157
print(textwrap.indent(str(table), " "))
157158
else:
158159
print("No installed packages were found.")
160+
161+
162+
DEFAULT_MARKER = "sqliteimport-inject-here"
163+
164+
165+
@group.command(name="inject", no_args_is_help=True)
166+
@click.option(
167+
"--database",
168+
type=click.Path(
169+
exists=True, dir_okay=False, file_okay=True, path_type=pathlib.Path
170+
),
171+
help="The database of packages to inject.",
172+
)
173+
@click.option(
174+
"--target-file",
175+
type=click.Path(
176+
exists=True, dir_okay=False, file_okay=True, path_type=pathlib.Path
177+
),
178+
help=(
179+
"""
180+
The Python code file to inject sqliteimport
181+
and the `--database` of packages into.
182+
183+
The target file WILL NOT be overwritten by default;
184+
it can only be overwritten if it is specified again as the `--output-file`.
185+
"""
186+
),
187+
)
188+
@click.option(
189+
"--marker",
190+
default=DEFAULT_MARKER,
191+
help=(
192+
f"""
193+
The marker to search for in the `--target-file`.
194+
195+
The marker must exist in the `--target-file`,
196+
and must be a standalone Python comment.
197+
For example, by default the marker is "{DEFAULT_MARKER}",
198+
so the `--target-file` should contain code like this somewhere:
199+
200+
\b
201+
# {DEFAULT_MARKER}
202+
\b
203+
"""
204+
),
205+
)
206+
@click.option(
207+
"--output-file",
208+
type=click.Path(dir_okay=False, file_okay=True, path_type=pathlib.Path),
209+
help=(
210+
"""
211+
The output file to write, containing the code of the `--target-file`
212+
combined with the sqliteimport source code and database of dependencies.
213+
"""
214+
),
215+
)
216+
@click.option(
217+
"--overwrite",
218+
is_flag=True,
219+
help=(
220+
"""
221+
If set, the `--output-file` will be overwritten if it exists.
222+
223+
By default, the `--output-file` will never be overwritten.
224+
"""
225+
),
226+
)
227+
def inject(
228+
database: pathlib.Path,
229+
target_file: pathlib.Path,
230+
output_file: pathlib.Path,
231+
marker: str,
232+
overwrite: bool,
233+
) -> None:
234+
"""Inject sqliteimport and a database of dependencies into a target code file.
235+
236+
After injection, the code will have the ability to load its own dependencies.
237+
This is accomplished by encoding and adding the sqliteimport source code
238+
-- along with the database of packages -- directly into the code file.
239+
240+
The `--marker` option can be used to configure where the code is injected.
241+
For example, using the default marker, the target code file might look like this:
242+
243+
\b
244+
def main() -> None:
245+
# sqliteimport-inject-here
246+
\b
247+
import requests
248+
requests.get(...)
249+
\b
250+
if __name__ == "__main__":
251+
main()
252+
253+
Note: this relies on APIs that were first introduced in Python 3.11.
254+
Injection will fail on older version of Python.
255+
"""
256+
257+
if output_file.exists() and not overwrite:
258+
click.echo("The output file already exists.")
259+
sys.exit(1)
260+
prologue = injector.generate_prologue(database)
261+
code = target_file.read_text()
262+
injector.inject_prologue(prologue, code, marker)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# This file is a part of sqliteimport <https://github.com/kurtmckee/sqliteimport>
2+
# Copyright 2024-2025 Kurt McKee <[email protected]>
3+
# SPDX-License-Identifier: MIT
4+
5+
import importlib.machinery
6+
import importlib.metadata
7+
import sqlite3
8+
import sys
9+
import types
10+
import typing
11+
12+
# IGNORE: START
13+
# -------------
14+
# The lines here allow coherent type-checking of this file.
15+
# However, the actual lines are removed and replaced when this template is rendered.
16+
database: bytes = b""
17+
sqliteimport_modules: dict[str, str] = {} # Inject: sqliteimport_modules
18+
# -------------
19+
# IGNORE: END
20+
21+
22+
if sys.version_info < (3, 11):
23+
msg = """
24+
Python 3.11 or higher is required to run this program.
25+
(Python {sys.version} detected.)
26+
----
27+
If you are a user:
28+
This program may support Python 3.10 and lower in other circumstances.
29+
However, it is packaged in a way that requires Python 3.11 and higher.
30+
If you have access to Python 3.11 or higher,
31+
you may be able to re-run the program.
32+
----
33+
If you are developer:
34+
This program's Python dependencies were injected using sqliteimport.
35+
Only Python 3.11 and higher have compatible sqlite3 APIs to support this.
36+
"""
37+
raise RuntimeError(msg)
38+
39+
40+
class DictFinder(importlib.metadata.DistributionFinder):
41+
def __init__(self, modules: dict[str, str]) -> None:
42+
self.modules = modules
43+
44+
def find_spec(
45+
self,
46+
fullname: str,
47+
path: typing.Sequence[str] | None,
48+
target: types.ModuleType | None = None,
49+
) -> importlib.machinery.ModuleSpec | None:
50+
if not (fullname == "sqliteimport" or fullname.startswith("sqliteimport.")):
51+
return None
52+
if fullname not in self.modules:
53+
return None
54+
55+
if fullname == "sqliteimport":
56+
path = "sqliteimport/__init__.py"
57+
is_package = True
58+
else:
59+
path = fullname.replace(".", "/") + ".py"
60+
is_package = False
61+
source = self.modules[fullname]
62+
code = compile(source, filename=path, mode="exec", dont_inherit=True)
63+
spec = importlib.machinery.ModuleSpec(
64+
name=fullname,
65+
loader=DictLoader(code, source),
66+
origin=f"dict://{path}",
67+
is_package=is_package,
68+
)
69+
spec.has_location = False
70+
spec.cached = None
71+
72+
return spec
73+
74+
def find_distributions(
75+
self,
76+
context: importlib.metadata.DistributionFinder.Context | None = None,
77+
) -> typing.Iterable[importlib.metadata.Distribution]:
78+
yield from ()
79+
80+
81+
class DictLoader(importlib.abc.InspectLoader):
82+
def __init__(self, code: types.CodeType, source: str) -> None:
83+
self.code = code
84+
self.source = source
85+
86+
def exec_module(self, module: types.ModuleType) -> None:
87+
exec(self.code, module.__dict__)
88+
89+
def get_source(self, fullname: str) -> str:
90+
return self.source
91+
92+
93+
# Import sqliteimport.
94+
sys.meta_path.insert(0, DictFinder(sqliteimport_modules))
95+
import sqliteimport # noqa: E402
96+
97+
del sys.meta_path[0]
98+
99+
# Load the database in-memory.
100+
connection = sqlite3.connect(":memory:")
101+
connection.deserialize(database)
102+
sqliteimport.load(connection)

src/sqliteimport/injector.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# This file is a part of sqliteimport <https://github.com/kurtmckee/sqliteimport>
2+
# Copyright 2024-2025 Kurt McKee <[email protected]>
3+
# SPDX-License-Identifier: MIT
4+
5+
import importlib.resources
6+
import pathlib
7+
import textwrap
8+
9+
10+
def generate_prologue(database_path: pathlib.Path) -> str:
11+
header = textwrap.dedent(
12+
"""\
13+
# The code in this block was generated by sqliteimport.
14+
# https://github.com/kurtmckee/sqliteimport
15+
# Copyright Kurt McKee <[email protected]>
16+
# SPDX-License-Identifier: MIT
17+
"""
18+
).rstrip()
19+
lines: list[str] = [header]
20+
21+
# Add code variables.
22+
sqliteimport_modules = get_sqliteimport_modules()
23+
lines.append(f"sqliteimport_modules = {sqliteimport_modules!r}")
24+
25+
template = importlib.resources.read_text("sqliteimport", "injector-template.py")
26+
drop_lines = False
27+
for line in template.splitlines():
28+
if not line.strip():
29+
continue
30+
if line.endswith("# IGNORE: START"):
31+
drop_lines = True
32+
continue
33+
if line.endswith("# IGNORE: END"):
34+
drop_lines = False
35+
continue
36+
if drop_lines:
37+
continue
38+
if line.lstrip().startswith("#"):
39+
continue
40+
lines.append(line)
41+
42+
wrapper = textwrap.dedent(
43+
"""
44+
# BEGIN GENERATED CODE BLOCK. DO NOT EDIT!
45+
def __sqliteimport_setup(database: bytes) -> None:
46+
{function_block}
47+
__sqliteimport_database = {database!r}
48+
__sqliteimport_setup(database=__sqliteimport_database)
49+
del __sqliteimport_database
50+
del __sqliteimport_setup
51+
# END GENERATED CODE BLOCK.
52+
"""
53+
)
54+
function_block = textwrap.indent("\n".join(lines), " ")
55+
database = database_path.read_bytes()
56+
return wrapper.format(function_block=function_block, database=database)
57+
58+
59+
def inject_prologue(prologue: str, code: str, marker: str) -> str:
60+
"""Inject the *prologue* into *code*."""
61+
62+
lines: list[str] = []
63+
code_iter = iter(code.splitlines())
64+
for line in code_iter:
65+
if line.strip() == f"# {marker}":
66+
prefix = line[: line.find("#")]
67+
lines.append(textwrap.indent(prologue, prefix))
68+
break
69+
lines.append(line)
70+
lines.extend(code_iter)
71+
return "\n".join(lines)
72+
73+
74+
def get_sqliteimport_modules() -> dict[str, str]:
75+
files: dict[str, str] = {}
76+
for file in importlib.resources.files("sqliteimport").iterdir():
77+
if not file.name.endswith(".py"):
78+
continue
79+
stem = file.name[:-3]
80+
if stem == "__init__":
81+
fullname = "sqliteimport"
82+
else:
83+
fullname = f"sqliteimport.{stem}"
84+
files[fullname] = file.read_text()
85+
return files

0 commit comments

Comments
 (0)