From 5cd76ebcd3982ef21a02d166f17d4daa419afb34 Mon Sep 17 00:00:00 2001 From: nicolasbisurgi Date: Wed, 24 Jun 2026 18:57:54 -0300 Subject: [PATCH] feat(cli): add --config flag to override config.ini location (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `--config PATH` flag (long-form only) to every TM1-connecting command (run, build, tasks …, resume). It points a single invocation at an arbitrary config.ini (TM1 connection parameters), enabling a read-only config.ini shared with other tm1py utilities. Resolution precedence (highest wins): --config > RUSHTI_DIR > legacy CWD > config/. The resolved path is threaded explicitly as config_path into the TM1 connection layer (connect_to_tm1_instance, setup_tm1_services) rather than mutating the module-level CONFIG global, which is demoted to the no-flag default. The execution.py `from rushti.cli import CONFIG` fallback is removed in favour of a required, explicit argument. --config relocates only config.ini; settings.ini keeps --settings and logging_config.ini keeps its own resolution. A missing path fails fast (exit 1, clean message, no traceback). Not added to stats/db, which open no TM1 connection. No behavioural change when the flag is absent. See docs/adr/0003-config-ini-location-resolution.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 36 +++-- CONTEXT.md | 29 +++- README.md | 5 + docs/advanced/cli-reference.md | 12 ++ docs/advanced/settings-reference.md | 16 ++ src/rushti/app_paths.py | 22 +++ src/rushti/cli.py | 19 ++- src/rushti/commands/build.py | 18 ++- src/rushti/commands/resume.py | 5 + src/rushti/commands/tasks/__init__.py | 15 +- src/rushti/execution.py | 6 +- tests/unit/test_config_flag.py | 218 ++++++++++++++++++++++++++ 12 files changed, 376 insertions(+), 25 deletions(-) create mode 100644 tests/unit/test_config_flag.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ac371a6..7295c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,20 @@ All notable changes to RushTI are documented in this file. -## Unreleased — `feat/issue-156-chore-task-kind` +## [Unreleased] + +- **Added: `--config PATH` CLI flag** (closes #164). Overrides the location of + `config.ini` (TM1 connection parameters) for a single invocation, on every + TM1-connecting command (`run`, `build`, `tasks …`, `resume`). Precedence: + `--config` > `RUSHTI_DIR` > legacy CWD > `config/`. The flag relocates only + `config.ini` — `settings.ini` (`--settings`) and `logging_config.ini` keep + their own resolution. A missing path fails fast (exit 1, no traceback). The + resolved path is threaded explicitly into the TM1 connection layer rather + than mutating a global. Enables sharing one read-only `config.ini` with other + tm1py utilities. No behavioural change when the flag is absent. See + `docs/adr/0003-config-ini-location-resolution.md`. + +## [2.3.0] - 2026-06-17 - **Added: TM1 chore execution as a first-class task kind** (closes #156). Mixed process + chore taskfiles are now supported across JSON, TXT, and @@ -29,13 +42,10 @@ All notable changes to RushTI are documented in this file. - Dashboard: the per-task "Process" column is replaced by a unified "Task target" column with a `[P]` / `[C]` kind indicator so process and chore rows render side-by-side. - -## Unreleased — docs: `--mode` for cube reads (#160) - - **Docs fix:** corrected the long-standing claim that `--mode` is - deprecated/ignored. It is only auto-detected (and ignored) for **file - sources** (`--tasks`). A **cube read** (`--tm1-instance`) cannot infer - the mode — every workflow occupies the same cube measures — so it + deprecated/ignored (#160). It is only auto-detected (and ignored) for + **file sources** (`--tasks`). A **cube read** (`--tm1-instance`) cannot + infer the mode — every workflow occupies the same cube measures — so it defaults to `norm` and silently drops the `predecessors` measure unless `--mode opt` is passed. This caused predecessors to disappear from cube-read execution plans (e.g. `Sample_Optimal_Mode`). Updated the CLI @@ -43,13 +53,21 @@ All notable changes to RushTI are documented in this file. (new "Choosing the mode for cube reads" section), getting-started task-files page, and the `rushti run --help` text. -## Unreleased — `feat/issue-154-v12-load-results` +## [2.2.3] - 2026-06-01 + +- Fix: `TM1Service` kwarg collision when connection parameters are set in + `config.ini` (#158). + +## [2.2.2] - 2026-05-20 - Fix: `rushti build` now installs a TM1-version-aware `}rushti.load.results` TI (closes #154). On v12 targets the body no longer references the removed `CubeGetLogChanges` / `CubeSetLogChanges` / `ExecuteCommand` functions; source-file cleanup uses the TM1-native `ASCIIDelete` instead of shelling out via `cmd /c del`. The v11 body is unchanged. + +## [2.2.1] - 2026-05-20 + - **Per-workflow `tm1_instance` setting** for results push and auto-load. Set it inside a JSON taskfile's `settings` block to override the `settings.ini` default per workflow. Resolution chain (highest wins): @@ -71,7 +89,7 @@ All notable changes to RushTI are documented in this file. `--tm1-instance` instead. The legacy flag is aliased and continues to work; a `DEPRECATION:` warning fires on use. -## Unreleased — `feat/issue-146-detailed-results` +## [2.2.0] - 2026-05-18 - Add `--detailed-results` for per-execution cube rows (closes #146). - Log migration hint at run start when `--detailed-results` is enabled. diff --git a/CONTEXT.md b/CONTEXT.md index 6930f29..c8930b5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -118,7 +118,34 @@ The effective value for a settings-driven knob is resolved in this order Three knobs are *not* settings-driven and don't follow this chain: - Per-task `instance` and `process` — taskfile-only, no fallback. -- TM1 connection parameters — `config.ini` only. +- TM1 connection parameters — `config.ini` only (the *values*; the file's + *location* is resolved separately, see below). + +--- + +## config.ini location resolution + +`config.ini` holds TM1 connection parameters only; its *location* is resolved +by `resolve_config_path("config.ini", cli_path=…)` (`app_paths.py`) in this +order (highest wins): + +1. **`--config` CLI flag** — explicit path to a `config.ini` file. Present on + every TM1-connecting command (`run`, `build`, `tasks …`, `resume`); not on + `stats`/`db`, which touch only local SQLite + settings.ini. A missing path + fails fast with a clean error, no traceback. +2. **`RUSHTI_DIR` env var** — looks in `{RUSHTI_DIR}/config/config.ini`. +3. **Legacy CWD** — `./config.ini` (deprecated, warns once). +4. **`config/config.ini`** — the recommended default location. + +`--config` relocates **only `config.ini`** — settings.ini keeps `--settings`, +logging_config.ini keeps its own resolution, and `RUSHTI_DIR` still governs +those siblings. The flag exists so RushTI can share one read-only `config.ini` +with other tm1py utilities (e.g. OptimusPy) instead of duplicating it. When +`--config` is absent, resolution is unchanged from prior behaviour. + +The resolved path is **threaded explicitly** as `config_path` into the TM1 +connection layer (`connect_to_tm1_instance`, `setup_tm1_services`); the +module-level `CONFIG` global in `cli.py` is now only the no-flag default. --- diff --git a/README.md b/README.md index f4fe872..527bbe8 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,11 @@ user = admin password = apple ``` +RushTI looks for `config.ini` under `config/` (or `RUSHTI_DIR`). To point a +single run at a different file — e.g. a `config.ini` shared with other tm1py +utilities — pass `--config PATH` to any TM1-connecting command (`run`, `build`, +`tasks`, `resume`). + **2. Create a task file** ```json diff --git a/docs/advanced/cli-reference.md b/docs/advanced/cli-reference.md index 6d235d8..39adf39 100644 --- a/docs/advanced/cli-reference.md +++ b/docs/advanced/cli-reference.md @@ -55,6 +55,7 @@ rushti --tasks FILE [options] # 'run' is the default command | `--retries` | `-r` | INT | `0` | Retry count for failed TI executions. Uses exponential backoff. | | `--result` | `-o` | PATH | *(empty)* | Output CSV path for execution summary. Omit to skip CSV creation. | | `--settings` | `-s` | PATH | auto | Path to `settings.ini`. Auto-discovered if omitted. | +| `--config` | | PATH | auto | Path to `config.ini` (TM1 connection parameters). Overrides `RUSHTI_DIR`/default location. Relocates **only** `config.ini`; `settings.ini` and `logging_config.ini` keep their own resolution. A missing path fails fast (exit 1, no traceback). | | `--mode` | `-m` | CHOICE | `norm` | Execution mode: `norm` or `opt`. **Ignored for file sources** (`--tasks`), where mode is auto-detected from file content. **Required for cube reads** (`--tm1-instance`) when the workflow uses explicit `predecessors` — pass `--mode opt`, otherwise the cube is read in `norm` mode and predecessors are dropped. See [TM1 integration → Choosing the mode for cube reads](../features/tm1-integration.md#choosing-the-mode-for-cube-reads). | | `--exclusive` | `-x` | FLAG | `false` | Enable exclusive mode. Waits for other RushTI sessions to finish. | | `--force` | `-f` | FLAG | `false` | Bypass exclusive mode checks and run immediately. | @@ -123,6 +124,7 @@ rushti resume --tasks FILE [options] # auto-finds checkpoint | `--resume-from` | | STR | *(none)* | Resume from a specific task ID, overriding checkpoint state. | | `--max-workers` | `-w` | INT | *(from settings)* | Maximum parallel workers. | | `--settings` | `-s` | PATH | auto | Path to `settings.ini`. | +| `--config` | | PATH | auto | Path to `config.ini` (TM1 connection parameters). Forwarded into the resumed run. | | `--force` | `-f` | FLAG | `false` | Force resume even if checkpoint does not match the current task file. | | `--log-level` | `-L` | CHOICE | `INFO` | Override log level. | @@ -164,6 +166,7 @@ rushti tasks export --tm1-instance tm1srv01 -W DailyETL --output daily.json | `--tm1-instance` | | STR | Read source from TM1 | | `--workflow` | `-W` | STR | Workflow in TM1 | | `--settings` | `-s` | PATH | Path to `settings.ini` | +| `--config` | | PATH | Path to `config.ini` (TM1 connection parameters). Overrides `RUSHTI_DIR`/default location. | --- @@ -183,6 +186,7 @@ rushti tasks expand --tasks template.json --output expanded.json | `--tm1-instance` | | STR | TM1 source instance | | `--workflow` | `-W` | STR | Workflow in TM1 | | `--settings` | `-s` | PATH | Path to `settings.ini` | +| `--config` | | PATH | Path to `config.ini` (TM1 connection parameters). Overrides `RUSHTI_DIR`/default location. | --- @@ -203,6 +207,7 @@ rushti tasks visualize --tasks daily-etl.json --output dag.html --show-parameter | `--tm1-instance` | | STR | TM1 source instance | | `--workflow` | `-W` | STR | Workflow in TM1 | | `--settings` | `-s` | PATH | Path to `settings.ini` | +| `--config` | | PATH | Path to `config.ini` (TM1 connection parameters). Overrides `RUSHTI_DIR`/default location. | --- @@ -224,6 +229,7 @@ rushti tasks validate --tasks daily-etl.json --json > validation.json | `--tm1-instance` | | STR | TM1 source instance | | `--workflow` | `-W` | STR | Workflow in TM1 | | `--settings` | `-s` | PATH | Path to `settings.ini` | +| `--config` | | PATH | Path to `config.ini` (TM1 connection parameters). Overrides `RUSHTI_DIR`/default location. | --- @@ -241,6 +247,7 @@ rushti tasks push --tasks daily-etl.json --tm1-instance tm1srv01 | `--tm1-instance` | | STR | TM1 instance to push the taskfile to. Context disambiguates the role — on `tasks push` this is the destination. | | `--target-tm1-instance` | | STR | **Deprecated alias** for `--tm1-instance`. Still works; emits a `DEPRECATION:` warning when used. | | `--settings` | `-s` | PATH | Path to `settings.ini` | +| `--config` | | PATH | Path to `config.ini` (TM1 connection parameters). Overrides `RUSHTI_DIR`/default location. | --- @@ -265,6 +272,7 @@ rushti stats list tasks --workflow daily-etl --limit 50 | `--workflow` | `-W` | STR | Workflow (*required*) | | `--limit` | `-n` | INT | Maximum items to show (default: 20) | | `--settings` | `-s` | PATH | Path to `settings.ini` | +| `--config` | | PATH | Path to `config.ini` (TM1 connection parameters). Overrides `RUSHTI_DIR`/default location. | --- @@ -283,6 +291,7 @@ rushti stats export --workflow daily-etl --run-id 20260115_103000 --output run.c | `--run-id` | `-r` | STR | Specific run ID to export (all runs if omitted) | | `--output` | `-o` | PATH | Output CSV file path (*required*) | | `--settings` | `-s` | PATH | Path to `settings.ini` | +| `--config` | | PATH | Path to `config.ini` (TM1 connection parameters). Overrides `RUSHTI_DIR`/default location. | --- @@ -301,6 +310,7 @@ rushti stats visualize --workflow daily-etl --runs 10 --output dashboard.html | `--runs` | `-n` | INT | Number of recent runs to display (default: 5) | | `--output` | `-o` | PATH | Output HTML file path (default: `visualizations/rushti_dashboard_.html`) | | `--settings` | `-s` | PATH | Path to `settings.ini` | +| `--config` | | PATH | Path to `config.ini` (TM1 connection parameters). Overrides `RUSHTI_DIR`/default location. | !!! info "DAG reflects the latest executed run" Both the dashboard and the DAG are sourced from the stats database — not from the live taskfile or TM1 cube definition. Editing a workflow definition without re-running it will not update either visualization. Re-run the workflow to refresh. @@ -328,6 +338,7 @@ rushti stats analyze --workflow daily-etl --report report.json --ewma-alpha 0.5 | `--runs` | `-n` | INT | Number of recent runs to analyze (default: 10) | | `--ewma-alpha` | | FLOAT | EWMA smoothing factor 0--1 (default: 0.3). Higher = more weight on recent runs. | | `--settings` | `-s` | PATH | Path to `settings.ini` | +| `--config` | | PATH | Path to `config.ini` (TM1 connection parameters). Overrides `RUSHTI_DIR`/default location. | --- @@ -374,6 +385,7 @@ rushti build --tm1-instance INSTANCE [options] | `--tm1-instance` | | STR | TM1 instance name from `config.ini` (*required*) | | `--force` | `-f` | FLAG | Delete and recreate existing objects | | `--settings` | `-s` | PATH | Path to `settings.ini` | +| `--config` | | PATH | Path to `config.ini` (TM1 connection parameters). Overrides `RUSHTI_DIR`/default location. | | `--log-level` | `-L` | CHOICE | Override log level | ### Examples diff --git a/docs/advanced/settings-reference.md b/docs/advanced/settings-reference.md index 5047560..d998c41 100644 --- a/docs/advanced/settings-reference.md +++ b/docs/advanced/settings-reference.md @@ -33,6 +33,22 @@ RushTI searches for `settings.ini` in the following order: If no file is found, built-in defaults are used for all settings. +### config.ini location + +`config.ini` (TM1 connection parameters) is resolved separately, with its own +precedence (highest wins): + +1. `--config` CLI flag (TM1-connecting commands: `run`, `build`, `tasks …`, `resume`) +2. `RUSHTI_DIR` environment variable (looks for `config/config.ini` under this directory) +3. `./config.ini` (current directory -- legacy location, deprecation warning emitted) +4. `./config/config.ini` (recommended location) + +`--config` relocates **only** `config.ini`; `settings.ini` keeps `--settings` +and `logging_config.ini` keeps its own resolution. Use it to share a single +read-only `config.ini` with other tm1py utilities. A missing `--config` path +fails fast (exit 1, no traceback). When `--config` is absent, resolution is +unchanged. + --- ## Configuration Sections diff --git a/src/rushti/app_paths.py b/src/rushti/app_paths.py index ec70c9e..184f02b 100644 --- a/src/rushti/app_paths.py +++ b/src/rushti/app_paths.py @@ -6,6 +6,7 @@ can be emitted once logging is initialized. """ +import argparse import logging import os @@ -15,6 +16,7 @@ "CURRENT_DIRECTORY", "resolve_config_path", "log_legacy_path_warnings", + "add_config_arg", ] @@ -73,6 +75,26 @@ def resolve_config_path(filename: str, warn_on_legacy: bool = True, cli_path: st return new_path +def add_config_arg(parser: argparse.ArgumentParser) -> None: + """Add the ``--config`` argument to a parser. + + Long-form only (``-c`` is taken by ``resume --checkpoint``). Relocates + only ``config.ini`` (TM1 connection parameters); ``settings.ini`` and + ``logging_config.ini`` keep their own resolution. The resolved value is + fed to :func:`resolve_config_path` as ``cli_path``. + """ + parser.add_argument( + "--config", + dest="config", + default=None, + metavar="FILE", + help=( + "Path to a config.ini file (TM1 connection parameters). " + "Overrides RUSHTI_DIR/default location." + ), + ) + + def log_legacy_path_warnings(logger: logging.Logger) -> None: """Log deprecation warnings for any legacy paths that were used. diff --git a/src/rushti/cli.py b/src/rushti/cli.py index baa998b..51b1b2e 100644 --- a/src/rushti/cli.py +++ b/src/rushti/cli.py @@ -19,6 +19,7 @@ from TM1py.Utils import integerize_version from rushti.app_paths import ( + add_config_arg, log_legacy_path_warnings, resolve_config_path, ) @@ -273,6 +274,9 @@ def create_argument_parser() -> argparse.ArgumentParser: # Add taskfile source arguments add_taskfile_source_args(parser, required=False, include_settings=True) + # Add --config (relocates config.ini only) + add_config_arg(parser) + parser.add_argument( "--max-workers", "-w", @@ -426,6 +430,7 @@ def parse_named_arguments(argv: list): "workflow": workflow, "log_level": args.log_level, "detailed_results": args.detailed_results, + "config": args.config, } return tasks_file_path, cli_args @@ -471,6 +476,7 @@ def parse_arguments(argv: list): "exclusive": None, "no_checkpoint": False, "log_level": None, # Not supported in positional style + "config": None, # Legacy positional style does not support --config } return tasks_file, cli_args @@ -626,6 +632,14 @@ def main() -> int: if resume_context: cli_args.update(resume_context) + # Resolve config.ini location, threading the resolved path explicitly into + # the TM1 connection layer (CLI --config > RUSHTI_DIR > legacy CWD > config/). + # Fail fast with a clean message (no traceback) on an explicit bad path. + try: + config_path = resolve_config_path("config.ini", cli_path=cli_args.get("config")) + except FileNotFoundError: + sys.exit(f"RushTI: --config file not found: {cli_args.get('config')}") + # Apply log level override if specified apply_log_level(cli_args.get("log_level")) @@ -645,7 +659,7 @@ def main() -> int: try: from rushti.tm1_integration import read_taskfile_from_tm1, connect_to_tm1_instance - tm1_source = connect_to_tm1_instance(tm1_instance, CONFIG) + tm1_source = connect_to_tm1_instance(tm1_instance, config_path) try: # Resolve execution mode for TM1 read (affects predecessor parsing) tm1_mode = "opt" if cli_args.get("execution_mode") == ExecutionMode.OPT else "norm" @@ -752,6 +766,7 @@ def main() -> int: tasks_file_path=tasks_file_path_for_services if not tm1_taskfile else None, workflow=workflow, exclusive=exclusive_mode, + config_path=config_path, tm1_instances=tm1_instances_needed, ) @@ -1106,7 +1121,7 @@ def main() -> int: ) tm1_upload = connect_to_tm1_instance( upload_instance, - CONFIG, + config_path, ) try: results_df = build_results_dataframe( diff --git a/src/rushti/commands/build.py b/src/rushti/commands/build.py index 7dc449b..d223a36 100644 --- a/src/rushti/commands/build.py +++ b/src/rushti/commands/build.py @@ -10,6 +10,7 @@ from TM1py import TM1Service +from rushti.app_paths import add_config_arg, resolve_config_path from rushti.logging_setup import add_log_level_arg, apply_log_level from rushti.settings import load_settings from rushti.tm1_build import build_logging_objects, get_build_status @@ -25,8 +26,6 @@ def run_build_command(argv: list) -> None: :param argv: Command line arguments """ - from rushti.cli import CONFIG - parser = argparse.ArgumentParser( prog=f"{APP_NAME} build", description="Create TM1 dimensions and cube for RushTI execution logging.", @@ -60,6 +59,7 @@ def run_build_command(argv: list) -> None: metavar="FILE", help="Path to settings.ini file", ) + add_config_arg(parser) add_log_level_arg(parser) args = parser.parse_args(argv[2:]) # Skip "rushti" and "build" @@ -68,16 +68,22 @@ def run_build_command(argv: list) -> None: # Load settings settings = load_settings(args.settings_file) + # Resolve config.ini location (CLI --config > RUSHTI_DIR > legacy CWD > config/) + try: + config_path = resolve_config_path("config.ini", cli_path=args.config) + except FileNotFoundError: + sys.exit(f"RushTI: --config file not found: {args.config}") + # Load config to get TM1 connection details - if not os.path.isfile(CONFIG): - print(f"Error: {CONFIG} does not exist") + if not os.path.isfile(config_path): + print(f"Error: {config_path} does not exist") sys.exit(1) config = configparser.ConfigParser() - config.read(CONFIG, encoding="utf-8") + config.read(config_path, encoding="utf-8") if args.tm1_instance not in config.sections(): - print(f"Error: Instance '{args.tm1_instance}' not found in {CONFIG}") + print(f"Error: Instance '{args.tm1_instance}' not found in {config_path}") print(f"Available instances: {', '.join(config.sections())}") sys.exit(1) diff --git a/src/rushti/commands/resume.py b/src/rushti/commands/resume.py index ed44db3..ee21e43 100644 --- a/src/rushti/commands/resume.py +++ b/src/rushti/commands/resume.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Optional +from rushti.app_paths import add_config_arg from rushti.checkpoint import find_checkpoint_for_taskfile, load_checkpoint from rushti.logging_setup import add_log_level_arg, apply_log_level from rushti.settings import load_settings @@ -86,6 +87,7 @@ def run_resume_command(argv: list) -> Optional[dict]: default=False, help="Force resume even if checkpoint doesn't match taskfile", ) + add_config_arg(parser) add_log_level_arg(parser) args = parser.parse_args(argv[2:]) @@ -200,6 +202,9 @@ def run_resume_command(argv: list) -> Optional[dict]: if args.settings_file: resume_argv.extend(["--settings", args.settings_file]) + if args.config: + resume_argv.extend(["--config", args.config]) + if args.force: resume_argv.append("--force") diff --git a/src/rushti/commands/tasks/__init__.py b/src/rushti/commands/tasks/__init__.py index 0d83ec7..32f64f8 100644 --- a/src/rushti/commands/tasks/__init__.py +++ b/src/rushti/commands/tasks/__init__.py @@ -9,8 +9,9 @@ """ import argparse +import sys -from rushti.app_paths import resolve_config_path +from rushti.app_paths import add_config_arg, resolve_config_path from rushti.commands.tasks.expand import handle_tasks_expand from rushti.commands.tasks.export import handle_tasks_export from rushti.commands.tasks.push import handle_tasks_push @@ -98,6 +99,7 @@ def run_tasks_command(argv: list) -> None: help="[TM1 sources only] 'norm' for wait-based sequencing, 'opt' for explicit predecessors. " "Ignored for file sources (auto-detected). Default: opt", ) + add_config_arg(export_parser) add_log_level_arg(export_parser) # --- push subcommand --- @@ -122,6 +124,7 @@ def run_tasks_command(argv: list) -> None: help="[TM1 sources only] 'norm' for wait-based sequencing, 'opt' for explicit predecessors. " "Ignored for file sources (auto-detected). Default: opt", ) + add_config_arg(push_parser) add_log_level_arg(push_parser) # --- expand subcommand --- @@ -155,6 +158,7 @@ def run_tasks_command(argv: list) -> None: help="[TM1 sources only] 'norm' for wait-based sequencing, 'opt' for explicit predecessors. " "Ignored for file sources (auto-detected). Default: opt", ) + add_config_arg(expand_parser) add_log_level_arg(expand_parser) # --- visualize subcommand --- @@ -188,6 +192,7 @@ def run_tasks_command(argv: list) -> None: help="[TM1 sources only] 'norm' for wait-based sequencing, 'opt' for explicit predecessors. " "Ignored for file sources (auto-detected). Default: opt", ) + add_config_arg(visualize_parser) add_log_level_arg(visualize_parser) # --- validate subcommand --- @@ -219,14 +224,18 @@ def run_tasks_command(argv: list) -> None: help="[TM1 sources only] 'norm' for wait-based sequencing, 'opt' for explicit predecessors. " "Ignored for file sources (auto-detected). Default: opt", ) + add_config_arg(validate_parser) add_log_level_arg(validate_parser) # Parse arguments (skip script name and 'tasks' command) args = parser.parse_args(argv[2:]) apply_log_level(args.log_level) - # Resolve config path - config_path = resolve_config_path("config.ini", cli_path=None) + # Resolve config.ini location (CLI --config > RUSHTI_DIR > legacy CWD > config/) + try: + config_path = resolve_config_path("config.ini", cli_path=args.config) + except FileNotFoundError: + sys.exit(f"RushTI: --config file not found: {args.config}") # Dispatch to appropriate handler based on subcommand if args.subcommand == "export": diff --git a/src/rushti/execution.py b/src/rushti/execution.py index 13622a1..11c4936 100644 --- a/src/rushti/execution.py +++ b/src/rushti/execution.py @@ -148,14 +148,12 @@ def setup_tm1_services( :param tasks_file_path: Path to the tasks file (can be None if tm1_instances provided) :param workflow: Workflow identifier for session context :param exclusive: Whether running in exclusive mode - :param config_path: Optional path to config.ini file (defaults to resolved CONFIG) + :param config_path: Path to config.ini file (resolved by the caller; required) :param tm1_instances: Optional set of TM1 instance names (used when reading from TM1) :return: Dictionary server_names and TM1py.TM1Service instances pairs """ if config_path is None: - from rushti.cli import CONFIG - - config_path = CONFIG + raise ValueError("config_path is required") config_file = config_path if not os.path.isfile(config_file): diff --git a/tests/unit/test_config_flag.py b/tests/unit/test_config_flag.py new file mode 100644 index 0000000..9c17ab6 --- /dev/null +++ b/tests/unit/test_config_flag.py @@ -0,0 +1,218 @@ +"""Tests for the ``--config`` CLI flag (issue #164). + +``--config PATH`` overrides the location of ``config.ini`` (TM1 connection +parameters) for a single invocation, on every TM1-connecting command +(``run``, ``build``, ``tasks …``, ``resume``). It does not exist on +``stats``/``db`` (no TM1 connection). + +What we test here (no TM1 required): +- ``add_config_arg`` parses ``--config`` (long-only, default None). +- ``run`` threads the flag through ``parse_arguments`` and fails fast on a + missing path. +- ``build`` resolves and uses the supplied path (clean exit on a bad path, + and the resolved path appears in the "instance not found" message). +- each ``tasks`` subcommand resolves the flag and forwards ``config_path`` + to its handler. +- ``resume`` forwards ``--config`` into the rebuilt argv so it survives the + hand-off to the ``run`` path. + +The precedence chain inside ``resolve_config_path`` itself +(``--config`` > ``RUSHTI_DIR`` > legacy CWD > ``config/``) is covered by +``test_config_resolution.py``/``test_cli_dispatch.py``. +""" + +import argparse +import sys + +import pytest + +from rushti import cli +from rushti.app_paths import add_config_arg +from rushti.commands.build import run_build_command +from rushti.commands.resume import run_resume_command +from rushti.commands.tasks import run_tasks_command + +# --------------------------------------------------------------------------- +# add_config_arg — the shared helper +# --------------------------------------------------------------------------- + + +class TestAddConfigArg: + def test_long_flag_parses_into_config_dest(self): + parser = argparse.ArgumentParser() + add_config_arg(parser) + args = parser.parse_args(["--config", "/some/config.ini"]) + assert args.config == "/some/config.ini" + + def test_default_is_none(self): + parser = argparse.ArgumentParser() + add_config_arg(parser) + args = parser.parse_args([]) + assert args.config is None + + def test_no_short_alias(self): + # -c must remain free (it's `resume --checkpoint`). + parser = argparse.ArgumentParser() + add_config_arg(parser) + with pytest.raises(SystemExit): + parser.parse_args(["-c", "/some/config.ini"]) + + +# --------------------------------------------------------------------------- +# run — parse threading + fail-fast +# --------------------------------------------------------------------------- + + +class TestRunConfigFlag: + def test_parse_arguments_threads_config(self, tmp_path): + taskfile = tmp_path / "tasks.json" + taskfile.write_text('{"version": "2.0", "tasks": []}') + cfg = tmp_path / "config.ini" + cfg.write_text("[tm1srv01]\n") + _, cli_args = cli.parse_arguments( + ["rushti", "--tasks", str(taskfile), "--config", str(cfg)] + ) + assert cli_args["config"] == str(cfg) + + def test_parse_arguments_config_absent_is_none(self, tmp_path): + taskfile = tmp_path / "tasks.json" + taskfile.write_text('{"version": "2.0", "tasks": []}') + _, cli_args = cli.parse_arguments(["rushti", "--tasks", str(taskfile)]) + assert cli_args["config"] is None + + def test_positional_style_has_config_none(self, tmp_path): + tasks_file = tmp_path / "tasks.txt" + tasks_file.write_text("instance=tm1srv01 process=}bedrock.server.wait\n") + _, cli_args = cli.parse_arguments(["rushti", str(tasks_file), "4"]) + assert cli_args["config"] is None + + def test_missing_config_path_exits_clean(self, tmp_path, monkeypatch): + taskfile = tmp_path / "tasks.json" + taskfile.write_text('{"version": "2.0", "tasks": []}') + bad = str(tmp_path / "nope.ini") + monkeypatch.setattr(sys, "argv", ["rushti", "--tasks", str(taskfile), "--config", bad]) + with pytest.raises(SystemExit) as exc_info: + cli.main() + # Clean string message (no traceback), and it names the bad path. + assert isinstance(exc_info.value.code, str) + assert bad in exc_info.value.code + + +# --------------------------------------------------------------------------- +# build +# --------------------------------------------------------------------------- + + +class TestBuildConfigFlag: + def test_missing_config_path_exits_clean(self, tmp_path): + bad = str(tmp_path / "nope.ini") + with pytest.raises(SystemExit) as exc_info: + run_build_command(["rushti", "build", "--tm1-instance", "tm1srv01", "--config", bad]) + assert isinstance(exc_info.value.code, str) + assert bad in exc_info.value.code + + def test_resolved_config_is_used(self, tmp_path, capsys): + # A real config.ini without the requested instance: build must report + # the supplied --config path, proving it read *our* file. + cfg = tmp_path / "shared-config.ini" + cfg.write_text("[tm1srv01]\naddress = localhost\n") + with pytest.raises(SystemExit): + run_build_command( + ["rushti", "build", "--tm1-instance", "MISSING", "--config", str(cfg)] + ) + out = capsys.readouterr().out + assert str(cfg) in out + assert "MISSING" in out + + +# --------------------------------------------------------------------------- +# tasks — flag present on every subcommand +# --------------------------------------------------------------------------- + + +TASKS_SUBCOMMANDS = ["export", "push", "expand", "visualize", "validate"] + + +class TestTasksConfigFlag: + @pytest.mark.parametrize("subcommand", TASKS_SUBCOMMANDS) + def test_subcommand_forwards_config_to_handler(self, subcommand, tmp_path, monkeypatch): + cfg = tmp_path / "config.ini" + cfg.write_text("[tm1srv01]\n") + taskfile = tmp_path / "t.json" + taskfile.write_text('{"version":"2.0","tasks":[]}') + output = tmp_path / "out.json" + + captured = {} + + def fake_handler(args, config_path): + captured["config_path"] = config_path + + monkeypatch.setattr(f"rushti.commands.tasks.handle_tasks_{subcommand}", fake_handler) + + argv = ["rushti", "tasks", subcommand, "--tasks", str(taskfile)] + # export/expand/visualize require --output + if subcommand in ("export", "expand", "visualize"): + argv += ["--output", str(output)] + argv += ["--config", str(cfg)] + + run_tasks_command(argv) + assert captured["config_path"] == str(cfg) + + def test_missing_config_path_exits_clean(self, tmp_path): + bad = str(tmp_path / "nope.ini") + taskfile = tmp_path / "t.json" + taskfile.write_text('{"version":"2.0","tasks":[]}') + with pytest.raises(SystemExit) as exc_info: + run_tasks_command( + ["rushti", "tasks", "validate", "--tasks", str(taskfile), "--config", bad] + ) + assert isinstance(exc_info.value.code, str) + assert bad in exc_info.value.code + + +# --------------------------------------------------------------------------- +# resume — flag survives the argv rebuild into the run path +# --------------------------------------------------------------------------- + + +class TestResumeConfigFlag: + def test_config_forwarded_into_rebuilt_argv(self, tmp_path, monkeypatch): + from rushti.checkpoint import Checkpoint, save_checkpoint + + taskfile = tmp_path / "tasks.json" + taskfile.write_text('{"version": "2.0", "tasks": []}') + cfg = tmp_path / "shared-config.ini" + cfg.write_text("[tm1srv01]\n") + + checkpoint = Checkpoint.create( + taskfile_path=str(taskfile), + workflow="resume-fixture", + task_ids=["1"], + ) + checkpoint_file = tmp_path / "checkpoint.json" + save_checkpoint(checkpoint, str(checkpoint_file)) + + monkeypatch.setattr(sys, "argv", ["rushti"]) # restored by monkeypatch + context = run_resume_command( + [ + "rushti", + "resume", + "--checkpoint", + str(checkpoint_file), + "--tasks", + str(taskfile), + "--force", + "--config", + str(cfg), + ] + ) + + # run_resume_command sets sys.argv for main() to re-parse. + assert context["resume"] is True + assert "--config" in sys.argv + idx = sys.argv.index("--config") + assert sys.argv[idx + 1] == str(cfg) + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))