Skip to content

Commit 190f03f

Browse files
reckless: add shebang installer for fancy plugins
This allows plugins with a uv run script to install themselves. Unfortunately it requires file access to all potential entrypoints to check if they are installable. Changelog-Added: reckless can now install plugins executable by shebang.
1 parent feba566 commit 190f03f

6 files changed

Lines changed: 197 additions & 47 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "testplugshebang",
3+
"short_description": "a plugin to test reckless installation with a UV shebang",
4+
"long_description": "This plugin is used in the reckless blackbox tests. This one manages its own dependency installation with uv invoked by #! from within the plugin.",
5+
"entrypoint": "testplugshebang.py",
6+
"requirements": ["python3"]
7+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pyln-client
2+
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env -S uv run --script
2+
3+
# /// script
4+
# requires-python = ">=3.9.2"
5+
# dependencies = [
6+
# "pyln-client>=25.12",
7+
# ]
8+
# ///
9+
10+
from pyln.client import Plugin
11+
12+
plugin = Plugin()
13+
14+
__version__ = 'v1'
15+
16+
17+
@plugin.init()
18+
def init(options, configuration, plugin, **kwargs):
19+
plugin.log("testplugshebang initialized")
20+
21+
22+
@plugin.method("plugintest")
23+
def plugintest(plugin):
24+
return ("success")
25+
26+
27+
plugin.run()

tests/data/recklessrepo/rkls_api_lightningd_plugins.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,14 @@
3434
"git_url": "https://api.github.com/repos/lightningd/plugins/git/trees/testplugpyproj",
3535
"download_url": null,
3636
"type": "dir"
37+
},
38+
{
39+
"name": "testplugshebang",
40+
"path": "testplugshebang",
41+
"url": "https://api.github.com/repos/lightningd/plugins/contents/webhook?ref=master",
42+
"html_url": "https://github.com/lightningd/plugins/tree/master/testplugshebang",
43+
"git_url": "https://api.github.com/repos/lightningd/plugins/git/trees/testplugshebang",
44+
"download_url": null,
45+
"type": "dir"
3746
}
3847
]

tests/test_reckless.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,21 @@ def test_reckless_uv_install(node_factory):
419419
r.check_stderr()
420420

421421

422+
@unittest.skipIf(VALGRIND and SLOW_MACHINE, "node too slow for starting plugin under valgrind")
423+
def test_reckless_shebang_install(node_factory):
424+
node = get_reckless_node(node_factory)
425+
node.start()
426+
r = reckless([f"--network={NETWORK}", "-v", "install", "testplugshebang"],
427+
dir=node.lightning_dir)
428+
assert r.returncode == 0
429+
installed_path = Path(node.lightning_dir) / 'reckless/testplugshebang'
430+
assert installed_path.is_dir()
431+
assert node.rpc.plugintest() == 'success'
432+
433+
assert r.search_stdout('using installer shebang')
434+
r.check_stderr()
435+
436+
422437
def test_reckless_available(node_factory):
423438
"""list available plugins"""
424439
n = get_reckless_node(node_factory)

tools/reckless

Lines changed: 137 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ class Installer:
157157
self.manager = manager # dependency manager (if required)
158158
self.dependency_file = None
159159
self.dependency_call = None
160+
# extra check routine to see if a source is installable by this Installer
161+
self.check = None
162+
160163

161164
def __repr__(self):
162165
return (f'<Installer {self.name}: '
@@ -175,7 +178,7 @@ class Installer:
175178
return False
176179
return True
177180

178-
def installable(self) -> bool:
181+
def installable(self, source) -> bool:
179182
'''Validate the necessary compiler and package manager executables are
180183
available to install. If these are defined, they are considered
181184
mandatory even though the user may have the requisite packages already
@@ -184,6 +187,8 @@ class Installer:
184187
return False
185188
if self.manager and not shutil.which(self.manager):
186189
return False
190+
if self.check:
191+
return self.check(source)
187192
return True
188193

189194
def add_entrypoint(self, entry: str):
@@ -484,9 +489,14 @@ class InstInfo:
484489
found_entry = sub.find(g, ftype=SourceFile)
485490
if found_entry:
486491
break
487-
# FIXME: handle a list of dependencies
488-
found_dep = sub.find(inst.dependency_file,
489-
ftype=SourceFile)
492+
493+
if inst.dependency_file:
494+
# FIXME: handle a list of dependencies
495+
found_dep = sub.find(inst.dependency_file,
496+
ftype=SourceFile)
497+
else:
498+
found_dep = None
499+
490500
if found_entry:
491501
# Success!
492502
if found_dep:
@@ -1031,6 +1041,47 @@ def install_to_python_virtual_environment(cloned_plugin: InstInfo):
10311041
return cloned_plugin
10321042

10331043

1044+
def have_files(source: SourceDir):
1045+
"""Do we have direct access to the files in this directory?"""
1046+
if source.srctype in [Source.DIRECTORY, Source.LOCAL_REPO,
1047+
Source.GIT_LOCAL_CLONE]:
1048+
return True
1049+
log.info(f'no files in {source.name} ({source.srctype})')
1050+
return False
1051+
1052+
1053+
def fetch_manifest(source: SourceDir) -> dict:
1054+
"""read and ingest a manifest from the provided source."""
1055+
log.debug(f'ingesting manifest from {source.name}: {source.location}/manifest.json ({source.srctype})')
1056+
# local_path = RECKLESS_DIR / '.remote_sources' / user
1057+
if source.srctype not in [Source.GIT_LOCAL_CLONE, Source.LOCAL_REPO, Source.DIRECTORY]:
1058+
log.info(f'oops! {source.srctype}')
1059+
return None
1060+
if source.srctype == Source.GIT_LOCAL_CLONE:
1061+
try:
1062+
repo = GithubRepository(source.parent_source.original_source)
1063+
path = RECKLESS_DIR / '.remote_sources' / repo.user / repo.name
1064+
except AssertionError:
1065+
log.info(f'could not parse github source {source.parent_source.original_source}')
1066+
return None
1067+
elif source.srctype in [Source.DIRECTORY, Source.LOCAL_REPO]:
1068+
path = Path(source.location)
1069+
else:
1070+
raise Exception(f"cannot access manifest in {source.srctype}: {source}")
1071+
if source.relative:
1072+
path = path / source.relative
1073+
path = path / 'manifest.json'
1074+
if not path.exists():
1075+
return None
1076+
with open(path, 'r+') as manifest_file:
1077+
try:
1078+
manifest = json.loads(manifest_file.read())
1079+
return manifest
1080+
except json.decoder.JSONDecodeError:
1081+
log.warning(f'{source.name} contains malformed manifest ({source.parent_source.original_source})')
1082+
return None
1083+
1084+
10341085
def cargo_installation(cloned_plugin: InstInfo):
10351086
call = ['cargo', 'build', '--release', '-vv']
10361087
# FIXME: the symlinked Cargo.toml allows the installer to identify a valid
@@ -1144,6 +1195,60 @@ def install_python_uv_legacy(cloned_plugin: InstInfo):
11441195
return cloned_plugin
11451196

11461197

1198+
def open_source_entrypoint(source: InstInfo) -> str:
1199+
if source.srctype not in [Source.GIT_LOCAL_CLONE, Source.LOCAL_REPO, Source.DIRECTORY]:
1200+
log.info(f'oops! {source.srctype}')
1201+
return None
1202+
assert source.entry
1203+
file = Path(source.source_loc)
1204+
# if source.subdir:
1205+
# file /= source.subdir
1206+
file /= source.entry
1207+
log.debug(f'checking entry file {str(file)}')
1208+
if file.exists():
1209+
# FIXME: check file encoding
1210+
try:
1211+
with open(file, 'r') as f:
1212+
return f.read()
1213+
except UnicodeDecodeError:
1214+
log.debug('failed to read source file')
1215+
return None
1216+
else:
1217+
log.debug('could not find source file')
1218+
1219+
return None
1220+
1221+
def check_for_shebang(source: InstInfo) -> bool:
1222+
# TODO: match name, open file, look for shebang
1223+
# FIXME: Just testing that the plugin runs right now
1224+
log.debug(f'checking for shebang in {source}')
1225+
if source.source_dir:
1226+
source.get_inst_details()
1227+
log.debug(f'given source dir {source.source_dir}')
1228+
log.debug(f'given entry {source.entry}')
1229+
if have_files(source.source_dir):
1230+
log.debug('Have access to files!')
1231+
entrypoint_file = open_source_entrypoint(source)
1232+
log.debug(f"first line: {entrypoint_file.split('\n')[0]}")
1233+
if entrypoint_file.split('\n')[0].startswith('#!'):
1234+
log.debug('have shebang')
1235+
# Calling the python interpreter will not manage dependencies.
1236+
# Leave this to another python installer.
1237+
for interpreter in ['bin/python', 'env python']:
1238+
if interpreter in entrypoint_file.split('\n')[0]:
1239+
return False
1240+
return True
1241+
1242+
1243+
# Open entrypoint file
1244+
# check for shebang
1245+
raise Exception('Keep going!')
1246+
if source.name == 'testplugshebang':
1247+
raise Exception("hold up!")
1248+
return True
1249+
return False
1250+
1251+
11471252
python3venv = Installer('python3venv', exe='python3',
11481253
manager='pip', entry='{name}.py')
11491254
python3venv.add_entrypoint('{name}')
@@ -1186,7 +1291,12 @@ rust_cargo = Installer('rust', manager='cargo', entry='Cargo.toml')
11861291
rust_cargo.add_dependency_file('Cargo.toml')
11871292
rust_cargo.dependency_call = cargo_installation
11881293

1189-
INSTALLERS = [pythonuv, pythonuvlegacy, python3venv, poetryvenv,
1294+
shebang = Installer('shebang', entry='{name}.py')
1295+
shebang.add_entrypoint('{name}')
1296+
# An extra installable check to see if a #! is present in the file
1297+
shebang.check = check_for_shebang
1298+
1299+
INSTALLERS = [shebang, pythonuv, pythonuvlegacy, python3venv, poetryvenv,
11901300
pyprojectViaPip, nodejs, rust_cargo]
11911301

11921302

@@ -1376,6 +1486,8 @@ def _checkout_commit(orig_src: InstInfo,
13761486
def _install_plugin(src: InstInfo) -> Union[InstInfo, None]:
13771487
"""make sure the repo exists and clone it."""
13781488
log.debug(f'Install requested from {src}.')
1489+
if src.source_dir and src.source_dir.parent_source:
1490+
log.debug(f'source has parent {src.source_dir.parent_source}')
13791491
if RECKLESS_CONFIG is None:
13801492
log.error('reckless install directory unavailable')
13811493
return None
@@ -1411,6 +1523,8 @@ def _install_plugin(src: InstInfo) -> Union[InstInfo, None]:
14111523
# FIXME: Validate path was cloned successfully.
14121524
# Depending on how we accessed the original source, there may be install
14131525
# details missing. Searching the cloned repo makes sure we have it.
1526+
# FIXME: This could be cloned to .remotesources and the global sources
1527+
# could then be updated with this new LoadedSource to save on additional cloning.
14141528
clone = LoadedSource(plugin_path)
14151529
clone.content.populate()
14161530
# Make sure we don't try to fetch again!
@@ -1426,14 +1540,29 @@ def _install_plugin(src: InstInfo) -> Union[InstInfo, None]:
14261540
if not plugin_path:
14271541
return None
14281542

1543+
# FIXME: replace src wholesale
1544+
# We have a hunch it's in this directory/source, so link it here.
1545+
inst_check_src = copy.copy(src)
1546+
if not inst_check_src.source_dir:
1547+
inst_check_src.source_loc = plugin_path
1548+
inst_check_src.source_dir = clone.content
1549+
inst_check_src.source_dir.parent_source = clone
1550+
1551+
if src.srctype == Source.GITHUB_REPO:
1552+
inst_check_src.srctype = Source.GIT_LOCAL_CLONE
1553+
else:
1554+
inst_check_src.srctype = clone.type
1555+
14291556
# Find a suitable installer
14301557
INSTALLER = None
14311558
for inst_method in INSTALLERS:
1432-
if not (inst_method.installable() and inst_method.executable()):
1559+
if not (inst_method.installable(inst_check_src) and inst_method.executable()):
14331560
continue
14341561
if inst_method.dependency_file is not None:
14351562
if inst_method.dependency_file not in os.listdir(plugin_path):
14361563
continue
1564+
if inst_method.check and not inst_method.check(inst_check_src):
1565+
continue
14371566
log.debug(f"using installer {inst_method.name}")
14381567
INSTALLER = inst_method
14391568
break
@@ -1685,6 +1814,8 @@ def search(plugin_name: str) -> Union[InstInfo, None]:
16851814
if not found:
16861815
continue
16871816
log.info(f"found {found.name} in source: {found.source_loc}")
1817+
# FIXME: remove debugging
1818+
log.debug(f"source type: {found.srctype}")
16881819
log.debug(f"entry: {found.entry}")
16891820
if found.subdir:
16901821
log.debug(f'sub-directory: {found.subdir}')
@@ -2086,47 +2217,6 @@ def listinstalled():
20862217
return plugins
20872218

20882219

2089-
def have_files(source: SourceDir):
2090-
"""Do we have direct access to the files in this directory?"""
2091-
if source.srctype in [Source.DIRECTORY, Source.LOCAL_REPO,
2092-
Source.GIT_LOCAL_CLONE]:
2093-
return True
2094-
log.info(f'no files in {source.name} ({source.srctype})')
2095-
return False
2096-
2097-
2098-
def fetch_manifest(source: SourceDir) -> dict:
2099-
"""read and ingest a manifest from the provided source."""
2100-
log.debug(f'ingesting manifest from {source.name}: {source.location}/manifest.json ({source.srctype})')
2101-
# local_path = RECKLESS_DIR / '.remote_sources' / user
2102-
if source.srctype not in [Source.GIT_LOCAL_CLONE, Source.LOCAL_REPO, Source.DIRECTORY]:
2103-
log.info(f'oops! {source.srctype}')
2104-
return None
2105-
if source.srctype == Source.GIT_LOCAL_CLONE:
2106-
try:
2107-
repo = GithubRepository(source.parent_source.original_source)
2108-
path = RECKLESS_DIR / '.remote_sources' / repo.user / repo.name
2109-
except AssertionError:
2110-
log.info(f'could not parse github source {source.parent_source.original_source}')
2111-
return None
2112-
elif source.srctype in [Source.DIRECTORY, Source.LOCAL_REPO]:
2113-
path = Path(source.location)
2114-
else:
2115-
raise Exception(f"cannot access manifest in {source.srctype}: {source}")
2116-
if source.relative:
2117-
path = path / source.relative
2118-
path = path / 'manifest.json'
2119-
if not path.exists():
2120-
return None
2121-
with open(path, 'r+') as manifest_file:
2122-
try:
2123-
manifest = json.loads(manifest_file.read())
2124-
return manifest
2125-
except json.decoder.JSONDecodeError:
2126-
log.warning(f'{source.name} contains malformed manifest ({source.parent_source.original_source})')
2127-
return None
2128-
2129-
21302220
def find_plugin_candidates(source: Union[LoadedSource, SourceDir], depth=2) -> list:
21312221
"""Filter through a source and return any candidates that appear to be
21322222
installable plugins with the registered installers."""

0 commit comments

Comments
 (0)