mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-27 08:16:25 +01:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8456059db | ||
|
|
efbab29f94 |
No files matched your search
@@ -35,9 +35,16 @@ class DependentModule(MVTModule):
|
|||||||
prerequisite_results = self.get_dependency_results(PrerequisiteModule)
|
prerequisite_results = self.get_dependency_results(PrerequisiteModule)
|
||||||
```
|
```
|
||||||
|
|
||||||
Selecting a single module also runs its transitive dependencies. If a dependency
|
Selecting a single module also runs its transitive dependencies.
|
||||||
is unavailable or the dependency graph contains a cycle, the command logs a
|
|
||||||
warning and does not run any modules.
|
A module can only depend on modules the command it runs in also has. When a
|
||||||
|
declared dependency is not among them, the command logs a warning naming the
|
||||||
|
module and the missing dependency, skips that module and everything depending
|
||||||
|
on it, and runs the rest of the analysis. Selecting such a module with
|
||||||
|
`--module` therefore leaves nothing to run, which the warning explains.
|
||||||
|
|
||||||
|
A cycle in the dependency graph is a programming error rather than a
|
||||||
|
configuration problem: the command logs a warning and runs no modules at all.
|
||||||
|
|
||||||
## Custom modules
|
## Custom modules
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -44,8 +44,8 @@ homepage = "https://docs.mvt.re/en/latest/"
|
|||||||
repository = "https://github.com/mvt-project/mvt"
|
repository = "https://github.com/mvt-project/mvt"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
mvt-ios = "mvt.ios:cli"
|
mvt-ios = "mvt.ios:main"
|
||||||
mvt-android = "mvt.android:cli"
|
mvt-android = "mvt.android:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||||
# https://license.mvt.re/1.1/
|
# https://license.mvt.re/1.1/
|
||||||
|
|
||||||
from .cli import cli
|
from .cli import cli, main
|
||||||
+16
-5
@@ -514,8 +514,19 @@ def download_indicators():
|
|||||||
ioc_updates.update()
|
ioc_updates.update()
|
||||||
|
|
||||||
|
|
||||||
register_cli_plugins(
|
# ==============================================================================
|
||||||
cli,
|
# Entry point of the mvt-android console script
|
||||||
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
|
# ==============================================================================
|
||||||
environment_variable=MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
def main() -> None:
|
||||||
)
|
"""Register the external commands and run the mvt-android CLI.
|
||||||
|
|
||||||
|
External commands are registered here rather than when this module is
|
||||||
|
imported, so that importing MVT never runs third-party code and a plugin
|
||||||
|
importing from MVT cannot re-enter a module that is still initializing.
|
||||||
|
"""
|
||||||
|
register_cli_plugins(
|
||||||
|
cli,
|
||||||
|
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
|
||||||
|
environment_variable=MVT_ANDROID_CUSTOM_COMMANDS_ENV,
|
||||||
|
)
|
||||||
|
cli()
|
||||||
+95
-16
@@ -317,6 +317,81 @@ class Command:
|
|||||||
console.print("")
|
console.print("")
|
||||||
console.print(panel)
|
console.print(panel)
|
||||||
|
|
||||||
|
def _skipped_modules(
|
||||||
|
self,
|
||||||
|
required: list[type[MVTModule]],
|
||||||
|
module_indexes: dict[type[MVTModule], int],
|
||||||
|
) -> dict[type[MVTModule], tuple[type[MVTModule], type[MVTModule]]]:
|
||||||
|
"""Return the modules to drop because a dependency is unavailable.
|
||||||
|
|
||||||
|
A module declaring a dependency this command cannot provide is unable
|
||||||
|
to run, and so is every module depending on it. Dropping only those
|
||||||
|
keeps a single wrong declaration - in a module scoped to several
|
||||||
|
commands, for example - from silencing an entire analysis.
|
||||||
|
|
||||||
|
The returned mapping gives, for each skipped module, the module which
|
||||||
|
is missing a dependency and the dependency it is missing.
|
||||||
|
"""
|
||||||
|
skipped: dict[type[MVTModule], tuple[type[MVTModule], type[MVTModule]]] = {}
|
||||||
|
# Skipping the one module a run was asked for leaves nothing to run,
|
||||||
|
# which the caller reports instead.
|
||||||
|
remainder = (
|
||||||
|
"" if self.module_name else " The rest of the analysis will still run."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Skipping one module can skip the modules depending on it, which the
|
||||||
|
# pass over the module list may already have gone past, so repeat the
|
||||||
|
# pass until nothing changes.
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
for module in required:
|
||||||
|
if module in skipped:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for dependency in module.dependencies:
|
||||||
|
if dependency not in module_indexes:
|
||||||
|
skipped[module] = (module, dependency)
|
||||||
|
changed = True
|
||||||
|
self.log.warning(
|
||||||
|
"Module %s will be SKIPPED: it depends on module "
|
||||||
|
"%s, which is not available in this command.%s",
|
||||||
|
module.__name__,
|
||||||
|
dependency.__name__,
|
||||||
|
remainder,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if dependency in skipped:
|
||||||
|
root, missing = skipped[dependency]
|
||||||
|
skipped[module] = (root, missing)
|
||||||
|
changed = True
|
||||||
|
if dependency is root:
|
||||||
|
self.log.warning(
|
||||||
|
"Module %s will be SKIPPED: it depends on "
|
||||||
|
"module %s, itself skipped for depending on "
|
||||||
|
"unavailable module %s.%s",
|
||||||
|
module.__name__,
|
||||||
|
dependency.__name__,
|
||||||
|
missing.__name__,
|
||||||
|
remainder,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.log.warning(
|
||||||
|
"Module %s will be SKIPPED: it depends on "
|
||||||
|
"skipped module %s, in a chain starting at "
|
||||||
|
"module %s, which depends on unavailable "
|
||||||
|
"module %s.%s",
|
||||||
|
module.__name__,
|
||||||
|
dependency.__name__,
|
||||||
|
root.__name__,
|
||||||
|
missing.__name__,
|
||||||
|
remainder,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
return skipped
|
||||||
|
|
||||||
def _ordered_modules(self) -> Optional[list[type[MVTModule]]]:
|
def _ordered_modules(self) -> Optional[list[type[MVTModule]]]:
|
||||||
"""Return enabled modules in stable topological order."""
|
"""Return enabled modules in stable topological order."""
|
||||||
modules = self._available_modules()
|
modules = self._available_modules()
|
||||||
@@ -329,30 +404,34 @@ class Command:
|
|||||||
else:
|
else:
|
||||||
selected = [module for module in modules if module.enabled]
|
selected = [module for module in modules if module.enabled]
|
||||||
|
|
||||||
required = set(selected)
|
required: set[type[MVTModule]] = set()
|
||||||
pending = list(selected)
|
pending = list(selected)
|
||||||
while pending:
|
while pending:
|
||||||
module = pending.pop()
|
module = pending.pop()
|
||||||
|
if module in required:
|
||||||
|
continue
|
||||||
|
required.add(module)
|
||||||
for dependency in module.dependencies:
|
for dependency in module.dependencies:
|
||||||
if dependency not in module_indexes:
|
# Unavailable dependencies are reported by _skipped_modules().
|
||||||
self.log.warning(
|
if dependency in module_indexes:
|
||||||
"Module %s depends on unavailable module %s. "
|
|
||||||
"No modules will be run.",
|
|
||||||
module.__name__,
|
|
||||||
dependency.__name__,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if dependency not in required:
|
|
||||||
required.add(dependency)
|
|
||||||
pending.append(dependency)
|
pending.append(dependency)
|
||||||
|
|
||||||
|
ordered_required = sorted(required, key=lambda module: module_indexes[module])
|
||||||
|
skipped = self._skipped_modules(ordered_required, module_indexes)
|
||||||
|
runnable = [module for module in ordered_required if module not in skipped]
|
||||||
|
if skipped and not runnable:
|
||||||
|
self.log.warning(
|
||||||
|
"Every selected module was skipped for an unavailable "
|
||||||
|
"dependency. No modules will be run."
|
||||||
|
)
|
||||||
|
|
||||||
dependents: dict[type[MVTModule], list[type[MVTModule]]] = {
|
dependents: dict[type[MVTModule], list[type[MVTModule]]] = {
|
||||||
module: [] for module in required
|
module: [] for module in runnable
|
||||||
}
|
}
|
||||||
indegree = {module: 0 for module in required}
|
indegree = {module: 0 for module in runnable}
|
||||||
for module in required:
|
for module in runnable:
|
||||||
for dependency in module.dependencies:
|
for dependency in module.dependencies:
|
||||||
if dependency not in required:
|
if dependency not in indegree:
|
||||||
continue
|
continue
|
||||||
dependents[dependency].append(module)
|
dependents[dependency].append(module)
|
||||||
indegree[module] += 1
|
indegree[module] += 1
|
||||||
@@ -371,7 +450,7 @@ class Command:
|
|||||||
if indegree[dependent] == 0:
|
if indegree[dependent] == 0:
|
||||||
heappush(ready, (module_indexes[dependent], dependent))
|
heappush(ready, (module_indexes[dependent], dependent))
|
||||||
|
|
||||||
if len(ordered) != len(required):
|
if len(ordered) != len(runnable):
|
||||||
cyclic_modules = sorted(
|
cyclic_modules = sorted(
|
||||||
(module.__name__ for module, count in indegree.items() if count > 0)
|
(module.__name__ for module, count in indegree.items() if count > 0)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||||
# https://license.mvt.re/1.1/
|
# https://license.mvt.re/1.1/
|
||||||
|
|
||||||
from .cli import cli
|
from .cli import cli, main
|
||||||
+16
-5
@@ -532,8 +532,19 @@ def download_iocs():
|
|||||||
ioc_updates.update()
|
ioc_updates.update()
|
||||||
|
|
||||||
|
|
||||||
register_cli_plugins(
|
# ==============================================================================
|
||||||
cli,
|
# Entry point of the mvt-ios console script
|
||||||
entry_point_group=IOS_CLI_PLUGIN_GROUP,
|
# ==============================================================================
|
||||||
environment_variable=MVT_IOS_CUSTOM_COMMANDS_ENV,
|
def main() -> None:
|
||||||
)
|
"""Register the external commands and run the mvt-ios CLI.
|
||||||
|
|
||||||
|
External commands are registered here rather than when this module is
|
||||||
|
imported, so that importing MVT never runs third-party code and a plugin
|
||||||
|
importing from MVT cannot re-enter a module that is still initializing.
|
||||||
|
"""
|
||||||
|
register_cli_plugins(
|
||||||
|
cli,
|
||||||
|
entry_point_group=IOS_CLI_PLUGIN_GROUP,
|
||||||
|
environment_variable=MVT_IOS_CUSTOM_COMMANDS_ENV,
|
||||||
|
)
|
||||||
|
cli()
|
||||||
@@ -157,7 +157,7 @@ class TestCommand:
|
|||||||
assert not hasattr(cmd, "initialized")
|
assert not hasattr(cmd, "initialized")
|
||||||
assert "Circular module dependency detected" in caplog.text
|
assert "Circular module dependency detected" in caplog.text
|
||||||
|
|
||||||
def test_unavailable_dependency_warns_and_stops(self, caplog):
|
def test_unavailable_dependency_only_skips_the_dependent_module(self, caplog):
|
||||||
class UnavailableModule(RecordingModule):
|
class UnavailableModule(RecordingModule):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -165,14 +165,97 @@ class TestCommand:
|
|||||||
dependencies = (UnavailableModule,)
|
dependencies = (UnavailableModule,)
|
||||||
|
|
||||||
cmd = RecordingCommand()
|
cmd = RecordingCommand()
|
||||||
cmd.modules = [DependentModule]
|
cmd.modules = [DependentModule, IndependentModule, FirstModule]
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
cmd.run()
|
||||||
|
|
||||||
|
assert RecordingModule.run_order == ["IndependentModule", "FirstModule"]
|
||||||
|
assert cmd.initialized
|
||||||
|
assert "Module DependentModule will be SKIPPED" in caplog.text
|
||||||
|
assert "depends on module UnavailableModule" in caplog.text
|
||||||
|
|
||||||
|
def test_modules_depending_on_a_skipped_module_are_skipped_too(self, caplog):
|
||||||
|
class UnavailableModule(RecordingModule):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SkippedModule(RecordingModule):
|
||||||
|
dependencies = (UnavailableModule,)
|
||||||
|
|
||||||
|
class DependsOnSkippedModule(RecordingModule):
|
||||||
|
dependencies = (SkippedModule,)
|
||||||
|
|
||||||
|
class DependsOnTheChain(RecordingModule):
|
||||||
|
dependencies = (DependsOnSkippedModule,)
|
||||||
|
|
||||||
|
cmd = RecordingCommand()
|
||||||
|
cmd.modules = [
|
||||||
|
DependsOnTheChain,
|
||||||
|
DependsOnSkippedModule,
|
||||||
|
SkippedModule,
|
||||||
|
IndependentModule,
|
||||||
|
]
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
cmd.run()
|
||||||
|
|
||||||
|
assert RecordingModule.run_order == ["IndependentModule"]
|
||||||
|
skip_warnings = [
|
||||||
|
record.getMessage()
|
||||||
|
for record in caplog.records
|
||||||
|
if "will be SKIPPED" in record.getMessage()
|
||||||
|
]
|
||||||
|
assert len(skip_warnings) == 3
|
||||||
|
assert [warning.split()[1] for warning in skip_warnings] == [
|
||||||
|
"SkippedModule",
|
||||||
|
"DependsOnSkippedModule",
|
||||||
|
"DependsOnTheChain",
|
||||||
|
]
|
||||||
|
# Every warning names the root cause: the module missing a dependency
|
||||||
|
# and the dependency it is missing.
|
||||||
|
assert all("UnavailableModule" in warning for warning in skip_warnings)
|
||||||
|
assert all("module SkippedModule" in warning for warning in skip_warnings[1:])
|
||||||
|
|
||||||
|
def test_explicitly_selected_module_with_missing_dependency_runs_nothing(
|
||||||
|
self, caplog
|
||||||
|
):
|
||||||
|
class UnavailableModule(RecordingModule):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DependentModule(RecordingModule):
|
||||||
|
dependencies = (UnavailableModule,)
|
||||||
|
|
||||||
|
cmd = RecordingCommand(module_name="DependentModule")
|
||||||
|
cmd.modules = [DependentModule, IndependentModule]
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING):
|
with caplog.at_level(logging.WARNING):
|
||||||
cmd.run()
|
cmd.run()
|
||||||
|
|
||||||
assert RecordingModule.run_order == []
|
assert RecordingModule.run_order == []
|
||||||
assert not hasattr(cmd, "initialized")
|
assert "Module DependentModule will be SKIPPED" in caplog.text
|
||||||
assert "depends on unavailable module UnavailableModule" in caplog.text
|
assert "No modules will be run" in caplog.text
|
||||||
|
# Nothing else was selected, so the warning must not promise that the
|
||||||
|
# analysis continues right before saying that it does not.
|
||||||
|
assert "The rest of the analysis will still run" not in caplog.text
|
||||||
|
|
||||||
|
def test_unaffected_dependency_chains_keep_their_order(self, caplog):
|
||||||
|
class UnavailableModule(RecordingModule):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SkippedModule(RecordingModule):
|
||||||
|
dependencies = (UnavailableModule,)
|
||||||
|
|
||||||
|
cmd = RecordingCommand()
|
||||||
|
cmd.modules = [ThirdModule, SkippedModule, SecondModule, FirstModule]
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
cmd.run()
|
||||||
|
|
||||||
|
assert RecordingModule.run_order == [
|
||||||
|
"FirstModule",
|
||||||
|
"SecondModule",
|
||||||
|
"ThirdModule",
|
||||||
|
]
|
||||||
|
|
||||||
def test_custom_modules_are_filtered_before_ordering(self):
|
def test_custom_modules_are_filtered_before_ordering(self):
|
||||||
cmd = RecordingCommand()
|
cmd = RecordingCommand()
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Mobile Verification Toolkit (MVT)
|
||||||
|
# Copyright (c) 2021-2026 The MVT Authors.
|
||||||
|
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||||
|
# https://license.mvt.re/1.1/
|
||||||
|
|
||||||
|
"""Helpers building throwaway plugin distributions for the tests.
|
||||||
|
|
||||||
|
Some plugin behaviour only shows up in a fresh interpreter: what an import
|
||||||
|
executes, and what a plugin sees when MVT is imported before or after it.
|
||||||
|
These helpers write an importable distribution with a real entry point and
|
||||||
|
run a script against it in a subprocess, with a temporary home so that the
|
||||||
|
subprocess cannot touch the configuration of whoever runs the tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
FIXTURE_COMMAND_NAME = "fixture-plugin"
|
||||||
|
FIXTURE_MODULE_NAME = "fixture_cli_plugin"
|
||||||
|
FIXTURE_DISTRIBUTION_NAME = "fixture-cli-plugin"
|
||||||
|
|
||||||
|
|
||||||
|
def write_cli_plugin_distribution(
|
||||||
|
site_path: Path,
|
||||||
|
entry_point_group: str,
|
||||||
|
module_source: str,
|
||||||
|
) -> Path:
|
||||||
|
"""Write a distribution registering a CLI plugin entry point.
|
||||||
|
|
||||||
|
:param site_path: Folder to write the distribution into, to be added to
|
||||||
|
the import path of the interpreter loading it.
|
||||||
|
:param entry_point_group: Entry-point group to register the command in.
|
||||||
|
:param module_source: Source of the plugin module, which must define a
|
||||||
|
Click command named `cli`.
|
||||||
|
:returns: The folder the distribution was written to.
|
||||||
|
"""
|
||||||
|
site_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
(site_path / f"{FIXTURE_MODULE_NAME}.py").write_text(
|
||||||
|
module_source, encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
dist_info = (
|
||||||
|
site_path / f"{FIXTURE_DISTRIBUTION_NAME.replace('-', '_')}-1.0.dist-info"
|
||||||
|
)
|
||||||
|
dist_info.mkdir(exist_ok=True)
|
||||||
|
(dist_info / "METADATA").write_text(
|
||||||
|
f"Metadata-Version: 2.1\nName: {FIXTURE_DISTRIBUTION_NAME}\nVersion: 1.0\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(dist_info / "entry_points.txt").write_text(
|
||||||
|
f"[{entry_point_group}]\n{FIXTURE_COMMAND_NAME} = {FIXTURE_MODULE_NAME}:cli\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return site_path
|
||||||
|
|
||||||
|
|
||||||
|
def run_isolated_python(
|
||||||
|
script: str,
|
||||||
|
home: Path,
|
||||||
|
site_path: Optional[Path] = None,
|
||||||
|
**environment: str,
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
"""Run a script in a fresh interpreter with its own configuration folder.
|
||||||
|
|
||||||
|
Importing MVT writes its configuration file, so the subprocess gets a
|
||||||
|
temporary home and no MVT environment variables from the test session.
|
||||||
|
"""
|
||||||
|
isolated_environment = {
|
||||||
|
key: value for key, value in os.environ.items() if not key.startswith("MVT_")
|
||||||
|
}
|
||||||
|
isolated_environment["HOME"] = str(home)
|
||||||
|
isolated_environment["XDG_CONFIG_HOME"] = str(home / "config")
|
||||||
|
if site_path is not None:
|
||||||
|
isolated_environment["PYTHONPATH"] = str(site_path)
|
||||||
|
isolated_environment.update(environment)
|
||||||
|
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-c", script],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=isolated_environment,
|
||||||
|
)
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# Mobile Verification Toolkit (MVT)
|
||||||
|
# Copyright (c) 2021-2026 The MVT Authors.
|
||||||
|
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||||
|
# https://license.mvt.re/1.1/
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import click
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import mvt.android
|
||||||
|
import mvt.ios
|
||||||
|
from mvt.android.cli import cli as android_cli
|
||||||
|
from mvt.android.cli import main as android_main
|
||||||
|
from mvt.common.cli_plugins import ANDROID_CLI_PLUGIN_GROUP, IOS_CLI_PLUGIN_GROUP
|
||||||
|
from mvt.ios.cli import cli as ios_cli
|
||||||
|
from mvt.ios.cli import main as ios_main
|
||||||
|
|
||||||
|
from .plugin_fixtures import (
|
||||||
|
FIXTURE_COMMAND_NAME,
|
||||||
|
run_isolated_python,
|
||||||
|
write_cli_plugin_distribution,
|
||||||
|
)
|
||||||
|
|
||||||
|
MARKER_PLUGIN_TEMPLATE = """
|
||||||
|
import os
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
# Touched when this module is imported, so a test can tell whether loading MVT
|
||||||
|
# executed the plugin.
|
||||||
|
open(os.environ["FIXTURE_PLUGIN_MARKER"], "a").close()
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
def cli():
|
||||||
|
click.echo("fixture plugin ran")
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROGRAMS = {
|
||||||
|
"mvt-ios": (mvt.ios, ios_cli, IOS_CLI_PLUGIN_GROUP),
|
||||||
|
"mvt-android": (mvt.android, android_cli, ANDROID_CLI_PLUGIN_GROUP),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def restore_cli_commands():
|
||||||
|
"""Undo the plugin registration main() performs on the shared CLI groups."""
|
||||||
|
originals = {
|
||||||
|
program: dict(group.commands) for program, (_, group, _) in PROGRAMS.items()
|
||||||
|
}
|
||||||
|
yield
|
||||||
|
for program, (_, group, _) in PROGRAMS.items():
|
||||||
|
group.commands.clear()
|
||||||
|
group.commands.update(originals[program])
|
||||||
|
if hasattr(group, "_mvt_external_command_sources"):
|
||||||
|
delattr(group, "_mvt_external_command_sources")
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fixture_entry_point(monkeypatch, entry_point_group, command):
|
||||||
|
def entry_points(*, group):
|
||||||
|
if group != entry_point_group:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
SimpleNamespace(
|
||||||
|
name=FIXTURE_COMMAND_NAME,
|
||||||
|
value="fixture_cli_plugin:cli",
|
||||||
|
load=lambda: command,
|
||||||
|
dist=SimpleNamespace(
|
||||||
|
metadata={"Name": "fixture-cli-plugin"}, version="1.0"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"mvt.common.cli_plugins.importlib.metadata.entry_points", entry_points
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _offline_argv(program, *arguments):
|
||||||
|
"""Build an argument list which keeps the CLI from checking for updates."""
|
||||||
|
return [
|
||||||
|
program,
|
||||||
|
"--disable-update-check",
|
||||||
|
"--disable-indicator-update-check",
|
||||||
|
*arguments,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("program", sorted(PROGRAMS))
|
||||||
|
def test_main_registers_installed_plugins_before_running_the_cli(
|
||||||
|
program, monkeypatch, capsys, restore_cli_commands
|
||||||
|
):
|
||||||
|
package, group, entry_point_group = PROGRAMS[program]
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
def fixture_command():
|
||||||
|
click.echo("fixture plugin ran")
|
||||||
|
|
||||||
|
_install_fixture_entry_point(monkeypatch, entry_point_group, fixture_command)
|
||||||
|
monkeypatch.setattr(sys, "argv", _offline_argv(program, FIXTURE_COMMAND_NAME))
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exit_info:
|
||||||
|
package.main()
|
||||||
|
|
||||||
|
assert exit_info.value.code == 0
|
||||||
|
assert "fixture plugin ran" in capsys.readouterr().out
|
||||||
|
assert FIXTURE_COMMAND_NAME in group.commands
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("program", sorted(PROGRAMS))
|
||||||
|
def test_main_completes_plugin_command_names(
|
||||||
|
program, monkeypatch, capsys, restore_cli_commands
|
||||||
|
):
|
||||||
|
package, _, entry_point_group = PROGRAMS[program]
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
def fixture_command():
|
||||||
|
pass
|
||||||
|
|
||||||
|
_install_fixture_entry_point(monkeypatch, entry_point_group, fixture_command)
|
||||||
|
complete_variable = f"_{program.upper().replace('-', '_')}_COMPLETE"
|
||||||
|
monkeypatch.setenv(complete_variable, "bash_complete")
|
||||||
|
monkeypatch.setenv("COMP_WORDS", f"{program} fixture")
|
||||||
|
monkeypatch.setenv("COMP_CWORD", "1")
|
||||||
|
monkeypatch.setattr(sys, "argv", [program])
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
package.main()
|
||||||
|
|
||||||
|
assert f"plain,{FIXTURE_COMMAND_NAME}" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("program", sorted(PROGRAMS))
|
||||||
|
def test_main_still_loads_commands_from_a_file(
|
||||||
|
program, monkeypatch, capsys, tmp_path, restore_cli_commands
|
||||||
|
):
|
||||||
|
package, _, entry_point_group = PROGRAMS[program]
|
||||||
|
command_path = tmp_path / "case_summary.py"
|
||||||
|
command_path.write_text(
|
||||||
|
"import click\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
'@click.command("case-summary")\n'
|
||||||
|
"def cli():\n"
|
||||||
|
' click.echo("case summary ran")\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_install_fixture_entry_point(
|
||||||
|
monkeypatch, entry_point_group, click.Command("unused")
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
_offline_argv(program, "--load-command", str(command_path), "case-summary"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exit_info:
|
||||||
|
package.main()
|
||||||
|
|
||||||
|
assert exit_info.value.code == 0
|
||||||
|
assert "case summary ran" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_console_script_targets_are_importable():
|
||||||
|
# [project.scripts] points at these, so they must stay on the packages.
|
||||||
|
assert mvt.ios.main is ios_main
|
||||||
|
assert mvt.android.main is android_main
|
||||||
|
|
||||||
|
|
||||||
|
def test_importing_mvt_does_not_run_installed_plugins(tmp_path):
|
||||||
|
site_path = write_cli_plugin_distribution(
|
||||||
|
tmp_path / "site", IOS_CLI_PLUGIN_GROUP, MARKER_PLUGIN_TEMPLATE
|
||||||
|
)
|
||||||
|
marker = tmp_path / "plugin-imported"
|
||||||
|
|
||||||
|
result = run_isolated_python(
|
||||||
|
"import mvt.ios.cli\nimport mvt.android.cli\nprint('imported')",
|
||||||
|
home=tmp_path / "home",
|
||||||
|
site_path=site_path,
|
||||||
|
FIXTURE_PLUGIN_MARKER=str(marker),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert "imported" in result.stdout
|
||||||
|
assert not marker.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_registering_the_plugins_runs_the_entry_point(tmp_path):
|
||||||
|
site_path = write_cli_plugin_distribution(
|
||||||
|
tmp_path / "site", IOS_CLI_PLUGIN_GROUP, MARKER_PLUGIN_TEMPLATE
|
||||||
|
)
|
||||||
|
marker = tmp_path / "plugin-imported"
|
||||||
|
|
||||||
|
result = run_isolated_python(
|
||||||
|
"import click\n"
|
||||||
|
"from mvt.common.cli_plugins import (\n"
|
||||||
|
" IOS_CLI_PLUGIN_GROUP,\n"
|
||||||
|
" BrokenPluginCommand,\n"
|
||||||
|
" register_installed_cli_commands,\n"
|
||||||
|
")\n"
|
||||||
|
"group = click.Group()\n"
|
||||||
|
"register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)\n"
|
||||||
|
f"command = group.commands[{FIXTURE_COMMAND_NAME!r}]\n"
|
||||||
|
"assert not isinstance(command, BrokenPluginCommand), command.help\n"
|
||||||
|
"print('registered')\n",
|
||||||
|
home=tmp_path / "home",
|
||||||
|
site_path=site_path,
|
||||||
|
FIXTURE_PLUGIN_MARKER=str(marker),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert "registered" in result.stdout
|
||||||
|
assert marker.exists()
|
||||||
Reference in new issue
Block a user