diff --git a/AGENTS.md b/AGENTS.md index 88753c6..1a264a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,10 +17,14 @@ The default config file is `.org-cli.yaml` in the current working directory. Top-level sections: -- `defaults` - default values for built-in CLI options (for example `--filter-priority`, `--order-by-priority`, `--buckets`) +- shared top-level option defaults such as `todo_states`, `done_states`, `mapping`, `exclude`, filters, ordering flags, and color behavior - `filter` - custom `--filter-` query snippets - `order-by` - custom `--order-by-` query snippets - `with` - custom `--with-` query snippets +- `tasks` - task-command defaults and capture templates +- `stats` - stats subcommand defaults +- `agenda` - agenda defaults and named views +- `board` - board defaults and named views ## Development Setup Several helpful Poetry tasks are defined in `pyproject.toml` that automate running mundane tasks. These tasks should be used whenever possible instead of running individual commands. diff --git a/README.md b/README.md index cf7b6b6..600e947 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,14 @@ This installs the `org` command. Top-level config sections: -- `defaults` for built-in option defaults +- shared top-level option defaults such as `todo_states`, `done_states`, `mapping`, `exclude`, filters, ordering flags, and color behavior - `filter` for custom `--filter-` query snippets - `order-by` for custom `--order-by-` query snippets - `with` for custom `--with-` query snippets -- `capture` for named task capture templates +- `tasks` for task-command defaults and named capture templates +- `stats` for stats subcommand defaults - `agenda` for named agenda views -- `board` for named board views +- `board` for board defaults and named board views See [docs/index.md](docs/index.md) for the full config layout. diff --git a/VIBES.org b/VIBES.org index baa28d9..a843621 100644 --- a/VIBES.org +++ b/VIBES.org @@ -8154,32 +8154,11 @@ Introduce a new package =org.query= and move the query-execution-related helpers - Current =org.query_language= package should be renamed to =org.query.engine= - The query compiler (=org.query.engine.compiler=) and execution engine (=org.query.engine.interpreter=) should be separated, with all the operator/function definitions also separated to their own module =org.query.engine.defs= -*** Streamline query execution -- One way to run a query used by all commands. -- Error handling left to the caller -- Runner accepts a list of files, context and a set of query stages that are all piped together, it performs query building, compilation and execution. - -*** TODO Streamline data processing. -:PROPERTIES: -:capture_time: [2026-06-12 Fri 23:54] -:context: -:END: - -The commands use a common ETL pipeline where they load some data from files, apply filters/ordering/mutators and lastly present the results in some way. -It would be nice to make this as streamlined as possible, ideally, =org.logic.query= would expose a function =run_query= that takes a list of documents, a textlist of query stages to run and the environment for the query to run in, and compiles and executes the query and returns the result. -That streamlined query execution should be used in all places that extract or transform the org document data - the data should be loaded, then processed with the =run_query= function and used as needed. -*** TODO Org file repository abstraction -:PROPERTIES: -:capture_time: [2026-06-13 Sat 00:12] -:context: +*** REWORKED Load configuration first and pasrs the object to the commands +:LOGBOOK: +- State "REWORKED" from "TODO" [2026-06-22 Mon 19:26] \\ + Needed a lot of massaging. :END: - -Add a new package =org.db= that will store the org file repository abstraction. -In that package, add a repository-pattern abstraction over one or more org files. The abstraction should automatically load all the files, maintain their modification state and automatically perform saving of the modified files whenever they change. -The abstraction should also support querying the repository using the query language. - -All commands should be updated to instantiate this repository and use its querying capabilities for all of their querying needs. -*** TODO Load configuration first and pass the object to the commands Do not use global state for configuration storage. The goal is for the commands to be stateless. The config processing flow should look like this: @@ -8223,6 +8202,667 @@ config: Update the commands to use this new structured configuration object (prefer the command-specific sections for the value, not =config.defaults=). Update the tests not to rely on the global state. +**** DONE src/org/cli.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-21 Sun 13:18] +:END: + +In file src/org/cli.py around line 70, near the ~from org.commands import board as board_command~ + +#+begin_example ++ ++app = build_app() + +#+end_example + +Don't leave compatibility layers for tests. This should be removed and the tests that import =app= directly should be updated to build the app directly. +**** DONE src/org/config/app.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-21 Sun 13:18] +:END: + +In file src/org/config/app.py around line 1960, near the ~def load_cli_config(argv: list[str]) -> LoadedCliConfig:~ + +#+begin_example + def build_default_map(defaults: dict[str, object]) -> CliDefaultMap: +- """Build Click default_map for Typer commands.""" ++ """Compatibility helper for tests that still exercise default-map shaping.""" + +#+end_example + +Don't define test compatibility layers, refactor the tests not to need that. + +**** DONE src/org/config/app.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-21 Sun 13:19] +:END: + +In file src/org/config/app.py around line 84, near the ~COMMAND_OPTION_NAMES = {~ + +#+begin_example ++MAP: dict[str, str] = {} ++ ++# Transitional compatibility for tests and non-command helper calls. + CONFIG_CUSTOM_FILTERS: dict[str, str] = {} + CONFIG_CUSTOM_ORDER_BY: dict[str, str] = {} + +#+end_example + +Remove all of this global state, don't leave any compatibility state for tests. Refactor tests not to need that. +**** DONE src/org/commands/agenda/views.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-21 Sun 18:14] +:END: + +In file src/org/commands/agenda/views.py around line 85, near the ~def _compile_view_section_specs(view: org.config.app.AgendaViewConfig) -> list[A~ + +#+begin_example ++ configured_views = org.config.app.current_app_config().agenda.views + +#+end_example + +Config should be threded along all the functions that require it. Don't extract the global context everywhere. The only place that should extract the config is the main command function only, the rest should pass the config along. +**** DONE src/org/commands/stats/command.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-21 Sun 18:14] +:END: + +In file src/org/commands/stats/command.py around line 41, near the ~def stats_callback(~ + +#+begin_example ++ app_config = ctx.find_root().obj + +#+end_example + +This should use the helper for extracting config found in =org.config.app=. +**** DONE src/org/commands/tasks/archive.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-21 Sun 18:14] +:END: + +In file src/org/commands/tasks/archive.py around line 141, near the ~def register(app: typer.Typer) -> None:~ + +#+begin_example ++ app_config = org.config.app.require_app_config(click.get_current_context()) + +#+end_example + +Context should be passed to this command exactly the same way as for all the other commands. Don't use =click= to extract the global context. This should apply to all the commands - use the same pattern - extract the config from the typer context in the main command function and pass the config along to command execution functions. + +**** DONE src/org/commands/tasks/capture/command.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-21 Sun 20:56] +:END: + +In file src/org/commands/tasks/capture/command.py around line 77, near the ~def capture_task(~ + +#+begin_example +-def run_tasks_capture(args: TasksCaptureArgs) -> None: + +#+end_example + +It makes more sense to pass =AppConfig= here instead of just the templates. All command execution functions should receive command-specific arguments and the =AppConfig= object. +**** REWORKED src/org/config/app.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "REWORKED" from "TODO" [2026-06-21 Sun 20:56] \\ + Needs rework. +:END: + +In file src/org/config/app.py around line 1556, near the ~def build_config_defaults(~ + +#+begin_example ++def _apply_default_dest(config: AppConfig, dest: str, value: object) -> None: ++ """Apply one validated config destination value onto AppConfig.""" ++ top_level_attrs = { ++ "color_flag", ++ "verbose", ++ "filter_priority", ++ "filter_level", ++ "filter_repeats_above", ++ "filter_repeats_below", ++ "filter_date_from", ++ "filter_date_until", ++ "filter_completed", ++ "filter_not_completed", ++ "order_by_file_order", ++ "order_by_file_order_reversed", ++ "order_by_priority", ++ "order_by_level", ++ "order_by_timestamp_asc", ++ "order_by_timestamp_desc", ++ "with_tags_as_category", ++ } ++ agenda_attrs = {"date", "no_completed", "no_overdue", "no_upcoming", "future_repeats"} ++ stats_attrs = { ++ "mapping", ++ "exclude", ++ "mapping_inline", ++ "exclude_inline", ++ "max_tags", ++ "max_relations", ++ "min_group_size", ++ "max_groups", ++ "use", ++ } + +#+end_example + +This should be done in the respective command's implementation instead. There's no need to handle the config generically for all commands. +Ideally, each command's config object (=AgendaConfig=, etc) should be used in the main command function to populate the defaults in command args object (=AgendaArgs=). The command should retrieve the config object passed to it, then apply config defaults, then apply CLI overrides and lastly thread those updated objects through all the command execution functions that require either. + +**** DONE src/org/commands/agenda/command.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-21 Sun 20:56] +:END: + +In file src/org/commands/agenda/command.py around line 403, near the ~def register(app: typer.Typer) -> None:~ + +#+begin_example ++ org.config.app.apply_config_defaults(args, app_config, sys.argv[1:]) + +#+end_example + +Sounds like this should be a local function, as each command will have a distinct way of populating the defaults. Refactor so that each command has a custom way to apply defaults on top of config passed to the command. +**** DONE src/org/commandds/agenda/command.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-21 Sun 22:20] +:END: + +In file src/org/commands/agenda/command.py around line 392, near the ~def register(app: typer.Typer) -> None:~ + +#+begin_example + app_config = org.config.app.require_app_config(ctx) +- org.config.app.apply_config_defaults(args, app_config, sys.argv[1:]) +- org.logging.log_applied_config_defaults(app_config, args, "agenda") ++ argv = sys.argv[1:] ++ applied_defaults: dict[str, object] = {} ++ for dest, value in ( ++ ("exclude", app_config.exclude), ++ ("exclude_inline", app_config.exclude_inline), ++ ("mapping", app_config.mapping), ++ ("mapping_inline", app_config.mapping_inline), ++ ("todo_states", ",".join(app_config.todo_states)), ++ ("done_states", ",".join(app_config.done_states)), ++ ("filter_priority", app_config.filter_priority), ++ ("filter_level", app_config.filter_level), ++ ("filter_repeats_above", app_config.filter_repeats_above), ++ ("filter_repeats_below", app_config.filter_repeats_below), ++ ("filter_date_from", app_config.filter_date_from), ++ ("filter_date_until", app_config.filter_date_until), ++ ("filter_properties", app_config.filter_properties), ++ ("filter_tags", app_config.filter_tags), ++ ("filter_headings", app_config.filter_headings), ++ ("filter_bodies", app_config.filter_bodies), ++ ("filter_completed", app_config.filter_completed), ++ ("filter_not_completed", app_config.filter_not_completed), ++ ("color_flag", app_config.color_flag), ++ ("order_by_file_order", app_config.order_by_file_order), ++ ("order_by_file_order_reversed", app_config.order_by_file_order_reversed), ++ ("order_by_priority", app_config.order_by_priority), ++ ("order_by_level", app_config.order_by_level), ++ ("order_by_timestamp_asc", app_config.order_by_timestamp_asc), ++ ("order_by_timestamp_desc", app_config.order_by_timestamp_desc), ++ ("with_tags_as_category", app_config.with_tags_as_category), ++ ("date", app_config.agenda.date), ++ ("days", app_config.agenda.days), ++ ("no_completed", app_config.agenda.no_completed), ++ ("no_overdue", app_config.agenda.no_overdue), ++ ("no_upcoming", app_config.agenda.no_upcoming), ++ ("future_repeats", app_config.agenda.future_repeats), ++ ("view", app_config.agenda.view), ++ ("max_results", app_config.agenda.max_results), ++ ("offset", app_config.agenda.offset), ++ ("width", app_config.agenda.width), ++ ): ++ org.config.app.apply_arg_default(args, argv, applied_defaults, dest, value) ++ org.logging.log_applied_config_defaults(applied_defaults, "agenda") + +#+end_example + +This is poor style. You already have =args= available along with all command arguments (=typer.Option= instances). You should extract the config from context first, then instantiate the options using defaults from the config object when provided. +**** DONE src/org/commands/tasks/edit.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-21 Sun 22:23] +:END: + +In file src/org/commands/tasks/edit.py around line 111, near the ~def register(app: typer.Typer) -> None:~ + +#+begin_example ++ applied_defaults: dict[str, object] = {} ++ org.logging.log_applied_config_defaults(applied_defaults, "tasks edit") + +#+end_example + +This now makes no sense. Log the config object passed to the commands and their final args object. +**** DONE src/org/config/app.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-21 Sun 22:23] +:END: + +In file src/org/config/app.py around line 878, near the ~def parse_config_sections(~ + +#+begin_example ++ """Legacy defaults-based config parsing is no longer supported.""" ++ del raw_config ++ return None + +#+end_example + +... Then remove the function together with the associated tests. + +**** CANCELLED src/org/commands/tasks/update.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "CANCELLED" from "TODO" [2026-06-22 Mon 00:18] \\ + No longer needed. +:END: + +In file src/org/commands/tasks/update.py around line 660, near the ~def register(app: typer.Typer) -> None:~ + +#+begin_example ++ app_config = org.config.app.require_app_config(ctx) ++ argv = sys.argv[1:] + +#+end_example + +It makes more sense to extract =sys.argv= in the implementation of =resolve_cli_value=. No reason to extract it here and pass it to the function. + +**** DONE src/org/config/app.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-22 Mon 10:44] +:END: + +In file src/org/config/app.py around line 1494, near the ~def load_cli_config(argv: list[str]) -> AppConfig:~ + +#+begin_example ++def require_app_config(ctx: object) -> AppConfig: ++ """Return AppConfig stored in the current root context.""" ++ root_context = cast("ContextWithRoot", ctx).find_root() ++ app_config = root_context.obj ++ if not isinstance(app_config, AppConfig): ++ raise typer.BadParameter("Application config is not available") + +#+end_example + +The argument should be a =typer.Context=, please clean that up. +**** DONE src/org/config/app.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-22 Mon 00:48] +:END: + +In file src/org/config/app.py around line 1299, near the ~def build_config_defaults(~ + +#+begin_example ++def build_default_app_config(config_path: str = ".org-cli.yaml") -> AppConfig: ++ """Build application config populated with code defaults.""" ++ return AppConfig( ++ config_path=config_path, ++ color_flag=None, ++ verbose=False, ++ todo_states=["TODO"], ++ done_states=["DONE"], ++ filter_priority=None, ++ filter_level=None, ++ filter_repeats_above=None, ++ filter_repeats_below=None, ++ filter_date_from=None, ++ filter_date_until=None, ++ filter_properties=None, ++ filter_tags=None, ++ filter_headings=None, ++ filter_bodies=None, ++ filter_completed=False, ++ filter_not_completed=False, ++ order_by_file_order=False, ++ order_by_file_order_reversed=False, ++ order_by_priority=False, ++ order_by_level=False, ++ order_by_timestamp_asc=False, ++ order_by_timestamp_desc=False, ++ with_tags_as_category=False, ++ filters=[], ++ orderings=[], ++ mutators=[], ++ stats=StatsConfig(), ++ tasks=TasksConfig(), ++ capture=CaptureConfig(templates={}), ++ agenda=AgendaConfig(views={}), ++ board=BoardConfig(views={}), + +#+end_example + +It would be nice if these defaults could be in the class definitions themselves. This would then just return an =AppConfig(config_path = config_path)=. +**** DONE src/org/config/app.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-22 Mon 00:38] +:END: + +In file src/org/config/app.py around line 1477, near the ~def load_cli_config(argv: list[str]) -> AppConfig:~ + +#+begin_example ++ app_config = build_default_app_config(str(config_path)) ++ for dest, value in {**defaults, **stats_defaults}.items(): ++ if dest not in COMMAND_OPTION_NAMES and dest not in {"mapping_inline", "exclude_inline"}: ++ continue ++ _apply_default_dest(app_config, dest, value) ++ ++ for dest, values in append_defaults.items(): ++ _apply_list_dest(app_config, dest, values) ++ ++ app_config.filters = _named_query_list(custom_filters) ++ app_config.orderings = _named_query_list(custom_order_by) ++ app_config.mutators = _named_query_list(custom_with) ++ app_config.capture = CaptureConfig(templates=capture_templates) ++ app_config.board = BoardConfig( ++ views=board_views, ++ view=app_config.board.view, ++ days=app_config.board.days, ++ max_results=app_config.board.max_results, ++ offset=app_config.board.offset, ++ width=app_config.board.width, ++ ) ++ app_config.agenda = AgendaConfig( ++ views=agenda_views, ++ date=app_config.agenda.date, ++ days=app_config.agenda.days, ++ no_completed=app_config.agenda.no_completed, ++ no_overdue=app_config.agenda.no_overdue, ++ no_upcoming=app_config.agenda.no_upcoming, ++ future_repeats=app_config.agenda.future_repeats, ++ view=app_config.agenda.view, ++ max_results=app_config.agenda.max_results, ++ offset=app_config.agenda.offset, ++ width=app_config.agenda.width, ++ ) + +#+end_example + +Looks like there's a lot of unnecessary copying here. Please refactor to just populate the values that need to be changed. +**** DONE src/org/config/app.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-22 Mon 00:38] +:END: + +In file src/org/config/app.py around line 1627, near the ~def load_cli_config(argv: list[str]) -> AppConfig:~ + +#+begin_example ++def command_config_values(config: AppConfig) -> dict[str, object]: ++ """Flatten AppConfig into command argument destination values.""" ++ values: dict[str, object] = { ++ "color_flag": config.color_flag, ++ "todo_states": ",".join(config.todo_states), ++ "done_states": ",".join(config.done_states), ++ "filter_priority": config.filter_priority, ++ "filter_level": config.filter_level, ++ "filter_repeats_above": config.filter_repeats_above, ++ "filter_repeats_below": config.filter_repeats_below, ++ "filter_date_from": config.filter_date_from, ++ "filter_date_until": config.filter_date_until, ++ "filter_properties": config.filter_properties, ++ "filter_tags": config.filter_tags, ++ "filter_headings": config.filter_headings, ++ "filter_bodies": config.filter_bodies, ++ "filter_completed": config.filter_completed, ++ "filter_not_completed": config.filter_not_completed, ++ "order_by_file_order": config.order_by_file_order, ++ "order_by_file_order_reversed": config.order_by_file_order_reversed, ++ "order_by_priority": config.order_by_priority, ++ "order_by_level": config.order_by_level, ++ "order_by_timestamp_asc": config.order_by_timestamp_asc, ++ "order_by_timestamp_desc": config.order_by_timestamp_desc, ++ "with_tags_as_category": config.with_tags_as_category, ++ "exclude": config.stats.exclude, ++ "exclude_inline": config.stats.exclude_inline, ++ "mapping": config.stats.mapping, ++ "mapping_inline": config.stats.mapping_inline, ++ "max_results": config.tasks.max_results, ++ "max_tags": config.stats.max_tags, ++ "max_relations": config.stats.max_relations, ++ "min_group_size": config.stats.min_group_size, ++ "max_groups": config.stats.max_groups, ++ "use": config.stats.use, ++ "tags": config.stats.tags, ++ "groups": config.stats.groups, ++ "details": config.tasks.details, ++ "out": config.tasks.out, ++ "out_theme": config.tasks.out_theme, ++ "pandoc_args": config.tasks.pandoc_args, ++ "date": config.agenda.date, ++ "days": config.agenda.days, ++ "no_completed": config.agenda.no_completed, ++ "no_overdue": config.agenda.no_overdue, ++ "no_upcoming": config.agenda.no_upcoming, ++ "future_repeats": config.agenda.future_repeats, ++ "view": config.board.view if config.board.view is not None else config.agenda.view, ++ "offset": config.board.offset if config.board.offset is not None else config.agenda.offset, ++ "width": config.board.width if config.board.width is not None else config.agenda.width, + +#+end_example + +This looks like it's redundant. +**** DONE src/org/commands/tasks/common.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-22 Mon 08:36] +:END: + +In file src/org/commands/tasks/common.py around line 222, near the ~def capture_template_names(templates: dict[str, dict[str, str]]) -> list[str]:~ + +#+begin_example +-def configured_capture_template_names() -> list[str]: ++def configured_capture_template_names(templates: dict[str, dict[str, str]]) -> list[str]: + """Return stable capture template names from loaded config.""" +- return capture_template_names(org.config.app.CONFIG_CAPTURE_TEMPLATES) + +#+end_example + +This function no longer makes sense. Use =capture_template_names()= instead in all callsites. Remove this function entirely. +**** DONE src/org/commands/tasks/capture/command.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-22 Mon 08:36] +:END: + +In file src/org/commands/tasks/capture/command.py around line 145, near the ~def register(app: typer.Typer) -> None:~ + +#+begin_example ++ _ = org.config.app.current_app_config() + +#+end_example + +This makes no sense. +**** DONE tests/cli_common/test_filter_construction.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-22 Mon 08:36] +:END: + +In file tests/cli_common/test_filter_construction.py around line 436, near the ~def test_build_query_text_preserves_mixed_ordering_cli_order(~ + +#+begin_example +- monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) ++ del monkeypatch + +#+end_example + +Don't use =del=, remove the unused arguments altogether from all the test functions that do that. + +**** DONE tests/commands/tasks/test_tasks_list_app.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-22 Mon 08:36] +:END: + +In file tests/commands/tasks/test_tasks_list_app.py around line 274, near the ~def test_run_tasks_list_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPa~ + +#+begin_example + assert args.files == [] ++ assert isinstance(config, org.config.app.AppConfig) + +#+end_example + +This makes no sense. +**** DONE tests/commands/test_tasks_capture_command.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-22 Mon 08:37] +:END: + +In file tests/commands/test_tasks_capture_command.py around line 53, near the ~def _make_capture_args(~ + +#+begin_example + def _set_capture_templates(templates: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]: +- """Replace global capture templates and return previous value.""" +- previous = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) +- org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() +- org.config.app.CONFIG_CAPTURE_TEMPLATES.update(templates) +- return previous ++ """Return one template map for explicit test passing.""" ++ return templates + + +-def _restore_capture_templates(previous: dict[str, dict[str, str]]) -> None: +- """Restore global capture templates from previous value.""" +- org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() +- org.config.app.CONFIG_CAPTURE_TEMPLATES.update(previous) ++def _restore_capture_templates(_previous: dict[str, dict[str, str]]) -> None: ++ """No-op symmetry helper for explicit-template tests.""" ++ return + +#+end_example + +Sounds like these two are no longer needed, clean this up. + +**** DONE src/org/commands/tasks/find.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-22 Mon 08:37] +:END: + +In file src/org/commands/tasks/find.py around line 287, near the ~def register(app: typer.Typer) -> None:~ + +#+begin_example ++ resolve_cli_value = org.config.app.resolve_cli_value + +#+end_example + +Don't do this. Import the function directly. + +**** DONE tests/cli/test_cli_runner.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-22 Mon 12:30] +:END: + +In file tests/cli/test_cli_runner.py around line 25, near the ~if TYPE_CHECKING:~ + +#+begin_example +-app = cli.build_app(org.config.app.build_default_app_config()) ++app = cli.build_app(org.config.app.AppConfig(config_path=".org-cli.yaml")) + +#+end_example + +Tests should not rely on the repo config. +**** DONE Config section for tasks sub-commands +:PROPERTIES: +:capture_time: [2026-06-21 Sun 21:06] +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-22 Mon 19:24] +:END: + +Add a top-level config section for tasks commands, name it =tasks=. Move the current top-level =capture= config into =tasks.capture=. Define a new section for the =tasks.list= that will contain all of its configuration & defaults. +*** TODO Streamline query execution +The commands use a common ETL pipeline where they load some data from files, apply filters/ordering/mutators and lastly present the results in some way. Sometimes the documents are then saved. +It would be nice to make query execution as streamlined as possible, ideally, =org.logic.query= would expose a function =run_query= that takes a list of documents, a list of query stages to run and the context for the query to run in, and compiles and executes the query and returns the result. +That streamlined query execution should be used in all places that extract or transform the org document data - the data should be loaded, then processed with the =run_query= function and used as needed. +Error handling should be left to the caller (=run_query= should not accept customizable error types/builders). + +*** TODO Org file repository abstraction +:PROPERTIES: +:capture_time: [2026-06-13 Sat 00:12] +:context: +:END: + +Add a new package =org.db= that will store the org file repository abstraction. +In that package, add a repository-pattern abstraction over one or more org files. The abstraction should: +- automatically load all the documents from files maintaining their last modification time, +- watch for external file changes and re-load a document as needed (file modification time more recent than currently stored one) +- allow explicitly reloading a document from file, +- allow saving the documents explicitly (dump file contents, reload and update the modification state), +- allow overwriting potentially externally modified document contents without generating errors, +- allow querying the repository using the query language: =repository.query(query_stages, context)=. +- cache discovered todo and done states for other commands, + +The abstraction should handle file conflicts with explicit errors (attempting to save a file that was modified outside of the CLI, file no longer exists, etc). These errors will be then handled by the commands in various ways (interactive commands should display a prompt asking for confirmation about overwriting or reloading and reapplying an action; non-interactive commands should print the error to the output). + +All commands should be updated to instantiate this repository and use its querying capabilities for all of their querying needs. +All commands should be updated to handle the file errors more or less gracefully. *** CANCELLED Use Textual header & footer widgets in the interactive commands :LOGBOOK: @@ -8783,6 +9423,10 @@ Split config handling to be per-command and have the config file reflect that. :END: After loading the config defaults, these should be populated in their respective config object sections. +*** Revise and reorganize command switches +- There are some CLI switches attached to all the commands that don't make any sense or them, such as: =exclude=, =mapping=, etc. These should be only available as switches & configuration for the commands that actually need them. +- If any switches are shared by all the commands at some level, the config values should be moved up the documentation tree (i.e. =todo_states= in top-level). + ** Dashboard command :Dashboard: - Reads a dashboard config and shows a visualization on a server. - Tabbed views for the agenda, board, backlog and stats diff --git a/docs/agenda_command.md b/docs/agenda_command.md index dd18fb9..f009646 100644 --- a/docs/agenda_command.md +++ b/docs/agenda_command.md @@ -82,6 +82,48 @@ Every interactive edit is saved immediately and logged through the standard `org - `--offset` - Skip first N processed tasks. - `--width` - Override console width (minimum: `50`). +## Configuration + +Command-specific defaults live under `agenda` in `.org-cli.yaml`. + +All available options: + +```yaml +agenda: + views: + - name: default + sections: + - name: Active work + filter: '.todo != null and not(.is_completed)' + order-by: '.scheduled or .deadline' + style: yellow + timeline: true + - name: Done today + filter: '.is_completed' + order-by: '.closed' + date: 2025-01-15 + days: 1 + no_completed: false + no_overdue: false + no_upcoming: false + future_repeats: false + view: default + max_results: 100 + offset: 0 + width: 100 +``` + +`agenda.views` is optional. When present, each view can define named `sections` with: + +- `name` - Section heading text. +- `filter` - Required query predicate used to select tasks for the section. +- `order-by` - Optional sort expression applied within the section. +- `style` - Optional Rich style string for the rendered section heading. +- `timeline` - Optional boolean that enables timeline-style rendering for the section. + +Shared top-level config still applies for filters, ordering flags, `with_tags_as_category`, +`mapping`, `exclude`, `todo_states`, `done_states`, and `color_flag`. + ## Available filters - `--filter-priority P` - Keep only tasks with priority equal to `P`. diff --git a/docs/board_command.md b/docs/board_command.md index 147ca93..231cfec 100644 --- a/docs/board_command.md +++ b/docs/board_command.md @@ -78,11 +78,35 @@ Every interactive edit is saved immediately and logged through the standard `org - `--days N` - Show completed tasks only when `.latest_timestamp` is within last `N` days (default: `7`). - `--width` - Override console width (minimum: `80`). +## Configuration + +Command-specific defaults live under `board` in `.org-cli.yaml`. + +All available options: + +```yaml +board: + views: + - name: kanban + columns: + - name: Backlog + filter: ".todo == null" + order-by: ".priority" + view: kanban + days: 7 + max_results: 100 + offset: 0 + width: 120 +``` + +Shared top-level config still applies for filters, ordering flags, `with_tags_as_category`, +`mapping`, `exclude`, `todo_states`, `done_states`, and `color_flag`. + View selection behavior: - If `--view` is set, that configured view must exist. - If `--view` is set but no board views are configured, the command fails with an explicit error. -- If `--view` is omitted, config defaults may provide `defaults: --view: `. +- If `--view` is omitted, `board.view` in config may select the default configured view. - If neither explicit nor default view is set, fallback columns are used. Filter errors: diff --git a/docs/index.md b/docs/index.md index fd32fe3..7ef9b81 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,15 +11,18 @@ - `org stats tags` - Focused stats for selected/top tags. See [stats_tags_command.md](stats_tags_command.md). - `org stats groups` - Stats for explicit or discovered tag groups. See [stats_groups_command.md](stats_groups_command.md). - `org tasks list` - Short or detailed task listing with ordering. See [tasks_list_command.md](tasks_list_command.md). +- `org tasks find` - Find tasks by selectors and full-text search. See [tasks_find_command.md](tasks_find_command.md). - `org board` - Interactive workflow board view of active tasks. See [board_command.md](board_command.md). - `org tasks add` - Create new task headings in Org files. See [tasks_add_command.md](tasks_add_command.md). +- `org tasks archive` - Archive matched task subtrees. See [tasks_archive_command.md](tasks_archive_command.md). +- `org tasks edit` - Edit one matched task subtree in an external editor. See [tasks_edit_command.md](tasks_edit_command.md). - `org tasks remove` - Delete one task heading and its subtree from Org files. See [tasks_remove_command.md](tasks_remove_command.md). - `org tasks update` - Update one matched task heading in Org files. See [tasks_update_command.md](tasks_update_command.md). - `org tasks capture` - Create tasks from named config templates. See [tasks_capture_command.md](tasks_capture_command.md). For query syntax details, use [query_language.md](query_language.md). -## Configuration and Defaults +## Configuration - Default config file: `.org-cli.yaml` in the current directory. - Override config file: `--config FILE`. @@ -28,15 +31,16 @@ For query syntax details, use [query_language.md](query_language.md). ### Config file layout -Config uses seven top-level sections: +Config uses shared top-level keys plus structured command sections: -- `defaults`: built-in option defaults (for example `--done-states`, `--limit`, `--filter-priority`, `--order-by-priority`). +- Shared top-level keys: `mapping`, `exclude`, `todo_states`, `done_states`, shared filters, shared ordering flags, `color_flag`, `verbose`, and `with_tags_as_category`. - `filter`: custom `--filter-` query snippets. - `order-by`: custom `--order-by-` query snippets. - `with`: custom `--with-` query snippets. -- `capture`: named capture templates under `capture.templates`. -- `agenda`: named agenda views under `agenda.views` with filter-based sections. -- `board`: named board views under `board.views` with filter-based columns. +- `tasks`: tasks subcommand defaults such as `tasks.capture.templates`, `tasks.list`, `tasks.query`, and `tasks.find`. +- `stats`: stats subcommand defaults such as `stats.all`, `stats.summary`, `stats.tags`, and `stats.groups`. +- `agenda`: agenda defaults and named views under `agenda.views`. +- `board`: board defaults and named views under `board.views`. Custom switch argument handling: @@ -46,12 +50,41 @@ Custom switch argument handling: Example: ```yaml -defaults: - --done-states: DONE,CANCELLED,DELEGATED - --limit: 10 - --mapping: examples/mapping_example.json - --exclude: examples/exclude_example.txt - --filter-priority: A +done_states: DONE,CANCELLED,DELEGATED +mapping: examples/mapping_example.json +exclude: examples/exclude_example.txt +filter_priority: A +stats: + all: + max_results: 10 + max_relations: 5 + use: tags + max_tags: 5 + min_group_size: 2 + max_groups: 5 + summary: + max_results: 10 + tags: + max_results: 10 + max_relations: 5 + use: tags + tags: + - Work + groups: + max_results: 10 + max_relations: 5 + use: tags + groups: + - Debugging,Erlang +tasks: + capture: + templates: + quick: + file: tasks.org + content: "* TODO {{title}}" + list: + max_results: 10 + out_theme: github-dark filter: level-above: select(.level > $arg) has-todo: select(.todo != null) @@ -61,8 +94,12 @@ with: priority-value: .properties.priority_value = .priority ``` +For exhaustive per-command config examples, see each command page's `Configuration` section. + Most analysis commands accept many `--filter-*` switches. Ordering controls are available on `org tasks list` and `org board` via built-in `--order-by-*` switches. +Config defaults use the current structured layout: shared options live at the top level, while command-specific defaults live under sections such as `tasks`, `stats`, `agenda`, and `board`. + Built-in argument defaults: - Shared defaults: `--offset 0`, `--todo-states TODO`, `--done-states DONE`. @@ -73,12 +110,13 @@ Built-in argument defaults: - Tasks list/board built-in ordering: `--order-by-priority`, `--order-by-level`, `--order-by-file-order`, `--order-by-file-order-reversed`, `--order-by-timestamp-asc`, `--order-by-timestamp-desc`. - Tasks list default ordering remains timestamp-desc (same as `--order-by-timestamp-desc`). -Repository-local defaults may override built-ins. In this repository, `.org-cli.yaml` sets: +Repository-local config may override built-ins. In this repository, `.org-cli.yaml` sets: -- `--done-states DONE,CANCELLED,DELEGATED` -- `--limit 10` -- `--mapping examples/mapping_example.json` -- `--exclude examples/exclude_example.txt` +- `done_states: DONE,CANCELLED` +- `todo_states: TODO,SUSPENDED` +- `mapping: examples/mapping_example.json` +- `exclude: examples/exclude_example.txt` +- `board.view: default` Common date formats for date filters: diff --git a/docs/stats_all_command.md b/docs/stats_all_command.md index 08b74cd..9f39af5 100644 --- a/docs/stats_all_command.md +++ b/docs/stats_all_command.md @@ -17,6 +17,25 @@ poetry run org stats all [OPTIONS] [FILE ...] - `--max-relations` - Limit relation entries per item. - `--with-tags-as-category` - Derive category from first tag. +## Configuration + +Command-specific defaults live under `stats.all` in `.org-cli.yaml`. + +All available options: + +```yaml +stats: + all: + max_results: 10 + max_tags: 5 + max_relations: 5 + min_group_size: 2 + max_groups: 5 + use: tags +``` + +Shared top-level config still applies for filters, `with_tags_as_category`, `mapping`, `exclude`, `todo_states`, `done_states`, and `color_flag`. + ## Available filters - `--filter-priority P` - Keep only tasks with priority equal to `P`. diff --git a/docs/stats_groups_command.md b/docs/stats_groups_command.md index 277a869..b0a5894 100644 --- a/docs/stats_groups_command.md +++ b/docs/stats_groups_command.md @@ -15,6 +15,25 @@ poetry run org stats groups [OPTIONS] [FILE ...] - `--max-relations` - Controls relation depth when auto-discovering groups. - `--use tags|heading|body` - Choose analysis source. +## Configuration + +Command-specific defaults live under `stats.groups` in `.org-cli.yaml`. + +All available options: + +```yaml +stats: + groups: + max_results: 10 + max_relations: 5 + use: tags + groups: + - Debugging,Erlang + - ProjectManagement,Jira +``` + +Shared top-level config still applies for filters, `with_tags_as_category`, `mapping`, `exclude`, `todo_states`, `done_states`, and `color_flag`. + ## Available filters - `--filter-priority P` - Keep only tasks with priority equal to `P`. diff --git a/docs/stats_summary_command.md b/docs/stats_summary_command.md index b8f0205..d4314eb 100644 --- a/docs/stats_summary_command.md +++ b/docs/stats_summary_command.md @@ -12,6 +12,20 @@ poetry run org stats summary [OPTIONS] [FILE ...] - `--with-tags-as-category` - Derive category from first tag. +## Configuration + +Command-specific defaults live under `stats.summary` in `.org-cli.yaml`. + +All available options: + +```yaml +stats: + summary: + max_results: 10 +``` + +Shared top-level config still applies for filters, `with_tags_as_category`, `mapping`, `exclude`, `todo_states`, `done_states`, and `color_flag`. + ## Available filters - `--filter-priority P` - Keep only tasks with priority equal to `P`. diff --git a/docs/stats_tags_command.md b/docs/stats_tags_command.md index 6a1e954..7ae304e 100644 --- a/docs/stats_tags_command.md +++ b/docs/stats_tags_command.md @@ -15,6 +15,25 @@ poetry run org stats tags [OPTIONS] [FILE ...] - `--max-relations` - Limit relation entries per tag. - `--limit`, `-n` - Limit how many tags are rendered. +## Configuration + +Command-specific defaults live under `stats.tags` in `.org-cli.yaml`. + +All available options: + +```yaml +stats: + tags: + max_results: 10 + max_relations: 5 + use: tags + tags: + - Work + - Debugging +``` + +Shared top-level config still applies for filters, `with_tags_as_category`, `mapping`, `exclude`, `todo_states`, `done_states`, and `color_flag`. + ## Available filters - `--filter-priority P` - Keep only tasks with priority equal to `P`. diff --git a/docs/tasks_add_command.md b/docs/tasks_add_command.md index f7ed39e..614c2c7 100644 --- a/docs/tasks_add_command.md +++ b/docs/tasks_add_command.md @@ -28,6 +28,20 @@ poetry run org tasks add [OPTIONS] [FILE ...] - `--parent ID_OR_TITLE` - Insert as child of the matching heading. - `--file FILE` - Target file to update. Overrides default file resolution from `[FILE ...]`. +## Configuration + +`org tasks add` has no dedicated command-specific config section. + +It only uses shared top-level config such as: + +```yaml +todo_states: TODO,WAITING +done_states: DONE,CANCELLED +mapping: examples/mapping_example.json +exclude: examples/exclude_example.txt +color_flag: true +``` + ## Parent matching - Parent lookup checks heading `ID` first, then heading title. diff --git a/docs/tasks_archive_command.md b/docs/tasks_archive_command.md new file mode 100644 index 0000000..7233a48 --- /dev/null +++ b/docs/tasks_archive_command.md @@ -0,0 +1,50 @@ +# `org tasks archive` + +Archive matched task headings and their full subtrees. + +## Usage + +```bash +poetry run org tasks archive [OPTIONS] [FILE ...] +``` + +## Command-specific switches + +- `--query-title TEXT` - Heading title text of the task to archive. +- `--query-id TEXT` - `ID` of the task to archive. +- `--query QUERY` - Generic query-language selector expression. + +## Configuration + +`org tasks archive` has no dedicated command-specific config section. + +It only uses shared top-level config such as: + +```yaml +todo_states: TODO,WAITING +done_states: DONE,CANCELLED +mapping: examples/mapping_example.json +exclude: examples/exclude_example.txt +color_flag: true +``` + +## Selector rules + +- Provide exactly one selector: `--query-title`, `--query-id`, or `--query`. +- Passing neither selector returns an error. +- Passing multiple selector switches returns an error. +- `--query` is wrapped as `.[] | select()` before execution. + +## Examples + +1) Archive by title + +```bash +poetry run org tasks archive --query-title "Update the docs" ROADMAP.org +``` + +2) Archive by ID + +```bash +poetry run org tasks archive --query-id task-123 ROADMAP.org +``` diff --git a/docs/tasks_capture_command.md b/docs/tasks_capture_command.md index 033d2b2..1e461d2 100644 --- a/docs/tasks_capture_command.md +++ b/docs/tasks_capture_command.md @@ -21,7 +21,7 @@ When provided, CLI switch values take precedence over template config values. ## Configuration -Capture templates live under `capture.templates` in `.org-cli.yaml`. +Capture templates live under `tasks.capture.templates` in `.org-cli.yaml`. Template fields: @@ -37,20 +37,21 @@ CLI `--parent` and template `parent` are intentionally different: Example: ```yaml -capture: - templates: - quick: - file: tasks.org - content: "* TODO {{title}}" - project-task: - file: tasks.org - parent: '.id == "project-1"' - content: | - ** TODO {{title}} - :PROPERTIES: - :ID: {{uuid}} - :CREATED: {{now}} - :END: +tasks: + capture: + templates: + quick: + file: tasks.org + content: "* TODO {{title}}" + project-task: + file: tasks.org + parent: '.id == "project-1"' + content: | + ** TODO {{title}} + :PROPERTIES: + :ID: {{uuid}} + :CREATED: {{now}} + :END: ``` ## Placeholders diff --git a/docs/tasks_edit_command.md b/docs/tasks_edit_command.md new file mode 100644 index 0000000..3294f62 --- /dev/null +++ b/docs/tasks_edit_command.md @@ -0,0 +1,49 @@ +# `org tasks edit` + +Edit one matched task subtree in an external editor. + +## Usage + +```bash +poetry run org tasks edit [OPTIONS] [FILE ...] +``` + +## Command-specific switches + +- `--query-title TEXT` - Heading title text of the task to edit. +- `--query-id TEXT` - `ID` of the task to edit. +- `--query QUERY` - Generic query-language selector expression. + +## Configuration + +`org tasks edit` has no dedicated command-specific config section. + +It only uses shared top-level config such as: + +```yaml +todo_states: TODO,WAITING +done_states: DONE,CANCELLED +mapping: examples/mapping_example.json +exclude: examples/exclude_example.txt +color_flag: true +``` + +## Selector rules + +- Provide exactly one selector: `--query-title`, `--query-id`, or `--query`. +- Matching must resolve to exactly one task. +- `--query` is wrapped as `.[] | select()` before execution. + +## Examples + +1) Edit by title + +```bash +poetry run org tasks edit --query-title "Update the docs" ROADMAP.org +``` + +2) Edit by ID + +```bash +poetry run org tasks edit --query-id task-123 ROADMAP.org +``` diff --git a/docs/tasks_find_command.md b/docs/tasks_find_command.md new file mode 100644 index 0000000..cde85de --- /dev/null +++ b/docs/tasks_find_command.md @@ -0,0 +1,58 @@ +# `org tasks find` + +Find tasks by exact selectors or full-text search. + +## Usage + +```bash +poetry run org tasks find [OPTIONS] [FILE ...] +``` + +## Command-specific switches + +- `--query-title TEXT` - Select tasks where title exactly matches `TEXT`. +- `--query-id ID` - Select tasks where `ID` exactly matches `ID`. +- `--query QUERY` - Select tasks with a query-language predicate. +- `--search-text TEXT` - Select tasks whose rendered full text contains `TEXT`. +- `--search-pattern REGEX` - Select tasks whose rendered full text matches `REGEX`. +- `--include-context N` - Include up to `N` parent levels for each matched task. +- `--out` - Output format (`org`, `json`, or any Pandoc writer format). +- `--out-theme` - Pygments syntax-highlighting theme for renderable output formats. +- `--pandoc-args` - Extra arguments forwarded to Pandoc during conversion. + +## Configuration + +Command-specific defaults live under `tasks.find` in `.org-cli.yaml`. + +All available options: + +```yaml +tasks: + find: + include_context: 0 + out: org + out_theme: github-dark + pandoc_args: "--wrap=none" +``` + +Shared top-level config still applies for `mapping`, `exclude`, `todo_states`, `done_states`, and color behavior. + +## Examples + +1) Find tasks by exact title + +```bash +poetry run org tasks find --query-title "Refactor codebase" examples/ARCHIVE_small +``` + +2) Search full task text + +```bash +poetry run org tasks find --search-text "Feature implementation details" examples/ARCHIVE_small +``` + +3) Include parent context for matched tasks + +```bash +poetry run org tasks find --search-pattern "Jira" --include-context 2 examples/ARCHIVE_small +``` diff --git a/docs/tasks_list_command.md b/docs/tasks_list_command.md index 04f976d..25a0a02 100644 --- a/docs/tasks_list_command.md +++ b/docs/tasks_list_command.md @@ -46,6 +46,25 @@ Interactive keys: - `--out-theme` - Pygments syntax-highlighting theme for renderable output formats (default: `github-dark`). - `--pandoc-args` - Extra arguments forwarded to Pandoc during conversion. +## Configuration + +Command-specific defaults live under `tasks.list` in `.org-cli.yaml`. + +All available options: + +```yaml +tasks: + list: + max_results: 10 + details: false + out: org + out_theme: github-dark + pandoc_args: "--wrap=none" +``` + +Shared top-level config still applies for filters, ordering flags, `with_tags_as_category`, +`mapping`, `exclude`, `todo_states`, `done_states`, and `color_flag`. + ## Available filters - `--filter-priority P` - Keep only tasks with priority equal to `P`. diff --git a/docs/tasks_query_command.md b/docs/tasks_query_command.md index 982ebeb..f422eb5 100644 --- a/docs/tasks_query_command.md +++ b/docs/tasks_query_command.md @@ -22,6 +22,25 @@ poetry run org tasks query [OPTIONS] QUERY [FILE ...] When you use `--offset` or `--limit`, include `$offset` and `$limit` in the query expression. +## Configuration + +Command-specific defaults live under `tasks.query` in `.org-cli.yaml`. + +All available options: + +```yaml +tasks: + query: + max_results: 10 + offset: 0 + width: 80 + out: org + out_theme: github-dark + pandoc_args: "--wrap=none" +``` + +Shared top-level config still applies for `mapping`, `exclude`, `todo_states`, `done_states`, and `color_flag`. + ## Examples 1) Fetch headings from all tasks diff --git a/docs/tasks_remove_command.md b/docs/tasks_remove_command.md index 9948cfe..171bb3e 100644 --- a/docs/tasks_remove_command.md +++ b/docs/tasks_remove_command.md @@ -17,6 +17,20 @@ poetry run org tasks remove [OPTIONS] [FILE ...] - `--yes` - Automatically confirm deletion without prompting. - `--color/--no-color` - Force color behavior for interactive prompt. +## Configuration + +`org tasks remove` has no dedicated command-specific config section. + +It only uses shared top-level config such as: + +```yaml +todo_states: TODO,WAITING +done_states: DONE,CANCELLED +mapping: examples/mapping_example.json +exclude: examples/exclude_example.txt +color_flag: true +``` + ## Selector rules - Provide exactly one selector: `--query-title`, `--query-id`, or `--query`. diff --git a/docs/tasks_update_command.md b/docs/tasks_update_command.md index aecb6d7..1728a80 100644 --- a/docs/tasks_update_command.md +++ b/docs/tasks_update_command.md @@ -18,6 +18,20 @@ poetry run org tasks update [OPTIONS] [FILE ...] Provide exactly one selector: `--query-title`, `--query-id`, or `--query`. +## Configuration + +`org tasks update` has no dedicated command-specific config section. + +It only uses shared top-level config such as: + +```yaml +todo_states: TODO,WAITING +done_states: DONE,CANCELLED +mapping: examples/mapping_example.json +exclude: examples/exclude_example.txt +color_flag: true +``` + ## Update switches - `--level N` - New heading level. diff --git a/plans/config-stateless-refactor.md b/plans/config-stateless-refactor.md new file mode 100644 index 0000000..f4ca8c9 --- /dev/null +++ b/plans/config-stateless-refactor.md @@ -0,0 +1,158 @@ +# Stateless Configuration Refactor Plan + +## Goals + +- Load configuration before building the command tree. +- Use `ctx.obj` to carry configuration through command execution. +- Eliminate global configuration storage and make commands stateless. +- Include output-affecting CLI settings such as `--color` and `--verbose` in `AppConfig`. +- Remove the legacy `defaults` bag entirely if feasible during the refactor. + +## Target Flow + +1. Create a well-defined structured configuration object. +2. Populate that object with code defaults. +3. Determine the configuration file path. +4. Load and validate the configuration file sections. +5. Merge file values into the structured configuration object. +6. Build the Typer command tree after configuration is loaded. +7. Pass the configuration object through `ctx.obj`. +8. In each command, derive an effective command config by overlaying CLI switch values. +9. Log the effective config object. +10. Execute the command using values from that resulting config only. + +## Configuration Shape + +Introduce a structured `AppConfig` model and supporting dataclasses. The final shape should cover: + +- `color: bool | None` +- `verbose: bool` +- `todo_states: list[str]` +- `done_states: list[str]` +- `filters: list[...]` +- `orderings: list[...]` +- `mutators: list[...]` +- `stats.exclude: str | None` +- `stats.mapping: str | None` +- `capture.templates: ...` +- `agenda.views: ...` +- `board.views: ...` + +If some transitional compatibility is needed while the refactor is in progress, keep it narrow and local. The end goal is to remove the old `defaults` dict entirely. + +## Main Refactor Steps + +### 1. Introduce Structured Config Types + +- Add `AppConfig` and nested config dataclasses in `src/org/config/app.py`. +- Represent file-backed sections directly instead of storing them in module globals. +- Include `color` and `verbose` in the structured config. +- Replace `LoadedCliConfig` with a structured load result based on `AppConfig`. + +### 2. Split Config Loading Into Explicit Stages + +- Add helpers for: + - code-default construction + - config path resolution + - raw YAML loading + - section parsing + - structured merge/update +- Validate each section against a well-defined schema. +- Populate dedicated fields such as `todo_states` and `done_states` directly instead of mirroring them through a generic defaults map. + +### 3. Build The CLI After Config Load + +- Update `src/org/cli.py` so `main()` loads config first. +- Build and register the command tree after config is loaded. +- Store the loaded config in `ctx.obj`. +- Remove all writes to `CONFIG_*` globals and `DEFAULT_VERBOSE`. + +### 4. Replace Global Default Application + +- Remove `apply_config_defaults(args)` in its current global-state form. +- Introduce an explicit command overlay step that accepts `AppConfig` and CLI args. +- Prefer command-specific structured fields over any generic/default fallback. +- Ensure CLI switch values override file config values only where the user provided them. + +### 5. Refactor Query-Pipeline Config Access + +- Update `src/org/config/cli.py` helpers to accept `AppConfig` explicitly. +- Thread config into: + - `validate_custom_switches` + - `normalize_cli_files_for_custom_switches` + - `parse_filter_entries_from_argv` + - `parse_order_entries_from_argv` + - `parse_with_entries_from_argv` + - `build_pipeline_stages` +- Read custom filters/orderings/mutators from structured config instead of globals. + +### 6. Refactor Command Consumers + +- Update commands and helpers that currently read global config: + - stats commands + - tasks commands + - board commands + - agenda commands + - capture commands +- Pass config explicitly into: + - mapping/exclude resolution + - board view resolution + - agenda view resolution + - capture template resolution + - prompt/template-name helpers + +### 7. Update Logging + +- Change config logging helpers to accept `AppConfig` or the effective command config explicitly. +- Log the effective config object rather than reconstructing defaults from globals. +- Keep inline-value redaction behavior where needed. + +### 8. Remove Legacy Global State + +- Delete module-level config stores such as: + - `CONFIG_APPEND_DEFAULTS` + - `CONFIG_INLINE_DEFAULTS` + - `CONFIG_DEFAULTS` + - `CONFIG_CUSTOM_FILTERS` + - `CONFIG_CUSTOM_ORDER_BY` + - `CONFIG_CUSTOM_WITH` + - `CONFIG_CAPTURE_TEMPLATES` + - `CONFIG_BOARD_VIEWS` + - `CONFIG_AGENDA_VIEWS` + - `DEFAULT_VERBOSE` +- Remove remaining runtime dependencies on those names. + +### 9. Remove Legacy `defaults` If Feasible + +- Prefer deleting the old top-level `defaults` representation entirely. +- If full removal is too disruptive for one change, isolate any temporary compatibility layer to config loading only. +- Do not let runtime command execution depend on a generic `defaults` bag. + +### 10. Update Tests + +- Replace tests that monkeypatch global config with tests that construct explicit `AppConfig` values. +- Update CLI tests to verify: + - config is loaded before command registration/execution + - config is passed via `ctx.obj` + - command-local overlays produce the effective config +- Update query-pipeline tests to pass config explicitly. +- Update capture/board/agenda tests to pass templates/views explicitly instead of mutating globals. + +## Implementation Order + +1. Add `AppConfig` and config-loading/merging helpers. +2. Refactor `org.cli` to load config first and inject it through `ctx.obj`. +3. Refactor `org.config.cli` to accept config explicitly. +4. Refactor logging and command overlay/default resolution. +5. Refactor capture, board, and agenda config consumers. +6. Remove remaining globals and legacy paths. +7. Update tests. +8. Run `poetry run task check`. + +## Key Constraints + +- Prefer small, local changes over broad rewrites where possible. +- Keep commands stateless: no runtime dependence on module globals. +- Treat the structured config object as the single source of truth. +- Use `ctx.obj` for config propagation for now. +- Prefer command-specific fields over generic storage. diff --git a/src/org/cli.py b/src/org/cli.py index 2b1a2c0..84e8735 100644 --- a/src/org/cli.py +++ b/src/org/cli.py @@ -13,66 +13,44 @@ from org.commands.tasks import command as tasks_command -app = typer.Typer( - help="Analyze Emacs Org-mode archive files for task statistics.", - no_args_is_help=True, -) - - -@app.callback() -def main_callback( - verbose: bool | None = typer.Option( - None, - "--verbose", - "-v", - help="Enable verbose logging output", - ), -) -> None: - """Global CLI options.""" - if verbose is None and not org.config.app.DEFAULT_VERBOSE["value"]: - return - org.logging.configure_logging(org.config.app.resolve_verbose(verbose)) - +def build_app(config: org.config.app.AppConfig) -> typer.Typer: + """Build the Typer application after config is loaded.""" + app = typer.Typer( + help="Analyze Emacs Org-mode archive files for task statistics.", + no_args_is_help=True, + ) -agenda.register(app) -board_command.register(app) -stats.register(app) -tasks_command.register(app) + @app.callback() + def main_callback( + ctx: typer.Context, + verbose: bool | None = typer.Option( + None, + "--verbose", + "-v", + help="Enable verbose logging output", + ), + ) -> None: + """Global CLI options.""" + ctx.obj = config + if verbose is None and not config.verbose: + return + org.logging.configure_logging(config.verbose if verbose is None else verbose) + + agenda.register(app, config) + board_command.register(app, config) + stats.register(app, config) + tasks_command.register(app, config) + return app def main() -> None: """Run the CLI entry point.""" - loaded_config = org.config.app.load_cli_config(sys.argv) - defaults = loaded_config.defaults - if defaults is not None: - org.config.app.DEFAULT_VERBOSE["value"] = bool(defaults.pop("verbose", False)) - org.config.app.CONFIG_APPEND_DEFAULTS.clear() - org.config.app.CONFIG_APPEND_DEFAULTS.update(loaded_config.append_defaults) - org.config.app.CONFIG_INLINE_DEFAULTS.clear() - org.config.app.CONFIG_INLINE_DEFAULTS.update(loaded_config.inline_defaults) - org.config.app.CONFIG_DEFAULTS.clear() - if defaults is not None: - org.config.app.CONFIG_DEFAULTS.update(defaults) - org.config.app.CONFIG_CUSTOM_FILTERS.clear() - org.config.app.CONFIG_CUSTOM_FILTERS.update(loaded_config.custom_filters) - org.config.app.CONFIG_CUSTOM_ORDER_BY.clear() - org.config.app.CONFIG_CUSTOM_ORDER_BY.update(loaded_config.custom_order_by) - org.config.app.CONFIG_CUSTOM_WITH.clear() - org.config.app.CONFIG_CUSTOM_WITH.update(loaded_config.custom_with) - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(loaded_config.capture_templates) - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS.update(loaded_config.board_views) - org.config.app.CONFIG_AGENDA_VIEWS.clear() - org.config.app.CONFIG_AGENDA_VIEWS.update(loaded_config.agenda_views) - + app = build_app(org.config.app.load_cli_config(sys.argv)) command = typer.main.get_command(app) - default_map = org.config.app.build_default_map(defaults) if defaults else None command.main( args=sys.argv[1:], prog_name="org", standalone_mode=True, - default_map=default_map, ) diff --git a/src/org/commands/agenda/actions.py b/src/org/commands/agenda/actions.py index 54c42db..353b70f 100644 --- a/src/org/commands/agenda/actions.py +++ b/src/org/commands/agenda/actions.py @@ -13,7 +13,6 @@ from org.commands.tasks.capture import TasksCaptureArgs, capture_task from org.commands.tasks.common import ( - configured_capture_template_names, duration_to_org_text, iter_descendants, load_document, @@ -52,6 +51,8 @@ from org_parser.document import Document, Heading + from org.config.app import AppConfig + from .command import AgendaArgs from .views import AgendaViewContext @@ -79,6 +80,7 @@ class AgendaSession: status_message: str search_text: str view_ctx: AgendaViewContext + app_config: AppConfig run_external: Callable[[Callable[[], None]], None] | None = None @@ -200,7 +202,7 @@ def _save_document_changes(document: Document) -> None: def _reload_session_nodes(session: AgendaSession) -> None: """Reload nodes through standard processing pipeline after mutations.""" - nodes, _, _ = load_and_process_data(session.args) + nodes, _, _ = load_and_process_data(session.args, session.app_config) session.all_nodes = nodes session.nodes = filter_nodes_by_search(nodes, session.search_text) @@ -528,7 +530,7 @@ def apply_capture_task(session: AgendaSession, template_name: str) -> None: set_values=None, ) try: - capture_result = capture_task(capture_args) + capture_result = capture_task(capture_args, session.app_config.tasks.capture.templates) except KeyboardInterrupt: session.status_message = "Capture cancelled" return @@ -572,7 +574,7 @@ def can_activate_agenda_capture_prompt(session: AgendaSession) -> str | None: """Return status text when the capture prompt cannot be opened.""" if _timetable_schedule_for_selected_row(session) is None: return "Capture is available only on timetable time rows" - if not configured_capture_template_names(): + if not session.app_config.tasks.capture.templates: return "No capture templates configured" return None @@ -592,6 +594,7 @@ def apply_search_text(session: AgendaSession, search_text: str) -> None: def create_agenda_session( args: AgendaArgs, + config: AppConfig, nodes: list[Heading], render: RenderContext, view_ctx: AgendaViewContext, @@ -614,6 +617,7 @@ def create_agenda_session( status_message="", search_text="", view_ctx=view_ctx, + app_config=config, ) refresh_session(session, None) return session diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py index cd0c720..30a91ec 100644 --- a/src/org/commands/agenda/app.py +++ b/src/org/commands/agenda/app.py @@ -10,15 +10,13 @@ from textual.containers import Vertical from textual.widgets import Static +import org.config.app import org.tui.app import org.tui.footer import org.tui.help import org.tui.prompt import org.tui.selection -from org.commands.tasks.common import ( - clock_duration_prompt_label, - configured_capture_template_names, -) +from org.commands.tasks.common import clock_duration_prompt_label from org.pipeline.load import resolve_input_paths from . import actions, ui @@ -396,7 +394,7 @@ def action_prompt_capture(self) -> None: self._refresh_view() return - template_names = configured_capture_template_names() + template_names = sorted(self.session.app_config.tasks.capture.templates) self._open_selection( "Capture template", @@ -469,10 +467,11 @@ def action_quit_app(self) -> None: def run_agenda_app( args: AgendaArgs, + config: org.config.app.AppConfig, nodes: list[Heading], render: ui.RenderContext, view_ctx: AgendaViewContext, ) -> None: """Run the Textual-backed interactive agenda app.""" - session = create_agenda_session(args, nodes, render, view_ctx) + session = create_agenda_session(args, config, nodes, render, view_ctx) AgendaApp(session).run() diff --git a/src/org/commands/agenda/command.py b/src/org/commands/agenda/command.py index 901c89a..75bef8c 100644 --- a/src/org/commands/agenda/command.py +++ b/src/org/commands/agenda/command.py @@ -79,7 +79,7 @@ def _validate_agenda_args(args: AgendaArgs) -> None: ui.resolve_agenda_start_date(args.date) -def run_agenda(args: AgendaArgs) -> None: +def run_agenda(args: AgendaArgs, config: org.config.app.AppConfig) -> None: """Run the agenda command.""" color_enabled = setup_output(args) console = build_console(color_enabled, args.width) @@ -94,10 +94,10 @@ def run_agenda(args: AgendaArgs) -> None: args.max_results = _resolve_tasks_limit(args.max_results) _validate_agenda_args(args) - view_ctx = resolve_view_context(args) + view_ctx = resolve_view_context(args, config.agenda.views) with processing_status(console, color_enabled): - nodes, todo_states, done_states = load_and_process_data(args) + nodes, todo_states, done_states = load_and_process_data(args, config) if not nodes: console.print("No results", markup=False) @@ -108,6 +108,7 @@ def run_agenda(args: AgendaArgs) -> None: run_agenda_app( args, + config, nodes, ui.RenderContext( color_enabled=color_enabled, @@ -118,7 +119,7 @@ def run_agenda(args: AgendaArgs) -> None: ) -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, app_config: org.config.app.AppConfig) -> None: """Register the agenda command.""" @app.command( @@ -130,6 +131,7 @@ def register(app: typer.Typer) -> None: ), ) def agenda( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -142,55 +144,55 @@ def agenda( # noqa: PLR0913 help="Config file name to load from current directory", ), exclude: str | None = typer.Option( - None, + app_config.exclude, "--exclude", metavar="FILE", help="File containing words to exclude (one per line)", ), mapping: str | None = typer.Option( - None, + app_config.mapping, "--mapping", metavar="FILE", help="JSON file containing tag mappings (dict[str, str])", ), todo_states: str = typer.Option( - "TODO", + ",".join(app_config.todo_states), "--todo-states", metavar="KEYS", help="Comma-separated list of incomplete task states", ), done_states: str = typer.Option( - "DONE", + ",".join(app_config.done_states), "--done-states", metavar="KEYS", help="Comma-separated list of completed task states", ), filter_priority: str | None = typer.Option( - None, + app_config.filter_priority, "--filter-priority", metavar="P", help="Filter tasks where priority equals P", ), filter_level: int | None = typer.Option( - None, + app_config.filter_level, "--filter-level", metavar="N", help="Filter tasks where heading level equals N", ), filter_repeats_above: int | None = typer.Option( - None, + app_config.filter_repeats_above, "--filter-repeats-above", metavar="N", help="Filter tasks where repeat count > N (non-inclusive)", ), filter_repeats_below: int | None = typer.Option( - None, + app_config.filter_repeats_below, "--filter-repeats-below", metavar="N", help="Filter tasks where repeat count < N (non-inclusive)", ), filter_date_from: str | None = typer.Option( - None, + app_config.filter_date_from, "--filter-date-from", metavar="TIMESTAMP", help=( @@ -200,7 +202,7 @@ def agenda( # noqa: PLR0913 ), ), filter_date_until: str | None = typer.Option( - None, + app_config.filter_date_until, "--filter-date-until", metavar="TIMESTAMP", help=( @@ -210,25 +212,25 @@ def agenda( # noqa: PLR0913 ), ), filter_properties: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_properties, "--filter-property", metavar="KEY=VALUE", help="Filter tasks with exact property match (case-sensitive, can specify multiple)", ), filter_tags: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_tags, "--filter-tag", metavar="REGEX", help="Filter tasks where any tag matches regex (case-sensitive, can specify multiple)", ), filter_headings: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_headings, "--filter-heading", metavar="REGEX", help="Filter tasks where heading matches regex (case-sensitive, can specify multiple)", ), filter_bodies: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_bodies, "--filter-body", metavar="REGEX", help=( @@ -237,123 +239,124 @@ def agenda( # noqa: PLR0913 ), ), filter_completed: bool = typer.Option( - False, + app_config.filter_completed, "--filter-completed", help="Filter tasks with todo state in done keys", ), filter_not_completed: bool = typer.Option( - False, + app_config.filter_not_completed, "--filter-not-completed", help="Filter tasks with todo state in todo keys or without a todo state", ), color_flag: bool | None = typer.Option( - None, + app_config.color_flag, "--color/--no-color", help="Force colored output", ), width: int | None = typer.Option( - None, + app_config.agenda.width, "--width", metavar="N", min=50, help="Override auto-derived console width (minimum: 50)", ), max_results: int | None = typer.Option( - None, + app_config.agenda.max_results, "--limit", "-n", metavar="N", help="Maximum number of results to display (defaults to all results)", ), offset: int = typer.Option( - 0, + 0 if app_config.agenda.offset is None else app_config.agenda.offset, "--offset", metavar="N", help="Number of results to skip before displaying", ), order_by_level: bool = typer.Option( - False, + app_config.order_by_level, "--order-by-level", help="Order tasks by heading level (repeatable)", ), order_by_file_order: bool = typer.Option( - False, + app_config.order_by_file_order, "--order-by-file-order", help="Keep tasks in source file order (repeatable)", ), order_by_file_order_reversed: bool = typer.Option( - False, + app_config.order_by_file_order_reversed, "--order-by-file-order-reversed", help="Reverse source file order (repeatable)", ), order_by_priority: bool = typer.Option( - False, + app_config.order_by_priority, "--order-by-priority", help="Order by priority (repeatable)", ), order_by_timestamp_asc: bool = typer.Option( - False, + app_config.order_by_timestamp_asc, "--order-by-timestamp-asc", help="Order by oldest timestamp first (repeatable)", ), order_by_timestamp_desc: bool = typer.Option( - False, + app_config.order_by_timestamp_desc, "--order-by-timestamp-desc", help="Order by newest timestamp first (repeatable)", ), with_tags_as_category: bool = typer.Option( - False, + app_config.with_tags_as_category, "--with-tags-as-category", help="Preprocess nodes to set category from first tag", ), date: str | None = typer.Option( - None, + app_config.agenda.date, "--date", metavar="DATE", help="Agenda start date (default: today). Formats: YYYY-MM-DD or ISO datetime", ), days: int = typer.Option( - 1, + 1 if app_config.agenda.days is None else app_config.agenda.days, "--days", metavar="N", min=1, help="How many days to show starting from --date", ), no_completed: bool = typer.Option( - False, + False if app_config.agenda.no_completed is None else app_config.agenda.no_completed, "--no-completed", help="Omit tasks in completed states (including repeats)", ), no_overdue: bool = typer.Option( - False, + False if app_config.agenda.no_overdue is None else app_config.agenda.no_overdue, "--no-overdue", help="Omit overdue scheduled and deadline tasks", ), no_upcoming: bool = typer.Option( - False, + False if app_config.agenda.no_upcoming is None else app_config.agenda.no_upcoming, "--no-upcoming", help="Omit upcoming deadlines", ), future_repeats: bool = typer.Option( - True, + True if app_config.agenda.future_repeats is None else app_config.agenda.future_repeats, "--future-repeats/--no-future-repeats", help="Include potential future planning repeats in agenda days", ), view: str | None = typer.Option( - None, + app_config.agenda.view, "--view", metavar="NAME", help="Agenda view name to use from config (defaults to built-in view)", ), ) -> None: """Show agenda for one day or a date range.""" + app_config = org.config.app.require_app_config(ctx) args = AgendaArgs( files=files, config=config, exclude=exclude, mapping=mapping, - mapping_inline=None, - exclude_inline=None, + mapping_inline=app_config.mapping_inline, + exclude_inline=app_config.exclude_inline, todo_states=todo_states, done_states=done_states, filter_priority=filter_priority, @@ -387,7 +390,6 @@ def agenda( # noqa: PLR0913 future_repeats=future_repeats, view=view, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "agenda") + org.logging.log_command_config(app_config, "agenda") org.logging.log_command_arguments(args, "agenda") - run_agenda(args) + run_agenda(args, app_config) diff --git a/src/org/commands/agenda/views.py b/src/org/commands/agenda/views.py index d1679ab..e02bc9b 100644 --- a/src/org/commands/agenda/views.py +++ b/src/org/commands/agenda/views.py @@ -77,10 +77,12 @@ def _compile_view_section_specs(view: org.config.app.AgendaViewConfig) -> list[A return section_specs -def resolve_view_context(args: AgendaArgs) -> AgendaViewContext: +def resolve_view_context( + args: AgendaArgs, + configured_views: dict[str, org.config.app.AgendaViewConfig], +) -> AgendaViewContext: """Resolve configured or fallback view context for agenda rendering.""" selected_view = args.view.strip() if args.view else None - configured_views = org.config.app.CONFIG_AGENDA_VIEWS if selected_view is None: view = _fallback_agenda_view() diff --git a/src/org/commands/board/actions.py b/src/org/commands/board/actions.py index f3a34b9..edf9ef1 100644 --- a/src/org/commands/board/actions.py +++ b/src/org/commands/board/actions.py @@ -11,7 +11,7 @@ import org.config.app from org.commands.tasks.capture import TasksCaptureArgs, capture_task -from org.commands.tasks.common import configured_capture_template_names, save_document +from org.commands.tasks.common import save_document from org.logic.archive import archive_heading_subtree_and_save from org.logic.edit import edit_heading_subtree_in_external_editor from org.logic.search import filter_nodes_by_search @@ -32,6 +32,7 @@ from org_parser.document import Document, Heading + from org.config.app import AppConfig, BoardViewConfig from org.query.engine.compiler import CompiledQuery from .command import BoardArgs @@ -86,6 +87,7 @@ class BoardSession: selected_row_index: int scroll_offset: int status_message: str + app_config: AppConfig all_columns: Sequence[BoardColumn] = field(default_factory=list) search_text: str = "" @@ -200,11 +202,12 @@ def _resolve_selected_view_name(args: BoardArgs) -> str | None: return selected_view -def resolve_column_specs(args: BoardArgs) -> list[BoardColumnSpec]: +def resolve_column_specs( + args: BoardArgs, + configured_views: dict[str, BoardViewConfig], +) -> list[BoardColumnSpec]: """Resolve configured or fallback filter columns for board rendering.""" selected_view = _resolve_selected_view_name(args) - configured_views = org.config.app.CONFIG_BOARD_VIEWS - if selected_view is None: return compile_view_column_specs(_fallback_view_config()) @@ -319,7 +322,10 @@ def reload_session( preserve_identity: HeadingLocator | None, ) -> None: """Reload processed nodes and rebuild board columns.""" - nodes, discovered_todo_states, discovered_done_states = load_and_process_data(session.args) + nodes, discovered_todo_states, discovered_done_states = load_and_process_data( + session.args, + session.app_config, + ) todo_states, done_states = resolved_states( session.args, discovered_todo_states, @@ -333,20 +339,21 @@ def reload_session( session.done_states = done_states session.all_columns = build_selector_board_columns( filtered_nodes, - resolve_column_specs(session.args), + resolve_column_specs(session.args, session.app_config.board.views), ) refresh_visible_columns(session, preserve_identity) def create_board_session( args: BoardArgs, + config: AppConfig, nodes: list[Heading], - todo_states: list[str], - done_states: list[str], + state_lists: tuple[list[str], list[str]], color_enabled: bool, ) -> BoardSession: """Create interactive board session state.""" - columns = build_selector_board_columns(nodes, resolve_column_specs(args)) + todo_states, done_states = state_lists + columns = build_selector_board_columns(nodes, resolve_column_specs(args, config.board.views)) selected_column_index = 0 for index, column in enumerate(columns): if column.nodes: @@ -358,6 +365,7 @@ def create_board_session( nodes=nodes, todo_states=todo_states, done_states=done_states, + app_config=config, all_columns=columns, columns=columns, color_enabled=color_enabled, @@ -580,7 +588,7 @@ def apply_capture_task(session: BoardSession, template_name: str) -> None: set_values=None, ) try: - capture_result = capture_task(capture_args) + capture_result = capture_task(capture_args, session.app_config.tasks.capture.templates) except KeyboardInterrupt: session.status_message = "Capture cancelled" return @@ -622,7 +630,6 @@ def apply_search_text(session: BoardSession, search_text: str) -> None: def can_activate_capture_prompt(session: BoardSession) -> str | None: """Return status text when the capture prompt cannot be opened.""" - del session - if not configured_capture_template_names(): + if not session.app_config.tasks.capture.templates: return "No capture templates configured" return None diff --git a/src/org/commands/board/app.py b/src/org/commands/board/app.py index 3252633..d6a3d35 100644 --- a/src/org/commands/board/app.py +++ b/src/org/commands/board/app.py @@ -12,14 +12,12 @@ from textual.containers import Horizontal, Vertical from textual.widgets import Static +import org.config.app import org.tui.app import org.tui.footer import org.tui.help import org.tui.prompt import org.tui.selection -from org.commands.tasks.common import ( - configured_capture_template_names, -) from . import actions, ui from .actions import BoardSession, create_board_session @@ -398,7 +396,7 @@ def action_prompt_capture(self) -> None: self._refresh_view() return - template_names = configured_capture_template_names() + template_names = sorted(self.session.app_config.tasks.capture.templates) self._open_selection( "Capture template", @@ -416,11 +414,11 @@ def action_quit_app(self) -> None: def run_board_app( args: BoardArgs, + config: org.config.app.AppConfig, nodes: list[Heading], - todo_states: list[str], - done_states: list[str], + state_lists: tuple[list[str], list[str]], color_enabled: bool, ) -> None: """Run the Textual-backed interactive board app.""" - session = create_board_session(args, nodes, todo_states, done_states, color_enabled) + session = create_board_session(args, config, nodes, state_lists, color_enabled) BoardApp(session).run() diff --git a/src/org/commands/board/command.py b/src/org/commands/board/command.py index 0600585..9f1cb76 100644 --- a/src/org/commands/board/command.py +++ b/src/org/commands/board/command.py @@ -71,12 +71,12 @@ def _resolve_tasks_limit(max_results: int | None) -> int: return max_results -def _validate_board_args(args: BoardArgs) -> None: +def _validate_board_args(args: BoardArgs, config: org.config.app.AppConfig) -> None: """Validate board arguments and configured view selection.""" - actions.resolve_column_specs(args) + actions.resolve_column_specs(args, config.board.views) -def run_board(args: BoardArgs) -> None: +def run_board(args: BoardArgs, config: org.config.app.AppConfig) -> None: """Run the board command.""" color_enabled = setup_output(args) console = build_console(color_enabled, args.width) @@ -86,13 +86,13 @@ def run_board(args: BoardArgs) -> None: raise typer.BadParameter("--offset must be non-negative") if args.max_results is not None and args.max_results < 0: raise typer.BadParameter("--limit must be non-negative") - if args.days < 0: - raise typer.BadParameter("--days must be non-negative") + if args.days < 1: + raise typer.BadParameter("--days must be at least 1") args.max_results = _resolve_tasks_limit(args.max_results) - _validate_board_args(args) + _validate_board_args(args, config) with processing_status(console, color_enabled): - nodes, discovered_todo_states, discovered_done_states = load_and_process_data(args) + nodes, discovered_todo_states, discovered_done_states = load_and_process_data(args, config) nodes = actions.filter_recent_completed_nodes(nodes, args.days) todo_states, done_states = actions.resolved_states( args, @@ -107,10 +107,10 @@ def run_board(args: BoardArgs) -> None: if not (sys.stdin.isatty() and sys.stdout.isatty()): raise click.UsageError("org board requires a TTY") - run_board_app(args, nodes, todo_states, done_states, color_enabled) + run_board_app(args, config, nodes, (todo_states, done_states), color_enabled) -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, app_config: org.config.app.AppConfig) -> None: """Register the board command.""" @app.command( @@ -122,6 +122,7 @@ def register(app: typer.Typer) -> None: ), ) def board( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -134,55 +135,55 @@ def board( # noqa: PLR0913 help="Config file name to load from current directory", ), exclude: str | None = typer.Option( - None, + app_config.exclude, "--exclude", metavar="FILE", help="File containing words to exclude (one per line)", ), mapping: str | None = typer.Option( - None, + app_config.mapping, "--mapping", metavar="FILE", help="JSON file containing tag mappings (dict[str, str])", ), todo_states: str = typer.Option( - "TODO", + ",".join(app_config.todo_states), "--todo-states", metavar="KEYS", help="Comma-separated list of incomplete task states", ), done_states: str = typer.Option( - "DONE", + ",".join(app_config.done_states), "--done-states", metavar="KEYS", help="Comma-separated list of completed task states", ), filter_priority: str | None = typer.Option( - None, + app_config.filter_priority, "--filter-priority", metavar="P", help="Filter tasks where priority equals P", ), filter_level: int | None = typer.Option( - None, + app_config.filter_level, "--filter-level", metavar="N", help="Filter tasks where heading level equals N", ), filter_repeats_above: int | None = typer.Option( - None, + app_config.filter_repeats_above, "--filter-repeats-above", metavar="N", help="Filter tasks where repeat count > N (non-inclusive)", ), filter_repeats_below: int | None = typer.Option( - None, + app_config.filter_repeats_below, "--filter-repeats-below", metavar="N", help="Filter tasks where repeat count < N (non-inclusive)", ), filter_date_from: str | None = typer.Option( - None, + app_config.filter_date_from, "--filter-date-from", metavar="TIMESTAMP", help=( @@ -192,7 +193,7 @@ def board( # noqa: PLR0913 ), ), filter_date_until: str | None = typer.Option( - None, + app_config.filter_date_until, "--filter-date-until", metavar="TIMESTAMP", help=( @@ -202,25 +203,25 @@ def board( # noqa: PLR0913 ), ), filter_properties: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_properties, "--filter-property", metavar="KEY=VALUE", help="Filter tasks with exact property match (case-sensitive, can specify multiple)", ), filter_tags: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_tags, "--filter-tag", metavar="REGEX", help="Filter tasks where any tag matches regex (case-sensitive, can specify multiple)", ), filter_headings: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_headings, "--filter-heading", metavar="REGEX", help="Filter tasks where heading matches regex (case-sensitive, can specify multiple)", ), filter_bodies: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_bodies, "--filter-body", metavar="REGEX", help=( @@ -229,96 +230,98 @@ def board( # noqa: PLR0913 ), ), filter_completed: bool = typer.Option( - False, + app_config.filter_completed, "--filter-completed", help="Filter tasks with todo state in done keys", ), filter_not_completed: bool = typer.Option( - False, + app_config.filter_not_completed, "--filter-not-completed", help="Filter tasks with todo state in todo keys or without a todo state", ), color_flag: bool | None = typer.Option( - None, + app_config.color_flag, "--color/--no-color", help="Force colored output", ), view: str | None = typer.Option( - None, + app_config.board.view, "--view", metavar="NAME", help="Configured board view name", ), width: int | None = typer.Option( - None, + app_config.board.width, "--width", metavar="N", min=80, help="Override auto-derived console width (minimum: 80)", ), max_results: int | None = typer.Option( - None, + app_config.board.max_results, "--limit", "-n", metavar="N", help="Maximum number of results to display (defaults to all results)", ), offset: int = typer.Option( - 0, + 0 if app_config.board.offset is None else app_config.board.offset, "--offset", metavar="N", help="Number of results to skip before displaying", ), days: int = typer.Option( - 7, + 7 if app_config.board.days is None else app_config.board.days, "--days", metavar="N", + min=1, help="Show completed tasks modified in last N days", ), order_by_level: bool = typer.Option( - False, + app_config.order_by_level, "--order-by-level", help="Order tasks by heading level (repeatable)", ), order_by_file_order: bool = typer.Option( - False, + app_config.order_by_file_order, "--order-by-file-order", help="Keep tasks in source file order (repeatable)", ), order_by_file_order_reversed: bool = typer.Option( - False, + app_config.order_by_file_order_reversed, "--order-by-file-order-reversed", help="Reverse source file order (repeatable)", ), order_by_priority: bool = typer.Option( - False, + app_config.order_by_priority, "--order-by-priority", help="Order by priority (repeatable)", ), order_by_timestamp_asc: bool = typer.Option( - False, + app_config.order_by_timestamp_asc, "--order-by-timestamp-asc", help="Order by oldest timestamp first (repeatable)", ), order_by_timestamp_desc: bool = typer.Option( - False, + app_config.order_by_timestamp_desc, "--order-by-timestamp-desc", help="Order by newest timestamp first (repeatable)", ), with_tags_as_category: bool = typer.Option( - False, + app_config.with_tags_as_category, "--with-tags-as-category", help="Preprocess nodes to set category from first tag", ), ) -> None: """Display tasks as an interactive board.""" + app_config = org.config.app.require_app_config(ctx) args = BoardArgs( files=files, config=config, exclude=exclude, mapping=mapping, - mapping_inline=None, - exclude_inline=None, + mapping_inline=app_config.mapping_inline, + exclude_inline=app_config.exclude_inline, todo_states=todo_states, done_states=done_states, filter_priority=filter_priority, @@ -347,7 +350,6 @@ def board( # noqa: PLR0913 order_by_timestamp_desc=order_by_timestamp_desc, with_tags_as_category=with_tags_as_category, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "board") + org.logging.log_command_config(app_config, "board") org.logging.log_command_arguments(args, "board") - run_board(args) + run_board(args, app_config) diff --git a/src/org/commands/stats/all.py b/src/org/commands/stats/all.py index e846f03..2139aaf 100644 --- a/src/org/commands/stats/all.py +++ b/src/org/commands/stats/all.py @@ -2,7 +2,6 @@ from __future__ import annotations -import sys from dataclasses import dataclass from typing import TYPE_CHECKING @@ -181,7 +180,7 @@ def _build_panel( color_enabled: bool, height: int | None = None, ) -> Panel: - """Create a Panel for a section using shared config.""" + """Create a Panel for a section using shared app_config.""" panel_height = _section_height(section) if height is None else height return Panel( _panel_body(section.body, color_enabled), @@ -520,7 +519,7 @@ def _format_groups_body( return lines_to_text(lines) -def run_stats(args: StatsAllArgs) -> None: +def run_stats(args: StatsAllArgs, config: org.config.app.AppConfig) -> None: """Run the stats command.""" color_enabled = setup_output(args) console = build_console(color_enabled, args.width) @@ -531,7 +530,7 @@ def run_stats(args: StatsAllArgs) -> None: with processing_status(console, color_enabled): mapping = org.config.app.resolve_mapping(args) exclude_set = org.config.app.resolve_exclude_set(args) - nodes, todo_states, done_states = load_and_process_data(args) + nodes, todo_states, done_states = load_and_process_data(args, config) if nodes: result = analyze(nodes, mapping, args.use, args.max_relations) date_from, date_until = resolve_date_filters(args) @@ -558,11 +557,12 @@ def run_stats(args: StatsAllArgs) -> None: console.print(layout) -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, app_config: org.config.app.AppConfig) -> None: """Register the stats all command.""" @app.command("all", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) def stats_all( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -575,55 +575,55 @@ def stats_all( # noqa: PLR0913 help="Config file name to load from current directory", ), exclude: str | None = typer.Option( - None, + app_config.exclude, "--exclude", metavar="FILE", help="File containing words to exclude (one per line)", ), mapping: str | None = typer.Option( - None, + app_config.mapping, "--mapping", metavar="FILE", help="JSON file containing tag mappings (dict[str, str])", ), todo_states: str = typer.Option( - "TODO", + ",".join(app_config.todo_states), "--todo-states", metavar="KEYS", help="Comma-separated list of incomplete task states", ), done_states: str = typer.Option( - "DONE", + ",".join(app_config.done_states), "--done-states", metavar="KEYS", help="Comma-separated list of completed task states", ), filter_priority: str | None = typer.Option( - None, + app_config.filter_priority, "--filter-priority", metavar="P", help="Filter tasks where priority equals P", ), filter_level: int | None = typer.Option( - None, + app_config.filter_level, "--filter-level", metavar="N", help="Filter tasks where heading level equals N", ), filter_repeats_above: int | None = typer.Option( - None, + app_config.filter_repeats_above, "--filter-repeats-above", metavar="N", help="Filter tasks where repeat count > N (non-inclusive)", ), filter_repeats_below: int | None = typer.Option( - None, + app_config.filter_repeats_below, "--filter-repeats-below", metavar="N", help="Filter tasks where repeat count < N (non-inclusive)", ), filter_date_from: str | None = typer.Option( - None, + app_config.filter_date_from, "--filter-date-from", metavar="TIMESTAMP", help=( @@ -633,7 +633,7 @@ def stats_all( # noqa: PLR0913 ), ), filter_date_until: str | None = typer.Option( - None, + app_config.filter_date_until, "--filter-date-until", metavar="TIMESTAMP", help=( @@ -643,25 +643,25 @@ def stats_all( # noqa: PLR0913 ), ), filter_properties: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_properties, "--filter-property", metavar="KEY=VALUE", help="Filter tasks with exact property match (case-sensitive, can specify multiple)", ), filter_tags: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_tags, "--filter-tag", metavar="REGEX", help="Filter tasks where any tag matches regex (case-sensitive, can specify multiple)", ), filter_headings: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_headings, "--filter-heading", metavar="REGEX", help="Filter tasks where heading matches regex (case-sensitive, can specify multiple)", ), filter_bodies: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_bodies, "--filter-body", metavar="REGEX", help=( @@ -670,17 +670,17 @@ def stats_all( # noqa: PLR0913 ), ), filter_completed: bool = typer.Option( - False, + app_config.filter_completed, "--filter-completed", help="Filter tasks with todo state in done keys", ), filter_not_completed: bool = typer.Option( - False, + app_config.filter_not_completed, "--filter-not-completed", help="Filter tasks with todo state in todo keys or without a todo state", ), color_flag: bool | None = typer.Option( - None, + app_config.color_flag, "--color/--no-color", help="Force colored output", ), @@ -692,56 +692,59 @@ def stats_all( # noqa: PLR0913 help="Override auto-derived console width (minimum: 50)", ), max_results: int | None = typer.Option( - None, + app_config.stats.all.max_results, "--limit", "-n", metavar="N", help="Maximum number of results to display", ), max_tags: int = typer.Option( - 5, + 5 if app_config.stats.all.max_tags is None else app_config.stats.all.max_tags, "--max-tags", metavar="N", help="Maximum number of tags to display in TAGS section (use 0 to omit section)", ), use: str = typer.Option( - "tags", + "tags" if app_config.stats.all.use is None else app_config.stats.all.use, "--use", metavar="CATEGORY", help="Category to display: tags, heading, or body", ), with_tags_as_category: bool = typer.Option( - False, + app_config.with_tags_as_category, "--with-tags-as-category", help="Preprocess nodes to set category from first tag", ), max_relations: int = typer.Option( - 5, + 5 if app_config.stats.all.max_relations is None else app_config.stats.all.max_relations, "--max-relations", metavar="N", help="Maximum number of relations to display per item (use 0 to omit sections)", ), min_group_size: int = typer.Option( - 2, + 2 + if app_config.stats.all.min_group_size is None + else app_config.stats.all.min_group_size, "--min-group-size", metavar="N", help="Minimum group size to display", ), max_groups: int = typer.Option( - 5, + 5 if app_config.stats.all.max_groups is None else app_config.stats.all.max_groups, "--max-groups", metavar="N", help="Maximum number of tag groups to display (use 0 to omit section)", ), ) -> None: """Show overall task stats.""" + app_config = org.config.app.require_app_config(ctx) args = StatsAllArgs( files=files, config=config, exclude=exclude, mapping=mapping, - mapping_inline=None, - exclude_inline=None, + mapping_inline=app_config.mapping_inline, + exclude_inline=app_config.exclude_inline, todo_states=todo_states, done_states=done_states, filter_priority=filter_priority, @@ -766,7 +769,6 @@ def stats_all( # noqa: PLR0913 min_group_size=min_group_size, max_groups=max_groups, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "stats all") + org.logging.log_command_config(app_config, "stats all") org.logging.log_command_arguments(args, "stats all") - run_stats(args) + run_stats(args, app_config) diff --git a/src/org/commands/stats/command.py b/src/org/commands/stats/command.py index 175b9e6..8edf021 100644 --- a/src/org/commands/stats/command.py +++ b/src/org/commands/stats/command.py @@ -11,31 +11,31 @@ from org.commands.stats import summary as stats_summary -stats_app = typer.Typer( - help="Analyze Org-mode archive files for task statistics.", - no_args_is_help=True, -) - - -@stats_app.callback() -def stats_callback( - verbose: bool | None = typer.Option( - None, - "--verbose", - "-v", - help="Enable verbose logging output", - ), -) -> None: - """Global stats CLI options.""" - if verbose is None and not org.config.app.DEFAULT_VERBOSE["value"]: - return - org.logging.configure_logging(org.config.app.resolve_verbose(verbose)) - - -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, config: org.config.app.AppConfig) -> None: """Register stats commands on the root CLI app.""" - stats_all.register(stats_app) - groups.register(stats_app) - stats_summary.register(stats_app) - tags.register(stats_app) + stats_app = typer.Typer( + help="Analyze Org-mode archive files for task statistics.", + no_args_is_help=True, + ) + + @stats_app.callback() + def stats_callback( + ctx: typer.Context, + verbose: bool | None = typer.Option( + None, + "--verbose", + "-v", + help="Enable verbose logging output", + ), + ) -> None: + """Global stats CLI options.""" + app_config = org.config.app.require_app_config(ctx) + if verbose is None and not app_config.verbose: + return + org.logging.configure_logging(app_config.verbose if verbose is None else verbose) + + stats_all.register(stats_app, config) + groups.register(stats_app, config) + stats_summary.register(stats_app, config) + tags.register(stats_app, config) app.add_typer(stats_app, name="stats") diff --git a/src/org/commands/stats/groups.py b/src/org/commands/stats/groups.py index 4c296c6..adefc6c 100644 --- a/src/org/commands/stats/groups.py +++ b/src/org/commands/stats/groups.py @@ -2,7 +2,6 @@ from __future__ import annotations -import sys from dataclasses import dataclass from typing import TYPE_CHECKING @@ -135,7 +134,7 @@ def format_group_list( return lines_to_text(apply_indent(lines, indent)) -def run_stats_groups(args: GroupsArgs) -> None: +def run_stats_groups(args: GroupsArgs, config: org.config.app.AppConfig) -> None: """Run the stats groups command.""" color_enabled = setup_output(args) console = build_console(color_enabled, args.width) @@ -145,7 +144,7 @@ def run_stats_groups(args: GroupsArgs) -> None: with processing_status(console, color_enabled): mapping = org.config.app.resolve_mapping(args) exclude_set = org.config.app.resolve_exclude_set(args) - nodes, _, _ = load_and_process_data(args) + nodes, _, _ = load_and_process_data(args, config) if not nodes: output = None @@ -196,7 +195,7 @@ def run_stats_groups(args: GroupsArgs) -> None: print_output(console, output, color_enabled, end="") -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, app_config: org.config.app.AppConfig) -> None: """Register the stats groups command.""" @app.command( @@ -204,6 +203,7 @@ def register(app: typer.Typer) -> None: context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, ) def stats_groups( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -216,55 +216,55 @@ def stats_groups( # noqa: PLR0913 help="Config file name to load from current directory", ), exclude: str | None = typer.Option( - None, + app_config.exclude, "--exclude", metavar="FILE", help="File containing words to exclude (one per line)", ), mapping: str | None = typer.Option( - None, + app_config.mapping, "--mapping", metavar="FILE", help="JSON file containing tag mappings (dict[str, str])", ), todo_states: str = typer.Option( - "TODO", + ",".join(app_config.todo_states), "--todo-states", metavar="KEYS", help="Comma-separated list of incomplete task states", ), done_states: str = typer.Option( - "DONE", + ",".join(app_config.done_states), "--done-states", metavar="KEYS", help="Comma-separated list of completed task states", ), filter_priority: str | None = typer.Option( - None, + app_config.filter_priority, "--filter-priority", metavar="P", help="Filter tasks where priority equals P", ), filter_level: int | None = typer.Option( - None, + app_config.filter_level, "--filter-level", metavar="N", help="Filter tasks where heading level equals N", ), filter_repeats_above: int | None = typer.Option( - None, + app_config.filter_repeats_above, "--filter-repeats-above", metavar="N", help="Filter tasks where repeat count > N (non-inclusive)", ), filter_repeats_below: int | None = typer.Option( - None, + app_config.filter_repeats_below, "--filter-repeats-below", metavar="N", help="Filter tasks where repeat count < N (non-inclusive)", ), filter_date_from: str | None = typer.Option( - None, + app_config.filter_date_from, "--filter-date-from", metavar="TIMESTAMP", help=( @@ -274,7 +274,7 @@ def stats_groups( # noqa: PLR0913 ), ), filter_date_until: str | None = typer.Option( - None, + app_config.filter_date_until, "--filter-date-until", metavar="TIMESTAMP", help=( @@ -284,25 +284,25 @@ def stats_groups( # noqa: PLR0913 ), ), filter_properties: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_properties, "--filter-property", metavar="KEY=VALUE", help="Filter tasks with exact property match (case-sensitive, can specify multiple)", ), filter_tags: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_tags, "--filter-tag", metavar="REGEX", help="Filter tasks where any tag matches regex (case-sensitive, can specify multiple)", ), filter_headings: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_headings, "--filter-heading", metavar="REGEX", help="Filter tasks where heading matches regex (case-sensitive, can specify multiple)", ), filter_bodies: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_bodies, "--filter-body", metavar="REGEX", help=( @@ -311,17 +311,17 @@ def stats_groups( # noqa: PLR0913 ), ), filter_completed: bool = typer.Option( - False, + app_config.filter_completed, "--filter-completed", help="Filter tasks with todo state in done keys", ), filter_not_completed: bool = typer.Option( - False, + app_config.filter_not_completed, "--filter-not-completed", help="Filter tasks with todo state in todo keys or without a todo state", ), color_flag: bool | None = typer.Option( - None, + app_config.color_flag, "--color/--no-color", help="Force colored output", ), @@ -333,44 +333,49 @@ def stats_groups( # noqa: PLR0913 help="Override auto-derived console width (minimum: 50)", ), max_results: int | None = typer.Option( - 10, + 10 + if app_config.stats.groups.max_results is None + else app_config.stats.groups.max_results, "--limit", "-n", metavar="N", help="Maximum number of results to display", ), use: str = typer.Option( - "tags", + "tags" if app_config.stats.groups.use is None else app_config.stats.groups.use, "--use", metavar="CATEGORY", help="Category to display: tags, heading, or body", ), groups: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.stats.groups.groups, "--group", metavar="TAGS", help="Comma-separated list of tags to group (can specify multiple)", ), with_tags_as_category: bool = typer.Option( - False, + app_config.with_tags_as_category, "--with-tags-as-category", help="Preprocess nodes to set category from first tag", ), max_relations: int = typer.Option( - 5, + 5 + if app_config.stats.groups.max_relations is None + else app_config.stats.groups.max_relations, "--max-relations", metavar="N", help="Maximum number of relations to consider per item (use 0 to omit sections)", ), ) -> None: """Show tag groups for selected groups or top results.""" + app_config = org.config.app.require_app_config(ctx) args = GroupsArgs( files=files, config=config, exclude=exclude, mapping=mapping, - mapping_inline=None, - exclude_inline=None, + mapping_inline=app_config.mapping_inline, + exclude_inline=app_config.exclude_inline, todo_states=todo_states, done_states=done_states, filter_priority=filter_priority, @@ -396,7 +401,6 @@ def stats_groups( # noqa: PLR0913 min_group_size=0, max_groups=0, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "stats groups") + org.logging.log_command_config(app_config, "stats groups") org.logging.log_command_arguments(args, "stats groups") - run_stats_groups(args) + run_stats_groups(args, app_config) diff --git a/src/org/commands/stats/summary.py b/src/org/commands/stats/summary.py index 170eb4e..fdc825c 100644 --- a/src/org/commands/stats/summary.py +++ b/src/org/commands/stats/summary.py @@ -2,7 +2,6 @@ from __future__ import annotations -import sys from dataclasses import dataclass from typing import TYPE_CHECKING @@ -218,13 +217,13 @@ def format_tasks_summary( return lines_to_text(apply_indent(lines, indent)) -def run_stats_summary(args: SummaryArgs) -> None: +def run_stats_summary(args: SummaryArgs, config: org.config.app.AppConfig) -> None: """Run the stats summary command.""" color_enabled = setup_output(args) console = build_console(color_enabled, args.width) _validate_summary_arguments(args) with processing_status(console, color_enabled): - nodes, todo_states, done_states = load_and_process_data(args) + nodes, todo_states, done_states = load_and_process_data(args, config) if not nodes: output = None @@ -271,7 +270,7 @@ def run_stats_summary(args: SummaryArgs) -> None: print_output(console, output, color_enabled, end="") -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, app_config: org.config.app.AppConfig) -> None: """Register the stats summary command.""" @app.command( @@ -279,6 +278,7 @@ def register(app: typer.Typer) -> None: context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, ) def stats_summary( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -291,55 +291,55 @@ def stats_summary( # noqa: PLR0913 help="Config file name to load from current directory", ), exclude: str | None = typer.Option( - None, + app_config.exclude, "--exclude", metavar="FILE", help="File containing words to exclude (one per line)", ), mapping: str | None = typer.Option( - None, + app_config.mapping, "--mapping", metavar="FILE", help="JSON file containing tag mappings (dict[str, str])", ), todo_states: str = typer.Option( - "TODO", + ",".join(app_config.todo_states), "--todo-states", metavar="KEYS", help="Comma-separated list of incomplete task states", ), done_states: str = typer.Option( - "DONE", + ",".join(app_config.done_states), "--done-states", metavar="KEYS", help="Comma-separated list of completed task states", ), filter_priority: str | None = typer.Option( - None, + app_config.filter_priority, "--filter-priority", metavar="P", help="Filter tasks where priority equals P", ), filter_level: int | None = typer.Option( - None, + app_config.filter_level, "--filter-level", metavar="N", help="Filter tasks where heading level equals N", ), filter_repeats_above: int | None = typer.Option( - None, + app_config.filter_repeats_above, "--filter-repeats-above", metavar="N", help="Filter tasks where repeat count > N (non-inclusive)", ), filter_repeats_below: int | None = typer.Option( - None, + app_config.filter_repeats_below, "--filter-repeats-below", metavar="N", help="Filter tasks where repeat count < N (non-inclusive)", ), filter_date_from: str | None = typer.Option( - None, + app_config.filter_date_from, "--filter-date-from", metavar="TIMESTAMP", help=( @@ -349,7 +349,7 @@ def stats_summary( # noqa: PLR0913 ), ), filter_date_until: str | None = typer.Option( - None, + app_config.filter_date_until, "--filter-date-until", metavar="TIMESTAMP", help=( @@ -359,25 +359,25 @@ def stats_summary( # noqa: PLR0913 ), ), filter_properties: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_properties, "--filter-property", metavar="KEY=VALUE", help="Filter tasks with exact property match (case-sensitive, can specify multiple)", ), filter_tags: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_tags, "--filter-tag", metavar="REGEX", help="Filter tasks where any tag matches regex (case-sensitive, can specify multiple)", ), filter_headings: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_headings, "--filter-heading", metavar="REGEX", help="Filter tasks where heading matches regex (case-sensitive, can specify multiple)", ), filter_bodies: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_bodies, "--filter-body", metavar="REGEX", help=( @@ -386,17 +386,17 @@ def stats_summary( # noqa: PLR0913 ), ), filter_completed: bool = typer.Option( - False, + app_config.filter_completed, "--filter-completed", help="Filter tasks with todo state in done keys", ), filter_not_completed: bool = typer.Option( - False, + app_config.filter_not_completed, "--filter-not-completed", help="Filter tasks with todo state in todo keys or without a todo state", ), color_flag: bool | None = typer.Option( - None, + app_config.color_flag, "--color/--no-color", help="Force colored output", ), @@ -408,26 +408,29 @@ def stats_summary( # noqa: PLR0913 help="Override auto-derived console width (minimum: 50)", ), max_results: int | None = typer.Option( - 10, + 10 + if app_config.stats.summary.max_results is None + else app_config.stats.summary.max_results, "--limit", "-n", metavar="N", help="Maximum number of results to display", ), with_tags_as_category: bool = typer.Option( - False, + app_config.with_tags_as_category, "--with-tags-as-category", help="Preprocess nodes to set category from first tag", ), ) -> None: """Show overall task stats without tag sections.""" + app_config = org.config.app.require_app_config(ctx) args = SummaryArgs( files=files, config=config, exclude=exclude, mapping=mapping, - mapping_inline=None, - exclude_inline=None, + mapping_inline=app_config.mapping_inline, + exclude_inline=app_config.exclude_inline, todo_states=todo_states, done_states=done_states, filter_priority=filter_priority, @@ -447,7 +450,6 @@ def stats_summary( # noqa: PLR0913 max_results=max_results, with_tags_as_category=with_tags_as_category, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "stats summary") + org.logging.log_command_config(app_config, "stats summary") org.logging.log_command_arguments(args, "stats summary") - run_stats_summary(args) + run_stats_summary(args, app_config) diff --git a/src/org/commands/stats/tags.py b/src/org/commands/stats/tags.py index adff97e..28e3371 100644 --- a/src/org/commands/stats/tags.py +++ b/src/org/commands/stats/tags.py @@ -2,7 +2,6 @@ from __future__ import annotations -import sys from dataclasses import dataclass from typing import TYPE_CHECKING @@ -158,7 +157,7 @@ def _resolve_tag_values(args: TagsArgs, mapping: dict[str, str]) -> list[str] | return tag_values -def run_stats_tags(args: TagsArgs) -> None: +def run_stats_tags(args: TagsArgs, config: org.config.app.AppConfig) -> None: """Run the stats tags command.""" color_enabled = setup_output(args) console = build_console(color_enabled, args.width) @@ -168,7 +167,7 @@ def run_stats_tags(args: TagsArgs) -> None: with processing_status(console, color_enabled): mapping = org.config.app.resolve_mapping(args) exclude_set = org.config.app.resolve_exclude_set(args) - nodes, _, _ = load_and_process_data(args) + nodes, _, _ = load_and_process_data(args, config) if not nodes: output = None @@ -206,7 +205,7 @@ def run_stats_tags(args: TagsArgs) -> None: print_output(console, output, color_enabled, end="") -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, app_config: org.config.app.AppConfig) -> None: """Register the stats tags command.""" @app.command( @@ -214,6 +213,7 @@ def register(app: typer.Typer) -> None: context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, ) def stats_tags( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -226,55 +226,55 @@ def stats_tags( # noqa: PLR0913 help="Config file name to load from current directory", ), exclude: str | None = typer.Option( - None, + app_config.exclude, "--exclude", metavar="FILE", help="File containing words to exclude (one per line)", ), mapping: str | None = typer.Option( - None, + app_config.mapping, "--mapping", metavar="FILE", help="JSON file containing tag mappings (dict[str, str])", ), todo_states: str = typer.Option( - "TODO", + ",".join(app_config.todo_states), "--todo-states", metavar="KEYS", help="Comma-separated list of incomplete task states", ), done_states: str = typer.Option( - "DONE", + ",".join(app_config.done_states), "--done-states", metavar="KEYS", help="Comma-separated list of completed task states", ), filter_priority: str | None = typer.Option( - None, + app_config.filter_priority, "--filter-priority", metavar="P", help="Filter tasks where priority equals P", ), filter_level: int | None = typer.Option( - None, + app_config.filter_level, "--filter-level", metavar="N", help="Filter tasks where heading level equals N", ), filter_repeats_above: int | None = typer.Option( - None, + app_config.filter_repeats_above, "--filter-repeats-above", metavar="N", help="Filter tasks where repeat count > N (non-inclusive)", ), filter_repeats_below: int | None = typer.Option( - None, + app_config.filter_repeats_below, "--filter-repeats-below", metavar="N", help="Filter tasks where repeat count < N (non-inclusive)", ), filter_date_from: str | None = typer.Option( - None, + app_config.filter_date_from, "--filter-date-from", metavar="TIMESTAMP", help=( @@ -284,7 +284,7 @@ def stats_tags( # noqa: PLR0913 ), ), filter_date_until: str | None = typer.Option( - None, + app_config.filter_date_until, "--filter-date-until", metavar="TIMESTAMP", help=( @@ -294,25 +294,25 @@ def stats_tags( # noqa: PLR0913 ), ), filter_properties: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_properties, "--filter-property", metavar="KEY=VALUE", help="Filter tasks with exact property match (case-sensitive, can specify multiple)", ), filter_tags: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_tags, "--filter-tag", metavar="REGEX", help="Filter tasks where any tag matches regex (case-sensitive, can specify multiple)", ), filter_headings: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_headings, "--filter-heading", metavar="REGEX", help="Filter tasks where heading matches regex (case-sensitive, can specify multiple)", ), filter_bodies: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_bodies, "--filter-body", metavar="REGEX", help=( @@ -321,17 +321,17 @@ def stats_tags( # noqa: PLR0913 ), ), filter_completed: bool = typer.Option( - False, + app_config.filter_completed, "--filter-completed", help="Filter tasks with todo state in done keys", ), filter_not_completed: bool = typer.Option( - False, + app_config.filter_not_completed, "--filter-not-completed", help="Filter tasks with todo state in todo keys or without a todo state", ), color_flag: bool | None = typer.Option( - None, + app_config.color_flag, "--color/--no-color", help="Force colored output", ), @@ -343,44 +343,47 @@ def stats_tags( # noqa: PLR0913 help="Override auto-derived console width (minimum: 50)", ), max_results: int | None = typer.Option( - 10, + 10 if app_config.stats.tags.max_results is None else app_config.stats.tags.max_results, "--limit", "-n", metavar="N", help="Maximum number of results to display", ), use: str = typer.Option( - "tags", + "tags" if app_config.stats.tags.use is None else app_config.stats.tags.use, "--use", metavar="CATEGORY", help="Category to display: tags, heading, or body", ), tags: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.stats.tags.tags, "--tag", metavar="TAG", help="Tag to display (can specify multiple)", ), with_tags_as_category: bool = typer.Option( - False, + app_config.with_tags_as_category, "--with-tags-as-category", help="Preprocess nodes to set category from first tag", ), max_relations: int = typer.Option( - 5, + 5 + if app_config.stats.tags.max_relations is None + else app_config.stats.tags.max_relations, "--max-relations", metavar="N", help="Maximum number of relations to display per item (use 0 to omit sections)", ), ) -> None: """Show tag stats for selected tags or top results.""" + app_config = org.config.app.require_app_config(ctx) args = TagsArgs( files=files, config=config, exclude=exclude, mapping=mapping, - mapping_inline=None, - exclude_inline=None, + mapping_inline=app_config.mapping_inline, + exclude_inline=app_config.exclude_inline, todo_states=todo_states, done_states=done_states, filter_priority=filter_priority, @@ -406,7 +409,6 @@ def stats_tags( # noqa: PLR0913 min_group_size=2, max_groups=0, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "stats tags") + org.logging.log_command_config(app_config, "stats tags") org.logging.log_command_arguments(args, "stats tags") - run_stats_tags(args) + run_stats_tags(args, app_config) diff --git a/src/org/commands/tasks/add.py b/src/org/commands/tasks/add.py index 87433e3..577d8de 100644 --- a/src/org/commands/tasks/add.py +++ b/src/org/commands/tasks/add.py @@ -311,8 +311,9 @@ def _attach_heading(document: Document, parent_heading: Heading | None, heading: parent_heading.children.append(heading) -def run_tasks_add(args: AddArgs) -> None: +def run_tasks_add(args: AddArgs, config: org.config.app.AppConfig) -> None: """Run the tasks add command.""" + del config _validate_heading_option_exclusivity(args) read_from_stdin = _should_read_task_from_stdin(args) @@ -334,11 +335,13 @@ def run_tasks_add(args: AddArgs) -> None: save_document(document) -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, config: org.config.app.AppConfig) -> None: """Register the tasks add command.""" + del config @app.command("add") def tasks_add( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -457,6 +460,8 @@ def tasks_add( # noqa: PLR0913 ), ) -> None: """Create a new task heading and append it to a selected org document.""" + app_config = org.config.app.require_app_config(ctx) + argv = sys.argv[1:] args = AddArgs( files=files, config=config, @@ -478,7 +483,7 @@ def tasks_add( # noqa: PLR0913 parent=parent, file=file, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks add") + del argv + org.logging.log_command_config(app_config, "tasks add") org.logging.log_command_arguments(args, "tasks add") - run_tasks_add(args) + run_tasks_add(args, app_config) diff --git a/src/org/commands/tasks/archive.py b/src/org/commands/tasks/archive.py index 46c38a3..3290956 100644 --- a/src/org/commands/tasks/archive.py +++ b/src/org/commands/tasks/archive.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import sys from dataclasses import dataclass import typer @@ -51,8 +50,9 @@ def _selected_archive_roots(headings: list[Heading]) -> list[Heading]: return roots -def run_tasks_archive(args: ArchiveArgs) -> None: +def run_tasks_archive(args: ArchiveArgs, config: org.config.app.AppConfig) -> None: """Run the tasks archive command.""" + del config filenames = resolve_input_paths(args.files) selector_query = resolve_task_selector_query(args.query_title, args.query_id, args.query) selected_headings = resolve_headings_by_query(filenames, selector_query) @@ -84,11 +84,13 @@ def run_tasks_archive(args: ArchiveArgs) -> None: typer.echo(f"Archived {len(archive_roots)} tasks.") -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, config: org.config.app.AppConfig) -> None: """Register the tasks archive command.""" + del config @app.command("archive") - def tasks_archive( + def tasks_archive( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -120,6 +122,7 @@ def tasks_archive( ), ) -> None: """Archive selected task heading and subtree.""" + app_config = org.config.app.require_app_config(ctx) args = ArchiveArgs( files=files, config=config, @@ -127,7 +130,6 @@ def tasks_archive( query_id=query_id, query=query, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks archive") + org.logging.log_command_config(app_config, "tasks archive") org.logging.log_command_arguments(args, "tasks archive") - run_tasks_archive(args) + run_tasks_archive(args, app_config) diff --git a/src/org/commands/tasks/capture/command.py b/src/org/commands/tasks/capture/command.py index 6fd489d..aabe6d3 100644 --- a/src/org/commands/tasks/capture/command.py +++ b/src/org/commands/tasks/capture/command.py @@ -32,9 +32,11 @@ def _is_interactive_terminal() -> bool: return sys.stdin.isatty() and sys.stdout.isatty() -def capture_task(args: TasksCaptureArgs) -> TasksCaptureResult: +def capture_task( + args: TasksCaptureArgs, + templates: dict[str, dict[str, str]], +) -> TasksCaptureResult: """Capture one task from templates and return created heading metadata.""" - templates = org.config.app.CONFIG_CAPTURE_TEMPLATES _require_templates(templates) interactive_used = False @@ -59,7 +61,7 @@ def capture_task(args: TasksCaptureArgs) -> TasksCaptureResult: f"'{resolved_args.template_name}'. Valid templates: {valid_names}", ) - plan = prepare_capture_plan(resolved_args, resolved_args.template_name) + plan = prepare_capture_plan(resolved_args, resolved_args.template_name, templates) if plan.unresolved_placeholders: if not interactive_terminal: raise _missing_placeholder_error(plan.unresolved_placeholders) @@ -71,20 +73,21 @@ def capture_task(args: TasksCaptureArgs) -> TasksCaptureResult: return finalize_capture_plan(plan, plan.values, interactive_used=interactive_used) -def run_tasks_capture(args: TasksCaptureArgs) -> None: +def run_tasks_capture(args: TasksCaptureArgs, config: org.config.app.AppConfig) -> None: """Run tasks capture command using configured templates.""" if args.template_name is None and not _is_interactive_terminal(): raise click.UsageError( "org tasks capture requires a TTY unless TEMPLATE_NAME and all values are provided", ) - result = capture_task(args) + result = capture_task(args, config.tasks.capture.templates) if not result.interactive_used: typer.echo(result.heading.id or result.heading.title_text) -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, config: org.config.app.AppConfig) -> None: """Register the tasks capture command.""" + del config @app.command( "capture", @@ -93,7 +96,8 @@ def register(app: typer.Typer) -> None: _CAPTURE_HELP_ENTRIES, ), ) - def capture( + def capture( # noqa: PLR0913 + ctx: typer.Context, template_name: str | None = typer.Argument( None, metavar="TEMPLATE_NAME", @@ -125,6 +129,7 @@ def capture( ), ) -> None: """Create a task from a configured capture template.""" + app_config = org.config.app.require_app_config(ctx) args = TasksCaptureArgs( template_name=template_name, config=config, @@ -133,7 +138,7 @@ def capture( set_values=set_values, ) org.logging.log_command_arguments(args, "tasks capture") - run_tasks_capture(args) + run_tasks_capture(args, app_config) __all__ = ["TasksCaptureArgs", "TasksCaptureResult", "register", "run_tasks_capture"] diff --git a/src/org/commands/tasks/capture/domain.py b/src/org/commands/tasks/capture/domain.py index 49b558b..ab08825 100644 --- a/src/org/commands/tasks/capture/domain.py +++ b/src/org/commands/tasks/capture/domain.py @@ -13,7 +13,6 @@ from org_parser.document import Document, Heading from rich.syntax import Syntax -import org.config.app from org.commands.tasks.common import load_document, resolve_parent_heading, save_document from org.pipeline.format import DEFAULT_OUTPUT_THEME from org.query.runner import compile_query_or_raise, execute_query_or_raise @@ -332,9 +331,12 @@ def _attach_heading(document: Document, parent_heading: Heading | None, heading: parent_heading.children.append(heading) -def prepare_capture_plan(args: TasksCaptureArgs, template_name: str) -> CapturePlan: +def prepare_capture_plan( + args: TasksCaptureArgs, + template_name: str, + templates: dict[str, dict[str, str]], +) -> CapturePlan: """Prepare capture plan from resolved template and current document state.""" - templates = org.config.app.CONFIG_CAPTURE_TEMPLATES template = templates[template_name] target_file = template["file"] if args.file is None else args.file template_parent_selector = template.get("parent") diff --git a/src/org/commands/tasks/command.py b/src/org/commands/tasks/command.py index 8f85ea4..920b36a 100644 --- a/src/org/commands/tasks/command.py +++ b/src/org/commands/tasks/command.py @@ -1,5 +1,9 @@ """Tasks command wiring.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + import typer from org.commands.tasks import add as tasks_add @@ -13,19 +17,23 @@ from org.commands.tasks import update as tasks_update -def register(app: typer.Typer) -> None: +if TYPE_CHECKING: + import org.config.app + + +def register(app: typer.Typer, config: org.config.app.AppConfig) -> None: """Register tasks commands on the root CLI app.""" tasks_app = typer.Typer( help="Search and update tasks in Org-mode archives.", no_args_is_help=True, ) - tasks_add.register(tasks_app) - tasks_archive.register(tasks_app) - tasks_capture.register(tasks_app) - tasks_edit.register(tasks_app) - tasks_find.register(tasks_app) - tasks_list.register(tasks_app) - tasks_query.register(tasks_app) - tasks_remove.register(tasks_app) - tasks_update.register(tasks_app) + tasks_add.register(tasks_app, config) + tasks_archive.register(tasks_app, config) + tasks_capture.register(tasks_app, config) + tasks_edit.register(tasks_app, config) + tasks_find.register(tasks_app, config) + tasks_list.register(tasks_app, config) + tasks_query.register(tasks_app, config) + tasks_remove.register(tasks_app, config) + tasks_update.register(tasks_app, config) app.add_typer(tasks_app, name="tasks") diff --git a/src/org/commands/tasks/common.py b/src/org/commands/tasks/common.py index 606175b..603b4f4 100644 --- a/src/org/commands/tasks/common.py +++ b/src/org/commands/tasks/common.py @@ -13,7 +13,6 @@ from org_parser.text import CompletionCounter from org_parser.time import Timestamp -import org.config.app from org.query.runner import compile_query_or_raise, execute_query_or_raise @@ -216,11 +215,6 @@ def capture_template_names(templates: dict[str, dict[str, str]]) -> list[str]: return sorted(templates) -def configured_capture_template_names() -> list[str]: - """Return stable capture template names from loaded config.""" - return capture_template_names(org.config.app.CONFIG_CAPTURE_TEMPLATES) - - def parse_clock_duration(value: str) -> timedelta: """Parse user-entered clock duration text.""" normalized = value.strip().lower() diff --git a/src/org/commands/tasks/edit.py b/src/org/commands/tasks/edit.py index e001686..1b45fd0 100644 --- a/src/org/commands/tasks/edit.py +++ b/src/org/commands/tasks/edit.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import sys from dataclasses import dataclass import typer @@ -32,8 +31,9 @@ class EditArgs: query: str | None -def run_tasks_edit(args: EditArgs) -> None: +def run_tasks_edit(args: EditArgs, config: org.config.app.AppConfig) -> None: """Run the tasks edit command.""" + del config filenames = resolve_input_paths(args.files) selector_query = resolve_task_selector_query(args.query_title, args.query_id, args.query) selected_headings = resolve_headings_by_query(filenames, selector_query) @@ -59,11 +59,13 @@ def run_tasks_edit(args: EditArgs) -> None: typer.echo("Edited 1 task.") -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, config: org.config.app.AppConfig) -> None: """Register the tasks edit command.""" + del config @app.command("edit") - def tasks_edit( + def tasks_edit( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -102,7 +104,7 @@ def tasks_edit( query_id=query_id, query=query, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks edit") + app_config = org.config.app.require_app_config(ctx) + org.logging.log_command_config(app_config, "tasks edit") org.logging.log_command_arguments(args, "tasks edit") - run_tasks_edit(args) + run_tasks_edit(args, app_config) diff --git a/src/org/commands/tasks/find.py b/src/org/commands/tasks/find.py index 37b8401..3dc861e 100644 --- a/src/org/commands/tasks/find.py +++ b/src/org/commands/tasks/find.py @@ -4,7 +4,6 @@ import json import re -import sys from dataclasses import dataclass from typing import cast @@ -126,8 +125,9 @@ def _nodes_with_context(nodes: list[Heading], include_context: int) -> list[Head return ordered_nodes -def run_tasks_find(args: TasksFindArgs) -> None: +def run_tasks_find(args: TasksFindArgs, config: org.config.app.AppConfig) -> None: """Run the tasks find command.""" + del config if args.include_context < 0: raise typer.BadParameter("--include-context must be non-negative") @@ -167,11 +167,12 @@ def run_tasks_find(args: TasksFindArgs) -> None: print_prepared_output(console, prepared_output) -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, app_config: org.config.app.AppConfig) -> None: """Register the tasks find command.""" @app.command("find") def find_command( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -184,25 +185,25 @@ def find_command( # noqa: PLR0913 help="Config file name to load from current directory", ), exclude: str | None = typer.Option( - None, + app_config.exclude, "--exclude", metavar="FILE", help="File containing words to exclude (one per line)", ), mapping: str | None = typer.Option( - None, + app_config.mapping, "--mapping", metavar="FILE", help="JSON file containing tag mappings (dict[str, str])", ), todo_states: str = typer.Option( - "TODO", + ",".join(app_config.todo_states), "--todo-states", metavar="KEYS", help="Comma-separated list of incomplete task states", ), done_states: str = typer.Option( - "DONE", + ",".join(app_config.done_states), "--done-states", metavar="KEYS", help="Comma-separated list of completed task states", @@ -244,13 +245,15 @@ def find_command( # noqa: PLR0913 ), ), include_context: int = typer.Option( - 0, + 0 + if app_config.tasks.find.include_context is None + else app_config.tasks.find.include_context, "--include-context", metavar="N", help="Include up to N parent levels for each matched task", ), color_flag: bool | None = typer.Option( - None, + app_config.color_flag, "--color/--no-color", help="Force colored output", ), @@ -262,30 +265,33 @@ def find_command( # noqa: PLR0913 help="Override auto-derived console width (minimum: 50)", ), out: str = typer.Option( - OutputFormat.ORG, + OutputFormat.ORG if app_config.tasks.find.out is None else app_config.tasks.find.out, "--out", help="Output format: org, json, or any pandoc writer format", ), out_theme: str = typer.Option( - DEFAULT_OUTPUT_THEME, + DEFAULT_OUTPUT_THEME + if app_config.tasks.find.out_theme is None + else app_config.tasks.find.out_theme, "--out-theme", help="Syntax theme for highlighted output blocks", ), pandoc_args: str | None = typer.Option( - None, + app_config.tasks.find.pandoc_args, "--pandoc-args", metavar="ARGS", help="Additional arguments forwarded to pandoc export", ), ) -> None: """Find tasks by selectors and full-text search.""" + app_config = org.config.app.require_app_config(ctx) args = TasksFindArgs( files=files, config=config, exclude=exclude, mapping=mapping, - mapping_inline=None, - exclude_inline=None, + mapping_inline=app_config.mapping_inline, + exclude_inline=app_config.exclude_inline, todo_states=todo_states, done_states=done_states, query_title=query_title, @@ -300,7 +306,6 @@ def find_command( # noqa: PLR0913 out_theme=out_theme, pandoc_args=pandoc_args, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks find") + org.logging.log_command_config(app_config, "tasks find") org.logging.log_command_arguments(args, "tasks find") - run_tasks_find(args) + run_tasks_find(args, app_config) diff --git a/src/org/commands/tasks/list/actions.py b/src/org/commands/tasks/list/actions.py index 0cf0bcd..e68a934 100644 --- a/src/org/commands/tasks/list/actions.py +++ b/src/org/commands/tasks/list/actions.py @@ -35,6 +35,8 @@ if TYPE_CHECKING: from org_parser.document import Document, Heading + from org.config.app import AppConfig + from .command import ListArgs, _TasksListSessionData @@ -55,6 +57,7 @@ class TasksListSession: scroll_offset: int status_message: str search_text: str + app_config: AppConfig TASKS_LIST_HELP_ENTRIES = [ @@ -144,7 +147,7 @@ def reload_session_nodes( ) -> bool: """Reload session nodes via standard processing pipeline.""" try: - nodes, todo_states, done_states = load_and_process_data(session.args) + nodes, todo_states, done_states = load_and_process_data(session.args, session.app_config) except typer.BadParameter as err: session.status_message = str(err) return False @@ -232,7 +235,7 @@ def apply_capture_task(session: TasksListSession, template_name: str) -> None: set_values=None, ) try: - capture_result = capture_task(capture_args) + capture_result = capture_task(capture_args, session.app_config.tasks.capture.templates) except KeyboardInterrupt: session.status_message = "Capture cancelled" return @@ -391,7 +394,11 @@ def move_selection(session: TasksListSession, step: int) -> None: session.selected_index = (session.selected_index + step) % len(session.visible_nodes) -def create_tasks_list_session(args: ListArgs, data: _TasksListSessionData) -> TasksListSession: +def create_tasks_list_session( + args: ListArgs, + config: AppConfig, + data: _TasksListSessionData, +) -> TasksListSession: """Create interactive tasks list session state.""" session = TasksListSession( args=args, @@ -404,6 +411,7 @@ def create_tasks_list_session(args: ListArgs, data: _TasksListSessionData) -> Ta scroll_offset=0, status_message="", search_text="", + app_config=config, ) ensure_selection_bounds(session) return session diff --git a/src/org/commands/tasks/list/app.py b/src/org/commands/tasks/list/app.py index 034e968..08ae5f3 100644 --- a/src/org/commands/tasks/list/app.py +++ b/src/org/commands/tasks/list/app.py @@ -11,6 +11,7 @@ from textual.containers import Vertical from textual.widgets import Static +import org.config.app import org.tui.app import org.tui.footer import org.tui.help @@ -18,7 +19,6 @@ import org.tui.selection from org.commands.tasks.common import ( PlanningTimestampField, - configured_capture_template_names, planning_prompt_label, tags_prompt_label, ) @@ -70,10 +70,15 @@ class TasksListApp(org.tui.app.CommandApp): Binding("escape", "quit_app", show=False), ] - def __init__(self, args: ListArgs, data: _TasksListSessionData) -> None: + def __init__( + self, + args: ListArgs, + config: org.config.app.AppConfig, + data: _TasksListSessionData, + ) -> None: """Build one tasks list app from CLI args and loaded session data.""" super().__init__() - self.session = create_tasks_list_session(args, data) + self.session = create_tasks_list_session(args, config, data) def compose(self) -> ComposeResult: """Build the main app layout.""" @@ -303,7 +308,7 @@ def _cancel_search(self, previous_text: str) -> None: def action_prompt_capture(self) -> None: """Prompt for a capture template and create a task.""" - template_names = configured_capture_template_names() + template_names = sorted(self.session.app_config.tasks.capture.templates) if not template_names: self.session.status_message = "No capture templates configured" self._refresh_view() @@ -372,6 +377,10 @@ def action_prompt_closed(self) -> None: self._prompt_planning("closed") -def run_tasks_list_app(args: ListArgs, data: _TasksListSessionData) -> None: +def run_tasks_list_app( + args: ListArgs, + config: org.config.app.AppConfig, + data: _TasksListSessionData, +) -> None: """Run the Textual-backed interactive tasks list app.""" - TasksListApp(args, data).run() + TasksListApp(args, config, data).run() diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index 0d124a9..a7371c6 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -347,6 +347,7 @@ def _run_tasks_list_static( def _run_tasks_list_interactive( args: ListArgs, + config: org.config.app.AppConfig, data: _TasksListSessionData, ) -> None: """Run interactive tasks-list UI session via Textual.""" @@ -354,7 +355,7 @@ def _run_tasks_list_interactive( console = build_console(data.color_enabled, args.width) console.print("No results", markup=False) return - run_tasks_list_app(args, data) + run_tasks_list_app(args, config, data) def _is_interactive_tty() -> bool: @@ -362,7 +363,7 @@ def _is_interactive_tty() -> bool: return sys.stdin.isatty() and sys.stdout.isatty() -def run_tasks_list(args: ListArgs) -> None: +def run_tasks_list(args: ListArgs, config: org.config.app.AppConfig) -> None: """Run the tasks list command.""" color_enabled = setup_output(args) console = build_console(color_enabled, args.width) @@ -373,7 +374,7 @@ def run_tasks_list(args: ListArgs) -> None: args.max_results = _resolve_tasks_limit(args.max_results, console.height) with processing_status(console, color_enabled): - nodes, todo_states, done_states = load_and_process_data(args) + nodes, todo_states, done_states = load_and_process_data(args, config) session_data = _TasksListSessionData( nodes=nodes, todo_states=todo_states, @@ -390,10 +391,10 @@ def run_tasks_list(args: ListArgs) -> None: "org tasks list requires a TTY unless --details or --out is provided", ) - _run_tasks_list_interactive(args, session_data) + _run_tasks_list_interactive(args, config, session_data) -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, app_config: org.config.app.AppConfig) -> None: """Register the tasks list command.""" @app.command( @@ -405,6 +406,7 @@ def register(app: typer.Typer) -> None: ), ) def tasks_list( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -417,55 +419,55 @@ def tasks_list( # noqa: PLR0913 help="Config file name to load from current directory", ), exclude: str | None = typer.Option( - None, + app_config.exclude, "--exclude", metavar="FILE", help="File containing words to exclude (one per line)", ), mapping: str | None = typer.Option( - None, + app_config.mapping, "--mapping", metavar="FILE", help="JSON file containing tag mappings (dict[str, str])", ), todo_states: str = typer.Option( - "TODO", + ",".join(app_config.todo_states), "--todo-states", metavar="KEYS", help="Comma-separated list of incomplete task states", ), done_states: str = typer.Option( - "DONE", + ",".join(app_config.done_states), "--done-states", metavar="KEYS", help="Comma-separated list of completed task states", ), filter_priority: str | None = typer.Option( - None, + app_config.filter_priority, "--filter-priority", metavar="P", help="Filter tasks where priority equals P", ), filter_level: int | None = typer.Option( - None, + app_config.filter_level, "--filter-level", metavar="N", help="Filter tasks where heading level equals N", ), filter_repeats_above: int | None = typer.Option( - None, + app_config.filter_repeats_above, "--filter-repeats-above", metavar="N", help="Filter tasks where repeat count > N (non-inclusive)", ), filter_repeats_below: int | None = typer.Option( - None, + app_config.filter_repeats_below, "--filter-repeats-below", metavar="N", help="Filter tasks where repeat count < N (non-inclusive)", ), filter_date_from: str | None = typer.Option( - None, + app_config.filter_date_from, "--filter-date-from", metavar="TIMESTAMP", help=( @@ -475,7 +477,7 @@ def tasks_list( # noqa: PLR0913 ), ), filter_date_until: str | None = typer.Option( - None, + app_config.filter_date_until, "--filter-date-until", metavar="TIMESTAMP", help=( @@ -485,25 +487,25 @@ def tasks_list( # noqa: PLR0913 ), ), filter_properties: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_properties, "--filter-property", metavar="KEY=VALUE", help="Filter tasks with exact property match (case-sensitive, can specify multiple)", ), filter_tags: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_tags, "--filter-tag", metavar="REGEX", help="Filter tasks where any tag matches regex (case-sensitive, can specify multiple)", ), filter_headings: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_headings, "--filter-heading", metavar="REGEX", help="Filter tasks where heading matches regex (case-sensitive, can specify multiple)", ), filter_bodies: list[str] | None = typer.Option( # noqa: B008 - None, + app_config.filter_bodies, "--filter-body", metavar="REGEX", help=( @@ -512,17 +514,17 @@ def tasks_list( # noqa: PLR0913 ), ), filter_completed: bool = typer.Option( - False, + app_config.filter_completed, "--filter-completed", help="Filter tasks with todo state in done keys", ), filter_not_completed: bool = typer.Option( - False, + app_config.filter_not_completed, "--filter-not-completed", help="Filter tasks with todo state in todo keys or without a todo state", ), color_flag: bool | None = typer.Option( - None, + app_config.color_flag, "--color/--no-color", help="Force colored output", ), @@ -534,7 +536,7 @@ def tasks_list( # noqa: PLR0913 help="Override auto-derived console width (minimum: 50)", ), max_results: int | None = typer.Option( - None, + app_config.tasks.list.max_results, "--limit", "-n", metavar="N", @@ -547,63 +549,66 @@ def tasks_list( # noqa: PLR0913 help="Number of results to skip before displaying", ), details: bool | None = typer.Option( - None, + app_config.tasks.list.details, "--details", help="Show full org node details", ), order_by_level: bool = typer.Option( - False, + app_config.order_by_level, "--order-by-level", help="Order tasks by heading level (repeatable)", ), order_by_file_order: bool = typer.Option( - False, + app_config.order_by_file_order, "--order-by-file-order", help="Keep tasks in source file order (repeatable)", ), order_by_file_order_reversed: bool = typer.Option( - False, + app_config.order_by_file_order_reversed, "--order-by-file-order-reversed", help="Reverse source file order (repeatable)", ), order_by_priority: bool = typer.Option( - False, + app_config.order_by_priority, "--order-by-priority", help="Order by priority (repeatable)", ), order_by_timestamp_asc: bool = typer.Option( - False, + app_config.order_by_timestamp_asc, "--order-by-timestamp-asc", help="Order by oldest timestamp first (repeatable)", ), order_by_timestamp_desc: bool = typer.Option( - False, + app_config.order_by_timestamp_desc, "--order-by-timestamp-desc", help="Order by newest timestamp first (repeatable)", ), with_tags_as_category: bool = typer.Option( - False, + app_config.with_tags_as_category, "--with-tags-as-category", help="Preprocess nodes to set category from first tag", ), out: str | None = typer.Option( - None, + app_config.tasks.list.out, "--out", help="Output format: org, json, or any pandoc writer format", ), out_theme: str = typer.Option( - DEFAULT_OUTPUT_THEME, + DEFAULT_OUTPUT_THEME + if app_config.tasks.list.out_theme is None + else app_config.tasks.list.out_theme, "--out-theme", help="Syntax theme for highlighted output blocks", ), pandoc_args: str | None = typer.Option( - None, + app_config.tasks.list.pandoc_args, "--pandoc-args", metavar="ARGS", help="Additional arguments forwarded to pandoc export", ), ) -> None: """List tasks matching filters.""" + app_config = org.config.app.require_app_config(ctx) details_switch_present = details is not None out_switch_present = out is not None args = ListArgs( @@ -611,8 +616,8 @@ def tasks_list( # noqa: PLR0913 config=config, exclude=exclude, mapping=mapping, - mapping_inline=None, - exclude_inline=None, + mapping_inline=app_config.mapping_inline, + exclude_inline=app_config.exclude_inline, todo_states=todo_states, done_states=done_states, filter_priority=filter_priority, @@ -643,8 +648,7 @@ def tasks_list( # noqa: PLR0913 out_theme=out_theme, pandoc_args=pandoc_args, ) - org.config.app.apply_config_defaults(args) args.noninteractive = details_switch_present or out_switch_present - org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks list") + org.logging.log_command_config(app_config, "tasks list") org.logging.log_command_arguments(args, "tasks list") - run_tasks_list(args) + run_tasks_list(args, app_config) diff --git a/src/org/commands/tasks/query.py b/src/org/commands/tasks/query.py index c702b5d..c11d8c8 100644 --- a/src/org/commands/tasks/query.py +++ b/src/org/commands/tasks/query.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import sys from dataclasses import dataclass from typing import TYPE_CHECKING, Protocol, cast @@ -220,8 +219,9 @@ class TasksQueryArgs: pandoc_args: str | None -def run_tasks_query(args: TasksQueryArgs) -> None: +def run_tasks_query(args: TasksQueryArgs, config: org.config.app.AppConfig) -> None: """Run the tasks query command.""" + del config color_enabled = setup_output(args) console = build_console(color_enabled, args.width) if args.offset < 0: @@ -265,11 +265,12 @@ def run_tasks_query(args: TasksQueryArgs) -> None: print_prepared_output(console, prepared_output) -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, app_config: org.config.app.AppConfig) -> None: """Register the tasks query command.""" @app.command("query") def query_command( # noqa: PLR0913 + ctx: typer.Context, query: str = typer.Argument(..., metavar="QUERY", help="jq-style query expression"), files: list[str] | None = typer.Argument( # noqa: B008 None, @@ -283,80 +284,85 @@ def query_command( # noqa: PLR0913 help="Config file name to load from current directory", ), exclude: str | None = typer.Option( - None, + app_config.exclude, "--exclude", metavar="FILE", help="File containing words to exclude (one per line)", ), mapping: str | None = typer.Option( - None, + app_config.mapping, "--mapping", metavar="FILE", help="JSON file containing tag mappings (dict[str, str])", ), todo_states: str = typer.Option( - "TODO", + ",".join(app_config.todo_states), "--todo-states", metavar="KEYS", help="Comma-separated list of incomplete task states", ), done_states: str = typer.Option( - "DONE", + ",".join(app_config.done_states), "--done-states", metavar="KEYS", help="Comma-separated list of completed task states", ), color_flag: bool | None = typer.Option( - None, + app_config.color_flag, "--color/--no-color", help="Force colored output", ), width: int | None = typer.Option( - None, + app_config.tasks.query.width, "--width", metavar="N", min=50, help="Override auto-derived console width (minimum: 50)", ), max_results: int = typer.Option( - 10, + 10 + if app_config.tasks.query.max_results is None + else app_config.tasks.query.max_results, "--limit", "-n", metavar="N", help="Maximum number of results to display", ), offset: int = typer.Option( - 0, + 0 if app_config.tasks.query.offset is None else app_config.tasks.query.offset, "--offset", metavar="N", help="Number of results to skip before displaying", ), out: str = typer.Option( - OutputFormat.ORG, + OutputFormat.ORG if app_config.tasks.query.out is None else app_config.tasks.query.out, "--out", help="Output format: org, json, or any pandoc writer format", ), out_theme: str = typer.Option( - DEFAULT_OUTPUT_THEME, + DEFAULT_OUTPUT_THEME + if app_config.tasks.query.out_theme is None + else app_config.tasks.query.out_theme, "--out-theme", help="Syntax theme for highlighted output blocks", ), pandoc_args: str | None = typer.Option( - None, + app_config.tasks.query.pandoc_args, "--pandoc-args", metavar="ARGS", help="Additional arguments forwarded to pandoc export", ), ) -> None: """Query tasks using jq-style expressions.""" + app_config = org.config.app.require_app_config(ctx) args = TasksQueryArgs( query=query, files=files, config=config, exclude=exclude, mapping=mapping, - mapping_inline=None, - exclude_inline=None, + mapping_inline=app_config.mapping_inline, + exclude_inline=app_config.exclude_inline, todo_states=todo_states, done_states=done_states, color_flag=color_flag, @@ -367,7 +373,6 @@ def query_command( # noqa: PLR0913 out_theme=out_theme, pandoc_args=pandoc_args, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks query") + org.logging.log_command_config(app_config, "tasks query") org.logging.log_command_arguments(args, "tasks query") - run_tasks_query(args) + run_tasks_query(args, app_config) diff --git a/src/org/commands/tasks/remove.py b/src/org/commands/tasks/remove.py index 85f5288..19454bf 100644 --- a/src/org/commands/tasks/remove.py +++ b/src/org/commands/tasks/remove.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import sys from dataclasses import dataclass import typer @@ -63,8 +62,9 @@ def _selected_delete_roots(headings: list[Heading]) -> list[Heading]: return roots -def run_tasks_remove(args: RemoveArgs) -> None: +def run_tasks_remove(args: RemoveArgs, config: org.config.app.AppConfig) -> None: """Run the tasks remove command.""" + del config filenames = resolve_input_paths(args.files) selector_query = resolve_task_selector_query(args.query_title, args.query_id, args.query) @@ -108,11 +108,12 @@ def run_tasks_remove(args: RemoveArgs) -> None: typer.echo(f"Deleted {selected_count} tasks.") -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, config: org.config.app.AppConfig) -> None: """Register the tasks remove command.""" @app.command("remove") def tasks_remove( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -148,12 +149,13 @@ def tasks_remove( # noqa: PLR0913 help="Automatically confirm without prompting", ), color_flag: bool | None = typer.Option( - None, + config.color_flag, "--color/--no-color", help="Force colored output", ), ) -> None: """Delete one task heading and its subtree from a selected org document.""" + app_config = org.config.app.require_app_config(ctx) args = RemoveArgs( files=files, config=config, @@ -163,7 +165,6 @@ def tasks_remove( # noqa: PLR0913 yes=yes, color_flag=color_flag, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks remove") + org.logging.log_command_config(app_config, "tasks remove") org.logging.log_command_arguments(args, "tasks remove") - run_tasks_remove(args) + run_tasks_remove(args, app_config) diff --git a/src/org/commands/tasks/update.py b/src/org/commands/tasks/update.py index 3970709..94c9c8a 100644 --- a/src/org/commands/tasks/update.py +++ b/src/org/commands/tasks/update.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import sys from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -399,8 +398,9 @@ def _apply_field_updates(args: UpdateArgs, heading: Heading) -> None: heading.body = args.body -def run_tasks_update(args: UpdateArgs) -> None: +def run_tasks_update(args: UpdateArgs, config: org.config.app.AppConfig) -> None: """Run the tasks update command.""" + del config selector_query = resolve_task_selector_query(args.query_title, args.query_id, args.query) _validate_update_option_conflicts(args) filenames = resolve_input_paths(args.files) @@ -463,11 +463,12 @@ def run_tasks_update(args: UpdateArgs) -> None: typer.echo(f"Updated {selected_count} tasks.") -def register(app: typer.Typer) -> None: +def register(app: typer.Typer, config: org.config.app.AppConfig) -> None: """Register the tasks update command.""" @app.command("update") def tasks_update( # noqa: PLR0913 + ctx: typer.Context, files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -647,12 +648,13 @@ def tasks_update( # noqa: PLR0913 help="Automatically confirm without prompting", ), color_flag: bool | None = typer.Option( - None, + config.color_flag, "--color/--no-color", help="Force colored output", ), ) -> None: """Update one task heading and persist the modified org document.""" + app_config = org.config.app.require_app_config(ctx) args = UpdateArgs( files=files, config=config, @@ -686,7 +688,6 @@ def tasks_update( # noqa: PLR0913 yes=yes, color_flag=color_flag, ) - org.config.app.apply_config_defaults(args) - org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks update") + org.logging.log_command_config(app_config, "tasks update") org.logging.log_command_arguments(args, "tasks update") - run_tasks_update(args) + run_tasks_update(args, app_config) diff --git a/src/org/config/app.py b/src/org/config/app.py index 796dcf6..e4484f9 100644 --- a/src/org/config/app.py +++ b/src/org/config/app.py @@ -4,10 +4,10 @@ import json import re -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Protocol, TypedDict, TypeGuard, cast +from typing import TYPE_CHECKING, TypeGuard, TypeVar, cast import typer import yaml @@ -64,20 +64,8 @@ } -CONFIG_APPEND_DEFAULTS: dict[str, list[str]] = {} -CONFIG_INLINE_DEFAULTS: dict[str, object] = {} -CONFIG_DEFAULTS: dict[str, object] = {} -CONFIG_CUSTOM_FILTERS: dict[str, str] = {} -CONFIG_CUSTOM_ORDER_BY: dict[str, str] = {} -CONFIG_CUSTOM_WITH: dict[str, str] = {} -CONFIG_CAPTURE_TEMPLATES: dict[str, dict[str, str]] = {} -CONFIG_BOARD_VIEWS: dict[str, BoardViewConfig] = {} -CONFIG_AGENDA_VIEWS: dict[str, AgendaViewConfig] = {} - MAP: dict[str, str] = {} -DEFAULT_VERBOSE: dict[str, bool] = {"value": False} - TAGS: set[str] = set() HEADING = { @@ -159,58 +147,10 @@ CATEGORY_NAMES = {"tags": "tags", "heading": "heading words", "body": "body words"} +T = TypeVar("T") -DEST_TO_OPTION_NAME: dict[str, str] = { - "date": "--date", - "days": "--days", - "color_flag": "--color/--no-color", - "config": "--config", - "details": "--details", - "done_states": "--done-states", - "exclude": "--exclude", - "exclude_inline": "--exclude", - "filter_bodies": "--filter-body", - "filter_completed": "--filter-completed", - "filter_date_from": "--filter-date-from", - "filter_date_until": "--filter-date-until", - "filter_priority": "--filter-priority", - "filter_headings": "--filter-heading", - "filter_level": "--filter-level", - "filter_not_completed": "--filter-not-completed", - "filter_properties": "--filter-property", - "filter_repeats_above": "--filter-repeats-above", - "filter_repeats_below": "--filter-repeats-below", - "filter_tags": "--filter-tag", - "groups": "--group", - "mapping": "--mapping", - "mapping_inline": "--mapping", - "max_groups": "--max-groups", - "max_relations": "--max-relations", - "max_results": "--limit", - "max_tags": "--max-tags", - "min_group_size": "--min-group-size", - "no_completed": "--no-completed", - "no_overdue": "--no-overdue", - "no_upcoming": "--no-upcoming", - "future_repeats": "--future-repeats/--no-future-repeats", - "offset": "--offset", - "out": "--out", - "out_theme": "--out-theme", - "pandoc_args": "--pandoc-args", - "order_by_file_order": "--order-by-file-order", - "order_by_file_order_reversed": "--order-by-file-order-reversed", - "order_by_priority": "--order-by-priority", - "order_by_level": "--order-by-level", - "order_by_timestamp_asc": "--order-by-timestamp-asc", - "order_by_timestamp_desc": "--order-by-timestamp-desc", - "tags": "--tag", - "todo_states": "--todo-states", - "use": "--use", - "view": "--view", - "verbose": "--verbose", - "with_tags_as_category": "--with-tags-as-category", - "width": "--width", -} +if TYPE_CHECKING: + from collections.abc import Callable def normalize_exclude_values(values: list[str]) -> set[str]: @@ -221,7 +161,7 @@ def normalize_exclude_values(values: list[str]) -> set[str]: def resolve_verbose(verbose: bool | None) -> bool: """Resolve effective verbose setting from CLI flag and config defaults.""" if verbose is None: - return DEFAULT_VERBOSE["value"] + return False return verbose @@ -305,42 +245,6 @@ def resolve_exclude_set(args: object) -> set[str]: return load_exclude_list(exclude_file) or DEFAULT_EXCLUDE -@dataclass -class ConfigOptions: - """Config option mapping metadata.""" - - int_options: dict[str, tuple[str, int | None]] - bool_options: dict[str, tuple[str, bool | None]] - str_options: dict[str, str] - list_options: dict[str, str] - - -@dataclass -class ConfigContext: - """Config default targets and option metadata.""" - - defaults: dict[str, object] - stats_defaults: dict[str, object] - append_defaults: dict[str, list[str]] - global_options: ConfigOptions - stats_options: ConfigOptions - - -@dataclass -class LoadedCliConfig: - """Fully parsed CLI config payload.""" - - defaults: dict[str, object] - append_defaults: dict[str, list[str]] - inline_defaults: dict[str, object] - custom_filters: dict[str, str] - custom_order_by: dict[str, str] - custom_with: dict[str, str] - capture_templates: dict[str, dict[str, str]] - board_views: dict[str, BoardViewConfig] - agenda_views: dict[str, AgendaViewConfig] - - @dataclass(frozen=True) class BoardColumnConfig: """One configured board column.""" @@ -377,36 +281,203 @@ class AgendaViewConfig: sections: list[AgendaSectionConfig] -class StatsDefaultMap(TypedDict): - """Default map values for stats subcommands.""" +@dataclass(frozen=True) +class NamedQueryConfig: + """One named custom query snippet.""" - all: dict[str, object] - summary: dict[str, object] - tags: dict[str, object] - groups: dict[str, object] + name: str + query: str -class TasksDefaultMap(TypedDict): - """Default map values for tasks subcommands.""" +@dataclass +class StatsAllConfig: + """Structured configuration for stats all defaults.""" - list: dict[str, object] - query: dict[str, object] + max_results: int | None = None + max_tags: int | None = None + max_relations: int | None = None + min_group_size: int | None = None + max_groups: int | None = None + use: str | None = None -class CliDefaultMap(TypedDict): - """Top-level Typer Click default_map structure.""" +@dataclass +class StatsSummaryConfig: + """Structured configuration for stats summary defaults.""" - stats: StatsDefaultMap - tasks: TasksDefaultMap - board: dict[str, object] - agenda: dict[str, object] + max_results: int | None = None -class ConfigDefaultsTarget(Protocol): - """Protocol for args that accept inline defaults.""" +@dataclass +class StatsTagsConfig: + """Structured configuration for stats tags defaults.""" + + max_results: int | None = None + max_relations: int | None = None + use: str | None = None + tags: list[str] | None = None + + +@dataclass +class StatsGroupsConfig: + """Structured configuration for stats groups defaults.""" + + max_results: int | None = None + max_relations: int | None = None + use: str | None = None + groups: list[str] | None = None + + +@dataclass +class StatsConfig: + """Structured configuration for stats subcommand defaults.""" + + all: StatsAllConfig = field(default_factory=StatsAllConfig) + summary: StatsSummaryConfig = field(default_factory=StatsSummaryConfig) + tags: StatsTagsConfig = field(default_factory=StatsTagsConfig) + groups: StatsGroupsConfig = field(default_factory=StatsGroupsConfig) - mapping_inline: dict[str, str] | None - exclude_inline: list[str] | None + +@dataclass +class TasksCaptureConfig: + """Structured configuration for tasks capture templates.""" + + templates: dict[str, dict[str, str]] = field(default_factory=dict) + + +@dataclass +class TasksListConfig: + """Structured configuration for tasks list defaults.""" + + max_results: int | None = None + details: bool | None = None + out: str | None = None + out_theme: str | None = None + pandoc_args: str | None = None + + +@dataclass +class TasksQueryConfig: + """Structured configuration for tasks query defaults.""" + + max_results: int | None = None + offset: int | None = None + width: int | None = None + out: str | None = None + out_theme: str | None = None + pandoc_args: str | None = None + + +@dataclass +class TasksFindConfig: + """Structured configuration for tasks find defaults.""" + + include_context: int | None = None + out: str | None = None + out_theme: str | None = None + pandoc_args: str | None = None + + +@dataclass +class TasksConfig: + """Structured configuration for tasks subcommand defaults.""" + + capture: TasksCaptureConfig = field(default_factory=TasksCaptureConfig) + list: TasksListConfig = field(default_factory=TasksListConfig) + query: TasksQueryConfig = field(default_factory=TasksQueryConfig) + find: TasksFindConfig = field(default_factory=TasksFindConfig) + + +@dataclass +class AgendaConfig: + """Structured configuration for agenda command settings.""" + + views: dict[str, AgendaViewConfig] = field(default_factory=dict) + date: str | None = None + days: int | None = None + no_completed: bool | None = None + no_overdue: bool | None = None + no_upcoming: bool | None = None + future_repeats: bool | None = None + view: str | None = None + max_results: int | None = None + offset: int | None = None + width: int | None = None + + +@dataclass +class BoardConfig: + """Structured configuration for board command settings.""" + + views: dict[str, BoardViewConfig] = field(default_factory=dict) + view: str | None = None + days: int | None = None + max_results: int | None = None + offset: int | None = None + width: int | None = None + + +@dataclass +class AppConfig: + """Structured application configuration passed through ctx.obj.""" + + config_path: str + color_flag: bool | None = None + verbose: bool = False + todo_states: list[str] = field(default_factory=lambda: ["TODO"]) + done_states: list[str] = field(default_factory=lambda: ["DONE"]) + exclude: str | None = None + exclude_inline: list[str] | None = None + mapping: str | None = None + mapping_inline: dict[str, str] | None = None + filter_priority: str | None = None + filter_level: int | None = None + filter_repeats_above: int | None = None + filter_repeats_below: int | None = None + filter_date_from: str | None = None + filter_date_until: str | None = None + filter_properties: list[str] | None = None + filter_tags: list[str] | None = None + filter_headings: list[str] | None = None + filter_bodies: list[str] | None = None + filter_completed: bool = False + filter_not_completed: bool = False + order_by_file_order: bool = False + order_by_file_order_reversed: bool = False + order_by_priority: bool = False + order_by_level: bool = False + order_by_timestamp_asc: bool = False + order_by_timestamp_desc: bool = False + with_tags_as_category: bool = False + filters: list[NamedQueryConfig] = field(default_factory=list) + orderings: list[NamedQueryConfig] = field(default_factory=list) + mutators: list[NamedQueryConfig] = field(default_factory=list) + stats: StatsConfig = field(default_factory=StatsConfig) + tasks: TasksConfig = field(default_factory=TasksConfig) + agenda: AgendaConfig = field(default_factory=AgendaConfig) + board: BoardConfig = field(default_factory=BoardConfig) + + def custom_filter_map(self) -> dict[str, str]: + """Return configured custom filters keyed by name.""" + return {item.name: item.query for item in self.filters} + + def custom_order_by_map(self) -> dict[str, str]: + """Return configured custom orderings keyed by name.""" + return {item.name: item.query for item in self.orderings} + + def custom_with_map(self) -> dict[str, str]: + """Return configured custom mutators keyed by name.""" + return {item.name: item.query for item in self.mutators} + + @property + def board_views(self) -> dict[str, BoardViewConfig]: + """Compatibility accessor for board views.""" + return self.board.views + + @property + def agenda_views(self) -> dict[str, AgendaViewConfig]: + """Compatibility accessor for agenda views.""" + return self.agenda.views def load_config(filepath: str) -> tuple[dict[str, object], bool]: @@ -479,20 +550,6 @@ def is_string_dict(value: object) -> TypeGuard[dict[str, str]]: ) -def parse_color_defaults(config: dict[str, object]) -> tuple[dict[str, object], bool]: - """Parse color-related config defaults.""" - defaults: dict[str, object] = {} - if "--color" in config and "--no-color" in config: - return ({}, False) - - if "--color" in config: - defaults["color_flag"] = True - if "--no-color" in config: - defaults["color_flag"] = False - - return (defaults, True) - - def validate_int_option(value: object, min_value: int | None) -> int | None: """Validate integer option value.""" if not isinstance(value, int): @@ -551,235 +608,190 @@ def validate_list_option(key: str, value: object) -> list[str] | None: return list(value) -def apply_mapping_config(value: object, defaults: dict[str, object]) -> bool: - """Apply mapping config entry.""" - if isinstance(value, str): - if not value.strip(): - return False - defaults["mapping"] = value - return True - if is_string_dict(value): - defaults["mapping_inline"] = value - return True - return False - - -def apply_exclude_config(value: object, defaults: dict[str, object]) -> bool: - """Apply exclude config entry.""" - if isinstance(value, str): - if not value.strip(): - return False - defaults["exclude"] = value - return True - if is_string_list(value): - defaults["exclude_inline"] = list(value) - return True - return False +def _parse_optional_string_field( + section: dict[str, object], + key: str, + option_name: str, +) -> tuple[bool, str | None]: + """Parse one optional string field from a config section.""" + if key not in section: + return (True, None) + value = validate_str_option(option_name, section[key]) + return (value is not None, value) -def apply_int_option( - value: object, - dest: str, +def _parse_optional_int_field( + section: dict[str, object], + key: str, min_value: int | None, - defaults: dict[str, object], -) -> bool: - """Apply integer config option.""" - int_value = validate_int_option(value, min_value) - if int_value is None: - return False - defaults[dest] = int_value - return True - - -def apply_bool_option( - value: object, - dest: str, - configured_value: bool | None, - defaults: dict[str, object], -) -> bool: - """Apply boolean config option.""" - if configured_value is None and not isinstance(value, bool): - return False - - defaults[dest] = value if configured_value is None else configured_value - return True +) -> tuple[bool, int | None]: + """Parse one optional integer field from a config section.""" + if key not in section: + return (True, None) + value = validate_int_option(section[key], min_value) + return (value is not None, value) -def apply_str_option( +def _parse_optional_bool_field( + section: dict[str, object], key: str, - value: object, - dest: str, - defaults: dict[str, object], -) -> bool: - """Apply string config option.""" - str_value = validate_str_option(key, value) - if str_value is None: - return False - defaults[dest] = str_value - return True +) -> tuple[bool, bool | None]: + """Parse one optional boolean field from a config section.""" + if key not in section: + return (True, None) + value = section[key] + if not isinstance(value, bool): + return (False, None) + return (True, value) -def apply_list_option( - key: str, - value: object, - dest: str, - append_defaults: dict[str, list[str]], -) -> bool: - """Apply list config option.""" - list_value = validate_list_option(key, value) - if list_value is None: - return False - append_defaults[dest] = list_value - return True +def _parse_named_query_sections( + config: dict[str, object], +) -> tuple[dict[str, str], dict[str, str], dict[str, str]] | None: + """Parse the custom filter/order-by/with sections.""" + filter_section = config.get("filter", {}) + order_by_section = config.get("order-by", {}) + with_section = config.get("with", {}) + if ( + not is_string_dict(filter_section) + or not is_string_dict(order_by_section) + or not is_string_dict(with_section) + ): + return None + return (filter_section, order_by_section, with_section) -def apply_config_entry_by_options( +def _parse_optional_string_list_field( + section: dict[str, object], key: str, - value: object, - defaults: dict[str, object], - append_defaults: dict[str, list[str]], - options: ConfigOptions, -) -> bool: - """Apply a config entry using option metadata.""" - if key in options.int_options: - dest, min_value = options.int_options[key] - return apply_int_option(value, dest, min_value, defaults) - if key in options.bool_options: - dest, configured_value = options.bool_options[key] - return apply_bool_option(value, dest, configured_value, defaults) - if key in options.str_options: - return apply_str_option(key, value, options.str_options[key], defaults) - if key in options.list_options: - return apply_list_option(key, value, options.list_options[key], append_defaults) +) -> tuple[bool, list[str] | None]: + """Parse one optional raw string-list field from a config section.""" + if key not in section: + return (True, None) + value = section[key] + if not is_string_list(value): + return (False, None) + return (True, list(value)) + + +def _parse_shared_simple_entry(key: str, value: object) -> tuple[bool, dict[str, object]]: + """Parse one simple shared top-level config entry.""" + if key == "color_flag": + return (isinstance(value, bool), {key: value} if isinstance(value, bool) else {}) + + bool_keys = { + "verbose", + "filter_completed", + "filter_not_completed", + "order_by_file_order", + "order_by_file_order_reversed", + "order_by_priority", + "order_by_level", + "order_by_timestamp_asc", + "order_by_timestamp_desc", + "with_tags_as_category", + } + if key in bool_keys: + return (isinstance(value, bool), {key: value} if isinstance(value, bool) else {}) + + int_keys = {"filter_level", "filter_repeats_above", "filter_repeats_below"} + if key in int_keys: + int_value = validate_int_option(value, None) + return (int_value is not None, {key: int_value} if int_value is not None else {}) + + string_options = { + "filter_priority": "--filter-priority", + "filter_date_from": "--filter-date-from", + "filter_date_until": "--filter-date-until", + } + if key in string_options: + str_value = validate_str_option(string_options[key], value) + return (str_value is not None, {key: str_value} if str_value is not None else {}) + + list_options = { + "filter_properties": "--filter-property", + "filter_tags": "--filter-tag", + "filter_headings": "--filter-heading", + "filter_bodies": "--filter-body", + } + if key in list_options: + list_value = validate_list_option(list_options[key], value) + return (list_value is not None, {key: list_value} if list_value is not None else {}) - return True + return (True, {}) -def apply_config_entry( - key: str, - value: object, - context: ConfigContext, -) -> bool: - """Apply a config entry to defaults if valid.""" - if key == "--mapping": - return apply_mapping_config(value, context.defaults) - - if key == "--exclude": - return apply_exclude_config(value, context.defaults) - - option_sets = ( - (context.global_options, context.defaults), - (context.stats_options, context.stats_defaults), - ) +def _parse_shared_special_entry(key: str, value: object) -> tuple[bool, dict[str, object]]: + """Parse one special shared top-level config entry.""" + if key == "todo_states": + todo_states = validate_str_option("--todo-states", value) + return ( + todo_states is not None, + {key: _parse_state_list(todo_states)} if todo_states is not None else {}, + ) + if key == "done_states": + done_states = validate_str_option("--done-states", value) + return ( + done_states is not None, + {key: _parse_state_list(done_states)} if done_states is not None else {}, + ) + if key == "mapping": + return _parse_mapping_or_inline_entry(value) + if key == "exclude": + return _parse_exclude_or_inline_entry(value) + return (True, {}) - for options, defaults in option_sets: - if ( - key in options.int_options - or key in options.bool_options - or key in options.str_options - or key in options.list_options - ): - return apply_config_entry_by_options( - key, - value, - defaults, - context.append_defaults, - options, - ) - - return False - - -def parse_config_sections( - raw_config: dict[str, object], -) -> ( - tuple[ - dict[str, object], - dict[str, str], - dict[str, str], - dict[str, str], - dict[str, dict[str, str]], - dict[str, BoardViewConfig], - dict[str, AgendaViewConfig], - ] - | None -): - """Parse top-level config sections. - - Accepted shape: - { - "defaults": { ... }, - "filter": {"name": "query"}, - "order-by": {"name": "query"}, - "with": {"name": "query"}, - "capture": { - "templates": { - "name": { - "file": "path", - "content": "* TODO {{title}}", - "parent": ".id == 'project-1'" - } - } - } - } - """ - allowed_keys = {"defaults", "filter", "order-by", "with", "capture", "board", "agenda"} - defaults_section = raw_config.get("defaults", {}) - filter_section = raw_config.get("filter", {}) - order_by_section = raw_config.get("order-by", {}) - with_section = raw_config.get("with", {}) - - sections_are_valid = ( - not any(key not in allowed_keys for key in raw_config) - and isinstance(defaults_section, dict) - and is_string_dict(filter_section) - and is_string_dict(order_by_section) - and is_string_dict(with_section) - ) - if not sections_are_valid: - return None - capture_templates = parse_capture_templates_section(raw_config.get("capture", {})) - if capture_templates is None: - return None +def _parse_mapping_or_inline_entry(value: object) -> tuple[bool, dict[str, object]]: + """Parse one shared mapping entry from file or inline form.""" + if isinstance(value, str) and value.strip(): + return (True, {"mapping": value}) + if is_string_dict(value): + return (True, {"mapping_inline": value}) + return (False, {}) - board_views: dict[str, BoardViewConfig] = {} - if "board" in raw_config: - parsed_board_views = parse_board_section(raw_config["board"]) - if parsed_board_views is None: - return None - board_views = parsed_board_views - agenda_views: dict[str, AgendaViewConfig] = {} - if "agenda" in raw_config: - parsed_agenda_views = parse_agenda_section(raw_config["agenda"]) - if parsed_agenda_views is None: - return None - agenda_views = parsed_agenda_views - - return ( - cast("dict[str, object]", defaults_section), - cast("dict[str, str]", filter_section), - cast("dict[str, str]", order_by_section), - cast("dict[str, str]", with_section), - capture_templates, - board_views, - agenda_views, - ) +def _parse_exclude_or_inline_entry(value: object) -> tuple[bool, dict[str, object]]: + """Parse one shared exclude entry from file or inline form.""" + if isinstance(value, str) and value.strip(): + return (True, {"exclude": value}) + if is_string_list(value): + return (True, {"exclude_inline": list(value)}) + return (False, {}) -def parse_board_section(value: object) -> dict[str, BoardViewConfig] | None: +def parse_board_section(value: object) -> BoardConfig | None: """Parse board section from top-level config value.""" if not isinstance(value, dict): return None - allowed_board_keys = {"views"} + allowed_board_keys = {"views", "view", "days", "max_results", "offset", "width"} if any(key not in allowed_board_keys for key in value): return None - if "views" not in value: + + views: dict[str, BoardViewConfig] = {} + if "views" in value: + parsed_views = parse_board_views(value.get("views")) + if parsed_views is None: + return None + views = parsed_views + + valid_view, view_name = _parse_optional_string_field(value, "view", "--view") + valid_days, days = _parse_optional_int_field(value, "days", 1) + valid_max_results, max_results = _parse_optional_int_field(value, "max_results", None) + valid_offset, offset = _parse_optional_int_field(value, "offset", 0) + valid_width, width = _parse_optional_int_field(value, "width", 50) + if not all((valid_view, valid_days, valid_max_results, valid_offset, valid_width)): return None - return parse_board_views(value.get("views")) + return BoardConfig( + views=views, + view=view_name, + days=days, + max_results=max_results, + offset=offset, + width=width, + ) def parse_board_views(value: object) -> dict[str, BoardViewConfig] | None: @@ -858,18 +870,73 @@ def parse_board_column(value: object) -> BoardColumnConfig | None: ) -def parse_agenda_section(value: object) -> dict[str, AgendaViewConfig] | None: +def parse_agenda_section(value: object) -> AgendaConfig | None: """Parse agenda section from top-level config value.""" if not isinstance(value, dict): return None - allowed_agenda_keys = {"views"} + allowed_agenda_keys = { + "views", + "date", + "days", + "no_completed", + "no_overdue", + "no_upcoming", + "future_repeats", + "view", + "max_results", + "offset", + "width", + } if any(key not in allowed_agenda_keys for key in value): return None - if "views" not in value: + + views: dict[str, AgendaViewConfig] = {} + if "views" in value: + parsed_views = parse_agenda_views(value.get("views")) + if parsed_views is None: + return None + views = parsed_views + + valid_date, date = _parse_optional_string_field(value, "date", "--date") + valid_days, days = _parse_optional_int_field(value, "days", 1) + valid_view, view_name = _parse_optional_string_field(value, "view", "--view") + valid_max_results, max_results = _parse_optional_int_field(value, "max_results", None) + valid_offset, offset = _parse_optional_int_field(value, "offset", 0) + valid_width, width = _parse_optional_int_field(value, "width", 50) + valid_no_completed, no_completed = _parse_optional_bool_field(value, "no_completed") + valid_no_overdue, no_overdue = _parse_optional_bool_field(value, "no_overdue") + valid_no_upcoming, no_upcoming = _parse_optional_bool_field(value, "no_upcoming") + valid_future_repeats, future_repeats = _parse_optional_bool_field(value, "future_repeats") + if not all( + ( + valid_date, + valid_days, + valid_view, + valid_max_results, + valid_offset, + valid_width, + valid_no_completed, + valid_no_overdue, + valid_no_upcoming, + valid_future_repeats, + ), + ): return None - return parse_agenda_views(value.get("views")) + return AgendaConfig( + views=views, + date=date, + days=days, + no_completed=no_completed, + no_overdue=no_overdue, + no_upcoming=no_upcoming, + future_repeats=future_repeats, + view=view_name, + max_results=max_results, + offset=offset, + width=width, + ) def parse_agenda_views(value: object) -> dict[str, AgendaViewConfig] | None: @@ -1012,123 +1079,491 @@ def parse_capture_template(value: object) -> dict[str, str] | None: return parsed -def build_config_defaults( - config: dict[str, object], -) -> tuple[dict[str, object], dict[str, object], dict[str, list[str]]] | None: - """Validate config values and build defaults. +def parse_shared_config(raw_config: dict[str, object]) -> dict[str, object] | None: + """Parse shared top-level config values into AppConfig field values.""" + parsed: dict[str, object] = {} - Args: - config: Raw config dict + for key, value in raw_config.items(): + valid, parsed_value = _parse_shared_simple_entry(key, value) + if not valid: + return None + if parsed_value: + parsed.update(parsed_value) + continue - Returns: - Tuple of (defaults, append_defaults) or None if malformed - """ - defaults: dict[str, object] = {} - stats_defaults: dict[str, object] = {} - append_defaults: dict[str, list[str]] = {} - valid = True - - color_defaults, color_valid = parse_color_defaults(config) - if not color_valid: - return None - defaults.update(color_defaults) - - stats_int_options: dict[str, tuple[str, int | None]] = { - "--limit": ("max_results", None), - "--max-tags": ("max_tags", 0), - "--max-relations": ("max_relations", 0), - "--min-group-size": ("min_group_size", 0), - "--max-groups": ("max_groups", 0), - } + valid, parsed_value = _parse_shared_special_entry(key, value) + if not valid: + return None + parsed.update(parsed_value) - global_int_options: dict[str, tuple[str, int | None]] = { - "--days": ("days", 1), - "--filter-level": ("filter_level", None), - "--filter-repeats-above": ("filter_repeats_above", None), - "--filter-repeats-below": ("filter_repeats_below", None), - "--offset": ("offset", 0), - "--width": ("width", 50), - } + return parsed - stats_bool_options: dict[str, tuple[str, bool | None]] = { - "--with-tags-as-category": ("with_tags_as_category", None), - } - global_bool_options: dict[str, tuple[str, bool | None]] = { - "--details": ("details", None), - "--filter-completed": ("filter_completed", None), - "--filter-not-completed": ("filter_not_completed", None), - "--no-completed": ("no_completed", None), - "--no-overdue": ("no_overdue", None), - "--no-upcoming": ("no_upcoming", None), - "--future-repeats": ("future_repeats", True), - "--no-future-repeats": ("future_repeats", False), - "--order-by-file-order": ("order_by_file_order", None), - "--order-by-file-order-reversed": ("order_by_file_order_reversed", None), - "--order-by-level": ("order_by_level", None), - "--order-by-priority": ("order_by_priority", None), - "--order-by-timestamp-asc": ("order_by_timestamp_asc", None), - "--order-by-timestamp-desc": ("order_by_timestamp_desc", None), - "--verbose": ("verbose", None), - } +def parse_stats_section(value: object) -> StatsConfig | None: + """Parse stats section from top-level config value.""" + if not isinstance(value, dict): + return None - stats_str_options: dict[str, str] = {"--use": "use"} - - global_str_options: dict[str, str] = { - "--date": "date", - "--todo-states": "todo_states", - "--done-states": "done_states", - "--filter-date-from": "filter_date_from", - "--filter-date-until": "filter_date_until", - "--filter-priority": "filter_priority", - "--out": "out", - "--out-theme": "out_theme", - "--pandoc-args": "pandoc_args", - "--config": "config", - "--view": "view", - } + allowed_keys = {"all", "summary", "tags", "groups"} + if any(key not in allowed_keys for key in value): + return None - global_list_options: dict[str, str] = { - "--filter-property": "filter_properties", - "--filter-tag": "filter_tags", - "--filter-heading": "filter_headings", - "--filter-body": "filter_bodies", + all_config = _parse_optional_config_section( + value, + "all", + parse_stats_all_section, + StatsAllConfig(), + ) + summary_config = _parse_optional_config_section( + value, + "summary", + parse_stats_summary_section, + StatsSummaryConfig(), + ) + tags_config = _parse_optional_config_section( + value, + "tags", + parse_stats_tags_section, + StatsTagsConfig(), + ) + groups_config = _parse_optional_config_section( + value, + "groups", + parse_stats_groups_section, + StatsGroupsConfig(), + ) + + return StatsConfig( + all=all_config, + summary=summary_config, + tags=tags_config, + groups=groups_config, + ) + + +def parse_stats_all_section(value: object) -> StatsAllConfig | None: + """Parse stats.all section from config value.""" + if not isinstance(value, dict): + return None + + allowed_keys = { + "max_results", + "max_tags", + "max_relations", + "min_group_size", + "max_groups", + "use", } + if any(key not in allowed_keys for key in value): + return None - global_options = ConfigOptions( - int_options=global_int_options, - bool_options=global_bool_options, - str_options=global_str_options, - list_options=global_list_options, + max_results: int | None = None + max_tags: int | None = None + max_relations: int | None = None + min_group_size: int | None = None + max_groups: int | None = None + for key, min_value in { + "max_results": None, + "max_tags": 0, + "max_relations": 0, + "min_group_size": 0, + "max_groups": 0, + }.items(): + valid, int_value = _parse_optional_int_field(value, key, min_value) + if not valid: + return None + if key == "max_results": + max_results = int_value + elif key == "max_tags": + max_tags = int_value + elif key == "max_relations": + max_relations = int_value + elif key == "min_group_size": + min_group_size = int_value + else: + max_groups = int_value + + valid_use, use_value = _parse_optional_string_field(value, "use", "--use") + if not valid_use: + return None + + return StatsAllConfig( + max_results=max_results, + max_tags=max_tags, + max_relations=max_relations, + min_group_size=min_group_size, + max_groups=max_groups, + use=use_value, ) - stats_options = ConfigOptions( - int_options=stats_int_options, - bool_options=stats_bool_options, - str_options=stats_str_options, - list_options={"--group": "groups", "--tag": "tags"}, + +def parse_stats_summary_section(value: object) -> StatsSummaryConfig | None: + """Parse stats.summary section from config value.""" + if not isinstance(value, dict): + return None + + allowed_keys = {"max_results"} + if any(key not in allowed_keys for key in value): + return None + + valid_max_results, max_results = _parse_optional_int_field(value, "max_results", None) + if not valid_max_results: + return None + return StatsSummaryConfig(max_results=max_results) + + +def parse_stats_tags_section(value: object) -> StatsTagsConfig | None: + """Parse stats.tags section from config value.""" + if not isinstance(value, dict): + return None + + allowed_keys = {"max_results", "max_relations", "use", "tags"} + if any(key not in allowed_keys for key in value): + return None + + max_results: int | None = None + max_relations: int | None = None + for key, min_value in {"max_results": None, "max_relations": 0}.items(): + valid, int_value = _parse_optional_int_field(value, key, min_value) + if not valid: + return None + if key == "max_results": + max_results = int_value + else: + max_relations = int_value + + valid_use, use_value = _parse_optional_string_field(value, "use", "--use") + valid_tags, tags = _parse_optional_string_list_field(value, "tags") + if not all((valid_use, valid_tags)): + return None + return StatsTagsConfig( + max_results=max_results, + max_relations=max_relations, + use=use_value, + tags=tags, ) - context = ConfigContext( - defaults=defaults, - stats_defaults=stats_defaults, - append_defaults=append_defaults, - global_options=global_options, - stats_options=stats_options, + +def parse_stats_groups_section(value: object) -> StatsGroupsConfig | None: + """Parse stats.groups section from config value.""" + if not isinstance(value, dict): + return None + + allowed_keys = {"max_results", "max_relations", "use", "groups"} + if any(key not in allowed_keys for key in value): + return None + + max_results: int | None = None + max_relations: int | None = None + for key, min_value in {"max_results": None, "max_relations": 0}.items(): + valid, int_value = _parse_optional_int_field(value, key, min_value) + if not valid: + return None + if key == "max_results": + max_results = int_value + else: + max_relations = int_value + + valid_use, use_value = _parse_optional_string_field(value, "use", "--use") + valid_groups, groups = _parse_optional_string_list_field(value, "groups") + if not all((valid_use, valid_groups)): + return None + return StatsGroupsConfig( + max_results=max_results, + max_relations=max_relations, + use=use_value, + groups=groups, ) - for key, value in config.items(): - if key in ("--color", "--no-color"): - continue - if not apply_config_entry(key, value, context): - valid = False - break +def _parse_out_theme(section: dict[str, object], key: str) -> str | None: + """Parse one optional out-theme or pandoc-args string field.""" + if key not in section: + return None + raw = section[key] + if raw is not None and not isinstance(raw, str): + raise ValueError + return raw - if not valid: + +def parse_tasks_capture_section(value: object) -> TasksCaptureConfig | None: + """Parse tasks.capture section from config value.""" + templates = parse_capture_templates_section(value) + if templates is None: return None + return TasksCaptureConfig(templates=templates) + - return (defaults, stats_defaults, append_defaults) +def parse_tasks_list_section(value: object) -> TasksListConfig | None: + """Parse tasks.list section from config value.""" + if not isinstance(value, dict): + return None + + allowed_keys = {"max_results", "details", "out", "out_theme", "pandoc_args"} + if any(key not in allowed_keys for key in value): + return None + + valid_max_results, max_results = _parse_optional_int_field(value, "max_results", None) + valid_details, details = _parse_optional_bool_field(value, "details") + valid_out, out = _parse_optional_string_field(value, "out", "--out") + if not all((valid_max_results, valid_details, valid_out)): + return None + try: + out_theme = _parse_out_theme(value, "out_theme") + pandoc_args = _parse_out_theme(value, "pandoc_args") + except ValueError: + return None + + return TasksListConfig( + max_results=max_results, + details=details, + out=out, + out_theme=out_theme, + pandoc_args=pandoc_args, + ) + + +def parse_tasks_query_section(value: object) -> TasksQueryConfig | None: + """Parse tasks.query section from config value.""" + if not isinstance(value, dict): + return None + + allowed_keys = {"max_results", "offset", "width", "out", "out_theme", "pandoc_args"} + if any(key not in allowed_keys for key in value): + return None + + valid_max_results, max_results = _parse_optional_int_field(value, "max_results", None) + valid_offset, offset = _parse_optional_int_field(value, "offset", 0) + valid_width, width = _parse_optional_int_field(value, "width", 50) + valid_out, out = _parse_optional_string_field(value, "out", "--out") + if not all((valid_max_results, valid_offset, valid_width, valid_out)): + return None + try: + out_theme = _parse_out_theme(value, "out_theme") + pandoc_args = _parse_out_theme(value, "pandoc_args") + except ValueError: + return None + + return TasksQueryConfig( + max_results=max_results, + offset=offset, + width=width, + out=out, + out_theme=out_theme, + pandoc_args=pandoc_args, + ) + + +def parse_tasks_find_section(value: object) -> TasksFindConfig | None: + """Parse tasks.find section from config value.""" + if not isinstance(value, dict): + return None + + allowed_keys = {"include_context", "out", "out_theme", "pandoc_args"} + if any(key not in allowed_keys for key in value): + return None + + valid_include_context, include_context = _parse_optional_int_field(value, "include_context", 0) + valid_out, out = _parse_optional_string_field(value, "out", "--out") + if not all((valid_include_context, valid_out)): + return None + try: + out_theme = _parse_out_theme(value, "out_theme") + pandoc_args = _parse_out_theme(value, "pandoc_args") + except ValueError: + return None + + return TasksFindConfig( + include_context=include_context, + out=out, + out_theme=out_theme, + pandoc_args=pandoc_args, + ) + + +def parse_tasks_section(value: object) -> TasksConfig | None: + """Parse tasks section from top-level config value.""" + if not isinstance(value, dict): + return None + + allowed_keys = {"capture", "list", "query", "find"} + if any(key not in allowed_keys for key in value): + return None + + capture_config = _parse_optional_config_section( + value, + "capture", + parse_tasks_capture_section, + TasksCaptureConfig(), + ) + list_config = _parse_optional_config_section( + value, + "list", + parse_tasks_list_section, + TasksListConfig(), + ) + query_config = _parse_optional_config_section( + value, + "query", + parse_tasks_query_section, + TasksQueryConfig(), + ) + find_config = _parse_optional_config_section( + value, + "find", + parse_tasks_find_section, + TasksFindConfig(), + ) + + return TasksConfig( + capture=capture_config, + list=list_config, + query=query_config, + find=find_config, + ) + + +def _validate_top_level_config_keys(config: dict[str, object]) -> None: + """Validate allowed top-level config keys.""" + allowed_keys = { + "color_flag", + "verbose", + "todo_states", + "done_states", + "exclude", + "mapping", + "filter_priority", + "filter_level", + "filter_repeats_above", + "filter_repeats_below", + "filter_date_from", + "filter_date_until", + "filter_properties", + "filter_tags", + "filter_headings", + "filter_bodies", + "filter_completed", + "filter_not_completed", + "order_by_file_order", + "order_by_file_order_reversed", + "order_by_priority", + "order_by_level", + "order_by_timestamp_asc", + "order_by_timestamp_desc", + "with_tags_as_category", + "filter", + "order-by", + "with", + "board", + "agenda", + "stats", + "tasks", + } + if any(key not in allowed_keys for key in config): + raise typer.BadParameter("Malformed config") + + +def _parse_structured_sections( + config: dict[str, object], +) -> tuple[ + dict[str, object], + BoardConfig, + AgendaConfig, + StatsConfig, + TasksConfig, +]: + """Parse structured top-level config sections into typed objects.""" + shared_config = parse_shared_config(config) + if shared_config is None: + raise typer.BadParameter("Malformed config") + + board_config = _parse_optional_config_section( + config, + "board", + parse_board_section, + BoardConfig(), + ) + agenda_config = _parse_optional_config_section( + config, + "agenda", + parse_agenda_section, + AgendaConfig(), + ) + stats_config = _parse_optional_config_section( + config, + "stats", + parse_stats_section, + StatsConfig(), + ) + tasks_config = _parse_optional_config_section( + config, + "tasks", + parse_tasks_section, + TasksConfig(), + ) + + return ( + shared_config, + board_config, + agenda_config, + stats_config, + tasks_config, + ) + + +def _parse_optional_config_section( + config: dict[str, object], + key: str, + parser: Callable[[object], T | None], + default: T, +) -> T: + """Parse one optional config section, returning default when absent.""" + if key not in config: + return default + parsed = parser(config[key]) + if parsed is None: + raise typer.BadParameter("Malformed config") + return parsed + + +def _build_loaded_app_config(config: dict[str, object], config_path: Path) -> AppConfig: + """Build AppConfig from one validated raw config object.""" + _validate_top_level_config_keys(config) + + named_query_sections = _parse_named_query_sections(config) + if named_query_sections is None: + raise typer.BadParameter("Malformed config") + filter_section, order_by_section, with_section = named_query_sections + ( + shared_config, + board_config, + agenda_config, + stats_config, + tasks_config, + ) = _parse_structured_sections(config) + + app_config = AppConfig(config_path=str(config_path)) + for key, shared_value in shared_config.items(): + setattr(app_config, key, shared_value) + + app_config.filters = _named_query_list(filter_section) + app_config.orderings = _named_query_list(order_by_section) + app_config.mutators = _named_query_list(with_section) + app_config.stats = stats_config + app_config.tasks = tasks_config + app_config.board = board_config + app_config.agenda = agenda_config + return app_config + + +def _parse_state_list(value: str) -> list[str]: + """Parse comma-separated TODO/DONE state list from validated config string.""" + return [part.strip() for part in value.split(",") if part.strip()] + + +def _named_query_list(items: dict[str, str]) -> list[NamedQueryConfig]: + """Build stable named query config list from a mapping.""" + return [NamedQueryConfig(name=name, query=query) for name, query in items.items()] def parse_config_argument(argv: list[str]) -> str: @@ -1142,7 +1577,7 @@ def parse_config_argument(argv: list[str]) -> str: return default -def load_cli_config(argv: list[str]) -> LoadedCliConfig: +def load_cli_config(argv: list[str]) -> AppConfig: """Load config defaults from the configured file path.""" config_name = parse_config_argument(argv) config_path = Path(config_name) @@ -1153,119 +1588,12 @@ def load_cli_config(argv: list[str]) -> LoadedCliConfig: if load_error: raise typer.BadParameter("Malformed config") - config_sections = parse_config_sections(config) - if config_sections is None: - raise typer.BadParameter("Malformed config") - - ( - defaults_config, - custom_filters, - custom_order_by, - custom_with, - capture_templates, - board_views, - agenda_views, - ) = config_sections - - config_defaults = build_config_defaults(defaults_config) - if config_defaults is None: - raise typer.BadParameter("Malformed config") - - defaults, stats_defaults, append_defaults = config_defaults - - inline_defaults: dict[str, object] = {} - for key in ("mapping_inline", "exclude_inline"): - if key in defaults: - inline_defaults[key] = defaults[key] - - combined_defaults = {**defaults, **stats_defaults} - filtered_defaults = { - key: value for key, value in combined_defaults.items() if key in COMMAND_OPTION_NAMES - } - - return LoadedCliConfig( - defaults=filtered_defaults, - append_defaults=append_defaults, - inline_defaults=inline_defaults, - custom_filters=custom_filters, - custom_order_by=custom_order_by, - custom_with=custom_with, - capture_templates=capture_templates, - board_views=board_views, - agenda_views=agenda_views, - ) - - -def build_default_map(defaults: dict[str, object]) -> CliDefaultMap: - """Build Click default_map for Typer commands.""" - all_defaults = {key: value for key, value in defaults.items() if key not in {"tags", "groups"}} - - task_command_disallowed = { - "max_tags", - "max_relations", - "max_groups", - "min_group_size", - "use", - "tags", - "groups", - } - stats_tasks_defaults = { - key: value for key, value in defaults.items() if key not in task_command_disallowed - } - tasks_list_defaults = { - key: value for key, value in defaults.items() if key not in task_command_disallowed - } - tasks_query_defaults = { - key: value for key, value in defaults.items() if key not in task_command_disallowed - } - board_disallowed = task_command_disallowed.union( - {"details", "out", "out_theme", "pandoc_args"}, - ) - board_defaults = {key: value for key, value in defaults.items() if key not in board_disallowed} - agenda_disallowed = task_command_disallowed.union( - {"details", "out", "out_theme", "pandoc_args"}, - ) - agenda_defaults = { - key: value for key, value in defaults.items() if key not in agenda_disallowed - } - tags_defaults = { - key: value - for key, value in defaults.items() - if key not in {"max_tags", "max_groups", "min_group_size", "groups"} - } - - groups_defaults = { - key: value - for key, value in defaults.items() - if key not in {"max_tags", "max_groups", "min_group_size", "tags"} - } - - return { - "stats": { - "all": all_defaults, - "summary": stats_tasks_defaults, - "tags": tags_defaults, - "groups": groups_defaults, - }, - "tasks": {"list": tasks_list_defaults, "query": tasks_query_defaults}, - "board": board_defaults, - "agenda": agenda_defaults, - } + return _build_loaded_app_config(config, config_path) -def apply_config_defaults(args: object) -> None: - """Apply config-provided defaults for append and inline values.""" - for dest, values in CONFIG_APPEND_DEFAULTS.items(): - if not hasattr(args, dest): - continue - if getattr(args, dest, None) is None: - setattr(args, dest, values) - - mapping_inline = cast("dict[str, str] | None", CONFIG_INLINE_DEFAULTS.get("mapping_inline")) - exclude_inline = cast("list[str] | None", CONFIG_INLINE_DEFAULTS.get("exclude_inline")) - if hasattr(args, "mapping_inline"): - target = cast("ConfigDefaultsTarget", args) - target.mapping_inline = mapping_inline if mapping_inline is not None else None - if hasattr(args, "exclude_inline"): - target = cast("ConfigDefaultsTarget", args) - target.exclude_inline = exclude_inline if exclude_inline is not None else None +def require_app_config(ctx: typer.Context) -> AppConfig: + """Return AppConfig stored in the current root context.""" + app_config = ctx.find_root().obj + if not isinstance(app_config, AppConfig): + raise typer.BadParameter("Application config is not available") + return app_config diff --git a/src/org/config/cli.py b/src/org/config/cli.py index 400ae86..bda60ab 100644 --- a/src/org/config/cli.py +++ b/src/org/config/cli.py @@ -5,16 +5,19 @@ import json import re from dataclasses import dataclass -from typing import Protocol +from typing import TYPE_CHECKING, Protocol import click -import org.config.app from org.logic.time import parse_date_argument from org.logic.validation import parse_property_filter from org.query.runner import build_custom_stage +if TYPE_CHECKING: + from org.config.app import AppConfig + + FILTER_OPTIONS_WITH_VALUE = { "--filter-priority", "--filter-level", @@ -112,23 +115,27 @@ def _query_uses_arg(query: str) -> bool: return bool(re.search(r"\$arg\b", query)) -def _resolve_custom_option(option: str) -> tuple[str, bool] | None: +def _resolve_custom_option(config: AppConfig, option: str) -> tuple[str, bool] | None: """Resolve configured custom option to (query, requires_arg).""" + custom_filters = config.custom_filter_map() + custom_order_by = config.custom_order_by_map() + custom_with = config.custom_with_map() + filter_name = _custom_option_name(option, "filter") if filter_name is not None: - query = org.config.app.CONFIG_CUSTOM_FILTERS.get(filter_name) + query = custom_filters.get(filter_name) if query is not None: return (query, _query_uses_arg(query)) order_name = _custom_option_name(option, "order-by") if order_name is not None: - query = org.config.app.CONFIG_CUSTOM_ORDER_BY.get(order_name) + query = custom_order_by.get(order_name) if query is not None: return (query, _query_uses_arg(query)) with_name = _custom_option_name(option, "with") if with_name is not None: - query = org.config.app.CONFIG_CUSTOM_WITH.get(with_name) + query = custom_with.get(with_name) if query is not None: return (query, _query_uses_arg(query)) @@ -140,7 +147,10 @@ def _required_custom_arg_error(option: str) -> click.BadParameter: return click.BadParameter(f"{option} requires exactly one argument") -def normalize_cli_files_for_custom_switches(files: list[str] | None) -> list[str] | None: +def normalize_cli_files_for_custom_switches( + config: AppConfig, + files: list[str] | None, +) -> list[str] | None: """Remove configured custom switch tokens from FILE argument values.""" if files is None: return None @@ -150,7 +160,7 @@ def normalize_cli_files_for_custom_switches(files: list[str] | None) -> list[str while index < len(files): token = files[index] option = _extract_option_token(token) - custom_option = _resolve_custom_option(option) + custom_option = _resolve_custom_option(config, option) if custom_option is None: normalized.append(token) index += 1 @@ -234,17 +244,21 @@ def _build_custom_invocation( ) -def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> None: +def validate_custom_switches( + config: AppConfig, + argv: list[str], + include_builtin_ordering: bool, +) -> None: """Validate prefixed custom switches against configured/built-in options.""" builtin_order_options = set(ORDER_BY_OPTION_TO_VALUE) if include_builtin_ordering else set() allowed_filter_options = FILTER_OPTIONS_WITH_VALUE.union(FILTER_OPTIONS_FLAGS).union( - {f"--filter-{name}" for name in org.config.app.CONFIG_CUSTOM_FILTERS}, + {f"--filter-{name}" for name in config.custom_filter_map()}, ) allowed_order_options = builtin_order_options.union( - {f"--order-by-{name}" for name in org.config.app.CONFIG_CUSTOM_ORDER_BY}, + {f"--order-by-{name}" for name in config.custom_order_by_map()}, ) allowed_with_options = WITH_OPTIONS_FLAGS.union( - {f"--with-{name}" for name in org.config.app.CONFIG_CUSTOM_WITH}, + {f"--with-{name}" for name in config.custom_with_map()}, ) for index, token in enumerate(argv): @@ -256,7 +270,7 @@ def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> if option.startswith("--with-") and option not in allowed_with_options: raise click.NoSuchOption(option) - custom_option = _resolve_custom_option(option) + custom_option = _resolve_custom_option(config, option) if custom_option is None: continue @@ -273,8 +287,12 @@ def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> raise _required_custom_arg_error(option) -def parse_filter_entries_from_argv(argv: list[str]) -> list[str | CustomStageInvocation]: +def parse_filter_entries_from_argv( + config: AppConfig, + argv: list[str], +) -> list[str | CustomStageInvocation]: """Parse built-in and custom filter switch occurrences in argv order.""" + custom_filters = config.custom_filter_map() entries: list[str | CustomStageInvocation] = [] index = 0 builtins = FILTER_OPTIONS_WITH_VALUE.union(FILTER_OPTIONS_FLAGS) @@ -288,11 +306,11 @@ def parse_filter_entries_from_argv(argv: list[str]) -> list[str | CustomStageInv continue name = _custom_option_name(option, "filter") - if name is None or name not in org.config.app.CONFIG_CUSTOM_FILTERS or option in builtins: + if name is None or name not in custom_filters or option in builtins: index += 1 continue - query = org.config.app.CONFIG_CUSTOM_FILTERS[name] + query = custom_filters[name] requires_arg = _query_uses_arg(query) if token.startswith(f"{option}="): entries.append( @@ -314,10 +332,12 @@ def parse_filter_entries_from_argv(argv: list[str]) -> list[str | CustomStageInv def parse_order_entries_from_argv( + config: AppConfig, argv: list[str], include_builtin_ordering: bool, ) -> list[str | CustomStageInvocation]: """Parse built-in and custom ordering switch occurrences in argv order.""" + custom_order_by = config.custom_order_by_map() entries: list[str | CustomStageInvocation] = [] index = 0 builtin_options = set(ORDER_BY_OPTION_TO_VALUE) if include_builtin_ordering else set() @@ -332,15 +352,11 @@ def parse_order_entries_from_argv( continue name = _custom_option_name(option, "order-by") - if ( - name is None - or name not in org.config.app.CONFIG_CUSTOM_ORDER_BY - or option in builtin_options - ): + if name is None or name not in custom_order_by or option in builtin_options: index += 1 continue - query = org.config.app.CONFIG_CUSTOM_ORDER_BY[name] + query = custom_order_by[name] requires_arg = _query_uses_arg(query) if token.startswith(f"{option}="): entries.append( @@ -361,8 +377,12 @@ def parse_order_entries_from_argv( return entries -def parse_with_entries_from_argv(argv: list[str]) -> list[str | CustomStageInvocation]: +def parse_with_entries_from_argv( + config: AppConfig, + argv: list[str], +) -> list[str | CustomStageInvocation]: """Parse built-in and custom enrichment switch occurrences in argv order.""" + custom_with = config.custom_with_map() entries: list[str | CustomStageInvocation] = [] index = 0 while index < len(argv): @@ -374,11 +394,11 @@ def parse_with_entries_from_argv(argv: list[str]) -> list[str | CustomStageInvoc continue name = _custom_option_name(option, "with") - if name is None or name not in org.config.app.CONFIG_CUSTOM_WITH: + if name is None or name not in custom_with: index += 1 continue - query = org.config.app.CONFIG_CUSTOM_WITH[name] + query = custom_with[name] requires_arg = _query_uses_arg(query) if token.startswith(f"{option}="): entries.append( @@ -604,9 +624,14 @@ def _builtin_order_stages(value: str) -> list[str]: return order_stages.get(value, []) -def build_order_stages(args: object, argv: list[str], include_builtin_ordering: bool) -> list[str]: +def build_order_stages( + config: AppConfig, + args: object, + argv: list[str], + include_builtin_ordering: bool, +) -> list[str]: """Build query stages for ordering pipeline.""" - order_entries = parse_order_entries_from_argv(argv, include_builtin_ordering) + order_entries = parse_order_entries_from_argv(config, argv, include_builtin_ordering) order_values: list[str | CustomStageInvocation] if include_builtin_ordering: builtin_order_values = [entry for entry in order_entries if isinstance(entry, str)] @@ -638,9 +663,9 @@ def build_order_stages(args: object, argv: list[str], include_builtin_ordering: return order_stages -def build_with_stages(args: WithArgs, argv: list[str]) -> list[str]: +def build_with_stages(config: AppConfig, args: WithArgs, argv: list[str]) -> list[str]: """Build query stages for built-in and custom enrichment pipeline.""" - entries = parse_with_entries_from_argv(argv) + entries = parse_with_entries_from_argv(config, argv) entries = extend_with_entries_with_defaults(entries, args) stages: list[str] = [] for entry in entries: @@ -663,15 +688,19 @@ def collect_custom_context_vars( def build_pipeline_stages( + config: AppConfig, args: QueryBuildArgs, argv: list[str], include_ordering: bool, ) -> list[str]: """Build the ordered query pipeline stages for CLI/config state.""" - validate_custom_switches(argv, include_ordering) - filter_order = extend_filter_order_with_defaults(parse_filter_entries_from_argv(argv), args) + validate_custom_switches(config, argv, include_ordering) + filter_order = extend_filter_order_with_defaults( + parse_filter_entries_from_argv(config, argv), + args, + ) filter_stages = build_filter_stages(args, filter_order) - with_stages = build_with_stages(args, argv) + with_stages = build_with_stages(config, args, argv) stages = [*with_stages, *filter_stages] - stages.extend(build_order_stages(args, argv, include_builtin_ordering=include_ordering)) + stages.extend(build_order_stages(config, args, argv, include_builtin_ordering=include_ordering)) return stages diff --git a/src/org/logging.py b/src/org/logging.py index 7c0995b..96042f3 100644 --- a/src/org/logging.py +++ b/src/org/logging.py @@ -5,8 +5,6 @@ import logging import sys -import org.config.app - LOGGER_NAME = "org" logger = logging.getLogger(LOGGER_NAME) @@ -32,69 +30,35 @@ def configure_logging(verbose: bool) -> None: logger.handlers.clear() -def _format_default_log_entry(option_name: str, value: object) -> str: - """Format one option/value pair for config-default logging.""" - return f"{option_name}={value!r}" - - def _format_argument_log_entry(arg_name: str, value: object) -> str: """Format one argument/value pair for command argument logging.""" return f"{arg_name}={value!r}" -def _redact_inline_config_value(option_name: str, value: object) -> object: - """Redact inline mapping/exclude values in default logs.""" - if option_name in {"--mapping", "--exclude"} and isinstance(value, (dict, list)): +def _redact_config_value(field_name: str, value: object) -> object: + """Redact inline mapping/exclude values in config logs.""" + if field_name in {"mapping_inline", "exclude_inline"} and isinstance(value, (dict, list)): return "" return value -def log_applied_config_defaults(_args: object, _argv: list[str], command_name: str) -> None: - """Log config defaults loaded from config file.""" +def log_command_config(config: object, command_name: str) -> None: + """Log config object used to run one command.""" if not logger.isEnabledFor(logging.INFO): return - entries: list[str] = [] - - for dest, default_value in sorted( - org.config.app.CONFIG_DEFAULTS.items(), - key=lambda item: item[0], - ): - option_name = org.config.app.DEST_TO_OPTION_NAME.get(dest) - if option_name is None: - continue - entries.append( - _format_default_log_entry( - option_name, - _redact_inline_config_value(option_name, default_value), - ), - ) - - for dest, values in sorted( - org.config.app.CONFIG_APPEND_DEFAULTS.items(), - key=lambda item: item[0], - ): - option_name = org.config.app.DEST_TO_OPTION_NAME.get(dest) - if option_name is None: - continue - entries.append( - _format_default_log_entry( - option_name, - _redact_inline_config_value(option_name, values), - ), - ) - - for dest, option_name in ( - ("mapping_inline", "--mapping"), - ("exclude_inline", "--exclude"), - ): - inline_value = org.config.app.CONFIG_INLINE_DEFAULTS.get(dest) - if inline_value is None: - continue - entries.append(_format_default_log_entry(option_name, "")) + try: + config_items = vars(config).items() + except TypeError: + return + + entries = [ + _format_argument_log_entry(field_name, _redact_config_value(field_name, field_value)) + for field_name, field_value in sorted(config_items, key=lambda item: item[0]) + ] if entries: - logger.info("Config defaults applied (%s): %s", command_name, ", ".join(entries)) + logger.info("Command config (%s): %s", command_name, ", ".join(entries)) def log_command_arguments(args: object, command_name: str) -> None: diff --git a/src/org/pipeline/load.py b/src/org/pipeline/load.py index 01add86..cb1f99a 100644 --- a/src/org/pipeline/load.py +++ b/src/org/pipeline/load.py @@ -30,6 +30,8 @@ if TYPE_CHECKING: from org_parser.document import Document + from org.config.app import AppConfig + logger = logging.getLogger("org") @@ -186,12 +188,15 @@ def load_root_data(args: RootDataLoadArgs) -> tuple[list[Document], list[str], l return _resolve_and_load_roots(args) -def load_and_process_data(args: DataLoadArgs) -> tuple[list[Heading], list[str], list[str]]: +def load_and_process_data( + args: DataLoadArgs, + config: AppConfig, +) -> tuple[list[Heading], list[str], list[str]]: """Load nodes, preprocess, and apply query-based filters/ordering.""" include_ordering = hasattr(args, "order_by_level") - validate_custom_switches(sys.argv, include_ordering) + validate_custom_switches(config, sys.argv, include_ordering) - normalized_files = normalize_cli_files_for_custom_switches(args.files) + normalized_files = normalize_cli_files_for_custom_switches(config, args.files) args.files = normalized_files todo_states, done_states = validate_global_arguments(args) @@ -205,7 +210,7 @@ def load_and_process_data(args: DataLoadArgs) -> tuple[list[Heading], list[str], include_slice = include_ordering and hasattr(args, "offset") and hasattr(args, "max_results") try: query = build_query_from_stages( - build_pipeline_stages(args, sys.argv, include_ordering), + build_pipeline_stages(config, args, sys.argv, include_ordering), include_slice, ) except QueryParseError as exc: diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index e80a97e..2a4fd20 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -3,7 +3,7 @@ from __future__ import annotations import sys -from types import SimpleNamespace +from pathlib import Path from typing import TYPE_CHECKING import typer @@ -16,8 +16,12 @@ import pytest -def test_cli_main_builds_default_map(monkeypatch: pytest.MonkeyPatch) -> None: - """main should load config defaults and pass default_map to Typer command.""" +FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" +EMPTY_CONFIG_PATH = str((FIXTURES_DIR / "empty-config.yaml").resolve()) + + +def test_cli_main_invokes_typer_command(monkeypatch: pytest.MonkeyPatch) -> None: + """main should load config, build the app, and invoke the Typer command.""" recorded: dict[str, object] = {} class DummyCommand: @@ -26,35 +30,21 @@ def main( args: list[str], prog_name: str, standalone_mode: bool, - default_map: object, ) -> None: recorded["args"] = args recorded["prog_name"] = prog_name recorded["standalone_mode"] = standalone_mode - recorded["default_map"] = default_map def fake_get_command(_app: object) -> DummyCommand: return DummyCommand() + loaded_config = org.config.app.AppConfig(config_path=EMPTY_CONFIG_PATH) + loaded_config.stats.all.max_results = 3 + monkeypatch.setattr( org.config.app, "load_cli_config", - lambda _argv: org.config.app.LoadedCliConfig( - defaults={"max_results": 3}, - append_defaults={}, - inline_defaults={}, - custom_filters={}, - custom_order_by={}, - custom_with={}, - capture_templates={}, - board_views={}, - agenda_views={}, - ), - ) - monkeypatch.setattr( - org.config.app, - "build_default_map", - lambda _defaults: {"stats": _defaults, "agenda": {}}, + lambda _argv: loaded_config, ) monkeypatch.setattr(typer.main, "get_command", fake_get_command) @@ -64,80 +54,17 @@ def fake_get_command(_app: object) -> DummyCommand: assert recorded["args"] == ["stats", "all", "--no-color", "file.org"] assert recorded["prog_name"] == "org" assert recorded["standalone_mode"] is True - assert recorded["default_map"] == {"stats": {"max_results": 3}, "agenda": {}} - - -def test_cli_main_updates_config_globals(monkeypatch: pytest.MonkeyPatch) -> None: - """main should update config append and inline defaults.""" - original_append = dict(org.config.app.CONFIG_APPEND_DEFAULTS) - original_inline = dict(org.config.app.CONFIG_INLINE_DEFAULTS) - original_custom_filters = dict(org.config.app.CONFIG_CUSTOM_FILTERS) - original_custom_order_by = dict(org.config.app.CONFIG_CUSTOM_ORDER_BY) - original_custom_with = dict(org.config.app.CONFIG_CUSTOM_WITH) - original_capture_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) - original_board_views = dict(org.config.app.CONFIG_BOARD_VIEWS) - org.config.app.CONFIG_APPEND_DEFAULTS.clear() - org.config.app.CONFIG_INLINE_DEFAULTS.clear() - org.config.app.CONFIG_CUSTOM_FILTERS.clear() - org.config.app.CONFIG_CUSTOM_ORDER_BY.clear() - org.config.app.CONFIG_CUSTOM_WITH.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_BOARD_VIEWS.clear() - - def fake_get_command(_app: object) -> SimpleNamespace: - return SimpleNamespace(main=lambda **_: None) - monkeypatch.setattr( - org.config.app, - "load_cli_config", - lambda _argv: org.config.app.LoadedCliConfig( - defaults={}, - append_defaults={"filter_tags": ["alpha"]}, - inline_defaults={"mapping_inline": {"a": "b"}}, - custom_filters={"my-filter": ".[]"}, - custom_order_by={"my-order": "."}, - custom_with={"my-with": "."}, - capture_templates={"quick": {"file": "tasks.org", "content": "* TODO {{title}}"}}, - board_views={ - "kanban": org.config.app.BoardViewConfig( - name="kanban", - columns=[ - org.config.app.BoardColumnConfig(name="TODO", filter='.todo == "TODO"'), - ], - ), - }, - agenda_views={}, - ), - ) - monkeypatch.setattr(org.config.app, "build_default_map", lambda _defaults: {}) - monkeypatch.setattr(typer.main, "get_command", fake_get_command) - monkeypatch.setattr(sys, "argv", ["org", "stats", "all"]) - - try: - cli.main() - - assert org.config.app.CONFIG_APPEND_DEFAULTS == {"filter_tags": ["alpha"]} - assert org.config.app.CONFIG_INLINE_DEFAULTS == {"mapping_inline": {"a": "b"}} - assert org.config.app.CONFIG_CUSTOM_FILTERS == {"my-filter": ".[]"} - assert org.config.app.CONFIG_CUSTOM_ORDER_BY == {"my-order": "."} - assert org.config.app.CONFIG_CUSTOM_WITH == {"my-with": "."} - assert org.config.app.CONFIG_CAPTURE_TEMPLATES == { - "quick": {"file": "tasks.org", "content": "* TODO {{title}}"}, - } - assert set(org.config.app.CONFIG_BOARD_VIEWS) == {"kanban"} - assert org.config.app.CONFIG_BOARD_VIEWS["kanban"].name == "kanban" - finally: - org.config.app.CONFIG_APPEND_DEFAULTS.clear() - org.config.app.CONFIG_APPEND_DEFAULTS.update(original_append) - org.config.app.CONFIG_INLINE_DEFAULTS.clear() - org.config.app.CONFIG_INLINE_DEFAULTS.update(original_inline) - org.config.app.CONFIG_CUSTOM_FILTERS.clear() - org.config.app.CONFIG_CUSTOM_FILTERS.update(original_custom_filters) - org.config.app.CONFIG_CUSTOM_ORDER_BY.clear() - org.config.app.CONFIG_CUSTOM_ORDER_BY.update(original_custom_order_by) - org.config.app.CONFIG_CUSTOM_WITH.clear() - org.config.app.CONFIG_CUSTOM_WITH.update(original_custom_with) - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_capture_templates) - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS.update(original_board_views) + +def test_build_app_callback_stores_loaded_config() -> None: + """The root callback should place the loaded AppConfig in ctx.obj.""" + config = org.config.app.AppConfig(config_path=EMPTY_CONFIG_PATH) + app = cli.build_app(config) + assert app.registered_callback is not None + callback = app.registered_callback.callback + assert callback is not None + + ctx = typer.Context(typer.main.get_command(app)) + callback(ctx, None) + + assert ctx.obj is config diff --git a/tests/cli/test_cli_runner.py b/tests/cli/test_cli_runner.py index 21140b4..91fadb0 100644 --- a/tests/cli/test_cli_runner.py +++ b/tests/cli/test_cli_runner.py @@ -9,15 +9,33 @@ from typer.testing import CliRunner import org.config.app -from org.cli import app +from org import cli +from org.config.app import NamedQueryConfig if TYPE_CHECKING: import pytest + import typer FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" +EMPTY_CONFIG_PATH = str((FIXTURES_DIR / "empty-config.yaml").resolve()) ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") +app = cli.build_app(org.config.app.AppConfig(config_path=EMPTY_CONFIG_PATH)) + + +def _build_app( + *, + custom_filters: dict[str, str] | None = None, + capture_templates: dict[str, dict[str, str]] | None = None, +) -> typer.Typer: + config = org.config.app.AppConfig(config_path=EMPTY_CONFIG_PATH) + config.filters = [ + NamedQueryConfig(name=name, query=query) for name, query in (custom_filters or {}).items() + ] + if capture_templates is not None: + config.tasks.capture.templates = capture_templates + return cli.build_app(config) class _CliResult(Protocol): @@ -200,86 +218,58 @@ def test_cli_runner_tasks_list_custom_filter_without_arg() -> None: """Custom filter switches should not be parsed as FILE arguments.""" runner = CliRunner() fixture_path = str((FIXTURES_DIR / "multiple_tags.org").resolve()) - original_filters = dict(org.config.app.CONFIG_CUSTOM_FILTERS) - - try: - org.config.app.CONFIG_CUSTOM_FILTERS.clear() - org.config.app.CONFIG_CUSTOM_FILTERS.update({"has-todo": "select(.todo != null)"}) - - result = runner.invoke( - app, - [ - "tasks", - "list", - "--no-color", - "--out", - "org", - "--filter-has-todo", - fixture_path, - ], - ) + result = runner.invoke( + _build_app(custom_filters={"has-todo": "select(.todo != null)"}), + [ + "tasks", + "list", + "--no-color", + "--out", + "org", + "--filter-has-todo", + fixture_path, + ], + ) - assert result.exit_code == 0 - assert "* TODO Refactor" in result.stdout - assert ":Maintenance:" in result.stdout - finally: - org.config.app.CONFIG_CUSTOM_FILTERS.clear() - org.config.app.CONFIG_CUSTOM_FILTERS.update(original_filters) + assert result.exit_code == 0 + assert "* TODO Refactor" in result.stdout + assert ":Maintenance:" in result.stdout def test_cli_runner_tasks_list_custom_filter_with_arg() -> None: """Custom filter argument value should not be parsed as FILE argument.""" runner = CliRunner() fixture_path = str((FIXTURES_DIR / "multiple_tags.org").resolve()) - original_filters = dict(org.config.app.CONFIG_CUSTOM_FILTERS) - - try: - org.config.app.CONFIG_CUSTOM_FILTERS.clear() - org.config.app.CONFIG_CUSTOM_FILTERS.update({"level-above": "select(.level > $arg)"}) - - result = runner.invoke( - app, - [ - "tasks", - "list", - "--no-color", - "--out", - "org", - "--filter-level-above", - "0", - fixture_path, - ], - ) + result = runner.invoke( + _build_app(custom_filters={"level-above": "select(.level > $arg)"}), + [ + "tasks", + "list", + "--no-color", + "--out", + "org", + "--filter-level-above", + "0", + fixture_path, + ], + ) - assert result.exit_code == 0 - assert "* TODO Refactor" in result.stdout - assert ":Maintenance:" in result.stdout - finally: - org.config.app.CONFIG_CUSTOM_FILTERS.clear() - org.config.app.CONFIG_CUSTOM_FILTERS.update(original_filters) + assert result.exit_code == 0 + assert "* TODO Refactor" in result.stdout + assert ":Maintenance:" in result.stdout def test_cli_runner_tasks_list_custom_filter_required_arg_error() -> None: """Custom filters with $arg should fail when the argument is missing.""" runner = CliRunner() - original_filters = dict(org.config.app.CONFIG_CUSTOM_FILTERS) - - try: - org.config.app.CONFIG_CUSTOM_FILTERS.clear() - org.config.app.CONFIG_CUSTOM_FILTERS.update({"level-above": "select(.level > $arg)"}) - - result = runner.invoke( - app, - ["tasks", "list", "--no-color", "--filter-level-above"], - ) + result = runner.invoke( + _build_app(custom_filters={"level-above": "select(.level > $arg)"}), + ["tasks", "list", "--no-color", "--filter-level-above"], + ) - assert result.exit_code != 0 - # FIXME This is only required on CI for some reason. - combined_output = clean_combined_output(result) - assert "--filter-level-above requires exactly one argument" in combined_output - finally: - org.config.app.CONFIG_CUSTOM_FILTERS.clear() - org.config.app.CONFIG_CUSTOM_FILTERS.update(original_filters) + assert result.exit_code != 0 + combined_output = clean_combined_output(result) + assert "--filter-level-above requires exactly one argument" in combined_output def test_cli_runner_board_requires_tty() -> None: @@ -825,27 +815,21 @@ def test_cli_runner_capture_direct_template_path(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) - - try: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update( - { + result = runner.invoke( + _build_app( + capture_templates={ "quick": { "file": str(target), "content": "* TODO {{title}}", }, }, - ) - - result = runner.invoke(app, ["tasks", "capture", "quick", "--set", "title=Write docs"]) + ), + ["tasks", "capture", "quick", "--set", "title=Write docs"], + ) - assert result.exit_code == 0 - updated = target.read_text(encoding="utf-8") - assert "* TODO Write docs" in updated - finally: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + assert result.exit_code == 0 + updated = target.read_text(encoding="utf-8") + assert "* TODO Write docs" in updated def test_cli_runner_capture_uses_interactive_form_runner( @@ -856,33 +840,29 @@ def test_cli_runner_capture_uses_interactive_form_runner( runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) support_path = "org.commands.tasks.capture.command._is_interactive_terminal" form_runner_path = "org.commands.tasks.capture.command.run_capture_form_app" + monkeypatch.setattr(support_path, lambda: True) + monkeypatch.setattr( + form_runner_path, + lambda plan: {**plan.values, "title": "Write docs", "owner": "Jane"}, + ) - try: - monkeypatch.setattr(support_path, lambda: True) - monkeypatch.setattr( - form_runner_path, - lambda plan: {**plan.values, "title": "Write docs", "owner": "Jane"}, - ) - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update( - { + result = runner.invoke( + _build_app( + capture_templates={ "quick": { "file": str(target), "content": "* TODO {{title}} @{{owner}}", }, }, - ) - - result = runner.invoke(app, ["tasks", "capture", "quick"], color=True) + ), + ["tasks", "capture", "quick"], + color=True, + ) - assert result.exit_code == 0 - assert "* TODO Write docs @Jane" in target.read_text(encoding="utf-8") - finally: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + assert result.exit_code == 0 + assert "* TODO Write docs @Jane" in target.read_text(encoding="utf-8") def test_cli_runner_capture_accepts_file_override(tmp_path: Path) -> None: @@ -892,38 +872,29 @@ def test_cli_runner_capture_accepts_file_override(tmp_path: Path) -> None: override_target = tmp_path / "override.org" configured_target.write_text("* TODO Configured\n", encoding="utf-8") override_target.write_text("* TODO Override\n", encoding="utf-8") - original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) - - try: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update( - { + result = runner.invoke( + _build_app( + capture_templates={ "quick": { "file": str(configured_target), "content": "* TODO {{title}}", }, }, - ) - - result = runner.invoke( - app, - [ - "tasks", - "capture", - "quick", - "--file", - str(override_target), - "--set", - "title=Write docs", - ], - ) + ), + [ + "tasks", + "capture", + "quick", + "--file", + str(override_target), + "--set", + "title=Write docs", + ], + ) - assert result.exit_code == 0 - assert "Write docs" not in configured_target.read_text(encoding="utf-8") - assert "Write docs" in override_target.read_text(encoding="utf-8") - finally: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + assert result.exit_code == 0 + assert "Write docs" not in configured_target.read_text(encoding="utf-8") + assert "Write docs" in override_target.read_text(encoding="utf-8") def test_cli_runner_capture_accepts_parent_override(tmp_path: Path) -> None: @@ -934,32 +905,23 @@ def test_cli_runner_capture_accepts_parent_override(tmp_path: Path) -> None: "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n", encoding="utf-8", ) - original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) - - try: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update( - { + result = runner.invoke( + _build_app( + capture_templates={ "child": { "file": str(target), "content": "** TODO {{title}}", "parent": '.id == "one"', }, }, - ) - - result = runner.invoke( - app, - ["tasks", "capture", "child", "--parent", "two", "--set", "title=From cli"], - ) + ), + ["tasks", "capture", "child", "--parent", "two", "--set", "title=From cli"], + ) - assert result.exit_code == 0 - updated = target.read_text(encoding="utf-8") - assert "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n" in updated - assert "* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n** TODO From cli\n" in updated - finally: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + assert result.exit_code == 0 + updated = target.read_text(encoding="utf-8") + assert "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n" in updated + assert "* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n** TODO From cli\n" in updated def test_cli_runner_capture_parent_override_accepts_title(tmp_path: Path) -> None: @@ -970,32 +932,23 @@ def test_cli_runner_capture_parent_override_accepts_title(tmp_path: Path) -> Non "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n", encoding="utf-8", ) - original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) - - try: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update( - { + result = runner.invoke( + _build_app( + capture_templates={ "child": { "file": str(target), "content": "** TODO {{title}}", "parent": '.id == "one"', }, }, - ) - - result = runner.invoke( - app, - ["tasks", "capture", "child", "--parent", "Two", "--set", "title=By title"], - ) + ), + ["tasks", "capture", "child", "--parent", "Two", "--set", "title=By title"], + ) - assert result.exit_code == 0 - updated = target.read_text(encoding="utf-8") - assert "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n" in updated - assert "* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n** TODO By title\n" in updated - finally: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + assert result.exit_code == 0 + updated = target.read_text(encoding="utf-8") + assert "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n" in updated + assert "* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n** TODO By title\n" in updated def test_cli_runner_capture_set_values_bypass_prompt(tmp_path: Path) -> None: @@ -1003,27 +956,21 @@ def test_cli_runner_capture_set_values_bypass_prompt(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) - - try: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update( - { + result = runner.invoke( + _build_app( + capture_templates={ "quick": { "file": str(target), "content": "* TODO {{title}} ({{title}})", }, }, - ) - - result = runner.invoke(app, ["tasks", "capture", "quick", "--set", "title=From cli"]) + ), + ["tasks", "capture", "quick", "--set", "title=From cli"], + ) - assert result.exit_code == 0 - assert result.stdout.strip() == "From cli (From cli)" - assert "* TODO From cli (From cli)" in target.read_text(encoding="utf-8") - finally: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + assert result.exit_code == 0 + assert result.stdout.strip() == "From cli (From cli)" + assert "* TODO From cli (From cli)" in target.read_text(encoding="utf-8") def test_cli_runner_capture_non_interactive_prefers_id_output(tmp_path: Path) -> None: @@ -1031,27 +978,21 @@ def test_cli_runner_capture_non_interactive_prefers_id_output(tmp_path: Path) -> runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) - - try: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update( - { + result = runner.invoke( + _build_app( + capture_templates={ "quick": { "file": str(target), "content": "* TODO {{title}}\n:PROPERTIES:\n:ID: task-99\n:END:", }, }, - ) - - result = runner.invoke(app, ["tasks", "capture", "quick", "--set", "title=From cli"]) + ), + ["tasks", "capture", "quick", "--set", "title=From cli"], + ) - assert result.exit_code == 0 - assert result.stdout.strip() == "task-99" - assert "* TODO From cli" in target.read_text(encoding="utf-8") - finally: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + assert result.exit_code == 0 + assert result.stdout.strip() == "task-99" + assert "* TODO From cli" in target.read_text(encoding="utf-8") def test_cli_runner_capture_set_values_ignores_unknown_parameter(tmp_path: Path) -> None: @@ -1059,26 +1000,20 @@ def test_cli_runner_capture_set_values_ignores_unknown_parameter(tmp_path: Path) runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) - - try: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update( - { + result = runner.invoke( + _build_app( + capture_templates={ "quick": { "file": str(target), "content": "* TODO Static", }, }, - ) - - result = runner.invoke(app, ["tasks", "capture", "quick", "--set", "foo=bar"]) + ), + ["tasks", "capture", "quick", "--set", "foo=bar"], + ) - assert result.exit_code == 0 - assert "* TODO Static" in target.read_text(encoding="utf-8") - finally: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + assert result.exit_code == 0 + assert "* TODO Static" in target.read_text(encoding="utf-8") def test_cli_runner_capture_accepts_empty_placeholder_value( @@ -1089,32 +1024,27 @@ def test_cli_runner_capture_accepts_empty_placeholder_value( runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) support_path = "org.commands.tasks.capture.command._is_interactive_terminal" form_runner_path = "org.commands.tasks.capture.command.run_capture_form_app" + monkeypatch.setattr(support_path, lambda: True) + monkeypatch.setattr(form_runner_path, lambda plan: {**plan.values, "title": ""}) - try: - monkeypatch.setattr(support_path, lambda: True) - monkeypatch.setattr(form_runner_path, lambda plan: {**plan.values, "title": ""}) - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update( - { + result = runner.invoke( + _build_app( + capture_templates={ "quick": { "file": str(target), "content": "* TODO {{title}}", }, }, - ) - - result = runner.invoke(app, ["tasks", "capture", "quick"]) + ), + ["tasks", "capture", "quick"], + ) - assert result.exit_code == 0 - updated = target.read_text(encoding="utf-8") - assert "* TODO Existing" in updated - assert updated.count("* TODO") == 2 - finally: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + assert result.exit_code == 0 + updated = target.read_text(encoding="utf-8") + assert "* TODO Existing" in updated + assert updated.count("* TODO") == 2 def test_cli_runner_capture_interactive_numeric_selection( @@ -1125,34 +1055,29 @@ def test_cli_runner_capture_interactive_numeric_selection( runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) support_path = "org.commands.tasks.capture.command._is_interactive_terminal" selection_path = "org.commands.tasks.capture.command.run_template_selection_app" form_runner_path = "org.commands.tasks.capture.command.run_capture_form_app" + monkeypatch.setattr(support_path, lambda: True) + monkeypatch.setattr(selection_path, lambda _names: "beta") + monkeypatch.setattr( + form_runner_path, + lambda plan: {**plan.values, "title": "Captured task"}, + ) - try: - monkeypatch.setattr(support_path, lambda: True) - monkeypatch.setattr(selection_path, lambda _names: "beta") - monkeypatch.setattr( - form_runner_path, - lambda plan: {**plan.values, "title": "Captured task"}, - ) - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update( - { + result = runner.invoke( + _build_app( + capture_templates={ "alpha": {"file": str(target), "content": "* TODO Alpha"}, "beta": {"file": str(target), "content": "* TODO {{title}}"}, }, - ) - - result = runner.invoke(app, ["tasks", "capture"]) + ), + ["tasks", "capture"], + ) - assert result.exit_code == 0 - updated = target.read_text(encoding="utf-8") - assert "* TODO Captured task" in updated - finally: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + assert result.exit_code == 0 + updated = target.read_text(encoding="utf-8") + assert "* TODO Captured task" in updated def test_cli_runner_capture_unknown_template_lists_valid_names(tmp_path: Path) -> None: @@ -1160,24 +1085,18 @@ def test_cli_runner_capture_unknown_template_lists_valid_names(tmp_path: Path) - runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) - - try: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update( - { + result = runner.invoke( + _build_app( + capture_templates={ "alpha": {"file": str(target), "content": "* TODO Alpha"}, "beta": {"file": str(target), "content": "* TODO Beta"}, }, - ) - - result = runner.invoke(app, ["tasks", "capture", "missing"]) + ), + ["tasks", "capture", "missing"], + ) - assert result.exit_code != 0 - combined_output = clean_combined_output(result) - assert "Unknown capture template 'missing'" in combined_output - assert "Valid templates: alpha," in combined_output - assert "beta" in combined_output - finally: - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + assert result.exit_code != 0 + combined_output = clean_combined_output(result) + assert "Unknown capture template 'missing'" in combined_output + assert "Valid templates: alpha," in combined_output + assert "beta" in combined_output diff --git a/tests/cli_common/test_filter_construction.py b/tests/cli_common/test_filter_construction.py index 71f2b7d..36e9e78 100644 --- a/tests/cli_common/test_filter_construction.py +++ b/tests/cli_common/test_filter_construction.py @@ -8,6 +8,9 @@ import pytest import typer +import org.logging +from org.config.app import AppConfig, NamedQueryConfig + def _build_query_text( args: FilterArgsStub, @@ -15,11 +18,17 @@ def _build_query_text( *, include_ordering: bool, include_slice: bool, + config: AppConfig | None = None, ) -> str: from org.config.cli import build_pipeline_stages from org.query.runner import build_query_text_from_stages - stages = build_pipeline_stages(args, argv, include_ordering) + stages = build_pipeline_stages( + AppConfig(config_path=".org-cli.yaml") if config is None else config, + args, + argv, + include_ordering, + ) return build_query_text_from_stages(stages, include_slice) @@ -29,14 +38,39 @@ def _build_query( *, include_ordering: bool, include_slice: bool, + config: AppConfig | None = None, ) -> object: from org.config.cli import build_pipeline_stages from org.query.runner import build_query_from_stages - stages = build_pipeline_stages(args, argv, include_ordering) + stages = build_pipeline_stages( + AppConfig(config_path=".org-cli.yaml") if config is None else config, + args, + argv, + include_ordering, + ) return build_query_from_stages(stages, include_slice) +def _build_config( + *, + custom_filters: dict[str, str] | None = None, + custom_order_by: dict[str, str] | None = None, + custom_with: dict[str, str] | None = None, +) -> AppConfig: + config = AppConfig(config_path=".org-cli.yaml") + config.filters = [ + NamedQueryConfig(name=name, query=query) for name, query in (custom_filters or {}).items() + ] + config.orderings = [ + NamedQueryConfig(name=name, query=query) for name, query in (custom_order_by or {}).items() + ] + config.mutators = [ + NamedQueryConfig(name=name, query=query) for name, query in (custom_with or {}).items() + ] + return config + + @dataclass class FilterArgsStub: """Stub arguments for query construction tests.""" @@ -200,28 +234,22 @@ def test_build_query_logs_query_before_compile(caplog: pytest.LogCaptureFixture) args = make_args(filter_tags=["simple"]) argv = ["org", "stats", "all", "--filter-tag", "simple", "file.org"] + org.logging.logger.setLevel(logging.INFO) + org.logging.logger.propagate = True with caplog.at_level(logging.INFO, logger="org"): _build_query(args, argv, include_ordering=False, include_slice=False) assert 'Query: [ .[] | select(.tags[] matches "simple") ]' in caplog.text -def test_build_query_text_with_custom_filter_and_optional_arg( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_build_query_text_with_custom_filter_and_optional_arg() -> None: """Custom filter switches should bind $arg and support omitted values.""" - import org.config.app - - monkeypatch.setattr( - org.config.app, - "CONFIG_CUSTOM_FILTERS", - { + config = _build_config( + custom_filters={ "todo-state": "select(.todo == $arg)", "has-todo": "select(.todo != null)", }, ) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) args = make_args(files=["file.org"]) argv = [ @@ -234,7 +262,13 @@ def test_build_query_text_with_custom_filter_and_optional_arg( "file.org", ] - query = _build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text( + args, + argv, + include_ordering=False, + include_slice=False, + config=config, + ) assert query == ( "[ .[] | let 3 as $arg in (select(.todo == $arg))" @@ -242,17 +276,10 @@ def test_build_query_text_with_custom_filter_and_optional_arg( ) -def test_collect_custom_context_vars_returns_empty_for_custom_args( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_collect_custom_context_vars_returns_empty_for_custom_args() -> None: """Custom arguments are embedded in query text and not added to context vars.""" - import org.config.app from org.config.cli import collect_custom_context_vars - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.v == $arg)"}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) - args = make_args(files=["file.org"]) argv = [ "org", @@ -276,13 +303,12 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( assert context_vars == {} -def test_build_query_text_custom_with_before_filters(monkeypatch: pytest.MonkeyPatch) -> None: +def test_build_query_text_custom_with_before_filters() -> None: """Custom enrichment stages should be applied before filters.""" - import org.config.app - - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"tagged": "select(.tag != null)"}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) + config = _build_config( + custom_filters={"tagged": "select(.tag != null)"}, + custom_with={"mark": '. + {"x": $arg}'}, + ) args = make_args(filter_completed=True, files=["file.org"]) argv = [ @@ -296,21 +322,21 @@ def test_build_query_text_custom_with_before_filters(monkeypatch: pytest.MonkeyP "file.org", ] - query = _build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text( + args, + argv, + include_ordering=False, + include_slice=False, + config=config, + ) assert query.startswith('[ .[] | let "one" as $arg in (. + {"x": $arg}) | ') assert "| let null as $arg in (select(.tag != null)) |" in query -def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order() -> None: """Built-in and custom with stages should preserve CLI order.""" - import org.config.app - - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) + config = _build_config(custom_with={"mark": '. + {"x": $arg}'}) args = make_args(with_tags_as_category=True, files=["file.org"]) argv = [ @@ -323,7 +349,13 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( "file.org", ] - query = _build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text( + args, + argv, + include_ordering=False, + include_slice=False, + config=config, + ) assert query == ( '[ .[] | let "one" as $arg in (. + {"x": $arg}) ' @@ -331,18 +363,20 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( ) -def test_build_query_text_custom_ordering_for_stats(monkeypatch: pytest.MonkeyPatch) -> None: +def test_build_query_text_custom_ordering_for_stats() -> None: """Custom order-by switches should work even when built-in ordering is disabled.""" - import org.config.app - - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) + config = _build_config(custom_order_by={"weight": "sort_by(.priority)"}) args = make_args(files=["file.org"]) argv = ["org", "stats", "all", "--order-by-weight", "file.org"] - query = _build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text( + args, + argv, + include_ordering=False, + include_slice=False, + config=config, + ) assert query == "[ .[] | let null as $arg in (sort_by(.priority)) ]" @@ -354,22 +388,18 @@ def test_load_and_process_data_logs_query_context(caplog: pytest.LogCaptureFixtu fixture_path = str((Path(__file__).resolve().parents[1] / "fixtures" / "simple.org").resolve()) args = make_args(files=[fixture_path], offset=0, max_results=1) + org.logging.logger.setLevel(logging.INFO) + org.logging.logger.propagate = True with caplog.at_level(logging.INFO, logger="org"): - load_and_process_data(args) + load_and_process_data(args, AppConfig(config_path=".org-cli.yaml")) assert "Query context:" in caplog.text assert "'todo_states': ['TODO']" in caplog.text -def test_build_query_text_preserves_mixed_ordering_cli_order( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_build_query_text_preserves_mixed_ordering_cli_order() -> None: """Built-in and custom ordering switches should follow CLI specification order.""" - import org.config.app - - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) + config = _build_config(custom_order_by={"weight": "sort_by(.priority)"}) args = make_args(order_by_timestamp_desc=True, files=["file.org"]) argv = [ @@ -381,7 +411,7 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( "file.org", ] - query = _build_query_text(args, argv, include_ordering=True, include_slice=False) + query = _build_query_text(args, argv, include_ordering=True, include_slice=False, config=config) assert query == ( "[ .[] | let null as $arg in (sort_by(.priority))" @@ -389,14 +419,8 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( ) -def test_build_query_text_rejects_unknown_custom_switch(monkeypatch: pytest.MonkeyPatch) -> None: +def test_build_query_text_rejects_unknown_custom_switch() -> None: """Unknown prefixed custom switches should fail validation.""" - import org.config.app - - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) - args = make_args(files=["file.org"]) argv = ["org", "tasks", "list", "--filter-unknown", "file.org"] @@ -404,19 +428,9 @@ def test_build_query_text_rejects_unknown_custom_switch(monkeypatch: pytest.Monk _build_query_text(args, argv, include_ordering=False, include_slice=False) -def test_build_query_text_custom_filter_requires_exactly_one_argument( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_build_query_text_custom_filter_requires_exactly_one_argument() -> None: """Custom filters using $arg should require exactly one argument.""" - import org.config.app - - monkeypatch.setattr( - org.config.app, - "CONFIG_CUSTOM_FILTERS", - {"level-above": "select(.level > $arg)"}, - ) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) + config = _build_config(custom_filters={"level-above": "select(.level > $arg)"}) args = make_args(files=["file.org"]) argv = ["org", "tasks", "list", "--filter-level-above"] @@ -425,57 +439,40 @@ def test_build_query_text_custom_filter_requires_exactly_one_argument( typer.BadParameter, match="--filter-level-above requires exactly one argument", ): - _build_query_text(args, argv, include_ordering=False, include_slice=False) + _build_query_text(args, argv, include_ordering=False, include_slice=False, config=config) -def test_build_query_text_custom_order_by_requires_exactly_one_argument( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_build_query_text_custom_order_by_requires_exactly_one_argument() -> None: """Custom order-by switches using $arg should require exactly one argument.""" - import org.config.app - - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by($arg)"}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) + config = _build_config(custom_order_by={"weight": "sort_by($arg)"}) args = make_args(files=["file.org"]) argv = ["org", "tasks", "list", "--order-by-weight"] with pytest.raises(typer.BadParameter, match="--order-by-weight requires exactly one argument"): - _build_query_text(args, argv, include_ordering=True, include_slice=False) + _build_query_text(args, argv, include_ordering=True, include_slice=False, config=config) -def test_build_query_text_custom_with_requires_exactly_one_argument( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_build_query_text_custom_with_requires_exactly_one_argument() -> None: """Custom enrichment switches using $arg should require exactly one argument.""" - import org.config.app - - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) + config = _build_config(custom_with={"mark": '. + {"x": $arg}'}) args = make_args(files=["file.org"]) argv = ["org", "tasks", "list", "--with-mark"] with pytest.raises(typer.BadParameter, match="--with-mark requires exactly one argument"): - _build_query_text(args, argv, include_ordering=False, include_slice=False) + _build_query_text(args, argv, include_ordering=False, include_slice=False, config=config) -def test_normalize_cli_files_consumes_required_custom_path_like_argument( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_normalize_cli_files_consumes_required_custom_path_like_argument() -> None: """Required custom arguments should consume path-like values.""" - import org.config.app from org.config.cli import normalize_cli_files_for_custom_switches - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.todo == $arg)"}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) + config = _build_config(custom_filters={"value": "select(.todo == $arg)"}) files = ["--filter-value", "README.md", "file.org"] - result = normalize_cli_files_for_custom_switches(files) + result = normalize_cli_files_for_custom_switches(config, files) assert result == ["file.org"] diff --git a/tests/commands/stats/test_stats_commands.py b/tests/commands/stats/test_stats_commands.py index c86a53c..3c710e8 100644 --- a/tests/commands/stats/test_stats_commands.py +++ b/tests/commands/stats/test_stats_commands.py @@ -8,6 +8,7 @@ import pytest import typer +import org.config.app from org.commands.stats import all as stats_all_command from org.commands.stats import groups as stats_groups from org.commands.stats import summary as stats_summary_command @@ -18,6 +19,11 @@ FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures") +def _app_config() -> org.config.app.AppConfig: + """Build default app config for direct command tests.""" + return org.config.app.AppConfig(config_path=".org-cli.yaml") + + def make_stats_all_args(files: list[str], **overrides: object) -> stats_all_command.StatsAllArgs: """Build StatsAllArgs with defaults and overrides.""" args = stats_all_command.StatsAllArgs( @@ -176,7 +182,7 @@ def test_run_stats_all_outputs_sections( args = make_stats_all_args([fixture_path]) monkeypatch.setattr(sys, "argv", ["org", "stats", "all"]) - stats_all_command.run_stats(args) + stats_all_command.run_stats(args, _app_config()) captured = capsys.readouterr().out assert "Total tasks:" in captured @@ -193,7 +199,7 @@ def test_run_stats_summary_excludes_tag_sections( args = make_summary_args([fixture_path]) monkeypatch.setattr(sys, "argv", ["org", "stats", "summary"]) - stats_summary_command.run_stats_summary(args) + stats_summary_command.run_stats_summary(args, _app_config()) captured = capsys.readouterr().out assert "Task states:" in captured @@ -210,7 +216,7 @@ def test_run_stats_summary_no_results( args = make_summary_args([fixture_path], filter_tags=["nomatch$"]) monkeypatch.setattr(sys, "argv", ["org", "stats", "summary"]) - stats_summary_command.run_stats_summary(args) + stats_summary_command.run_stats_summary(args, _app_config()) captured = capsys.readouterr().out assert "No results" in captured @@ -222,7 +228,7 @@ def test_run_stats_all_negative_max_results_raises_bad_parameter() -> None: args = make_stats_all_args([fixture_path], max_results=-1) with pytest.raises(typer.BadParameter, match="--limit must be non-negative"): - stats_all_command.run_stats(args) + stats_all_command.run_stats(args, _app_config()) def test_run_stats_summary_negative_max_results_raises_bad_parameter() -> None: @@ -231,7 +237,7 @@ def test_run_stats_summary_negative_max_results_raises_bad_parameter() -> None: args = make_summary_args([fixture_path], max_results=-1) with pytest.raises(typer.BadParameter, match="--limit must be non-negative"): - stats_summary_command.run_stats_summary(args) + stats_summary_command.run_stats_summary(args, _app_config()) def test_run_stats_tags_negative_max_results_raises_bad_parameter() -> None: @@ -240,7 +246,7 @@ def test_run_stats_tags_negative_max_results_raises_bad_parameter() -> None: args = make_tags_args([fixture_path], max_results=-1) with pytest.raises(typer.BadParameter, match="--limit must be non-negative"): - stats_tags.run_stats_tags(args) + stats_tags.run_stats_tags(args, _app_config()) def test_run_stats_groups_negative_max_results_raises_bad_parameter() -> None: @@ -249,7 +255,7 @@ def test_run_stats_groups_negative_max_results_raises_bad_parameter() -> None: args = make_groups_args([fixture_path], max_results=-1) with pytest.raises(typer.BadParameter, match="--limit must be non-negative"): - stats_groups.run_stats_groups(args) + stats_groups.run_stats_groups(args, _app_config()) def test_run_stats_tags_respects_tag_filter( @@ -261,7 +267,7 @@ def test_run_stats_tags_respects_tag_filter( args = make_tags_args([fixture_path], tags=["Test"]) monkeypatch.setattr(sys, "argv", ["org", "stats", "tags", "--tag", "Test"]) - stats_tags.run_stats_tags(args) + stats_tags.run_stats_tags(args, _app_config()) captured = capsys.readouterr().out assert "Test" in captured @@ -277,7 +283,7 @@ def test_run_stats_tags_tag_heading( args = make_tags_args([fixture_path], use="heading", tags=["Simple"]) monkeypatch.setattr(sys, "argv", ["org", "stats", "tags", "--tag", "Simple"]) - stats_tags.run_stats_tags(args) + stats_tags.run_stats_tags(args, _app_config()) captured = capsys.readouterr().out assert "simple" in captured @@ -292,7 +298,7 @@ def test_run_stats_groups_explicit_group( args = make_groups_args([fixture_path], groups=["python,programming"]) monkeypatch.setattr(sys, "argv", ["org", "stats", "groups", "--group", "python,programming"]) - stats_groups.run_stats_groups(args) + stats_groups.run_stats_groups(args, _app_config()) captured = capsys.readouterr().out assert "python, programming" in captured @@ -307,7 +313,7 @@ def test_run_stats_all_no_results( args = make_stats_all_args([fixture_path], filter_tags=["nomatch$"]) monkeypatch.setattr(sys, "argv", ["org", "stats", "all"]) - stats_all_command.run_stats(args) + stats_all_command.run_stats(args, _app_config()) captured = capsys.readouterr().out assert "No results" in captured @@ -325,7 +331,7 @@ def test_run_stats_all_category_preprocessor( ) monkeypatch.setattr(sys, "argv", ["org", "stats", "all"]) - stats_all_command.run_stats(args) + stats_all_command.run_stats(args, _app_config()) captured = capsys.readouterr().out assert "Task categories:" in captured @@ -340,7 +346,7 @@ def test_run_stats_all_omits_groups_when_disabled( args = make_stats_all_args([fixture_path], max_groups=0) monkeypatch.setattr(sys, "argv", ["org", "stats", "all"]) - stats_all_command.run_stats(args) + stats_all_command.run_stats(args, _app_config()) captured = capsys.readouterr().out assert "GROUPS" not in captured @@ -362,7 +368,7 @@ def test_run_stats_all_tasks_panel_grows_with_task_list( args = make_stats_all_args([fixture_path], width=120, max_results=40, max_tags=0, max_groups=0) monkeypatch.setattr(sys, "argv", ["org", "stats", "all", "--width", "120", "--limit", "40"]) - stats_all_command.run_stats(args) + stats_all_command.run_stats(args, _app_config()) captured = capsys.readouterr().out assert captured.count("Task-") == 40 @@ -390,7 +396,7 @@ def test_run_stats_all_two_column_default_limit_uses_summary_size( max_groups=0, ) monkeypatch.setattr(sys, "argv", ["org", "stats", "all", "--width", "120"]) - stats_all_command.run_stats(args) + stats_all_command.run_stats(args, _app_config()) captured = capsys.readouterr().out assert captured.count("Task-") > 10 @@ -419,7 +425,7 @@ def test_run_stats_all_single_column_default_limit_stays_ten( max_groups=0, ) monkeypatch.setattr(sys, "argv", ["org", "stats", "all", "--width", "119"]) - stats_all_command.run_stats(args) + stats_all_command.run_stats(args, _app_config()) captured = capsys.readouterr().out assert captured.count("Task-") == 10 @@ -544,7 +550,7 @@ def test_run_stats_all_narrow_layout_orders_sections_vertically( args = make_stats_all_args([fixture_path], width=119, max_results=3, max_tags=3, max_groups=3) monkeypatch.setattr(sys, "argv", ["org", "stats", "all", "--width", "119"]) - stats_all_command.run_stats(args) + stats_all_command.run_stats(args, _app_config()) captured = capsys.readouterr().out summary_index = captured.find("SUMMARY") diff --git a/tests/commands/tasks/test_tasks_add.py b/tests/commands/tasks/test_tasks_add.py index f46aa3c..52da600 100644 --- a/tests/commands/tasks/test_tasks_add.py +++ b/tests/commands/tasks/test_tasks_add.py @@ -10,9 +10,15 @@ import pytest import typer +import org.config.app from org.commands.tasks import add as tasks_add +def _run_tasks_add(args: tasks_add.AddArgs) -> None: + """Run tasks add with a default app config for direct tests.""" + tasks_add.run_tasks_add(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) + + if TYPE_CHECKING: from pathlib import Path @@ -59,7 +65,7 @@ def test_run_tasks_add_appends_top_level_heading_to_first_resolved_file(tmp_path body="Body text", ) - tasks_add.run_tasks_add(args) + _run_tasks_add(args) first_root = org_parser.loads(first.read_text(encoding="utf-8")) first_nodes = list(first_root) @@ -85,7 +91,7 @@ def test_run_tasks_add_uses_file_override_when_provided(tmp_path: Path) -> None: file=str(second), ) - tasks_add.run_tasks_add(args) + _run_tasks_add(args) assert "Target second" not in first.read_text(encoding="utf-8") assert "* TODO Target second" in second.read_text(encoding="utf-8") @@ -101,7 +107,7 @@ def test_run_tasks_add_inserts_child_of_parent_title_with_default_level(tmp_path args = make_add_args([str(source)], title="Added child", parent="Parent") - tasks_add.run_tasks_add(args) + _run_tasks_add(args) root = org_parser.loads(source.read_text(encoding="utf-8")) nodes = list(root) @@ -122,7 +128,7 @@ def test_run_tasks_add_inserts_child_of_parent_id(tmp_path: Path) -> None: args = make_add_args([str(source)], title="Added child", parent="parent-1") - tasks_add.run_tasks_add(args) + _run_tasks_add(args) root = org_parser.loads(source.read_text(encoding="utf-8")) nodes = list(root) @@ -137,7 +143,7 @@ def test_run_tasks_add_errors_when_parent_not_found(tmp_path: Path) -> None: args = make_add_args([str(source)], title="Child", parent="missing") with pytest.raises(typer.BadParameter, match="was not found"): - tasks_add.run_tasks_add(args) + _run_tasks_add(args) def test_run_tasks_add_errors_when_parent_is_ambiguous(tmp_path: Path) -> None: @@ -147,7 +153,7 @@ def test_run_tasks_add_errors_when_parent_is_ambiguous(tmp_path: Path) -> None: args = make_add_args([str(source)], title="Child", parent="Same") with pytest.raises(typer.BadParameter, match="ambiguous"): - tasks_add.run_tasks_add(args) + _run_tasks_add(args) def test_run_tasks_add_rejects_heading_with_mutually_exclusive_switches(tmp_path: Path) -> None: @@ -157,7 +163,7 @@ def test_run_tasks_add_rejects_heading_with_mutually_exclusive_switches(tmp_path args = make_add_args([str(source)], heading="* TODO One", title="Two") with pytest.raises(typer.BadParameter, match="--heading cannot be combined"): - tasks_add.run_tasks_add(args) + _run_tasks_add(args) def test_run_tasks_add_reads_task_source_from_stdin_when_heading_components_missing( @@ -177,7 +183,7 @@ def test_run_tasks_add_reads_task_source_from_stdin_when_heading_components_miss monkeypatch.setattr("sys.stdin", io.StringIO("* TODO From stdin\n")) - tasks_add.run_tasks_add(args) + _run_tasks_add(args) root = org_parser.loads(source.read_text(encoding="utf-8")) created = list(root)[-1] @@ -212,7 +218,7 @@ def test_run_tasks_add_applies_edits_to_stdin_task_source( monkeypatch.setattr("sys.stdin", io.StringIO("* TODO Base :old:\n** TODO Child\n")) - tasks_add.run_tasks_add(args) + _run_tasks_add(args) root = org_parser.loads(source.read_text(encoding="utf-8")) nodes = list(root) @@ -249,7 +255,7 @@ def test_run_tasks_add_stdin_parent_attach_relies_on_parser_normalization( monkeypatch.setattr("sys.stdin", io.StringIO("* TODO Child\n")) - tasks_add.run_tasks_add(args) + _run_tasks_add(args) root = org_parser.loads(source.read_text(encoding="utf-8")) child = next(node for node in root if node.title_text.strip() == "Child") @@ -274,7 +280,7 @@ def test_run_tasks_add_errors_when_stdin_task_source_is_empty( monkeypatch.setattr("sys.stdin", io.StringIO(" \n\t\n")) with pytest.raises(typer.BadParameter, match="Task heading is empty"): - tasks_add.run_tasks_add(args) + _run_tasks_add(args) def test_run_tasks_add_surfaces_invalid_template_parse_errors(tmp_path: Path) -> None: @@ -284,7 +290,7 @@ def test_run_tasks_add_surfaces_invalid_template_parse_errors(tmp_path: Path) -> args = make_add_args([str(source)], title="Two", scheduled="not-a-timestamp") with pytest.raises(typer.BadParameter, match="Invalid task template"): - tasks_add.run_tasks_add(args) + _run_tasks_add(args) def test_run_tasks_add_allows_heading_without_title_when_metadata_is_set(tmp_path: Path) -> None: @@ -293,7 +299,7 @@ def test_run_tasks_add_allows_heading_without_title_when_metadata_is_set(tmp_pat source.write_text("* TODO Parent\n", encoding="utf-8") args = make_add_args([str(source)], title=None, todo="TODO", priority="A", comment="true") - tasks_add.run_tasks_add(args) + _run_tasks_add(args) root = org_parser.loads(source.read_text(encoding="utf-8")) node = list(root)[-1] @@ -310,7 +316,7 @@ def test_run_tasks_add_rejects_invalid_comment_value(tmp_path: Path) -> None: args = make_add_args([str(source)], todo=None, title=None, comment="yes") with pytest.raises(typer.BadParameter, match="--comment must be either"): - tasks_add.run_tasks_add(args) + _run_tasks_add(args) def test_run_tasks_add_applies_json_properties_and_generates_default_id(tmp_path: Path) -> None: @@ -324,7 +330,7 @@ def test_run_tasks_add_applies_json_properties_and_generates_default_id(tmp_path id_value=None, ) - tasks_add.run_tasks_add(args) + _run_tasks_add(args) root = org_parser.loads(source.read_text(encoding="utf-8")) node = list(root)[-1] @@ -341,4 +347,4 @@ def test_run_tasks_add_rejects_invalid_properties_json(tmp_path: Path) -> None: args = make_add_args([str(source)], properties='["x"]') with pytest.raises(typer.BadParameter, match="--properties must be a JSON object"): - tasks_add.run_tasks_add(args) + _run_tasks_add(args) diff --git a/tests/commands/tasks/test_tasks_archive.py b/tests/commands/tasks/test_tasks_archive.py index e22e00d..8cfd536 100644 --- a/tests/commands/tasks/test_tasks_archive.py +++ b/tests/commands/tasks/test_tasks_archive.py @@ -10,9 +10,15 @@ import typer from org_parser.document import Heading +import org.config.app from org.commands.tasks import archive as tasks_archive +def _run_tasks_archive(args: tasks_archive.ArchiveArgs) -> None: + """Run tasks archive with a default app config for direct tests.""" + tasks_archive.run_tasks_archive(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) + + if TYPE_CHECKING: from pathlib import Path @@ -38,7 +44,7 @@ def test_run_tasks_archive_uses_default_archive_pattern(tmp_path: Path) -> None: source.write_text("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n", encoding="utf-8") archive.write_text("", encoding="utf-8") - tasks_archive.run_tasks_archive(make_archive_args([str(source)])) + _run_tasks_archive(make_archive_args([str(source)])) source_root = org_parser.loads(source.read_text(encoding="utf-8")) archive_root = org_parser.loads(archive.read_text(encoding="utf-8")) @@ -65,7 +71,7 @@ def test_run_tasks_archive_prefers_heading_archive_property(tmp_path: Path) -> N by_property.write_text("", encoding="utf-8") by_keyword.write_text("", encoding="utf-8") - tasks_archive.run_tasks_archive(make_archive_args([str(source)])) + _run_tasks_archive(make_archive_args([str(source)])) property_root = org_parser.loads(by_property.read_text(encoding="utf-8")) keyword_root = org_parser.loads(by_keyword.read_text(encoding="utf-8")) @@ -83,7 +89,7 @@ def test_run_tasks_archive_uses_document_archive_keyword(tmp_path: Path) -> None ) destination.write_text("", encoding="utf-8") - tasks_archive.run_tasks_archive(make_archive_args([str(source)])) + _run_tasks_archive(make_archive_args([str(source)])) destination_root = org_parser.loads(destination.read_text(encoding="utf-8")) assert [node.title_text.strip() for node in list(destination_root)] == ["Keep"] @@ -97,7 +103,7 @@ def test_run_tasks_archive_moves_to_named_parent_heading_in_same_file(tmp_path: encoding="utf-8", ) - tasks_archive.run_tasks_archive(make_archive_args([str(source)])) + _run_tasks_archive(make_archive_args([str(source)])) root = org_parser.loads(source.read_text(encoding="utf-8")) nodes = list(root) @@ -125,7 +131,7 @@ def test_run_tasks_archive_moves_to_named_parent_heading_in_other_file(tmp_path: ) destination.write_text("* TODO Archived\n", encoding="utf-8") - tasks_archive.run_tasks_archive(make_archive_args([str(source)])) + _run_tasks_archive(make_archive_args([str(source)])) source_root = org_parser.loads(source.read_text(encoding="utf-8")) destination_root = org_parser.loads(destination.read_text(encoding="utf-8")) @@ -144,10 +150,10 @@ def test_run_tasks_archive_requires_exactly_one_selector(tmp_path: Path) -> None source.write_text("* TODO Keep\n", encoding="utf-8") with pytest.raises(typer.BadParameter, match="exactly one task selector"): - tasks_archive.run_tasks_archive(make_archive_args([str(source)], query_id=None)) + _run_tasks_archive(make_archive_args([str(source)], query_id=None)) with pytest.raises(typer.BadParameter, match="exactly one task selector"): - tasks_archive.run_tasks_archive( + _run_tasks_archive( make_archive_args( [str(source)], query_title="Keep", @@ -163,10 +169,10 @@ def test_run_tasks_archive_supports_query_title_and_query(tmp_path: Path) -> Non source.write_text("* TODO A\n* TODO B\n", encoding="utf-8") archive.write_text("", encoding="utf-8") - tasks_archive.run_tasks_archive( + _run_tasks_archive( make_archive_args([str(source)], query_id=None, query_title="A"), ) - tasks_archive.run_tasks_archive( + _run_tasks_archive( make_archive_args( [str(source)], query_id=None, @@ -189,7 +195,7 @@ def test_run_tasks_archive_reports_missing_archive_file(tmp_path: Path) -> None: ) with pytest.raises(typer.BadParameter, match="not found"): - tasks_archive.run_tasks_archive(make_archive_args([str(source)])) + _run_tasks_archive(make_archive_args([str(source)])) def test_run_tasks_archive_reports_missing_archive_heading(tmp_path: Path) -> None: @@ -203,7 +209,7 @@ def test_run_tasks_archive_reports_missing_archive_heading(tmp_path: Path) -> No destination.write_text("* TODO Other\n", encoding="utf-8") with pytest.raises(typer.BadParameter, match="--parent 'Missing' was not found"): - tasks_archive.run_tasks_archive(make_archive_args([str(source)])) + _run_tasks_archive(make_archive_args([str(source)])) def test_run_tasks_archive_skips_descendant_matches_when_parent_selected( @@ -219,7 +225,7 @@ def test_run_tasks_archive_skips_descendant_matches_when_parent_selected( ) archive.write_text("", encoding="utf-8") - tasks_archive.run_tasks_archive( + _run_tasks_archive( make_archive_args( [str(source)], query_id=None, @@ -242,7 +248,7 @@ def test_run_tasks_archive_sets_archive_metadata_properties(tmp_path: Path) -> N ) archive.write_text("", encoding="utf-8") - tasks_archive.run_tasks_archive(make_archive_args([str(source)])) + _run_tasks_archive(make_archive_args([str(source)])) archive_root = org_parser.loads(archive.read_text(encoding="utf-8")) archived_heading = next(iter(archive_root)) diff --git a/tests/commands/tasks/test_tasks_edit.py b/tests/commands/tasks/test_tasks_edit.py index 80b6546..c48f423 100644 --- a/tests/commands/tasks/test_tasks_edit.py +++ b/tests/commands/tasks/test_tasks_edit.py @@ -9,10 +9,16 @@ import pytest import typer +import org.config.app from org.commands.tasks import edit as tasks_edit from org.logic import edit as editor_command +def _run_tasks_edit(args: tasks_edit.EditArgs) -> None: + """Run tasks edit with a default app config for direct tests.""" + tasks_edit.run_tasks_edit(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) + + if TYPE_CHECKING: from org_parser.document import Heading @@ -58,7 +64,7 @@ def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: return editor_command.DocumentEditResult(changed=True) monkeypatch.setattr(tasks_edit, "edit_heading_subtree_in_external_editor", _fake_edit) - tasks_edit.run_tasks_edit(make_edit_args([str(source)])) + _run_tasks_edit(make_edit_args([str(source)])) root = org_parser.loads(source.read_text(encoding="utf-8")) titles = [node.title_text.strip() for node in list(root)] @@ -78,7 +84,7 @@ def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: return editor_command.DocumentEditResult(changed=True) monkeypatch.setattr(tasks_edit, "edit_heading_subtree_in_external_editor", _fake_edit) - tasks_edit.run_tasks_edit(make_edit_args([str(source)], query_id=None, query_title="Keep")) + _run_tasks_edit(make_edit_args([str(source)], query_id=None, query_title="Keep")) root = org_parser.loads(source.read_text(encoding="utf-8")) assert next(iter(root)).title_text.strip() == "Updated" @@ -97,7 +103,7 @@ def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: return editor_command.DocumentEditResult(changed=True) monkeypatch.setattr(tasks_edit, "edit_heading_subtree_in_external_editor", _fake_edit) - tasks_edit.run_tasks_edit( + _run_tasks_edit( make_edit_args( [str(source)], query_id=None, @@ -115,10 +121,10 @@ def test_run_tasks_edit_requires_exactly_one_selector(tmp_path: Path) -> None: source.write_text("* TODO Keep\n", encoding="utf-8") with pytest.raises(typer.BadParameter, match="exactly one task selector"): - tasks_edit.run_tasks_edit(make_edit_args([str(source)], query_id=None, query_title=None)) + _run_tasks_edit(make_edit_args([str(source)], query_id=None, query_title=None)) with pytest.raises(typer.BadParameter, match="exactly one task selector"): - tasks_edit.run_tasks_edit( + _run_tasks_edit( make_edit_args( [str(source)], query_title="Keep", @@ -132,7 +138,7 @@ def test_run_tasks_edit_rejects_multiple_matches(tmp_path: Path) -> None: source.write_text("* TODO Same\n* TODO Same\n", encoding="utf-8") with pytest.raises(typer.BadParameter, match="matches exactly one task"): - tasks_edit.run_tasks_edit(make_edit_args([str(source)], query_id=None, query_title="Same")) + _run_tasks_edit(make_edit_args([str(source)], query_id=None, query_title="Same")) def test_run_tasks_edit_rejects_invalid_edited_content( @@ -150,7 +156,7 @@ def _raise_invalid(_heading: Heading) -> editor_command.DocumentEditResult: monkeypatch.setattr(tasks_edit, "edit_heading_subtree_in_external_editor", _raise_invalid) with pytest.raises(typer.BadParameter, match="Edited document content is invalid"): - tasks_edit.run_tasks_edit(make_edit_args([str(source)])) + _run_tasks_edit(make_edit_args([str(source)])) def test_run_tasks_edit_requires_editor_environment_variable( @@ -163,7 +169,7 @@ def test_run_tasks_edit_requires_editor_environment_variable( monkeypatch.delenv("EDITOR", raising=False) with pytest.raises(typer.BadParameter, match=r"\$EDITOR is not defined"): - tasks_edit.run_tasks_edit(make_edit_args([str(source)])) + _run_tasks_edit(make_edit_args([str(source)])) def test_run_tasks_edit_errors_on_non_zero_editor_exit( @@ -177,7 +183,7 @@ def test_run_tasks_edit_errors_on_non_zero_editor_exit( monkeypatch.setenv("EDITOR", "sh -c 'exit 7'") with pytest.raises(typer.BadParameter, match="Editor failed to open"): - tasks_edit.run_tasks_edit(make_edit_args([str(source)])) + _run_tasks_edit(make_edit_args([str(source)])) def test_run_tasks_edit_skips_save_when_content_is_unchanged( @@ -195,7 +201,7 @@ def _fake_no_change(_heading: Heading) -> editor_command.DocumentEditResult: monkeypatch.setattr(tasks_edit, "edit_heading_subtree_in_external_editor", _fake_no_change) - tasks_edit.run_tasks_edit(make_edit_args([str(source)])) + _run_tasks_edit(make_edit_args([str(source)])) assert capsys.readouterr().out.strip() == "No changes." assert source.read_text(encoding="utf-8") == original_text diff --git a/tests/commands/tasks/test_tasks_find.py b/tests/commands/tasks/test_tasks_find.py index fcc2fd5..64e48c2 100644 --- a/tests/commands/tasks/test_tasks_find.py +++ b/tests/commands/tasks/test_tasks_find.py @@ -9,7 +9,8 @@ from typer.testing import CliRunner -from org.cli import app +import org.config.app +from org import cli if TYPE_CHECKING: @@ -17,7 +18,9 @@ FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures") +EMPTY_CONFIG_PATH = os.path.join(FIXTURES_DIR, "empty-config.yaml") ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") +app = cli.build_app(org.config.app.AppConfig(config_path=EMPTY_CONFIG_PATH)) def _clean_output(text: str) -> str: diff --git a/tests/commands/tasks/test_tasks_list.py b/tests/commands/tasks/test_tasks_list.py index acf8ddb..b764d1d 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -102,6 +102,7 @@ def _make_session( ) -> actions.TasksListSession: return actions.create_tasks_list_session( make_list_args([]), + org.config.app.AppConfig(config_path=".org-cli.yaml"), _make_session_data(nodes, color_enabled=color_enabled), ) @@ -115,7 +116,7 @@ def test_run_tasks_list_no_results( args = make_list_args([fixture_path], filter_tags=["nomatch$"]) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--filter-tag", "nomatch$"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out assert captured.strip() == "No results" @@ -130,7 +131,7 @@ def test_run_tasks_list_details_output( args = make_list_args([fixture_path], details=True, max_results=1, width=200) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--details", "--width", "200"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out assert fixture_path in captured @@ -146,7 +147,7 @@ def test_run_tasks_list_short_output( args = make_list_args([fixture_path], max_results=2) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out lines = [line for line in captured.splitlines() if line.strip()] @@ -166,7 +167,7 @@ def test_run_tasks_list_offset_applied( args = make_list_args([fixture_path], max_results=1, offset=1) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--offset", "1"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out lines = [line for line in captured.splitlines() if line.strip()] @@ -184,7 +185,7 @@ def test_run_tasks_list_short_output_aligns_tags_to_width( args = make_list_args([fixture_path], max_results=1, width=60) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--width", "60"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out lines = [line for line in captured.splitlines() if line.strip()] @@ -202,7 +203,7 @@ def test_run_tasks_list_short_output_truncates_filename_and_heading_for_tags( args = make_list_args([fixture_path], max_results=1, width=50) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--width", "50"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out lines = [line for line in captured.splitlines() if line.strip()] @@ -222,7 +223,7 @@ def test_run_tasks_list_offset_no_results( args = make_list_args([fixture_path], max_results=10, offset=10) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--offset", "10"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out assert captured.strip() == "No results" @@ -234,7 +235,7 @@ def test_run_tasks_list_negative_max_results_raises_bad_parameter() -> None: args = make_list_args([fixture_path], max_results=-1) with pytest.raises(typer.BadParameter, match="--limit must be non-negative"): - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) def test_run_tasks_list_markdown_converts_nodes_to_single_document( @@ -254,7 +255,7 @@ def _fake_pandoc(org_text: str, output_format: str, pandoc_args: list[str]) -> s monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--out", "markdown"]) monkeypatch.setattr("org.commands.tasks.list.command._org_to_pandoc_format", _fake_pandoc) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out assert captured.strip() == "converted markdown" @@ -281,7 +282,7 @@ def _fake_pandoc(org_text: str, output_format: str, pandoc_args: list[str]) -> s monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--out", "gfm"]) monkeypatch.setattr("org.commands.tasks.list.command._org_to_pandoc_format", _fake_pandoc) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out assert captured.strip() == "converted gfm" @@ -309,7 +310,7 @@ def prepare(self, data: object) -> object: ) with pytest.raises(click.UsageError, match="pandoc missing"): - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) def test_run_tasks_list_pandoc_empty_results_prints_no_results( @@ -329,7 +330,7 @@ def _should_not_call(_org_text: str, _output_format: str, _pandoc_args: list[str ) monkeypatch.setattr("org.commands.tasks.list.command._org_to_pandoc_format", _should_not_call) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out assert captured.strip() == "No results" @@ -344,7 +345,7 @@ def test_run_tasks_list_json_emits_array_for_multiple_nodes( args = make_list_args([fixture_path], out=OutputFormat.JSON, max_results=2) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--out", "json"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out parsed = json.loads(captured) @@ -363,7 +364,7 @@ def test_run_tasks_list_json_emits_single_value_for_single_node( args = make_list_args([fixture_path], out=OutputFormat.JSON, max_results=1) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--out", "json", "-n", "1"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out parsed = json.loads(captured) @@ -384,7 +385,7 @@ def test_run_tasks_list_json_no_results_emits_empty_array( "argv", ["org", "tasks", "list", "--out", "json", "--filter-tag", "nomatch$"], ) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out parsed = json.loads(captured) @@ -400,7 +401,7 @@ def test_run_tasks_list_json_max_results_zero_emits_empty_array( args = make_list_args([fixture_path], out=OutputFormat.JSON, max_results=0) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--out", "json", "-n", "0"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out parsed = json.loads(captured) @@ -413,7 +414,7 @@ def test_run_tasks_list_invalid_pandoc_args_is_usage_error() -> None: args = make_list_args([fixture_path], out="gfm", pandoc_args='"') with pytest.raises(click.UsageError, match="No closing quotation"): - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) def test_run_tasks_list_defaults_limit_to_all_results_with_paging( @@ -444,7 +445,7 @@ def _fake_pager(*args: object, **kwargs: object) -> Iterator[None]: args = make_list_args([fixture_path], max_results=None) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) lines = [line for line in buffer.getvalue().splitlines() if line.strip()] assert len(lines) == 10 @@ -463,7 +464,7 @@ def test_run_tasks_list_unicode_heading_aligns_tags_to_visual_width( args = make_list_args([fixture_path], max_results=1, width=60) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--width", "60"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out lines = [line for line in captured.splitlines() if line.strip()] @@ -486,7 +487,7 @@ def test_run_tasks_list_details_wraps_long_lines_to_console_width( args = make_list_args([fixture_path], details=True, width=60, max_results=1) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--details", "--width", "60"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) captured = capsys.readouterr().out lines = [line for line in captured.splitlines() if line] @@ -527,7 +528,7 @@ def _fake_pager(*args: object, **kwargs: object) -> Iterator[None]: args = make_list_args([fixture_path], max_results=12) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--limit", "12"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) assert pager_called["value"] @@ -565,7 +566,7 @@ def _fake_pager(*args: object, **kwargs: object) -> Iterator[None]: args = make_list_args([fixture_path], max_results=3) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--limit", "3"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) assert not pager_called["value"] @@ -603,7 +604,7 @@ def _fake_pager(*args: object, **kwargs: object) -> Iterator[None]: args = make_list_args([fixture_path], max_results=12, out=OutputFormat.JSON) monkeypatch.setattr(sys, "argv", ["org", "tasks", "list", "--out", "json", "--limit", "12"]) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) assert not pager_called["value"] @@ -618,6 +619,7 @@ def test_run_tasks_list_uses_interactive_mode_when_tty_and_not_blocked( def _fake_interactive( _args: tasks_list.ListArgs, + _config: org.config.app.AppConfig, data: object, ) -> None: session_data = data @@ -637,7 +639,7 @@ def _fake_interactive( lambda *_args, **_kwargs: pytest.fail("static mode should not be used"), ) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) assert called["interactive"] @@ -648,7 +650,7 @@ def test_run_tasks_list_requires_tty_without_explicit_static_mode() -> None: args = make_list_args([fixture_path], noninteractive=False) with pytest.raises(click.UsageError, match="requires a TTY unless --details or --out"): - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) def test_run_tasks_list_details_switch_blocks_interactive_mode( @@ -681,7 +683,7 @@ def _fake_static( ) monkeypatch.setattr(tasks_list, "_run_tasks_list_static", _fake_static) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) assert called["static"] @@ -716,7 +718,7 @@ def _fake_static( ) monkeypatch.setattr(tasks_list, "_run_tasks_list_static", _fake_static) - tasks_list.run_tasks_list(args) + tasks_list.run_tasks_list(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) assert called["static"] @@ -849,16 +851,14 @@ def test_apply_capture_task_captures_task_and_reloads( session = _make_session(nodes) captured_node = node_from_org("* TODO Captured\n")[0] reload_args: dict[str, object] = {} - monkeypatch.setattr( - org.config.app, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) + session.app_config.tasks.capture.templates = { + "quick": {"file": "tasks.org", "content": "* TODO Captured"}, + } monkeypatch.setattr( actions, "capture_task", - lambda _args: capture_command.TasksCaptureResult( + lambda _args, _templates: capture_command.TasksCaptureResult( template_name="quick", heading=captured_node, document=captured_node.document, @@ -888,7 +888,7 @@ def test_apply_capture_task_reports_keyboard_interrupt_as_cancelled( nodes = node_from_org("* TODO A\n") session = _make_session(nodes) - def _raise_interrupt(_args: object) -> object: + def _raise_interrupt(_args: object, _templates: object) -> object: raise KeyboardInterrupt monkeypatch.setattr(actions, "capture_task", _raise_interrupt) @@ -904,7 +904,7 @@ def test_apply_capture_task_reports_bad_parameter_error( nodes = node_from_org("* TODO A\n") session = _make_session(nodes) - def _raise_bad_parameter(_args: object) -> object: + def _raise_bad_parameter(_args: object, _templates: object) -> object: raise typer.BadParameter("Capture failed") monkeypatch.setattr(actions, "capture_task", _raise_bad_parameter) diff --git a/tests/commands/tasks/test_tasks_list_app.py b/tests/commands/tasks/test_tasks_list_app.py index 4cad6ef..35965c6 100644 --- a/tests/commands/tasks/test_tasks_list_app.py +++ b/tests/commands/tasks/test_tasks_list_app.py @@ -28,12 +28,16 @@ def _make_session_data(nodes: list[Heading]) -> tasks_list._TasksListSessionData ) +def _default_config() -> org.config.app.AppConfig: + return org.config.app.AppConfig(config_path=".org-cli.yaml") + + def test_tasks_list_app_navigation_and_help_forwarding() -> None: """Tasks list app should support navigation and help key forwarding.""" async def _run() -> None: nodes = node_from_org("* TODO A\n* TODO B\n") - app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + app = TasksListApp(make_list_args([]), _default_config(), _make_session_data(nodes)) async with app.run_test() as pilot: await pilot.press("down") @@ -69,7 +73,7 @@ def test_tasks_list_app_search_prompt_filters_results_live() -> None: async def _run() -> None: nodes = node_from_org("* TODO Alpha\nBody\n* TODO Beta\nOther\n") - app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + app = TasksListApp(make_list_args([]), _default_config(), _make_session_data(nodes)) async with app.run_test() as pilot: app.action_prompt_search() @@ -97,7 +101,7 @@ def test_tasks_list_app_help_prompt_and_separator_widgets() -> None: async def _run() -> None: nodes = node_from_org("* TODO Alpha\n") - app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + app = TasksListApp(make_list_args([]), _default_config(), _make_session_data(nodes)) async with app.run_test() as pilot: assert app.screen.query_one("#tasks-footer-rule", Static) is not None @@ -118,7 +122,7 @@ def test_tasks_list_app_escape_cancels_prompt_without_exiting() -> None: async def _run() -> None: nodes = node_from_org("* TODO Alpha\n* TODO Beta\n") - app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + app = TasksListApp(make_list_args([]), _default_config(), _make_session_data(nodes)) async with app.run_test() as pilot: app.action_prompt_search() @@ -140,9 +144,10 @@ def test_tasks_list_app_capture_selection_uses_keyboard(monkeypatch: pytest.Monk async def _run() -> None: nodes = node_from_org("* TODO Alpha\n") - app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + config = _default_config() + config.tasks.capture.templates = {"quick": {}, "later": {}} + app = TasksListApp(make_list_args([]), config, _make_session_data(nodes)) called: list[str] = [] - monkeypatch.setattr(org.config.app, "CONFIG_CAPTURE_TEMPLATES", {"quick": {}, "later": {}}) monkeypatch.setattr( "org.commands.tasks.list.actions.apply_capture_task", lambda _session, template_name: called.append(template_name), @@ -168,7 +173,7 @@ def test_tasks_list_app_state_selection_uses_keyboard() -> None: async def _run() -> None: nodes = node_from_org("* TODO Alpha\n") - app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + app = TasksListApp(make_list_args([]), _default_config(), _make_session_data(nodes)) async with app.run_test() as pilot: app.action_prompt_state() @@ -193,6 +198,7 @@ def test_run_tasks_list_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPa def _fake_run( args: tasks_list.ListArgs, + _config: org.config.app.AppConfig, session_data: tasks_list._TasksListSessionData, ) -> None: called["value"] = True @@ -203,6 +209,7 @@ def _fake_run( tasks_list._run_tasks_list_interactive( make_list_args([]), + _default_config(), data, ) diff --git a/tests/commands/tasks/test_tasks_remove.py b/tests/commands/tasks/test_tasks_remove.py index d436f91..6da35f8 100644 --- a/tests/commands/tasks/test_tasks_remove.py +++ b/tests/commands/tasks/test_tasks_remove.py @@ -8,9 +8,15 @@ import pytest import typer +import org.config.app from org.commands.tasks import remove as tasks_remove +def _run_tasks_remove(args: tasks_remove.RemoveArgs) -> None: + """Run tasks remove with a default app config for direct tests.""" + tasks_remove.run_tasks_remove(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) + + if TYPE_CHECKING: from pathlib import Path @@ -40,7 +46,7 @@ def test_run_tasks_remove_removes_matching_title_subtree(tmp_path: Path) -> None ) args = make_remove_args([str(source)], query_title="Remove me") - tasks_remove.run_tasks_remove(args) + _run_tasks_remove(args) root = org_parser.loads(source.read_text(encoding="utf-8")) titles = [node.title_text.strip() for node in list(root)] @@ -64,7 +70,7 @@ def test_run_tasks_remove_removes_matching_id_subtree(tmp_path: Path) -> None: ) args = make_remove_args([str(source)], query_id="task-123") - tasks_remove.run_tasks_remove(args) + _run_tasks_remove(args) root = org_parser.loads(source.read_text(encoding="utf-8")) titles = [node.title_text.strip() for node in list(root)] @@ -78,7 +84,7 @@ def test_run_tasks_remove_requires_at_least_one_identifier(tmp_path: Path) -> No args = make_remove_args([str(source)]) with pytest.raises(typer.BadParameter, match="exactly one task selector"): - tasks_remove.run_tasks_remove(args) + _run_tasks_remove(args) def test_run_tasks_remove_rejects_title_and_id_together(tmp_path: Path) -> None: @@ -88,7 +94,7 @@ def test_run_tasks_remove_rejects_title_and_id_together(tmp_path: Path) -> None: args = make_remove_args([str(source)], query_title="Keep", query_id="task-123") with pytest.raises(typer.BadParameter, match="exactly one task selector"): - tasks_remove.run_tasks_remove(args) + _run_tasks_remove(args) def test_run_tasks_remove_deletes_all_matching_tasks(tmp_path: Path) -> None: @@ -97,7 +103,7 @@ def test_run_tasks_remove_deletes_all_matching_tasks(tmp_path: Path) -> None: source.write_text("* TODO Same\n* TODO Same\n* TODO Tail\n", encoding="utf-8") args = make_remove_args([str(source)], query_title="Same") - tasks_remove.run_tasks_remove(args) + _run_tasks_remove(args) root = org_parser.loads(source.read_text(encoding="utf-8")) titles = [node.title_text.strip() for node in list(root)] @@ -111,7 +117,7 @@ def test_run_tasks_remove_selects_query_title_with_escaped_characters(tmp_path: source.write_text(f"* TODO Keep\n* TODO {title}\n* TODO Tail\n", encoding="utf-8") args = make_remove_args([str(source)], query_title=title) - tasks_remove.run_tasks_remove(args) + _run_tasks_remove(args) root = org_parser.loads(source.read_text(encoding="utf-8")) titles = [node.title_text.strip() for node in list(root)] @@ -125,7 +131,7 @@ def test_run_tasks_remove_errors_when_no_tasks_match(tmp_path: Path) -> None: args = make_remove_args([str(source)], query_title="Missing") with pytest.raises(typer.BadParameter, match="No task matches"): - tasks_remove.run_tasks_remove(args) + _run_tasks_remove(args) def test_run_tasks_remove_removes_matching_query_subtree(tmp_path: Path) -> None: @@ -137,7 +143,7 @@ def test_run_tasks_remove_removes_matching_query_subtree(tmp_path: Path) -> None ) args = make_remove_args([str(source)], query='str(.title_text) == "Remove me"') - tasks_remove.run_tasks_remove(args) + _run_tasks_remove(args) root = org_parser.loads(source.read_text(encoding="utf-8")) titles = [node.title_text.strip() for node in list(root)] @@ -155,7 +161,7 @@ def test_run_tasks_remove_rejects_multiple_selector_switches(tmp_path: Path) -> ) with pytest.raises(typer.BadParameter, match="exactly one task selector"): - tasks_remove.run_tasks_remove(args) + _run_tasks_remove(args) def test_run_tasks_remove_cancels_when_confirmation_declined( @@ -172,7 +178,7 @@ def _decline_confirmation(*_: object, **__: object) -> bool: monkeypatch.setattr("rich.prompt.Confirm.ask", _decline_confirmation) - tasks_remove.run_tasks_remove(args) + _run_tasks_remove(args) updated = source.read_text(encoding="utf-8") assert "Remove me" in updated diff --git a/tests/commands/tasks/test_tasks_update.py b/tests/commands/tasks/test_tasks_update.py index 5e04c45..dd32edd 100644 --- a/tests/commands/tasks/test_tasks_update.py +++ b/tests/commands/tasks/test_tasks_update.py @@ -9,9 +9,15 @@ import typer from org_parser.document import Heading +import org.config.app from org.commands.tasks import update as tasks_update +def _run_tasks_update(args: tasks_update.UpdateArgs) -> None: + """Run tasks update with a default app config for direct tests.""" + tasks_update.run_tasks_update(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) + + if TYPE_CHECKING: from pathlib import Path @@ -62,7 +68,7 @@ def test_run_tasks_update_updates_title_by_id(tmp_path: Path) -> None: source.write_text("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n", encoding="utf-8") args = make_update_args([str(source)], title="Updated title") - tasks_update.run_tasks_update(args) + _run_tasks_update(args) root = org_parser.loads(source.read_text(encoding="utf-8")) node = next(iter(root)) @@ -81,7 +87,7 @@ def test_run_tasks_update_updates_todo_and_closed_by_title(tmp_path: Path) -> No closed="<2026-04-13>", ) - tasks_update.run_tasks_update(args) + _run_tasks_update(args) root = org_parser.loads(source.read_text(encoding="utf-8")) node = next(iter(root)) @@ -101,7 +107,7 @@ def test_run_tasks_update_selects_query_title_with_escaped_characters(tmp_path: title="Updated", ) - tasks_update.run_tasks_update(args) + _run_tasks_update(args) root = org_parser.loads(source.read_text(encoding="utf-8")) titles = [node.title_text.strip() for node in list(root)] @@ -114,12 +120,12 @@ def test_run_tasks_update_requires_exactly_one_identifier(tmp_path: Path) -> Non source.write_text("* TODO Keep\n", encoding="utf-8") with pytest.raises(typer.BadParameter, match="exactly one task selector"): - tasks_update.run_tasks_update( + _run_tasks_update( make_update_args([str(source)], query_id=None, query_title=None), ) with pytest.raises(typer.BadParameter, match="exactly one task selector"): - tasks_update.run_tasks_update( + _run_tasks_update( make_update_args([str(source)], query_id="task-1", query_title="Keep"), ) @@ -135,7 +141,7 @@ def test_run_tasks_update_supports_generic_query_selector(tmp_path: Path) -> Non title="Updated", ) - tasks_update.run_tasks_update(args) + _run_tasks_update(args) root = org_parser.loads(source.read_text(encoding="utf-8")) node = next(iter(root)) @@ -148,7 +154,7 @@ def test_run_tasks_update_rejects_query_with_other_selectors(tmp_path: Path) -> source.write_text("* TODO Foo\n", encoding="utf-8") with pytest.raises(typer.BadParameter, match="exactly one task selector"): - tasks_update.run_tasks_update( + _run_tasks_update( make_update_args( [str(source)], query='str(.title_text) == "Foo"', @@ -176,7 +182,7 @@ def _decline_confirmation(*_: object, **__: object) -> bool: monkeypatch.setattr("rich.prompt.Confirm.ask", _decline_confirmation) - tasks_update.run_tasks_update(args) + _run_tasks_update(args) updated = source.read_text(encoding="utf-8") assert "* TODO Foo" in updated @@ -188,7 +194,7 @@ def test_run_tasks_update_applies_to_all_matching_tasks(tmp_path: Path) -> None: source.write_text("* TODO Same\n* TODO Same\n* TODO Tail\n", encoding="utf-8") args = make_update_args([str(source)], query_id=None, query_title="Same", title="Updated") - tasks_update.run_tasks_update(args) + _run_tasks_update(args) root = org_parser.loads(source.read_text(encoding="utf-8")) titles = [node.title_text.strip() for node in list(root)] @@ -226,7 +232,7 @@ def test_run_tasks_update_clears_supported_fields_with_empty_string(tmp_path: Pa properties="", ) - tasks_update.run_tasks_update(args) + _run_tasks_update(args) root = org_parser.loads(source.read_text(encoding="utf-8")) node = next(iter(root)) @@ -250,7 +256,7 @@ def test_run_tasks_update_rejects_invalid_comment_value(tmp_path: Path) -> None: args = make_update_args([str(source)], comment="yes") with pytest.raises(typer.BadParameter, match="--comment must be either"): - tasks_update.run_tasks_update(args) + _run_tasks_update(args) def test_run_tasks_update_moves_to_parent_with_explicit_consistent_level(tmp_path: Path) -> None: @@ -268,7 +274,7 @@ def test_run_tasks_update_moves_to_parent_with_explicit_consistent_level(tmp_pat level=3, ) - tasks_update.run_tasks_update(args) + _run_tasks_update(args) root = org_parser.loads(source.read_text(encoding="utf-8")) nodes = list(root) @@ -286,7 +292,7 @@ def test_run_tasks_update_moves_to_top_level_when_parent_is_empty_string(tmp_pat source.write_text("* TODO Parent\n** TODO Child\n", encoding="utf-8") args = make_update_args([str(source)], query_id=None, query_title="Child", parent="") - tasks_update.run_tasks_update(args) + _run_tasks_update(args) root = org_parser.loads(source.read_text(encoding="utf-8")) nodes = list(root) @@ -301,7 +307,7 @@ def test_run_tasks_update_allows_explicit_top_level_level_above_one(tmp_path: Pa source.write_text("** TODO Task\n", encoding="utf-8") args = make_update_args([str(source)], query_id=None, query_title="Task", level=3) - tasks_update.run_tasks_update(args) + _run_tasks_update(args) root = org_parser.loads(source.read_text(encoding="utf-8")) task = next(iter(root)) @@ -316,7 +322,7 @@ def test_run_tasks_update_rejects_parent_descendant_loop(tmp_path: Path) -> None args = make_update_args([str(source)], query_id=None, query_title="A", parent="C") with pytest.raises(typer.BadParameter, match="descendant"): - tasks_update.run_tasks_update(args) + _run_tasks_update(args) def test_run_tasks_update_moves_heading_to_another_file(tmp_path: Path) -> None: @@ -330,7 +336,7 @@ def test_run_tasks_update_moves_heading_to_another_file(tmp_path: Path) -> None: destination.write_text("* TODO Existing\n", encoding="utf-8") args = make_update_args([str(source), str(destination)], file=str(destination)) - tasks_update.run_tasks_update(args) + _run_tasks_update(args) source_root = org_parser.loads(source.read_text(encoding="utf-8")) destination_root = org_parser.loads(destination.read_text(encoding="utf-8")) @@ -357,7 +363,7 @@ def test_run_tasks_update_moves_heading_to_destination_parent(tmp_path: Path) -> parent="parent-1", ) - tasks_update.run_tasks_update(args) + _run_tasks_update(args) destination_root = org_parser.loads(destination.read_text(encoding="utf-8")) destination_nodes = list(destination_root) @@ -377,7 +383,7 @@ def test_run_tasks_update_rejects_file_target_when_missing(tmp_path: Path) -> No args = make_update_args([str(source)], file=str(tmp_path / "missing.org")) with pytest.raises(typer.BadParameter, match="not found"): - tasks_update.run_tasks_update(args) + _run_tasks_update(args) def test_run_tasks_update_rejects_file_target_when_not_file(tmp_path: Path) -> None: @@ -390,7 +396,7 @@ def test_run_tasks_update_rejects_file_target_when_not_file(tmp_path: Path) -> N args = make_update_args([str(source)], file=str(tmp_path)) with pytest.raises(typer.BadParameter, match="not a file"): - tasks_update.run_tasks_update(args) + _run_tasks_update(args) def test_run_tasks_update_applies_json_properties_object(tmp_path: Path) -> None: @@ -399,7 +405,7 @@ def test_run_tasks_update_applies_json_properties_object(tmp_path: Path) -> None source.write_text("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n", encoding="utf-8") args = make_update_args([str(source)], properties='{"A":"1","B":"two"}') - tasks_update.run_tasks_update(args) + _run_tasks_update(args) root = org_parser.loads(source.read_text(encoding="utf-8")) node = next(iter(root)) @@ -413,7 +419,7 @@ def test_run_tasks_update_rejects_non_object_properties_json(tmp_path: Path) -> args = make_update_args([str(source)], properties='["x"]') with pytest.raises(typer.BadParameter, match="JSON object"): - tasks_update.run_tasks_update(args) + _run_tasks_update(args) def test_run_tasks_update_applies_fine_grained_tag_updates(tmp_path: Path) -> None: @@ -425,7 +431,7 @@ def test_run_tasks_update_applies_fine_grained_tag_updates(tmp_path: Path) -> No ) args = make_update_args([str(source)], add_tag=["b"], remove_tag=["a"]) - tasks_update.run_tasks_update(args) + _run_tasks_update(args) root = org_parser.loads(source.read_text(encoding="utf-8")) node = next(iter(root)) @@ -442,7 +448,7 @@ def test_run_tasks_update_rejects_remove_tag_when_target_missing(tmp_path: Path) args = make_update_args([str(source)], remove_tag=["missing"]) with pytest.raises(typer.BadParameter, match="--remove-tag target"): - tasks_update.run_tasks_update(args) + _run_tasks_update(args) def test_run_tasks_update_applies_fine_grained_property_updates(tmp_path: Path) -> None: @@ -458,7 +464,7 @@ def test_run_tasks_update_applies_fine_grained_property_updates(tmp_path: Path) remove_property=["OLD"], ) - tasks_update.run_tasks_update(args) + _run_tasks_update(args) root = org_parser.loads(source.read_text(encoding="utf-8")) node = next(iter(root)) @@ -475,7 +481,7 @@ def test_run_tasks_update_rejects_remove_property_when_target_missing(tmp_path: args = make_update_args([str(source)], remove_property=["MISSING"]) with pytest.raises(typer.BadParameter, match="--remove-property target"): - tasks_update.run_tasks_update(args) + _run_tasks_update(args) def test_run_tasks_update_rejects_add_property_without_separator(tmp_path: Path) -> None: @@ -488,7 +494,7 @@ def test_run_tasks_update_rejects_add_property_without_separator(tmp_path: Path) args = make_update_args([str(source)], add_property=["NOPE"]) with pytest.raises(typer.BadParameter, match="--add-property must be in KEY=VALUE"): - tasks_update.run_tasks_update(args) + _run_tasks_update(args) def test_run_tasks_update_applies_fine_grained_clock_entry_updates(tmp_path: Path) -> None: @@ -500,13 +506,13 @@ def test_run_tasks_update_applies_fine_grained_clock_entry_updates(tmp_path: Pat ) clock_entry = "CLOCK: [2026-04-14 Tue 09:00]--[2026-04-14 Tue 10:00] => 1:00" - tasks_update.run_tasks_update(make_update_args([str(source)], add_clock_entry=[clock_entry])) + _run_tasks_update(make_update_args([str(source)], add_clock_entry=[clock_entry])) root = org_parser.loads(source.read_text(encoding="utf-8")) node = next(iter(root)) assert len(node.clock_entries) == 1 - tasks_update.run_tasks_update(make_update_args([str(source)], remove_clock_entry=[clock_entry])) + _run_tasks_update(make_update_args([str(source)], remove_clock_entry=[clock_entry])) root = org_parser.loads(source.read_text(encoding="utf-8")) node = next(iter(root)) @@ -524,7 +530,7 @@ def test_run_tasks_update_rejects_remove_clock_entry_when_target_missing(tmp_pat args = make_update_args([str(source)], remove_clock_entry=[clock_entry]) with pytest.raises(typer.BadParameter, match="--remove-clock-entry target"): - tasks_update.run_tasks_update(args) + _run_tasks_update(args) def test_run_tasks_update_rejects_invalid_clock_entry_string(tmp_path: Path) -> None: @@ -537,7 +543,7 @@ def test_run_tasks_update_rejects_invalid_clock_entry_string(tmp_path: Path) -> args = make_update_args([str(source)], add_clock_entry=["[2026-04-14 Tue 09:00]"]) with pytest.raises(typer.BadParameter, match="valid Org clock entry"): - tasks_update.run_tasks_update(args) + _run_tasks_update(args) def test_run_tasks_update_applies_fine_grained_repeat_updates(tmp_path: Path) -> None: @@ -549,7 +555,7 @@ def test_run_tasks_update_applies_fine_grained_repeat_updates(tmp_path: Path) -> ) repeat = '- State "DONE" from "TODO" [2026-04-14 Tue 09:00]' - tasks_update.run_tasks_update(make_update_args([str(source)], add_repeat=[repeat])) + _run_tasks_update(make_update_args([str(source)], add_repeat=[repeat])) root = org_parser.loads(source.read_text(encoding="utf-8")) node = next(iter(root)) @@ -557,7 +563,7 @@ def test_run_tasks_update_applies_fine_grained_repeat_updates(tmp_path: Path) -> assert node.repeats[0].before == "TODO" assert node.repeats[0].after == "DONE" - tasks_update.run_tasks_update(make_update_args([str(source)], remove_repeat=[repeat])) + _run_tasks_update(make_update_args([str(source)], remove_repeat=[repeat])) root = org_parser.loads(source.read_text(encoding="utf-8")) node = next(iter(root)) @@ -575,7 +581,7 @@ def test_run_tasks_update_rejects_remove_repeat_when_target_missing(tmp_path: Pa args = make_update_args([str(source)], remove_repeat=[repeat]) with pytest.raises(typer.BadParameter, match="--remove-repeat target"): - tasks_update.run_tasks_update(args) + _run_tasks_update(args) def test_run_tasks_update_rejects_invalid_repeat_string(tmp_path: Path) -> None: @@ -588,7 +594,7 @@ def test_run_tasks_update_rejects_invalid_repeat_string(tmp_path: Path) -> None: args = make_update_args([str(source)], add_repeat=["- [ ] not-a-repeat"]) with pytest.raises(typer.BadParameter, match="valid Org repeat entry"): - tasks_update.run_tasks_update(args) + _run_tasks_update(args) def test_run_tasks_update_rejects_mixed_bulk_and_fine_grained_tag_updates(tmp_path: Path) -> None: @@ -600,7 +606,7 @@ def test_run_tasks_update_rejects_mixed_bulk_and_fine_grained_tag_updates(tmp_pa ) with pytest.raises(typer.BadParameter, match="--tags cannot be combined"): - tasks_update.run_tasks_update(make_update_args([str(source)], tags="a", add_tag=["b"])) + _run_tasks_update(make_update_args([str(source)], tags="a", add_tag=["b"])) def test_run_tasks_update_rejects_mixed_bulk_and_fine_grained_properties(tmp_path: Path) -> None: @@ -612,6 +618,6 @@ def test_run_tasks_update_rejects_mixed_bulk_and_fine_grained_properties(tmp_pat ) with pytest.raises(typer.BadParameter, match="--properties cannot be combined"): - tasks_update.run_tasks_update( + _run_tasks_update( make_update_args([str(source)], properties='{"A":"1"}', add_property=["B=2"]), ) diff --git a/tests/commands/test_agenda_app.py b/tests/commands/test_agenda_app.py index 0dd050e..6270a8f 100644 --- a/tests/commands/test_agenda_app.py +++ b/tests/commands/test_agenda_app.py @@ -38,6 +38,7 @@ def _make_app( *, color_enabled: bool = False, ) -> AgendaApp: + config = org.config.app.AppConfig(config_path=".org-cli.yaml") view = _fallback_agenda_view() view_ctx = AgendaViewContext(section_specs=_compile_view_section_specs(view), name=view.name) render = ui.RenderContext( @@ -45,7 +46,7 @@ def _make_app( done_states=["DONE"], todo_states=["TODO"], ) - return AgendaApp(actions.create_agenda_session(args, nodes, render, view_ctx)) + return AgendaApp(actions.create_agenda_session(args, config, nodes, render, view_ctx)) def _body_lines(app: AgendaApp) -> list[str]: @@ -178,13 +179,8 @@ async def _run() -> None: fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15") captured_node = next(iter(org_parser.loads("* TODO Captured\n"))) - monkeypatch.setattr( - org.config.app, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - def _fake_capture(_args: object) -> capture_command.TasksCaptureResult: + def _fake_capture(_args: object, _templates: object) -> capture_command.TasksCaptureResult: return capture_command.TasksCaptureResult( template_name="quick", heading=captured_node, @@ -196,6 +192,9 @@ def _fake_capture(_args: object) -> capture_command.TasksCaptureResult: monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) app = _make_app(args, list(org_parser.load(fixture_path))) + app.session.app_config.tasks.capture.templates = { + "quick": {"file": "tasks.org", "content": "* TODO Captured"}, + } monkeypatch.setattr(actions, "refresh_session", lambda _session, _identity: None) app.session.selected_row_index = next( index @@ -305,6 +304,7 @@ def test_run_agenda_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) def _fake_run( passed_args: agenda_command.AgendaArgs, + _config: org.config.app.AppConfig, nodes: list[Heading], render: ui.RenderContext, view_ctx: AgendaViewContext, @@ -319,6 +319,6 @@ def _fake_run( monkeypatch.setattr(sys.stdout, "isatty", lambda: True) monkeypatch.setattr("org.commands.agenda.command.run_agenda_app", _fake_run) - agenda_command.run_agenda(args) + agenda_command.run_agenda(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) assert called["value"] is True diff --git a/tests/commands/test_agenda_command.py b/tests/commands/test_agenda_command.py index ace1204..d5dadd7 100644 --- a/tests/commands/test_agenda_command.py +++ b/tests/commands/test_agenda_command.py @@ -38,6 +38,17 @@ FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") +def _app_config( + *, + agenda_views: dict[str, org.config.app.AgendaViewConfig] | None = None, +) -> org.config.app.AppConfig: + """Build app config for agenda tests with optional configured views.""" + config = org.config.app.AppConfig(config_path=".org-cli.yaml") + if agenda_views is not None: + config.agenda.views = agenda_views + return config + + def _make_args(files: list[str], **overrides: object) -> agenda_command.AgendaArgs: args = agenda_command.AgendaArgs( files=files, @@ -112,7 +123,13 @@ def _make_session( done_states=["DONE"] if done_states is None else done_states, todo_states=["TODO"] if todo_states is None else todo_states, ) - return actions.create_agenda_session(args, nodes, render, view_ctx) + return actions.create_agenda_session( + args, + _app_config(), + nodes, + render, + view_ctx, + ) def _pin_agenda_now(monkeypatch: pytest.MonkeyPatch) -> datetime: @@ -125,6 +142,14 @@ def _pin_agenda_now(monkeypatch: pytest.MonkeyPatch) -> datetime: def _render_agenda_output(args: agenda_command.AgendaArgs) -> str: + """Render agenda output through shared UI helpers for rendering tests.""" + return _render_agenda_output_with_config(args, _app_config()) + + +def _render_agenda_output_with_config( + args: agenda_command.AgendaArgs, + config: org.config.app.AppConfig, +) -> str: """Render agenda output through shared UI helpers for rendering tests.""" color_enabled = setup_output(args) args.max_results = agenda_command._resolve_tasks_limit(args.max_results) @@ -137,7 +162,7 @@ def _render_agenda_output(args: agenda_command.AgendaArgs) -> str: no_color=not color_enabled, force_terminal=color_enabled, ) - nodes, todo_states, done_states = load_and_process_data(args) + nodes, todo_states, done_states = load_and_process_data(args, config) ui.render_agenda( console, ui.AgendaRenderInput( @@ -150,7 +175,7 @@ def _render_agenda_output(args: agenda_command.AgendaArgs) -> str: todo_states=todo_states, ), ), - resolve_view_context(args), + resolve_view_context(args, config.agenda.views), ) return console_output.getvalue() @@ -486,18 +511,21 @@ def test_run_agenda_plain_view_rows_show_relative_day_labels( "DEADLINE: <2025-01-18 Sat>\n", ) - org.config.app.CONFIG_AGENDA_VIEWS.clear() - org.config.app.CONFIG_AGENDA_VIEWS["plain"] = org.config.app.AgendaViewConfig( - name="plain", - sections=[ - org.config.app.AgendaSectionConfig( - name="Planning", - filter=".scheduled != null or .deadline != null", - order_by=None, - style="white", - timeline=False, + config = _app_config( + agenda_views={ + "plain": org.config.app.AgendaViewConfig( + name="plain", + sections=[ + org.config.app.AgendaSectionConfig( + name="Planning", + filter=".scheduled != null or .deadline != null", + order_by=None, + style="white", + timeline=False, + ), + ], ), - ], + }, ) args = _make_args([fixture_path], date="2025-01-15", view="plain") @@ -506,7 +534,7 @@ def test_run_agenda_plain_view_rows_show_relative_day_labels( "argv", ["org", "agenda", "--date", "2025-01-15", "--view", "plain", fixture_path], ) - output = _render_agenda_output(args) + output = _render_agenda_output_with_config(args, config) assert "2 days ago" in output assert "today" in output @@ -566,18 +594,21 @@ def test_run_agenda_timeline_view_appends_selected_untimed_tasks( "SCHEDULED: <2025-01-15 Wed>\n", ) - org.config.app.CONFIG_AGENDA_VIEWS.clear() - org.config.app.CONFIG_AGENDA_VIEWS["timeline"] = org.config.app.AgendaViewConfig( - name="timeline", - sections=[ - org.config.app.AgendaSectionConfig( - name="Agenda", - filter=".scheduled != null", - order_by=None, - style="white", - timeline=True, + config = _app_config( + agenda_views={ + "timeline": org.config.app.AgendaViewConfig( + name="timeline", + sections=[ + org.config.app.AgendaSectionConfig( + name="Agenda", + filter=".scheduled != null", + order_by=None, + style="white", + timeline=True, + ), + ], ), - ], + }, ) args = _make_args([fixture_path], date="2025-01-15", view="timeline") @@ -586,7 +617,7 @@ def test_run_agenda_timeline_view_appends_selected_untimed_tasks( "argv", ["org", "agenda", "--date", "2025-01-15", "--view", "timeline", fixture_path], ) - output = _render_agenda_output(args) + output = _render_agenda_output_with_config(args, config) assert "09:30" in output assert "Timed task" in output @@ -709,7 +740,7 @@ def test_run_agenda_no_results_prints_message( args = _make_args([fixture_path], filter_tags=["nomatch$"]) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--filter-tag", "nomatch$"]) - agenda_command.run_agenda(args) + agenda_command.run_agenda(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) output = capsys.readouterr().out assert output.strip() == "No results" @@ -722,7 +753,7 @@ def test_run_agenda_rejects_negative_offset(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(sys, "argv", ["org", "agenda", "--offset", "-1"]) with pytest.raises(typer.BadParameter, match="--offset must be non-negative"): - agenda_command.run_agenda(args) + agenda_command.run_agenda(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) def test_run_agenda_rejects_negative_limit(monkeypatch: pytest.MonkeyPatch) -> None: @@ -732,7 +763,7 @@ def test_run_agenda_rejects_negative_limit(monkeypatch: pytest.MonkeyPatch) -> N monkeypatch.setattr(sys, "argv", ["org", "agenda", "--limit", "-1"]) with pytest.raises(typer.BadParameter, match="--limit must be non-negative"): - agenda_command.run_agenda(args) + agenda_command.run_agenda(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) def test_run_agenda_rejects_days_below_one(monkeypatch: pytest.MonkeyPatch) -> None: @@ -742,7 +773,7 @@ def test_run_agenda_rejects_days_below_one(monkeypatch: pytest.MonkeyPatch) -> N monkeypatch.setattr(sys, "argv", ["org", "agenda", "--days", "0"]) with pytest.raises(typer.BadParameter, match="--days must be at least 1"): - agenda_command.run_agenda(args) + agenda_command.run_agenda(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) def test_run_agenda_invalid_date_raises_bad_parameter(monkeypatch: pytest.MonkeyPatch) -> None: @@ -752,7 +783,7 @@ def test_run_agenda_invalid_date_raises_bad_parameter(monkeypatch: pytest.Monkey monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025/01/15"]) with pytest.raises(typer.BadParameter, match="--date must be in one of these formats"): - agenda_command.run_agenda(args) + agenda_command.run_agenda(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) def test_parse_clock_duration_accepts_multiple_formats() -> None: diff --git a/tests/commands/test_board.py b/tests/commands/test_board.py index a263929..35edc2b 100644 --- a/tests/commands/test_board.py +++ b/tests/commands/test_board.py @@ -34,6 +34,33 @@ FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") +def _board_views_configured() -> dict[str, org.config.app.BoardViewConfig]: + """Build a reusable configured board view set for tests.""" + return { + "kanban": org.config.app.BoardViewConfig( + name="kanban", + columns=[ + org.config.app.BoardColumnConfig(name="Backlog", filter=".todo == null"), + org.config.app.BoardColumnConfig( + name="Working", + filter=".todo != null and not(.is_completed)", + ), + ], + ), + } + + +def _app_config( + *, + board_views: dict[str, org.config.app.BoardViewConfig] | None = None, +) -> org.config.app.AppConfig: + """Build app config for board tests with optional configured views.""" + config = org.config.app.AppConfig(config_path=".org-cli.yaml") + if board_views is not None: + config.board.views = board_views + return config + + def make_board_args(files: list[str], **overrides: object) -> board_command.BoardArgs: """Build BoardArgs with defaults and overrides.""" args = board_command.BoardArgs( @@ -96,6 +123,10 @@ def _make_session( nodes: list[Heading], **overrides: object, ) -> actions.BoardSession: + app_config = cast( + "org.config.app.AppConfig", + overrides.pop("app_config", _app_config()), + ) resolved_columns = cast( "list[actions.BoardColumn] | None", overrides.pop("columns", None), @@ -109,6 +140,7 @@ def _make_session( nodes=nodes, todo_states=["TODO"], done_states=["DONE"], + app_config=app_config, columns=_default_columns(nodes) if resolved_columns is None else resolved_columns, color_enabled=False, selected_column_index=0, @@ -134,6 +166,14 @@ def _pin_board_now(monkeypatch: pytest.MonkeyPatch) -> None: def _render_board_output(args: board_command.BoardArgs) -> str: + """Render board output through shared UI helpers for rendering tests.""" + return _render_board_output_with_config(args, _app_config()) + + +def _render_board_output_with_config( + args: board_command.BoardArgs, + config: org.config.app.AppConfig, +) -> str: """Render board output through shared UI helpers for rendering tests.""" color_enabled = setup_output(args) args.max_results = board_command._resolve_tasks_limit(args.max_results) @@ -146,6 +186,7 @@ def _render_board_output(args: board_command.BoardArgs) -> str: ) nodes, discovered_todo_states, discovered_done_states = load_and_process_data( args, + config, ) nodes = actions.filter_recent_completed_nodes(nodes, args.days) todo_states, done_states = actions.resolved_states( @@ -155,7 +196,10 @@ def _render_board_output(args: board_command.BoardArgs) -> str: ) if not nodes: return "No results\n" - columns = actions.build_selector_board_columns(nodes, actions.resolve_column_specs(args)) + columns = actions.build_selector_board_columns( + nodes, + actions.resolve_column_specs(args, config.board.views), + ) ui.render_static_board( console, columns, @@ -297,7 +341,7 @@ def test_build_selector_board_columns_preserves_processed_order() -> None: "* TODO [#C] Low\n* TODO Middle\n* TODO [#A] High\n* TODO [#B] Medium\n", ) - specs = actions.resolve_column_specs(make_board_args([])) + specs = actions.resolve_column_specs(make_board_args([]), {}) columns = actions.build_selector_board_columns(nodes, specs) todo_column = next(column for column in columns if column.title == "TODO") @@ -521,7 +565,7 @@ def test_reload_session_keeps_same_task_selected_after_priority_reshuffle( nodes=original_nodes, columns=actions.build_selector_board_columns( original_nodes, - actions.resolve_column_specs(args), + actions.resolve_column_specs(args, {}), ), color_enabled=False, selected_column_index=1, @@ -539,7 +583,7 @@ def test_reload_session_keeps_same_task_selected_after_priority_reshuffle( monkeypatch.setattr( actions, "load_and_process_data", - lambda _args: (reloaded_nodes, ["TODO"], ["DONE"]), + lambda _args, _config: (reloaded_nodes, ["TODO"], ["DONE"]), ) actions.reload_session(session, preserve_identity) @@ -699,7 +743,7 @@ def test_run_board_rejects_width_below_80(monkeypatch: pytest.MonkeyPatch) -> No monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "79"]) with pytest.raises(typer.BadParameter, match="--width must be at least 80"): - board_command.run_board(args) + board_command.run_board(args, _app_config()) def test_run_board_limit_applies_before_grouping(monkeypatch: pytest.MonkeyPatch) -> None: @@ -735,7 +779,7 @@ def test_run_board_negative_max_results_raises_bad_parameter( monkeypatch.setattr(sys, "argv", ["org", "board", "--limit", "-1"]) with pytest.raises(typer.BadParameter, match="--limit must be non-negative"): - board_command.run_board(args) + board_command.run_board(args, _app_config()) def test_run_board_negative_offset_raises_bad_parameter( @@ -747,7 +791,17 @@ def test_run_board_negative_offset_raises_bad_parameter( monkeypatch.setattr(sys, "argv", ["org", "board", "--offset", "-1"]) with pytest.raises(typer.BadParameter, match="--offset must be non-negative"): - board_command.run_board(args) + board_command.run_board(args, _app_config()) + + +def test_run_board_zero_days_raises_bad_parameter(monkeypatch: pytest.MonkeyPatch) -> None: + """Board should reject zero-day completed task windows.""" + fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") + args = make_board_args([fixture_path], days=0) + + monkeypatch.setattr(sys, "argv", ["org", "board", "--days", "0"]) + with pytest.raises(typer.BadParameter, match="--days must be at least 1"): + board_command.run_board(args, _app_config()) def test_run_board_uses_pager_when_render_exceeds_console_height( @@ -778,9 +832,11 @@ def _fake_pager(*args: object, **kwargs: object) -> Iterator[None]: monkeypatch.setattr(console, "pager", _fake_pager) args = make_board_args([fixture_path], max_results=None) + config = _app_config() args.max_results = board_command._resolve_tasks_limit(args.max_results) nodes, discovered_todo_states, discovered_done_states = load_and_process_data( args, + config, ) nodes = actions.filter_recent_completed_nodes(nodes, args.days) todo_states, done_states = actions.resolved_states( @@ -788,7 +844,7 @@ def _fake_pager(*args: object, **kwargs: object) -> Iterator[None]: discovered_todo_states, discovered_done_states, ) - columns = actions.build_selector_board_columns(nodes, actions.resolve_column_specs(args)) + columns = actions.build_selector_board_columns(nodes, actions.resolve_column_specs(args, {})) ui.render_static_board( console, columns, @@ -813,23 +869,20 @@ def test_run_board_selector_uses_full_nodes_from_multiple_files( second_handle.write("* TODO Second file task\n") args = make_board_args([first_path, second_path], view="kanban", width=160) - original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( - name="kanban", - columns=[ - org.config.app.BoardColumnConfig(name="TODO", filter='str(.todo) == "TODO"'), - ], + config = _app_config( + board_views={ + "kanban": org.config.app.BoardViewConfig( + name="kanban", + columns=[ + org.config.app.BoardColumnConfig(name="TODO", filter='str(.todo) == "TODO"'), + ], + ), + }, ) - - try: - monkeypatch.setattr(sys, "argv", ["org", "board", "--view", "kanban", "--width", "160"]) - output = _render_board_output(args) - assert "First file task" in output - assert "Second file task" in output - finally: - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS.update(original_views) + monkeypatch.setattr(sys, "argv", ["org", "board", "--view", "kanban", "--width", "160"]) + output = _render_board_output_with_config(args, config) + assert "First file task" in output + assert "Second file task" in output def test_run_board_coalesce_completed_true_shows_completed_column( @@ -944,55 +997,31 @@ def test_run_board_uses_configured_view_columns(monkeypatch: pytest.MonkeyPatch) """Requested configured view should render configured columns.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args([fixture_path], view="kanban", width=150) - original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( - name="kanban", - columns=[ - org.config.app.BoardColumnConfig( - name="Backlog", - filter=".todo == null", - ), - org.config.app.BoardColumnConfig( - name="Working", - filter=".todo != null and not(.is_completed)", - ), - ], - ) - - try: - monkeypatch.setattr(sys, "argv", ["org", "board", "--view", "kanban", "--width", "150"]) - output = _render_board_output(args) - assert "Backlog" in output - assert "Working" in output - finally: - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS.update(original_views) + config = _app_config(board_views=_board_views_configured()) + monkeypatch.setattr(sys, "argv", ["org", "board", "--view", "kanban", "--width", "150"]) + output = _render_board_output_with_config(args, config) + assert "Backlog" in output + assert "Working" in output def test_run_board_missing_requested_view_raises(monkeypatch: pytest.MonkeyPatch) -> None: """Missing requested view should return explicit BadParameter.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="missing") - original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS["other"] = org.config.app.BoardViewConfig( - name="other", - columns=[ - org.config.app.BoardColumnConfig( - name="TODO", - filter='.todo == "TODO"', + config = _app_config( + board_views={ + "other": org.config.app.BoardViewConfig( + name="other", + columns=[ + org.config.app.BoardColumnConfig(name="TODO", filter='.todo == "TODO"'), + ], ), - ], + }, ) - try: - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(sys.stdout, "isatty", lambda: True) - with pytest.raises(typer.BadParameter, match="Requested board view not found"): - board_command.run_board(args) - finally: - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS.update(original_views) + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + with pytest.raises(typer.BadParameter, match="Requested board view not found"): + board_command.run_board(args, config) def test_run_board_requested_view_without_configured_views_raises( @@ -1001,46 +1030,23 @@ def test_run_board_requested_view_without_configured_views_raises( """Explicit --view should fail when no configured views exist.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") - original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) - org.config.app.CONFIG_BOARD_VIEWS.clear() - try: - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(sys.stdout, "isatty", lambda: True) - with pytest.raises(typer.BadParameter, match="no board views are configured"): - board_command.run_board(args) - finally: - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS.update(original_views) + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + with pytest.raises(typer.BadParameter, match="no board views are configured"): + board_command.run_board(args, _app_config(board_views={})) def test_run_board_uses_default_view_from_config(monkeypatch: pytest.MonkeyPatch) -> None: """Config-defaulted view value should drive board view resolution.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args([fixture_path], view=None, width=150) - original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( - name="kanban", - columns=[ - org.config.app.BoardColumnConfig(name="Backlog", filter=".todo == null"), - org.config.app.BoardColumnConfig( - name="Working", - filter=".todo != null and not(.is_completed)", - ), - ], - ) - - try: - # Simulate Typer default_map applying defaults: --view=kanban - args.view = "kanban" - specs = actions.resolve_column_specs(args) - assert [spec.name for spec in specs] == ["Backlog", "Working"] - monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "150"]) - output = _render_board_output(args) - assert "Backlog" in output - finally: - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS.update(original_views) + config = _app_config(board_views=_board_views_configured()) + args.view = "kanban" + specs = actions.resolve_column_specs(args, config.board.views) + assert [spec.name for spec in specs] == ["Backlog", "Working"] + monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "150"]) + output = _render_board_output_with_config(args, config) + assert "Backlog" in output def test_run_board_invalid_filter_or_order_by_parse_error_has_context( @@ -1049,22 +1055,20 @@ def test_run_board_invalid_filter_or_order_by_parse_error_has_context( """Filter/order-by parse failures should include view and column context.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") - original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( - name="kanban", - columns=[ - org.config.app.BoardColumnConfig(name="Broken", filter=".todo =="), - ], + config = _app_config( + board_views={ + "kanban": org.config.app.BoardViewConfig( + name="kanban", + columns=[ + org.config.app.BoardColumnConfig(name="Broken", filter=".todo =="), + ], + ), + }, ) - try: - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(sys.stdout, "isatty", lambda: True) - with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): - board_command.run_board(args) - finally: - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS.update(original_views) + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): + board_command.run_board(args, config) def test_run_board_invalid_filter_or_order_by_runtime_error_has_context( @@ -1073,30 +1077,28 @@ def test_run_board_invalid_filter_or_order_by_runtime_error_has_context( """Filter/order-by runtime failures should include view and column context.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") - original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( - name="kanban", - columns=[ - org.config.app.BoardColumnConfig( - name="Broken", - filter="unknown_fn(.todo)", + config = _app_config( + board_views={ + "kanban": org.config.app.BoardViewConfig( + name="kanban", + columns=[ + org.config.app.BoardColumnConfig( + name="Broken", + filter="unknown_fn(.todo)", + ), + ], ), - ], + }, ) monkeypatch.setattr( board_command, "load_and_process_data", - lambda _args: ([node_from_org("* TODO Task\n")[0]], ["TODO"], ["DONE"]), + lambda _args, _config: ([node_from_org("* TODO Task\n")[0]], ["TODO"], ["DONE"]), ) monkeypatch.setattr(sys.stdin, "isatty", lambda: True) monkeypatch.setattr(sys.stdout, "isatty", lambda: True) - try: - with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): - board_command.run_board(args) - finally: - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS.update(original_views) + with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): + board_command.run_board(args, config) def test_run_board_invalid_order_by_parse_error_has_context( @@ -1105,29 +1107,27 @@ def test_run_board_invalid_order_by_parse_error_has_context( """Invalid order-by parse should include view and column context.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") - original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( - name="kanban", - columns=[ - org.config.app.BoardColumnConfig( - name="Broken", - filter='.todo == "TODO"', - order_by=".priority ==", + config = _app_config( + board_views={ + "kanban": org.config.app.BoardViewConfig( + name="kanban", + columns=[ + org.config.app.BoardColumnConfig( + name="Broken", + filter='.todo == "TODO"', + order_by=".priority ==", + ), + ], ), - ], + }, ) - try: - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(sys.stdout, "isatty", lambda: True) - with pytest.raises( - typer.BadParameter, - match=r"Invalid board filter/order-by \(view=kanban, column=Broken\)", - ): - board_command.run_board(args) - finally: - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS.update(original_views) + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + with pytest.raises( + typer.BadParameter, + match=r"Invalid board filter/order-by \(view=kanban, column=Broken\)", + ): + board_command.run_board(args, config) def test_apply_state_move_reload_reassigns_task_across_selector_columns( @@ -1136,22 +1136,25 @@ def test_apply_state_move_reload_reassigns_task_across_selector_columns( """State changes should reload and move task to new selector column.""" args = make_board_args([], view="kanban", days=100000) source_node = node_from_org("#+TODO: TODO | DONE\n* TODO Task\n")[0] - original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( - name="kanban", - columns=[ - org.config.app.BoardColumnConfig(name="TODO", filter='.todo == "TODO"'), - org.config.app.BoardColumnConfig(name="DONE", filter='.todo == "DONE"'), - ], + config = _app_config( + board_views={ + "kanban": org.config.app.BoardViewConfig( + name="kanban", + columns=[ + org.config.app.BoardColumnConfig(name="TODO", filter='.todo == "TODO"'), + org.config.app.BoardColumnConfig(name="DONE", filter='.todo == "DONE"'), + ], + ), + }, ) session = _make_session( args=args, nodes=[source_node], + app_config=config, columns=actions.build_selector_board_columns( [source_node], - actions.resolve_column_specs(args), + actions.resolve_column_specs(args, config.board.views), ), color_enabled=False, selected_column_index=0, @@ -1167,6 +1170,7 @@ def _capture_save(document: Document) -> None: def _load_after_change( _args: board_command.BoardArgs, + _config: org.config.app.AppConfig, ) -> tuple[list[Heading], list[str], list[str]]: if source_node.todo == "DONE": return ( @@ -1181,16 +1185,12 @@ def _load_after_change( monkeypatch.setattr(actions, "_save_document_changes", _capture_save) monkeypatch.setattr(actions, "load_and_process_data", _load_after_change) - try: - actions.apply_state_move(session, direction=1) - assert saved_documents == [source_node.document] - assert session.selected_column_index == 1 - selected = actions.selected_node(session) - assert selected is not None - assert selected.todo == "DONE" - finally: - org.config.app.CONFIG_BOARD_VIEWS.clear() - org.config.app.CONFIG_BOARD_VIEWS.update(original_views) + actions.apply_state_move(session, direction=1) + assert saved_documents == [source_node.document] + assert session.selected_column_index == 1 + selected = actions.selected_node(session) + assert selected is not None + assert selected.todo == "DONE" def test_build_task_panel_renders_rich_title_content() -> None: diff --git a/tests/commands/test_board_app.py b/tests/commands/test_board_app.py index bbf7289..17db1e2 100644 --- a/tests/commands/test_board_app.py +++ b/tests/commands/test_board_app.py @@ -36,6 +36,10 @@ def _make_session( nodes: list[Heading], **overrides: object, ) -> actions.BoardSession: + app_config = cast( + "org.config.app.AppConfig", + overrides.pop("app_config", org.config.app.AppConfig(config_path=".org-cli.yaml")), + ) resolved_columns = cast("list[actions.BoardColumn] | None", overrides.pop("columns", None)) resolved_all_columns = cast( "list[actions.BoardColumn] | None", @@ -46,6 +50,7 @@ def _make_session( nodes=nodes, todo_states=["TODO"], done_states=["DONE"], + app_config=app_config, columns=[_col("TODO", nodes)] if resolved_columns is None else resolved_columns, color_enabled=False, selected_column_index=0, @@ -189,15 +194,14 @@ async def _run() -> None: nodes = node_from_org("* TODO Existing\n") captured_node = node_from_org("* TODO Captured\n")[0] reloaded: dict[str, object] = {} - monkeypatch.setattr( - org.config.app, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) + config = org.config.app.AppConfig(config_path=".org-cli.yaml") + config.tasks.capture.templates = { + "quick": {"file": "tasks.org", "content": "* TODO Captured"}, + } monkeypatch.setattr( actions, "capture_task", - lambda _args: capture_command.TasksCaptureResult( + lambda _args, _templates: capture_command.TasksCaptureResult( template_name="quick", heading=captured_node, document=captured_node.document, @@ -209,7 +213,7 @@ async def _run() -> None: lambda _session, preserve_identity: reloaded.update(identity=preserve_identity), ) - app = _make_app(_make_session(make_board_args([]), nodes)) + app = _make_app(_make_session(make_board_args([]), nodes, app_config=config)) async with app.run_test() as pilot: app.action_prompt_capture() @@ -224,11 +228,10 @@ async def _run() -> None: asyncio.run(_run()) -def test_board_app_capture_without_templates_sets_status(monkeypatch: pytest.MonkeyPatch) -> None: +def test_board_app_capture_without_templates_sets_status() -> None: """Capture should report a clear status when no templates are configured.""" async def _run() -> None: - monkeypatch.setattr(org.config.app, "CONFIG_CAPTURE_TEMPLATES", {}) app = _make_app(_make_session(make_board_args([]), node_from_org("* TODO Existing\n"))) async with app.run_test(): @@ -292,6 +295,6 @@ def _fake_run( monkeypatch.setattr(sys.stdout, "isatty", lambda: True) monkeypatch.setattr("org.commands.board.command.run_board_app", _fake_run) - board_command.run_board(args) + board_command.run_board(args, org.config.app.AppConfig(config_path=".org-cli.yaml")) assert called["value"] is True diff --git a/tests/commands/test_tasks_capture_command.py b/tests/commands/test_tasks_capture_command.py index 0228ab4..47b90c3 100644 --- a/tests/commands/test_tasks_capture_command.py +++ b/tests/commands/test_tasks_capture_command.py @@ -17,6 +17,15 @@ from pathlib import Path +def _app_config_with_templates( + templates: dict[str, dict[str, str]], +) -> org.config.app.AppConfig: + """Build app config with capture templates for command-entrypoint tests.""" + config = org.config.app.AppConfig(config_path=".org-cli.yaml") + config.tasks.capture.templates = templates + return config + + def _make_capture_args( template_name: str | None, *, @@ -34,61 +43,36 @@ def _make_capture_args( ) -def _set_capture_templates(templates: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]: - """Replace global capture templates and return previous value.""" - previous = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(templates) - return previous - - -def _restore_capture_templates(previous: dict[str, dict[str, str]]) -> None: - """Restore global capture templates from previous value.""" - org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() - org.config.app.CONFIG_CAPTURE_TEMPLATES.update(previous) - - def test_run_capture_errors_when_no_templates_configured() -> None: """Capture should fail with a clear error when templates are missing.""" - previous = _set_capture_templates({}) - try: - with pytest.raises(typer.BadParameter, match="No capture templates configured"): - capture.run_tasks_capture(_make_capture_args(template_name="quick")) - finally: - _restore_capture_templates(previous) + with pytest.raises(typer.BadParameter, match="No capture templates configured"): + capture.run_tasks_capture( + _make_capture_args(template_name="quick"), + _app_config_with_templates({}), + ) def test_run_capture_errors_for_unknown_template_name() -> None: """Capture should list valid names when an unknown template is requested.""" - previous = _set_capture_templates( - { - "alpha": {"file": "tasks.org", "content": "* TODO {{title}}"}, - "beta": {"file": "tasks.org", "content": "* TODO {{title}}"}, - }, - ) - try: - with pytest.raises(typer.BadParameter, match="Valid templates: alpha, beta"): - capture.capture_task(_make_capture_args(template_name="missing")) - finally: - _restore_capture_templates(previous) + templates = { + "alpha": {"file": "tasks.org", "content": "* TODO {{title}}"}, + "beta": {"file": "tasks.org", "content": "* TODO {{title}}"}, + } + with pytest.raises(typer.BadParameter, match="Valid templates: alpha, beta"): + capture.capture_task(_make_capture_args(template_name="missing"), templates) def test_capture_task_errors_when_template_omitted_in_noninteractive_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: """Non-interactive capture should require an explicit template name.""" - previous = _set_capture_templates( - {"quick": {"file": "tasks.org", "content": "* TODO Static"}}, + templates = {"quick": {"file": "tasks.org", "content": "* TODO Static"}} + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: False, ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.command._is_interactive_terminal", - lambda: False, - ) - with pytest.raises(typer.BadParameter, match="template name is required"): - capture.capture_task(_make_capture_args(template_name=None)) - finally: - _restore_capture_templates(previous) + with pytest.raises(typer.BadParameter, match="template name is required"): + capture.capture_task(_make_capture_args(template_name=None), templates) def test_capture_task_errors_when_placeholders_missing_in_noninteractive_mode( @@ -98,21 +82,16 @@ def test_capture_task_errors_when_placeholders_missing_in_noninteractive_mode( """Non-interactive capture should fail on unresolved placeholders.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - previous = _set_capture_templates( - {"quick": {"file": str(target), "content": "* TODO {{title}} @{{owner}}"}}, + templates = {"quick": {"file": str(target), "content": "* TODO {{title}} @{{owner}}"}} + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: False, ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.command._is_interactive_terminal", - lambda: False, - ) - with pytest.raises( - typer.BadParameter, - match="Missing placeholder values for: title, owner", - ): - capture.capture_task(_make_capture_args(template_name="quick")) - finally: - _restore_capture_templates(previous) + with pytest.raises( + typer.BadParameter, + match="Missing placeholder values for: title, owner", + ): + capture.capture_task(_make_capture_args(template_name="quick"), templates) def test_capture_task_uses_interactive_template_selection_and_form( @@ -122,29 +101,24 @@ def test_capture_task_uses_interactive_template_selection_and_form( """Interactive capture should use the selection app and form app helpers.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - previous = _set_capture_templates( - { - "alpha": {"file": str(target), "content": "* TODO Alpha"}, - "beta": {"file": str(target), "content": "* TODO {{title}} @{{owner}}"}, - }, + templates = { + "alpha": {"file": str(target), "content": "* TODO Alpha"}, + "beta": {"file": str(target), "content": "* TODO {{title}} @{{owner}}"}, + } + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_template_selection_app", + lambda _names: "beta", + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "Write docs", "owner": "Jane"}, ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.command._is_interactive_terminal", - lambda: True, - ) - monkeypatch.setattr( - "org.commands.tasks.capture.command.run_template_selection_app", - lambda _names: "beta", - ) - monkeypatch.setattr( - "org.commands.tasks.capture.command.run_capture_form_app", - lambda plan: {**plan.values, "title": "Write docs", "owner": "Jane"}, - ) - result = capture.capture_task(_make_capture_args(template_name=None)) - finally: - _restore_capture_templates(previous) + result = capture.capture_task(_make_capture_args(template_name=None), templates) assert result.template_name == "beta" assert result.interactive_used is True @@ -158,22 +132,17 @@ def test_capture_task_uses_form_app_for_unresolved_placeholders( """Interactive capture should use the form app when placeholders are unresolved.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - previous = _set_capture_templates( - {"quick": {"file": str(target), "content": "* TODO {{title}}"}}, + templates = {"quick": {"file": str(target), "content": "* TODO {{title}}"}} + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "From form"}, ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.command._is_interactive_terminal", - lambda: True, - ) - monkeypatch.setattr( - "org.commands.tasks.capture.command.run_capture_form_app", - lambda plan: {**plan.values, "title": "From form"}, - ) - result = capture.capture_task(_make_capture_args(template_name="quick")) - finally: - _restore_capture_templates(previous) + result = capture.capture_task(_make_capture_args(template_name="quick"), templates) assert result.interactive_used is True assert result.heading.title_text == "From form" @@ -183,15 +152,11 @@ def test_capture_task_returns_created_heading_metadata_noninteractive(tmp_path: """Non-interactive capture should return created heading metadata.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - previous = _set_capture_templates( - {"quick": {"file": str(target), "content": "* TODO {{title}}"}}, + templates = {"quick": {"file": str(target), "content": "* TODO {{title}}"}} + result = capture.capture_task( + _make_capture_args(template_name="quick", set_values=["title=Write docs"]), + templates, ) - try: - result = capture.capture_task( - _make_capture_args(template_name="quick", set_values=["title=Write docs"]), - ) - finally: - _restore_capture_templates(previous) assert result.template_name == "quick" assert result.document.filename == str(target) @@ -206,18 +171,16 @@ def test_run_tasks_capture_noninteractive_prints_created_task_id( """Non-interactive capture should print new task ID when available.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO {{title}}\n:PROPERTIES:\n:ID: task-42\n:END:", - }, + templates = { + "quick": { + "file": str(target), + "content": "* TODO {{title}}\n:PROPERTIES:\n:ID: task-42\n:END:", }, + } + capture.run_tasks_capture( + _make_capture_args("quick", set_values=["title=From set"]), + _app_config_with_templates(templates), ) - try: - capture.run_tasks_capture(_make_capture_args("quick", set_values=["title=From set"])) - finally: - _restore_capture_templates(previous) assert capsys.readouterr().out.strip() == "task-42" @@ -230,21 +193,19 @@ def test_run_tasks_capture_interactive_does_not_print_identifier( """Interactive capture should not print identifier output.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - previous = _set_capture_templates( - {"quick": {"file": str(target), "content": "* TODO {{title}}"}}, + templates = {"quick": {"file": str(target), "content": "* TODO {{title}}"}} + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "Write docs"}, + ) + capture.run_tasks_capture( + _make_capture_args(template_name="quick"), + _app_config_with_templates(templates), ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.command._is_interactive_terminal", - lambda: True, - ) - monkeypatch.setattr( - "org.commands.tasks.capture.command.run_capture_form_app", - lambda plan: {**plan.values, "title": "Write docs"}, - ) - capture.run_tasks_capture(_make_capture_args(template_name="quick")) - finally: - _restore_capture_templates(previous) assert capsys.readouterr().out.strip() == "" @@ -274,22 +235,17 @@ def test_prepare_capture_plan_uses_document_and_parent_placeholders( "* TODO Parent\n:PROPERTIES:\n:ID: parent-1\n:CATEGORY: Projects\n:END:\n", encoding="utf-8", ) - previous = _set_capture_templates( - { - "child": { - "file": str(target), - "parent": '.id == "parent-1"', - "content": ( - "** TODO doc={{document_title}} cat={{document_category}} " - "parent={{parent_title}} parent_id={{parent_id}}" - ), - }, + templates = { + "child": { + "file": str(target), + "parent": '.id == "parent-1"', + "content": ( + "** TODO doc={{document_title}} cat={{document_category}} " + "parent={{parent_title}} parent_id={{parent_id}}" + ), }, - ) - try: - plan = capture.prepare_capture_plan(_make_capture_args("child"), "child") - finally: - _restore_capture_templates(previous) + } + plan = capture.prepare_capture_plan(_make_capture_args("child"), "child", templates) assert plan.values["document_title"] == "Project File" assert plan.values["document_category"] == "Work" @@ -308,27 +264,25 @@ def test_run_capture_inserts_under_parent_query( "* TODO Project\n:PROPERTIES:\n:ID: project-1\n:END:\n\n* TODO Other\n", encoding="utf-8", ) - previous = _set_capture_templates( - { - "child": { - "file": str(target), - "content": "** TODO {{title}}", - "parent": '.id == "project-1"', - }, + templates = { + "child": { + "file": str(target), + "content": "** TODO {{title}}", + "parent": '.id == "project-1"', }, + } + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "Child task"}, + ) + capture.run_tasks_capture( + _make_capture_args(template_name="child"), + _app_config_with_templates(templates), ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.command._is_interactive_terminal", - lambda: True, - ) - monkeypatch.setattr( - "org.commands.tasks.capture.command.run_capture_form_app", - lambda plan: {**plan.values, "title": "Child task"}, - ) - capture.run_tasks_capture(_make_capture_args(template_name="child")) - finally: - _restore_capture_templates(previous) root = org_parser.loads(target.read_text(encoding="utf-8")) titles = [node.title_text.strip() for node in list(root)] @@ -345,26 +299,24 @@ def test_run_capture_cli_file_override_takes_precedence( configured_target.write_text("* TODO Configured\n", encoding="utf-8") override_target.write_text("* TODO Override\n", encoding="utf-8") - previous = _set_capture_templates( - { - "quick": { - "file": str(configured_target), - "content": "* TODO {{title}}", - }, + templates = { + "quick": { + "file": str(configured_target), + "content": "* TODO {{title}}", }, + } + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "From override"}, + ) + capture.run_tasks_capture( + _make_capture_args("quick", file=str(override_target)), + _app_config_with_templates(templates), ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.command._is_interactive_terminal", - lambda: True, - ) - monkeypatch.setattr( - "org.commands.tasks.capture.command.run_capture_form_app", - lambda plan: {**plan.values, "title": "From override"}, - ) - capture.run_tasks_capture(_make_capture_args("quick", file=str(override_target))) - finally: - _restore_capture_templates(previous) assert "From override" not in configured_target.read_text(encoding="utf-8") assert "From override" in override_target.read_text(encoding="utf-8") @@ -374,37 +326,33 @@ def test_run_capture_set_values_reject_invalid_format(tmp_path: Path) -> None: """Capture should reject --set entries that are not KEY=VALUE.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO {{title}}", - }, + templates = { + "quick": { + "file": str(target), + "content": "* TODO {{title}}", }, - ) - try: - with pytest.raises(typer.BadParameter, match="--set must be KEY=VALUE"): - capture.run_tasks_capture(_make_capture_args("quick", set_values=["title"])) - finally: - _restore_capture_templates(previous) + } + with pytest.raises(typer.BadParameter, match="--set must be KEY=VALUE"): + capture.run_tasks_capture( + _make_capture_args("quick", set_values=["title"]), + _app_config_with_templates(templates), + ) def test_run_capture_static_placeholders_use_org_timestamp_format(tmp_path: Path) -> None: """Capture should render {{today}} and {{now}} as Org-mode timestamps.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO today={{today}} now={{now}}", - }, + templates = { + "quick": { + "file": str(target), + "content": "* TODO today={{today}} now={{now}}", }, + } + capture.run_tasks_capture( + _make_capture_args("quick"), + _app_config_with_templates(templates), ) - try: - capture.run_tasks_capture(_make_capture_args("quick")) - finally: - _restore_capture_templates(previous) updated = target.read_text(encoding="utf-8") assert re.search(r"today=<\d{4}-\d{2}-\d{2} (Mon|Tue|Wed|Thu|Fri|Sat|Sun)>", updated) @@ -418,17 +366,15 @@ def test_run_capture_static_id_placeholder_uses_next_heading_number(tmp_path: Pa """Capture should render {{id}} as next heading count value.""" target = tmp_path / "tasks.org" target.write_text("* TODO One\n* TODO Two\n", encoding="utf-8") - previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO Added\n:PROPERTIES:\n:ID: {{id}}\n:END:", - }, + templates = { + "quick": { + "file": str(target), + "content": "* TODO Added\n:PROPERTIES:\n:ID: {{id}}\n:END:", }, + } + capture.run_tasks_capture( + _make_capture_args("quick"), + _app_config_with_templates(templates), ) - try: - capture.run_tasks_capture(_make_capture_args("quick")) - finally: - _restore_capture_templates(previous) assert ":ID: 3" in target.read_text(encoding="utf-8") diff --git a/tests/commands/test_tasks_query_command.py b/tests/commands/test_tasks_query_command.py index af16624..6fe0af0 100644 --- a/tests/commands/test_tasks_query_command.py +++ b/tests/commands/test_tasks_query_command.py @@ -4,6 +4,7 @@ import json import os +from typing import TYPE_CHECKING import click import pytest @@ -12,12 +13,24 @@ from org_parser.time import Clock, Timestamp from typer.testing import CliRunner -from org.cli import app +import org.config.app +from org import cli from org.commands.tasks.query import TasksQueryArgs, _is_org_object, run_tasks_query from org.pipeline.format import OutputFormat, OutputFormatError +if TYPE_CHECKING: + from pathlib import Path + + FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") +EMPTY_CONFIG_PATH = os.path.join(FIXTURES_DIR, "empty-config.yaml") +app = cli.build_app(org.config.app.AppConfig(config_path=EMPTY_CONFIG_PATH)) + + +def _run_tasks_query(args: TasksQueryArgs) -> None: + """Run tasks query with a default app config for direct tests.""" + run_tasks_query(args, org.config.app.AppConfig(config_path=EMPTY_CONFIG_PATH)) def _make_args(files: list[str], query: str, **overrides: object) -> TasksQueryArgs: @@ -49,7 +62,7 @@ def test_run_query_starts_from_root_nodes(capsys: pytest.CaptureFixture[str]) -> fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = _make_args([fixture_path], ".[] | .children | length") - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out assert captured.strip() == "3" @@ -62,7 +75,7 @@ def test_run_query_org_node_results_render_with_file_header( fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = _make_args([fixture_path], ".[] | .children | .[0]") - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out assert f"# {fixture_path}" in captured @@ -76,7 +89,7 @@ def test_run_query_org_root_results_render_with_file_header( fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = _make_args([fixture_path], ".[]") - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out assert f"# {fixture_path}" in captured @@ -103,7 +116,7 @@ def _fake_compiled_query(_stream: object, _context: object) -> list[object]: lambda _args: ([], ["TODO"], ["DONE"]), ) - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out assert captured.splitlines() == ["alpha", "beta"] @@ -129,7 +142,7 @@ def _fake_compiled_query(_stream: object, _context: object) -> list[object]: lambda _args: ([], ["TODO"], ["DONE"]), ) - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out assert json.loads(captured) == ["alpha", 1, None] @@ -155,7 +168,7 @@ def _fake_compiled_query(_stream: object, _context: object) -> list[object]: lambda _args: ([], ["TODO"], ["DONE"]), ) - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out assert json.loads(captured) is None @@ -185,7 +198,7 @@ def test_query_empty_scheduled_timestamp_renders_json_null( fixture_path = os.path.join(FIXTURES_DIR, "simple.org") args = _make_args([fixture_path], ".[][].scheduled") - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out assert captured.strip() == "null" @@ -196,7 +209,7 @@ def test_query_empty_org_result_set_prints_no_results(capsys: pytest.CaptureFixt fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = _make_args([fixture_path], ".[] | select(false)") - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out assert captured.strip() == "No results" @@ -208,7 +221,35 @@ def test_run_query_negative_max_results_raises_bad_parameter() -> None: args = _make_args([fixture_path], ".[]", max_results=-1) with pytest.raises(click.BadParameter, match="--limit must be non-negative"): - run_tasks_query(args) + _run_tasks_query(args) + + +def test_tasks_query_command_uses_query_config_defaults( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """CLI should pass tasks.query config defaults into command arguments.""" + config = org.config.app.AppConfig(config_path=str(tmp_path / ".org-cli.yaml")) + config.tasks.query.offset = 2 + config.tasks.query.width = 65 + configured_app = cli.build_app(config) + runner = CliRunner() + fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") + captured: dict[str, object] = {} + + def _fake_run_tasks_query( + args: TasksQueryArgs, + _config: org.config.app.AppConfig, + ) -> None: + captured["offset"] = args.offset + captured["width"] = args.width + + monkeypatch.setattr("org.commands.tasks.query.run_tasks_query", _fake_run_tasks_query) + + result = runner.invoke(configured_app, ["tasks", "query", ".[]", fixture_path]) + + assert result.exit_code == 0 + assert captured == {"offset": 2, "width": 65} def test_query_runtime_error_is_reported_as_usage_error() -> None: @@ -238,7 +279,7 @@ def _fake_pandoc(org_text: str, output_format: str, pandoc_args: list[str]) -> s return "converted markdown" monkeypatch.setattr("org.commands.tasks.query._org_to_pandoc_format", _fake_pandoc) - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out assert captured.strip() == "converted markdown" @@ -263,7 +304,7 @@ def _fake_pandoc(org_text: str, output_format: str, pandoc_args: list[str]) -> s return "converted scalar" monkeypatch.setattr("org.commands.tasks.query._org_to_pandoc_format", _fake_pandoc) - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out assert captured.strip() == "converted scalar" @@ -284,7 +325,7 @@ def test_run_query_accepts_arbitrary_pandoc_output_format( pandoc_args="--wrap=none", ) - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out assert captured.strip() @@ -316,7 +357,7 @@ def prepare( ) with pytest.raises(click.UsageError, match="pandoc missing"): - run_tasks_query(args) + _run_tasks_query(args) def test_run_query_pandoc_empty_results_prints_no_results( @@ -336,7 +377,7 @@ def _should_not_call(_org_text: str, _output_format: str, _pandoc_args: list[str ) monkeypatch.setattr("org.commands.tasks.query._org_to_pandoc_format", _should_not_call) - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out assert captured.strip() == "No results" @@ -347,7 +388,7 @@ def test_run_query_json_root_result_is_single_object(capsys: pytest.CaptureFixtu fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = _make_args([fixture_path], ".[]", out=OutputFormat.JSON) - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out parsed = json.loads(captured) @@ -362,7 +403,7 @@ def test_run_query_json_node_result_excludes_env(capsys: pytest.CaptureFixture[s fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = _make_args([fixture_path], ".[] | .children | .[0]", out=OutputFormat.JSON) - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out parsed = json.loads(captured) @@ -376,7 +417,7 @@ def test_run_query_json_scalars_emit_single_json_value(capsys: pytest.CaptureFix fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = _make_args([fixture_path], ".[] | .children | length", out=OutputFormat.JSON) - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out parsed = json.loads(captured) @@ -393,7 +434,7 @@ def test_run_query_json_preserves_multiple_collection_results( ] args = _make_args(fixture_paths, ".[] | .children", out=OutputFormat.JSON) - run_tasks_query(args) + _run_tasks_query(args) captured = capsys.readouterr().out parsed = json.loads(captured) @@ -425,4 +466,4 @@ def test_run_query_invalid_pandoc_args_is_usage_error() -> None: args = _make_args([fixture_path], ".[]", out="gfm", pandoc_args='"') with pytest.raises(click.UsageError, match="No closing quotation"): - run_tasks_query(args) + _run_tasks_query(args) diff --git a/tests/config/test_config_core.py b/tests/config/test_config_core.py index e2fa2ba..972144a 100644 --- a/tests/config/test_config_core.py +++ b/tests/config/test_config_core.py @@ -47,137 +47,47 @@ def test_load_config_non_dict_is_malformed(tmp_path: Path) -> None: assert malformed is True -def test_parse_color_defaults_conflict() -> None: - """Conflicting color flags should be rejected.""" - defaults, valid = org.config.app.parse_color_defaults({"--color": True, "--no-color": True}) - - assert defaults == {} - assert valid is False - - -def test_parse_color_defaults_ignores_value() -> None: - """Color defaults should be driven by key presence, not YAML value.""" - defaults, valid = org.config.app.parse_color_defaults({"--color": "yes"}) - - assert defaults == {"color_flag": True} - assert valid is True - - -def test_build_config_defaults_applies_values() -> None: - """Config defaults should populate summary/task defaults and append defaults.""" - raw: dict[str, object] = { - "--limit": 25, - "--max-tags": 3, - "--filter-tag": ["work", "team"], - "--use": "heading", - "--pandoc-args": "--wrap=none", - } - - defaults = org.config.app.build_config_defaults(raw) - - assert defaults is not None - default_values, stats_defaults, append_defaults = defaults - assert stats_defaults["max_results"] == 25 - assert stats_defaults["max_tags"] == 3 - assert stats_defaults["use"] == "heading" - assert default_values["pandoc_args"] == "--wrap=none" - assert append_defaults["filter_tags"] == ["work", "team"] - - -def test_build_config_defaults_rejects_invalid_entry() -> None: - """Invalid values should cause config defaults to be rejected.""" - raw: dict[str, object] = {"--limit": "not-a-number"} - - defaults = org.config.app.build_config_defaults(raw) - - assert defaults is None - - -def test_build_config_defaults_accepts_ordering_flags() -> None: - """Ordering switches should support boolean config defaults.""" - raw: dict[str, object] = { - "--order-by-priority": True, - "--order-by-level": True, - "--order-by-timestamp-desc": True, - } - - defaults = org.config.app.build_config_defaults(raw) - - assert defaults is not None - default_values, _stats_defaults, _append_defaults = defaults - assert default_values["order_by_priority"] is True - assert default_values["order_by_level"] is True - assert default_values["order_by_timestamp_desc"] is True - - -def test_build_config_defaults_keeps_regular_boolean_values() -> None: - """Single-form boolean defaults should still honor explicit false values.""" - raw: dict[str, object] = { - "--details": False, - "--order-by-level": False, - } - - defaults = org.config.app.build_config_defaults(raw) - - assert defaults is not None - default_values, _stats_defaults, _append_defaults = defaults - assert default_values["details"] is False - assert default_values["order_by_level"] is False - - -def test_build_config_defaults_ignores_value_for_paired_boolean_flags() -> None: - """Paired boolean flags should derive defaults from the config key itself.""" - raw: dict[str, object] = { - "--future-repeats": "enabled", - "--no-color": "disabled", - } - - defaults = org.config.app.build_config_defaults(raw) - - assert defaults is not None - default_values, _stats_defaults, _append_defaults = defaults - assert default_values["future_repeats"] is True - assert default_values["color_flag"] is False - - -def test_build_config_defaults_sets_false_for_negated_paired_flag() -> None: - """Negated paired flags should store the negative destination value.""" - raw: dict[str, object] = {"--no-future-repeats": True} - - defaults = org.config.app.build_config_defaults(raw) - - assert defaults is not None - default_values, _stats_defaults, _append_defaults = defaults - assert default_values["future_repeats"] is False - - -def test_build_config_defaults_accepts_view_default() -> None: - """Board view default should be accepted as a non-empty string.""" - raw: dict[str, object] = {"--view": "kanban"} - - defaults = org.config.app.build_config_defaults(raw) - - assert defaults is not None - default_values, _stats_defaults, _append_defaults = defaults - assert default_values["view"] == "kanban" - - -def test_build_config_defaults_rejects_non_boolean_ordering_flags() -> None: - """Ordering switch defaults must be boolean values.""" - raw: dict[str, object] = {"--order-by-level": "yes"} - - defaults = org.config.app.build_config_defaults(raw) - - assert defaults is None - - -def test_build_config_defaults_rejects_empty_view_default() -> None: - """Board view default must not be empty.""" - raw: dict[str, object] = {"--view": ""} +def test_load_cli_config_parses_shared_and_section_values( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Structured config should populate shared fields and command sections.""" + config_path = tmp_path / ".org-cli.yaml" + config_path.write_text( + ( + "color_flag: true\n" + "filter_tags:\n" + " - work\n" + "stats:\n" + " all:\n" + " use: heading\n" + " max_tags: 3\n" + "tasks:\n" + " query:\n" + " offset: 2\n" + " width: 72\n" + " list:\n" + ' pandoc_args: "--wrap=none"\n' + " find:\n" + " include_context: 2\n" + "board:\n" + " view: kanban\n" + ), + encoding="utf-8", + ) - defaults = org.config.app.build_config_defaults(raw) + monkeypatch.chdir(config_path.parent) + loaded = org.config.app.load_cli_config(["org"]) - assert defaults is None + assert loaded.color_flag is True + assert loaded.filter_tags == ["work"] + assert loaded.stats.all.max_tags == 3 + assert loaded.stats.all.use == "heading" + assert loaded.tasks.query.offset == 2 + assert loaded.tasks.query.width == 72 + assert loaded.tasks.list.pandoc_args == "--wrap=none" + assert loaded.tasks.find.include_context == 2 + assert loaded.board.view == "kanban" def test_parse_config_argument_prefers_cli_value() -> None: @@ -199,13 +109,19 @@ def test_parse_config_argument_default() -> None: assert org.config.app.parse_config_argument(["org", "stats", "all"]) == ".org-cli.yaml" -def test_load_cli_config_reads_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """load_cli_config should load defaults from config file.""" +def test_load_cli_config_reads_structured_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_cli_config should load structured config values from config file.""" config_path = tmp_path / ".org-cli.yaml" config_path.write_text( ( - "defaults:\n" - " --limit: 7\n" + "todo_states: TODO,WAITING\n" + "mapping: examples/mapping_example.json\n" + "stats:\n" + " summary:\n" + " max_results: 7\n" "filter:\n" " custom-filter: .[]\n" "order-by:\n" @@ -219,13 +135,13 @@ def test_load_cli_config_reads_defaults(tmp_path: Path, monkeypatch: pytest.Monk monkeypatch.chdir(config_path.parent) loaded = org.config.app.load_cli_config(["org"]) - assert loaded.defaults["max_results"] == 7 - assert loaded.append_defaults == {} - assert loaded.inline_defaults == {} - assert loaded.custom_filters == {"custom-filter": ".[]"} - assert loaded.custom_order_by == {"custom-order": "."} - assert loaded.custom_with == {"custom-with": "."} - assert loaded.capture_templates == {} + assert loaded.todo_states == ["TODO", "WAITING"] + assert loaded.mapping == "examples/mapping_example.json" + assert loaded.stats.summary.max_results == 7 + assert loaded.custom_filter_map() == {"custom-filter": ".[]"} + assert loaded.custom_order_by_map() == {"custom-order": "."} + assert loaded.custom_with_map() == {"custom-with": "."} + assert loaded.tasks.capture.templates == {} assert loaded.board_views == {} @@ -233,18 +149,18 @@ def test_load_cli_config_sections_are_optional( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Only defaults section should be enough for valid config.""" + """Only one structured command section should be enough for valid config.""" config_path = tmp_path / ".org-cli.yaml" - config_path.write_text("defaults:\n --limit: 7\n", encoding="utf-8") + config_path.write_text("stats:\n summary:\n max_results: 7\n", encoding="utf-8") monkeypatch.chdir(config_path.parent) loaded = org.config.app.load_cli_config(["org"]) - assert loaded.defaults["max_results"] == 7 - assert loaded.custom_filters == {} - assert loaded.custom_order_by == {} - assert loaded.custom_with == {} - assert loaded.capture_templates == {} + assert loaded.stats.summary.max_results == 7 + assert loaded.custom_filter_map() == {} + assert loaded.custom_order_by_map() == {} + assert loaded.custom_with_map() == {} + assert loaded.tasks.capture.templates == {} assert loaded.board_views == {} @@ -252,19 +168,20 @@ def test_load_cli_config_parses_capture_templates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Capture templates should load from capture.templates section.""" + """Capture templates should load from tasks.capture.templates section.""" config_path = tmp_path / ".org-cli.yaml" config_path.write_text( ( - "capture:\n" - " templates:\n" - " quick:\n" - " file: tasks.org\n" - ' content: "* TODO {{title}}"\n' - " under-project:\n" - " file: tasks.org\n" - ' content: "** TODO {{title}}"\n' - ' parent: ".id == \\"project-1\\""\n' + "tasks:\n" + " capture:\n" + " templates:\n" + " quick:\n" + " file: tasks.org\n" + ' content: "* TODO {{title}}"\n' + " under-project:\n" + " file: tasks.org\n" + ' content: "** TODO {{title}}"\n' + ' parent: ".id == \\"project-1\\""\n' ), encoding="utf-8", ) @@ -272,7 +189,7 @@ def test_load_cli_config_parses_capture_templates( monkeypatch.chdir(config_path.parent) loaded = org.config.app.load_cli_config(["org"]) - assert loaded.capture_templates == { + assert loaded.tasks.capture.templates == { "quick": {"file": "tasks.org", "content": "* TODO {{title}}"}, "under-project": { "file": "tasks.org", @@ -320,17 +237,18 @@ def test_load_cli_config_parses_board_views( ] -def test_load_cli_config_rejects_empty_board_section( +def test_load_cli_config_allows_empty_board_section( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Board section requires views key when present.""" + """Board section may be present without views or overrides.""" config_path = tmp_path / ".org-cli.yaml" config_path.write_text("board: {}\n", encoding="utf-8") monkeypatch.chdir(config_path.parent) - with pytest.raises(typer.BadParameter, match="Malformed config"): - org.config.app.load_cli_config(["org"]) + loaded = org.config.app.load_cli_config(["org"]) + + assert loaded.board.views == {} def test_load_cli_config_rejects_board_views_not_list( @@ -601,10 +519,10 @@ def test_load_cli_config_rejects_malformed_capture_templates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Malformed capture.templates content should fail config loading.""" + """Malformed tasks.capture.templates content should fail config loading.""" config_path = tmp_path / ".org-cli.yaml" config_path.write_text( - ("capture:\n templates:\n broken:\n file: tasks.org\n"), + ("tasks:\n capture:\n templates:\n broken:\n file: tasks.org\n"), encoding="utf-8", ) @@ -619,6 +537,30 @@ def test_load_cli_config_rejects_unknown_capture_template_fields( ) -> None: """Capture template objects should reject unknown keys.""" config_path = tmp_path / ".org-cli.yaml" + config_path.write_text( + ( + "tasks:\n" + " capture:\n" + " templates:\n" + " quick:\n" + " file: tasks.org\n" + ' content: "* TODO {{title}}"\n' + " target: root\n" + ), + encoding="utf-8", + ) + + monkeypatch.chdir(config_path.parent) + with pytest.raises(typer.BadParameter, match="Malformed config"): + org.config.app.load_cli_config(["org"]) + + +def test_load_cli_config_rejects_top_level_capture_section( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Top-level capture config is no longer supported.""" + config_path = tmp_path / ".org-cli.yaml" config_path.write_text( ( "capture:\n" @@ -626,7 +568,6 @@ def test_load_cli_config_rejects_unknown_capture_template_fields( " quick:\n" " file: tasks.org\n" ' content: "* TODO {{title}}"\n' - " target: root\n" ), encoding="utf-8", ) @@ -643,7 +584,23 @@ def test_load_cli_config_rejects_invalid_custom_section_values( """Custom sections must be object[string -> string].""" config_path = tmp_path / ".org-cli.yaml" config_path.write_text( - "defaults:\n --limit: 7\nfilter:\n custom-filter: 1\n", + "stats:\n summary:\n max_results: 7\nfilter:\n custom-filter: 1\n", + encoding="utf-8", + ) + + monkeypatch.chdir(config_path.parent) + with pytest.raises(typer.BadParameter, match="Malformed config"): + org.config.app.load_cli_config(["org"]) + + +def test_load_cli_config_rejects_flat_stats_section( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Flat stats keys are no longer supported.""" + config_path = tmp_path / ".org-cli.yaml" + config_path.write_text( + "stats:\n max_results: 7\n max_tags: 3\n", encoding="utf-8", ) @@ -655,7 +612,7 @@ def test_load_cli_config_rejects_invalid_custom_section_values( def test_load_cli_config_malformed_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Malformed YAML config should raise a BadParameter error.""" config_path = tmp_path / ".org-cli.yaml" - config_path.write_text("defaults: [1, 2\n", encoding="utf-8") + config_path.write_text("stats: [1, 2\n", encoding="utf-8") monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): @@ -689,215 +646,31 @@ def test_validate_helpers() -> None: assert org.config.app.validate_list_option("--filter-body", ["["]) is None -def test_apply_mapping_and_exclude_config() -> None: - """Mapping/exclude config should support string and inline forms.""" - defaults: dict[str, object] = {} - - assert org.config.app.apply_mapping_config("mapping.json", defaults) is True - assert defaults["mapping"] == "mapping.json" - - defaults.clear() - assert org.config.app.apply_mapping_config({"foo": "bar"}, defaults) is True - assert defaults["mapping_inline"] == {"foo": "bar"} - - defaults.clear() - assert org.config.app.apply_mapping_config("", defaults) is False - - defaults.clear() - assert org.config.app.apply_exclude_config("exclude.txt", defaults) is True - assert defaults["exclude"] == "exclude.txt" - - defaults.clear() - assert org.config.app.apply_exclude_config(["alpha", "beta"], defaults) is True - assert defaults["exclude_inline"] == ["alpha", "beta"] - - -def test_build_default_map_strips_command_specific_values() -> None: - """build_default_map should drop irrelevant defaults per command.""" - defaults = { - "max_results": 10, - "max_tags": 5, - "max_relations": 2, - "max_groups": 1, - "min_group_size": 2, - "use": "tags", - "tags": ["one"], - "groups": ["a,b"], - } - - default_map = org.config.app.build_default_map(defaults) - - summary_defaults = default_map["stats"]["all"] - assert "tags" not in summary_defaults - assert "groups" not in summary_defaults - - summary_defaults = default_map["stats"]["summary"] - assert "max_tags" not in summary_defaults - assert "max_relations" not in summary_defaults - assert "tags" not in summary_defaults - assert "groups" not in summary_defaults - - tags_defaults = default_map["stats"]["tags"] - assert "max_tags" not in tags_defaults - assert "max_groups" not in tags_defaults - assert "min_group_size" not in tags_defaults - assert "groups" not in tags_defaults - - groups_defaults = default_map["stats"]["groups"] - assert "max_tags" not in groups_defaults - assert "tags" not in groups_defaults - - -def test_build_default_map_keeps_ordering_boolean_defaults() -> None: - """Tasks list and board ordering defaults should remain boolean flags.""" - default_map = org.config.app.build_default_map( - { - "order_by_level": True, - "order_by_timestamp_desc": False, - }, - ) - - tasks_list_defaults = default_map["tasks"]["list"] - assert tasks_list_defaults["order_by_level"] is True - assert tasks_list_defaults["order_by_timestamp_desc"] is False - - board_defaults = default_map["board"] - assert board_defaults["order_by_level"] is True - assert board_defaults["order_by_timestamp_desc"] is False - - tasks_query_defaults = default_map["tasks"]["query"] - assert tasks_query_defaults["order_by_level"] is True - assert tasks_query_defaults["order_by_timestamp_desc"] is False - - -def test_build_default_map_strips_board_unsupported_defaults() -> None: - """Board default map should omit list-only output options.""" - default_map = org.config.app.build_default_map( - { - "details": True, - "max_results": 5, - "offset": 2, - "out": "json", - "out_theme": "vim", - "pandoc_args": "--wrap=none", - "order_by_level": True, - }, - ) - - board_defaults = default_map["board"] - assert "details" not in board_defaults - assert board_defaults["max_results"] == 5 - assert board_defaults["offset"] == 2 - assert "out" not in board_defaults - assert "out_theme" not in board_defaults - assert "pandoc_args" not in board_defaults - assert board_defaults["order_by_level"] is True - - -def test_build_default_map_includes_agenda_defaults() -> None: - """Agenda default map should include agenda-specific and shared task options.""" - default_map = org.config.app.build_default_map( - { - "date": "2025-01-15", - "days": 3, - "no_completed": True, - "no_overdue": True, - "no_upcoming": True, - "future_repeats": False, - "max_results": 5, - "offset": 2, - "order_by_level": True, - "out": "json", - "details": True, - "max_tags": 5, - }, - ) - - agenda_defaults = default_map["agenda"] - assert agenda_defaults["date"] == "2025-01-15" - assert agenda_defaults["days"] == 3 - assert agenda_defaults["no_completed"] is True - assert agenda_defaults["no_overdue"] is True - assert agenda_defaults["no_upcoming"] is True - assert agenda_defaults["future_repeats"] is False - assert agenda_defaults["max_results"] == 5 - assert agenda_defaults["offset"] == 2 - assert agenda_defaults["order_by_level"] is True - assert "out" not in agenda_defaults - assert "details" not in agenda_defaults - assert "max_tags" not in agenda_defaults - - -def test_apply_config_defaults_applies_inline_and_append_defaults() -> None: - """apply_config_defaults should fill append and inline values.""" - original_append = dict(org.config.app.CONFIG_APPEND_DEFAULTS) - original_inline = dict(org.config.app.CONFIG_INLINE_DEFAULTS) - try: - org.config.app.CONFIG_APPEND_DEFAULTS.clear() - org.config.app.CONFIG_INLINE_DEFAULTS.clear() - - org.config.app.CONFIG_APPEND_DEFAULTS["filter_tags"] = ["alpha"] - org.config.app.CONFIG_INLINE_DEFAULTS["mapping_inline"] = {"foo": "bar"} - - args = SimpleNamespace(filter_tags=None, mapping_inline=None, exclude_inline=None) - org.config.app.apply_config_defaults(args) - - assert args.filter_tags == ["alpha"] - assert args.mapping_inline == {"foo": "bar"} - assert args.exclude_inline is None - finally: - org.config.app.CONFIG_APPEND_DEFAULTS.clear() - org.config.app.CONFIG_APPEND_DEFAULTS.update(original_append) - org.config.app.CONFIG_INLINE_DEFAULTS.clear() - org.config.app.CONFIG_INLINE_DEFAULTS.update(original_inline) - - -def test_log_applied_config_defaults_logs_all_config_values( +def test_log_command_config_logs_all_config_values( caplog: pytest.LogCaptureFixture, ) -> None: - """Default logging should include all values loaded from config.""" - original_defaults = dict(org.config.app.CONFIG_DEFAULTS) - original_append = dict(org.config.app.CONFIG_APPEND_DEFAULTS) - original_inline = dict(org.config.app.CONFIG_INLINE_DEFAULTS) - try: - org.config.app.CONFIG_DEFAULTS.clear() - org.config.app.CONFIG_APPEND_DEFAULTS.clear() - org.config.app.CONFIG_INLINE_DEFAULTS.clear() - - org.config.app.CONFIG_DEFAULTS.update({"max_results": 10}) - org.config.app.CONFIG_APPEND_DEFAULTS.update({"filter_tags": ["work"]}) - org.config.app.CONFIG_INLINE_DEFAULTS.update({"mapping_inline": {"foo": "bar"}}) - - args = SimpleNamespace( - max_results=10, - filter_tags=["work"], - mapping_inline={"foo": "bar"}, - ) - - with caplog.at_level(logging.INFO, logger="org"): - org.logging.log_applied_config_defaults( - args, - ["stats", "all", "--limit", "20"], - "stats all", - ) - - assert "Config defaults applied (stats all):" in caplog.text - assert "--limit=10" in caplog.text - assert "--filter-tag=['work']" in caplog.text - assert "--mapping=''" in caplog.text - finally: - org.config.app.CONFIG_DEFAULTS.clear() - org.config.app.CONFIG_DEFAULTS.update(original_defaults) - org.config.app.CONFIG_APPEND_DEFAULTS.clear() - org.config.app.CONFIG_APPEND_DEFAULTS.update(original_append) - org.config.app.CONFIG_INLINE_DEFAULTS.clear() - org.config.app.CONFIG_INLINE_DEFAULTS.update(original_inline) + """Config logging should include values loaded for a command.""" + config = org.config.app.AppConfig(config_path=".org-cli.yaml") + config.stats.all.max_results = 10 + config.filter_tags = ["work"] + config.mapping_inline = {"foo": "bar"} + + org.logging.logger.setLevel(logging.INFO) + org.logging.logger.propagate = True + with caplog.at_level(logging.INFO, logger="org"): + org.logging.log_command_config(config, "stats all") + + assert "Command config (stats all):" in caplog.text + assert "filter_tags=['work']" in caplog.text + assert "mapping_inline=''" in caplog.text def test_log_command_arguments_logs_all_values(caplog: pytest.LogCaptureFixture) -> None: """Command argument logging should include all final argument values.""" args = SimpleNamespace(max_results=10, filter_tags=["work"]) + org.logging.logger.setLevel(logging.INFO) + org.logging.logger.propagate = True with caplog.at_level(logging.INFO, logger="org"): org.logging.log_command_arguments(args, "stats all") diff --git a/tests/test_chart_date_spanning.py b/tests/test_chart_date_spanning.py index 96c83d8..0cf3984 100644 --- a/tests/test_chart_date_spanning.py +++ b/tests/test_chart_date_spanning.py @@ -10,11 +10,14 @@ import pytest from typer.testing import CliRunner -from org.cli import app +import org.config.app +from org import cli FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" +EMPTY_CONFIG_PATH = str((FIXTURES_DIR / "empty-config.yaml").resolve()) ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") +app = cli.build_app(org.config.app.AppConfig(config_path=EMPTY_CONFIG_PATH)) class _CliResult(Protocol):