Skip to content

Commit 44e8c94

Browse files
authored
Merge pull request #1166 from cloudbees-oss/AIENG-263
[AIENG-263] prototyping the command line argument/option processing framework that works for us
2 parents fc0b1b6 + d47d203 commit 44e8c94

File tree

103 files changed

+4564
-2524
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

103 files changed

+4564
-2524
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ classifiers = [
1111
"Operating System :: OS Independent",
1212
]
1313
dependencies = [
14-
"typer>=0.19.1",
1514
"requests>=2.32.5",
1615
"urllib3>=2.5",
1716
"junitparser>=4.0.0",
1817
"more-itertools>=7.1.0",
1918
"python-dateutil>=2.9",
2019
"tabulate>=0.9",
20+
"click>=8.1,<8.2",
2121
]
2222
dynamic = ["version"]
2323

smart_tests/__main__.py

Lines changed: 40 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -1,179 +1,60 @@
11
import importlib
22
import importlib.util
3-
import logging
4-
import os
3+
import sys
54
from glob import glob
65
from os.path import basename, dirname, join
7-
from typing import Annotated
8-
9-
import typer
106

117
from smart_tests.app import Application
12-
from smart_tests.commands.detect_flakes import create_nested_command_app as create_detect_flakes_commands
13-
from smart_tests.commands.record.tests import create_nested_commands as create_record_target_commands
14-
from smart_tests.commands.subset import create_nested_commands as create_subset_target_commands
15-
from smart_tests.utils.test_runner_registry import get_registry
16-
17-
from .commands import compare, detect_flakes, inspect, record, stats, subset, verify
18-
from .utils import logger
19-
from .utils.env_keys import SKIP_CERT_VERIFICATION
20-
from .version import __version__
21-
22-
# Load all test runners at module level so they register their commands
23-
for f in glob(join(dirname(__file__), 'test_runners', "*.py")):
24-
f = basename(f)[:-3]
25-
if f == '__init__':
26-
continue
27-
importlib.import_module('smart_tests.test_runners.%s' % f)
28-
29-
# Create initial NestedCommand commands with built-in test runners
30-
try:
31-
create_subset_target_commands()
32-
create_record_target_commands()
33-
create_detect_flakes_commands()
34-
except Exception as e:
35-
# If NestedCommand creation fails, continue with legacy commands
36-
# This ensures backward compatibility
37-
logging.warning(f"Failed to create NestedCommand commands at import time: {e}")
38-
pass
39-
40-
# Global flag to track if plugins have been loaded and commands need rebuilding
41-
_plugins_loaded = False
42-
43-
44-
def _rebuild_nested_commands_with_plugins():
45-
"""Rebuild NestedCommand apps after plugins are loaded."""
46-
global _plugins_loaded
47-
if _plugins_loaded:
48-
return # Already rebuilt
49-
50-
try:
51-
# Clear existing commands from nested apps and rebuild
52-
for module_name in ['smart_tests.commands.subset', 'smart_tests.commands.record.tests',
53-
'smart_tests.commands.detect_flakes']:
54-
module = importlib.import_module(module_name)
55-
if hasattr(module, 'nested_command_app'):
56-
nested_app = module.nested_command_app
57-
nested_app.registered_commands.clear()
58-
nested_app.registered_groups.clear()
59-
if hasattr(module, 'create_nested_commands'):
60-
module.create_nested_commands()
61-
62-
_plugins_loaded = True
63-
logging.info("Successfully rebuilt NestedCommand apps with plugins")
64-
65-
except Exception as e:
66-
logging.warning(f"Failed to rebuild NestedCommand apps with plugins: {e}")
67-
import traceback
68-
logging.warning(f"Traceback: {traceback.format_exc()}")
69-
70-
71-
# Set up automatic rebuilding when new test runners are registered
72-
73-
74-
def _on_test_runner_registered():
75-
"""Callback triggered when new test runners are registered."""
76-
_rebuild_nested_commands_with_plugins()
8+
from smart_tests.args4p.command import Group
9+
from smart_tests.commands.compare import compare
10+
from smart_tests.commands.detect_flakes import detect_flakes
11+
from smart_tests.commands.inspect import inspect
12+
from smart_tests.commands.record import record
13+
from smart_tests.commands.stats import stats
14+
from smart_tests.commands.subset import subset
15+
from smart_tests.commands.verify import verify
16+
17+
cli = Group(name="cli", callback=Application)
18+
cli.add_command(record)
19+
cli.add_command(subset)
20+
# TODO: main.add_command(split_subset)
21+
cli.add_command(verify)
22+
cli.add_command(inspect)
23+
cli.add_command(stats)
24+
cli.add_command(compare)
25+
cli.add_command(detect_flakes)
26+
27+
28+
def _load_test_runners():
29+
# load all test runners
30+
for f in glob(join(dirname(__file__), 'test_runners', "*.py")):
31+
f = basename(f)[:-3]
32+
if f == '__init__':
33+
continue
34+
importlib.import_module(f'smart_tests.test_runners.{f}')
35+
36+
# load all plugins. Here we do a bit of command line parsing ourselves,
37+
# because the command line could look something like `smart-tests record tests myprofile --plugins ...
38+
plugin_dir = None
39+
if "--plugins" in sys.argv:
40+
idx = sys.argv.index("--plugins")
41+
if idx + 1 < len(sys.argv):
42+
plugin_dir = sys.argv[idx + 1]
7743

78-
79-
get_registry().set_on_register_callback(_on_test_runner_registered)
80-
81-
app = typer.Typer()
82-
83-
84-
def version_callback(value: bool):
85-
if value:
86-
typer.echo(f"smart-tests-cli {__version__}")
87-
raise typer.Exit()
88-
89-
90-
def main(
91-
ctx: typer.Context,
92-
log_level: Annotated[str, typer.Option(
93-
help="Set logger's log level (CRITICAL, ERROR, WARNING, AUDIT, INFO, DEBUG)."
94-
)] = logger.LOG_LEVEL_DEFAULT_STR,
95-
plugin_dir: Annotated[str | None, typer.Option(
96-
"--plugin-dir", "--plugins",
97-
help="Directory to load plugins from"
98-
)] = None,
99-
dry_run: Annotated[bool, typer.Option(
100-
help="Dry-run mode. No data is sent to the server. However, sometimes "
101-
"GET requests without payload data or side effects could be sent."
102-
"note: Since the dry run log is output together with the AUDIT log, "
103-
"even if the log-level is set to warning or higher, the log level will "
104-
"be forced to be set to AUDIT."
105-
)] = False,
106-
skip_cert_verification: Annotated[bool, typer.Option(
107-
help="Skip the SSL certificate check. This lets you bypass system setup issues "
108-
"like CERTIFICATE_VERIFY_FAILED, at the expense of vulnerability against "
109-
"a possible man-in-the-middle attack. Use it as an escape hatch, but with caution."
110-
)] = False,
111-
version: Annotated[bool | None, typer.Option(
112-
"--version", help="Show version and exit", callback=version_callback, is_eager=True
113-
)] = None,
114-
):
115-
level = logger.get_log_level(log_level)
116-
# In the case of dry-run, it is forced to set the level below the AUDIT.
117-
# This is because the dry-run log will be output along with the audit log.
118-
if dry_run and level > logger.LOG_LEVEL_AUDIT:
119-
level = logger.LOG_LEVEL_AUDIT
120-
121-
if not skip_cert_verification:
122-
skip_cert_verification = (os.environ.get(SKIP_CERT_VERIFICATION) is not None)
123-
124-
logging.basicConfig(level=level)
125-
126-
# load all plugins
12744
if plugin_dir:
12845
for f in glob(join(plugin_dir, '*.py')):
12946
spec = importlib.util.spec_from_file_location(
13047
f"smart_tests.plugins.{basename(f)[:-3]}", f)
131-
if spec is None:
132-
raise ImportError(f"Failed to create module spec for plugin: {f}")
133-
if spec.loader is None:
134-
raise ImportError(f"Plugin spec has no loader: {f}")
13548
plugin = importlib.util.module_from_spec(spec)
13649
spec.loader.exec_module(plugin)
13750

138-
# After loading plugins, rebuild NestedCommand apps to include plugin commands
139-
if plugin_dir:
140-
_rebuild_nested_commands_with_plugins()
141-
142-
ctx.obj = Application(dry_run=dry_run, skip_cert_verification=skip_cert_verification)
143-
144-
145-
# Use NestedCommand apps if available, otherwise fall back to legacy
146-
try:
147-
from smart_tests.commands.detect_flakes import nested_command_app as detect_flakes_target_app
148-
from smart_tests.commands.record.tests import nested_command_app as record_target_app
149-
from smart_tests.commands.subset import nested_command_app as subset_target_app
150-
151-
app.add_typer(record.app, name="record")
152-
app.add_typer(subset_target_app, name="subset") # Use NestedCommand version
153-
app.add_typer(verify.app, name="verify")
154-
app.add_typer(inspect.app, name="inspect")
155-
app.add_typer(stats.app, name="stats")
156-
app.add_typer(compare.app, name="compare")
157-
app.add_typer(detect_flakes_target_app, name="detect-flakes")
15851

159-
# Add record-target as a sub-app to record command
160-
record.app.add_typer(record_target_app, name="tests")
52+
_load_test_runners()
16153

162-
except Exception as e:
163-
logging.warning(f"Failed to use NestedCommand apps at init: {e}")
164-
# Fallback to original structure
165-
app.add_typer(record.app, name="record")
166-
app.add_typer(subset.app, name="subset")
167-
app.add_typer(verify.app, name="verify")
168-
app.add_typer(inspect.app, name="inspect")
169-
app.add_typer(stats.app, name="stats")
170-
app.add_typer(detect_flakes.app, name="detect-flakes")
17154

172-
app.callback()(main)
55+
def main():
56+
cli.main()
17357

174-
# For backward compatibility with tests that expect a Click CLI
175-
# We'll need to use Typer's testing utilities instead
176-
main = app
17758

17859
if __name__ == '__main__':
179-
app()
60+
main()

smart_tests/app.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,66 @@
22
# Currently it's used to keep global configurations.
33
#
44
# From command implementations, this is available via dependency injection
5+
6+
import logging
7+
import os
8+
from typing import Annotated
9+
10+
import click
11+
12+
import smart_tests.args4p.typer as typer
13+
from smart_tests.utils import logger
14+
from smart_tests.utils.env_keys import SKIP_CERT_VERIFICATION
15+
from smart_tests.version import __version__
16+
17+
518
class Application:
6-
def __init__(self, dry_run: bool = False, skip_cert_verification: bool = False):
19+
# Group commands that take the CLI profile as a sub-command shall set this parameter
20+
test_runner: str | None = None
21+
22+
# this maps to the main entry point of the CLI command
23+
def __init__(
24+
self,
25+
log_level: Annotated[str, typer.Option(
26+
help="Set logger's log level (CRITICAL, ERROR, WARNING, AUDIT, INFO, DEBUG)."
27+
)] = logger.LOG_LEVEL_DEFAULT_STR,
28+
plugin_dir: Annotated[str | None, typer.Option(
29+
"--plugins",
30+
help="Directory to load plugins from"
31+
)] = None,
32+
dry_run: Annotated[bool, typer.Option(
33+
help="Dry-run mode. No data is sent to the server. However, sometimes "
34+
"GET requests without payload data or side effects could be sent."
35+
"note: Since the dry run log is output together with the AUDIT log, "
36+
"even if the log-level is set to warning or higher, the log level will "
37+
"be forced to be set to AUDIT."
38+
)] = False,
39+
skip_cert_verification: Annotated[bool, typer.Option(
40+
help="Skip the SSL certificate check. This lets you bypass system setup issues "
41+
"like CERTIFICATE_VERIFY_FAILED, at the expense of vulnerability against "
42+
"a possible man-in-the-middle attack. Use it as an escape hatch, but with caution."
43+
)] = False,
44+
version: Annotated[bool, typer.Option(
45+
"--version", help="Show version and exit"
46+
)] = False,
47+
):
48+
if version:
49+
click.echo(f"smart-tests-cli {__version__}")
50+
raise typer.Exit(0)
51+
52+
level = logger.get_log_level(log_level)
53+
# In the case of dry-run, it is forced to set the level below the AUDIT.
54+
# This is because the dry-run log will be output along with the audit log.
55+
if dry_run and level > logger.LOG_LEVEL_AUDIT:
56+
level = logger.LOG_LEVEL_AUDIT
57+
logging.basicConfig(level=level)
58+
59+
# plugin_dir is processed earlier. If we do it here, it's too late
60+
761
# Dry run mode. This command is used by customers to inspect data we'd send to our server,
862
# but without actually doing so.
963
self.dry_run = dry_run
10-
# Skip SSL certificate validation
64+
65+
if not skip_cert_verification:
66+
skip_cert_verification = (os.environ.get(SKIP_CERT_VERIFICATION) is not None)
1167
self.skip_cert_verification = skip_cert_verification

0 commit comments

Comments
 (0)