Skip to content

Commit 67e22dd

Browse files
authored
feat: add script runner (#78)
* feat: add script runner * test: add test for scripts * refactor: process scripts in a nicer way * refactor: move `__init__.py` to `_cli.py` for `ape_run` * refactor: move config to top import (now that loading is differed) * refactor: empty list check can just be not * refactor: no need to handle return values * refactor: `--interactive` was not a flag * feat: run script using network * feat: run console on interactive * refactor: use importlib instead * fix: exception traceback * fix: use absolute paths for executing scripts (resolve to relative) * feat: add ability to display error info * test: actually do something in the script * fix: add script to sys.path temporarily to discover it * chore: ignore cache folder * test: clean up fixtures * test: fix issue with how isolated filesystem worked the function we were using created a *new* temp directory at the base of the project folder, which was not intended behavior (the intention was to leverage the project folder as the base directly)
1 parent 9b5c583 commit 67e22dd

File tree

11 files changed

+201
-30
lines changed

11 files changed

+201
-30
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,6 @@ dmypy.json
116116

117117
# setuptools-scm
118118
version.py
119+
120+
# Ape stuff
121+
.build/

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,6 @@ line_length = 100
2323
force_grid_wrap = 0
2424
include_trailing_comma = true
2525
known_third_party = ["IPython", "click", "dataclassy", "eth_abi", "eth_account", "eth_utils", "github", "hexbytes", "hypothesis", "hypothesis_jsonschema", "importlib_metadata", "pluggy", "pytest", "requests", "setuptools", "web3", "yaml"]
26-
known_first_party = ["ape_accounts", "ape"]
26+
known_first_party = ["ape_accounts", "ape_console", "ape"]
2727
multi_line_output = 3
2828
use_parentheses = true

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"ape_compile=ape_compile._cli:cli",
8080
"ape_console=ape_console._cli:cli",
8181
"ape_plugins=ape_plugins._cli:cli",
82+
"ape_run=ape_run._cli:cli",
8283
"ape_networks=ape_networks._cli:cli",
8384
],
8485
},
@@ -94,6 +95,7 @@
9495
"ape_infura",
9596
"ape_networks",
9697
"ape_plugins",
98+
"ape_run",
9799
"ape_test",
98100
"ape_pm",
99101
],
@@ -109,6 +111,7 @@
109111
"ape_ethereum": ["py.typed"],
110112
"ape_etherscan": ["py.typed"],
111113
"ape_infura": ["py.typed"],
114+
"ape_run": ["py.typed"],
112115
"ape_test": ["py.typed"],
113116
"ape_pm": ["py.typed"],
114117
},

src/ape/utils.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,39 @@ def get_distributions():
2727
return packages_distributions()
2828

2929

30+
def is_relative_to(path: Path, target: Path) -> bool:
31+
if hasattr(path, "is_relative_to"):
32+
# NOTE: Only available `>=3.9`
33+
return target.is_relative_to(path) # type: ignore
34+
35+
else:
36+
try:
37+
return target.relative_to(path) is not None
38+
except ValueError:
39+
return False
40+
41+
42+
def get_relative_path(target: Path, anchor: Path) -> Path:
43+
"""
44+
Compute the relative path of `target` relative to `anchor`,
45+
which may or may not share a common ancestor.
46+
NOTE: Both paths must be absolute
47+
"""
48+
assert anchor.is_absolute()
49+
assert target.is_absolute()
50+
51+
anchor_copy = Path(str(anchor))
52+
levels_deep = 0
53+
while not is_relative_to(anchor_copy, target):
54+
levels_deep += 1
55+
assert anchor_copy != anchor_copy.parent
56+
anchor_copy = anchor_copy.parent
57+
58+
return Path("/".join(".." for _ in range(levels_deep))).joinpath(
59+
str(target.relative_to(anchor_copy))
60+
)
61+
62+
3063
def get_package_version(obj: Any) -> str:
3164
# If value is already cached/static
3265
if hasattr(obj, "__version__"):

src/ape_run/__init__.py

Whitespace-only changes.

src/ape_run/_cli.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import sys
2+
from importlib import import_module
3+
from pathlib import Path
4+
5+
import click
6+
7+
from ape import config, networks
8+
from ape.utils import Abort, get_relative_path
9+
from ape_console._cli import NetworkChoice, console
10+
11+
# TODO: Migrate this to a CLI toolkit under `ape`
12+
13+
14+
def _run_script(script_path, interactive=False, verbose=False):
15+
script_path = get_relative_path(script_path, Path.cwd())
16+
script_parts = script_path.parts[:-1]
17+
18+
if any(p == ".." for p in script_parts):
19+
raise Abort("Cannot execute script from outside current directory")
20+
21+
# Add to Python path so we can search for the given script to import
22+
sys.path.append(str(script_path.parent.resolve()))
23+
24+
# Load the python module to find our hook functions
25+
try:
26+
py_module = import_module(script_path.stem)
27+
28+
except Exception as e:
29+
if verbose:
30+
raise e
31+
32+
else:
33+
raise Abort(f"Exception while executing script: {script_path}") from e
34+
35+
finally:
36+
# Undo adding the path to make sure it's not permanent
37+
sys.path.remove(str(script_path.parent.resolve()))
38+
39+
# Execute the hooks
40+
if hasattr(py_module, "cli"):
41+
# TODO: Pass context to `cli` before calling it
42+
py_module.cli()
43+
44+
elif hasattr(py_module, "main"):
45+
# NOTE: `main()` accepts no arguments
46+
py_module.main()
47+
48+
else:
49+
raise Abort("No `main` or `cli` method detected")
50+
51+
if interactive:
52+
return console()
53+
54+
55+
@click.command(short_help="Run scripts from the `scripts` folder")
56+
@click.argument("scripts", nargs=-1)
57+
@click.option(
58+
"-v",
59+
"--verbose",
60+
is_flag=True,
61+
default=False,
62+
help="Display errors from scripts",
63+
)
64+
@click.option(
65+
"-i",
66+
"--interactive",
67+
is_flag=True,
68+
default=False,
69+
help="Drop into interactive console session after running",
70+
)
71+
@click.option(
72+
"--network",
73+
type=NetworkChoice(case_sensitive=False),
74+
default=networks.default_ecosystem.name,
75+
help="Override the default network and provider. (see `ape networks list` for options)",
76+
show_default=True,
77+
show_choices=False,
78+
)
79+
def cli(scripts, verbose, interactive, network):
80+
"""
81+
NAME - Path or script name (from `scripts/` folder)
82+
83+
Run scripts from the `scripts` folder. A script must either define a `main()` method,
84+
or define an import named `cli` that is a `click.Command` or `click.Group` object.
85+
`click.Group` and `click.Command` objects will be provided with additional context, which
86+
will be injected dynamically during script execution. The dynamically injected objects are
87+
the exports from the `ape` top-level package (similar to how the console works)
88+
"""
89+
if not scripts:
90+
raise Abort("Must provide at least one script name or path")
91+
92+
scripts_folder = config.PROJECT_FOLDER / "scripts"
93+
94+
# Generate the lookup based on all the scripts defined in the project's `scripts/` folderi
95+
# NOTE: If folder does not exist, this will be empty (same as if there are no files)
96+
available_scripts = {p.stem: p.resolve() for p in scripts_folder.glob("*.py")}
97+
98+
with networks.parse_network_choice(network):
99+
for name in scripts:
100+
if Path(name).exists():
101+
script_file = Path(name).resolve()
102+
103+
elif not scripts_folder.exists():
104+
raise Abort("No `scripts/` directory detected to run script")
105+
106+
elif name not in available_scripts:
107+
raise Abort(f"No script named '{name}' detected in scripts folder")
108+
109+
else:
110+
script_file = available_scripts[name]
111+
112+
_run_script(script_file, interactive, verbose)

src/ape_run/py.typed

Whitespace-only changes.

tests/conftest.py

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,55 +4,47 @@
44
import pytest # type: ignore
55

66
import ape
7-
from ape import Project
8-
from ape import config as ape_config
97

10-
TEMP_FOLDER = Path(mkdtemp())
11-
# NOTE: Don't change this setting
12-
ape_config.DATA_FOLDER = TEMP_FOLDER / ".ape"
13-
# NOTE: Ensure that a temp path is used by default (avoids `.build` appearing in src)
14-
ape_config.PROJECT_FOLDER = TEMP_FOLDER
15-
ape.project = Project(TEMP_FOLDER)
8+
# NOTE: Ensure that we don't use local paths for these
9+
ape.config.DATA_FOLDER = Path(mkdtemp())
10+
ape.config.PROJECT_FOLDER = Path(mkdtemp())
1611

1712

1813
@pytest.fixture(scope="session")
1914
def config():
20-
yield ape_config
15+
yield ape.config
2116

2217

2318
@pytest.fixture(scope="session")
24-
def plugin_manager():
25-
from ape import plugin_manager
19+
def data_folder(config):
20+
yield config.DATA_FOLDER
2621

27-
yield plugin_manager
22+
23+
@pytest.fixture(scope="session")
24+
def plugin_manager():
25+
yield ape.plugin_manager
2826

2927

3028
@pytest.fixture(scope="session")
3129
def accounts():
32-
from ape import accounts
33-
34-
yield accounts
30+
yield ape.accounts
3531

3632

3733
@pytest.fixture(scope="session")
3834
def compilers():
39-
from ape import compilers
40-
41-
yield compilers
35+
yield ape.compilers
4236

4337

4438
@pytest.fixture(scope="session")
4539
def networks():
46-
from ape import networks
47-
48-
yield networks
40+
yield ape.networks
4941

5042

5143
@pytest.fixture(scope="session")
52-
def project():
53-
yield ape.project
44+
def project_folder(config):
45+
yield config.PROJECT_FOLDER
5446

5547

5648
@pytest.fixture(scope="session")
57-
def data_folder(config):
58-
yield config.DATA_FOLDER
49+
def project(config):
50+
yield ape.Project(config.PROJECT_FOLDER)

tests/integration/cli/conftest.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from distutils.dir_util import copy_tree
23
from pathlib import Path
34

@@ -30,11 +31,13 @@ def project(project_folder):
3031
ape.project = previous_project
3132

3233

33-
@pytest.fixture(scope="session")
34-
def runner(config):
34+
@pytest.fixture
35+
def runner(project_folder):
36+
previous_cwd = str(Path.cwd())
37+
os.chdir(str(project_folder))
3538
runner = CliRunner()
36-
with runner.isolated_filesystem(temp_dir=config.PROJECT_FOLDER):
37-
yield runner
39+
yield runner
40+
os.chdir(previous_cwd)
3841

3942

4043
@pytest.fixture(scope="session")
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from ape import networks
2+
3+
4+
def main():
5+
assert networks.active_provider.name == "test"
6+
print("Script ran!")

0 commit comments

Comments
 (0)