Skip to content

Commit b2c0761

Browse files
authored
Merge pull request #9 from schubergphilis/feat/choice-option-hints
feat: list valid choices for --strategy/--category errors
2 parents 201d2ad + 518c41e commit b2c0761

3 files changed

Lines changed: 87 additions & 18 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
[![Changelog](https://img.shields.io/badge/changelog-Keep%20a%20Changelog%201.1.0-orange)](https://keepachangelog.com/en/1.1.0/)
1818
[![Documentation: Diátaxis](https://img.shields.io/badge/docs-Di%C3%A1taxis-009485?logo=readthedocs&logoColor=white)](https://diataxis.fr/)
1919
[![Build](https://img.shields.io/badge/build-unknown-lightgrey)](https://github.com/features/actions)
20-
[![Coverage](https://img.shields.io/badge/coverage-62%25-orange)](https://coverage.readthedocs.io/)
20+
[![Coverage](https://img.shields.io/badge/coverage-65%25-orange)](https://coverage.readthedocs.io/)
2121
[![pyscn quality](https://img.shields.io/badge/pyscn-not%20rated-lightgrey)](https://pyscn.ludo-tech.org)
2222

2323
CLI to set overrides idempotently for multiple SLO's

src/datadog_slo_overrides_cli/datadog_slo_overrides_cli.py

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,17 @@
2626
import tomllib
2727
from dataclasses import dataclass
2828
from datetime import datetime
29+
from enum import Enum
2930
from importlib.metadata import PackageNotFoundError
3031
from importlib.metadata import version as package_version
3132
from pathlib import Path
33+
from typing import Any
3234
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
3335

3436
import niquests
3537
import typer
38+
import typer.core
39+
from typer._click.core import Context
3640

3741
__author__ = 'Yorick Hoorneman <yhoorneman@schubergphilis.com>'
3842
__docformat__ = 'google'
@@ -44,19 +48,33 @@
4448
__email__ = '<yhoorneman@schubergphilis.com>'
4549
__status__ = 'Development'
4650

47-
VALID_CATEGORIES = (
48-
'Scheduled Maintenance',
49-
'Outside Business Hours',
50-
'Deployment',
51-
'Other',
52-
)
51+
52+
class Category(str, Enum):
53+
"""Datadog correction categories accepted by ``--category``."""
54+
55+
SCHEDULED_MAINTENANCE = 'Scheduled Maintenance'
56+
OUTSIDE_BUSINESS_HOURS = 'Outside Business Hours'
57+
DEPLOYMENT = 'Deployment'
58+
OTHER = 'Other'
59+
60+
61+
VALID_CATEGORIES = tuple(c.value for c in Category)
62+
5363

5464
# How an existing correction is judged to already satisfy a requested window.
5565
# All three are idempotent (re-running the same command never duplicates).
56-
SKIP_IF_COVERED = 'skip-if-covered' # skip only if request is fully inside an existing one
57-
SKIP_IF_OVERLAP = 'skip-if-overlap' # skip on any overlap (may leave the request partly uncovered)
58-
SKIP_IF_EXACT = 'skip-if-exact' # skip only on an identical window (create even when overlapping)
59-
STRATEGIES = (SKIP_IF_COVERED, SKIP_IF_OVERLAP, SKIP_IF_EXACT)
66+
class Strategy(str, Enum):
67+
"""Skip policy accepted by ``--strategy`` (all idempotent)."""
68+
69+
SKIP_IF_COVERED = 'skip-if-covered' # skip only if request is fully inside an existing one
70+
SKIP_IF_OVERLAP = 'skip-if-overlap' # skip on any overlap (may leave the request partly uncovered)
71+
SKIP_IF_EXACT = 'skip-if-exact' # skip only on an identical window (create even when overlapping)
72+
73+
74+
SKIP_IF_COVERED = Strategy.SKIP_IF_COVERED.value
75+
SKIP_IF_OVERLAP = Strategy.SKIP_IF_OVERLAP.value
76+
SKIP_IF_EXACT = Strategy.SKIP_IF_EXACT.value
77+
STRATEGIES = tuple(s.value for s in Strategy)
6078

6179
# Built-in defaults for the non-secret settings a config file may override.
6280
DEFAULT_SITE = 'datadoghq.eu'
@@ -742,6 +760,41 @@ def execute(cfg: RunConfig) -> int:
742760
)
743761

744762

763+
class ChoiceHintCommand(typer.core.TyperCommand):
764+
"""A command that lists valid values when a choice option is given without one.
765+
766+
Click reports a bare ``Option '--x' requires an argument.`` for a missing value,
767+
raised while parsing before any option callback runs. This intercepts that error
768+
and, when ``--x`` is a choice option, appends the accepted values so the message
769+
is as helpful as the one shown for an *invalid* value.
770+
771+
Detection is by duck typing (``option_name``/``message`` on the error, ``choices``
772+
on the param type) so it survives Typer vendoring its own copy of Click.
773+
"""
774+
775+
def parse_args(self, ctx: Context, args: list[str]) -> list[str]:
776+
"""Parse args, enriching a choice option's missing-value error with its choices."""
777+
try:
778+
return super().parse_args(ctx, args)
779+
except Exception as exc: # re-raised unchanged unless it's a choice option
780+
err: Any = exc
781+
option_name = getattr(err, 'option_name', None)
782+
message = getattr(err, 'message', None)
783+
if option_name and message:
784+
choices = self._choices_for(ctx, option_name)
785+
if choices is not None:
786+
err.message = f'{message} Choose from {", ".join(map(repr, choices))}.'
787+
raise
788+
789+
def _choices_for(self, ctx: Context, option_name: str) -> tuple[str, ...] | None:
790+
"""Return the choices of the choice-typed param exposing ``option_name``, or None."""
791+
for param in self.get_params(ctx):
792+
if option_name in (*param.opts, *param.secondary_opts):
793+
choices = getattr(param.type, 'choices', None)
794+
return tuple(choices) if choices is not None else None
795+
return None
796+
797+
745798
def _version_callback(value: bool) -> None:
746799
"""Print the package version and exit when ``--version`` is passed."""
747800
if not value:
@@ -770,7 +823,7 @@ def _root(
770823
"""
771824

772825

773-
@app.command(no_args_is_help=True)
826+
@app.command(no_args_is_help=True, cls=ChoiceHintCommand)
774827
def run(
775828
tag: list[str] = typer.Option(
776829
None,
@@ -792,9 +845,9 @@ def run(
792845
None,
793846
help='Correction end: ISO 8601 or epoch. Required with --apply unless --rrule.',
794847
),
795-
category: str = typer.Option(
848+
category: Category = typer.Option(
796849
None,
797-
help=f'Correction category, one of {VALID_CATEGORIES} (config/default: {DEFAULT_CATEGORY}).',
850+
help=f'Correction category (config/default: {DEFAULT_CATEGORY}).',
798851
),
799852
description: str = typer.Option('', help='Free-text description stored on the correction.'),
800853
timezone: str = typer.Option(
@@ -806,9 +859,9 @@ def run(
806859
help="iCal RRULE for a recurring correction (e.g. 'FREQ=DAILY;INTERVAL=1').",
807860
),
808861
site: str = typer.Option(None, help=f'Datadog site (config/default: {DEFAULT_SITE}).'),
809-
strategy: str = typer.Option(
862+
strategy: Strategy = typer.Option(
810863
None,
811-
help=f'Skip policy, one of {STRATEGIES} (config/default: {DEFAULT_STRATEGY}). All are idempotent.',
864+
help=f'Skip policy (config/default: {DEFAULT_STRATEGY}). All are idempotent.',
812865
),
813866
api_key: str = typer.Option(
814867
None,
@@ -842,8 +895,8 @@ def run(
842895
app_key=resolved_app_key,
843896
site=site or settings.get('site') or DEFAULT_SITE,
844897
timezone=timezone or settings.get('timezone') or DEFAULT_TIMEZONE,
845-
category=category or settings.get('category') or DEFAULT_CATEGORY,
846-
strategy=strategy or settings.get('strategy') or DEFAULT_STRATEGY,
898+
category=(category.value if category else None) or settings.get('category') or DEFAULT_CATEGORY,
899+
strategy=(strategy.value if strategy else None) or settings.get('strategy') or DEFAULT_STRATEGY,
847900
description=description,
848901
tags=list(tag or []),
849902
tags_query=tags_query,

tests/test_datadog_slo_overrides_cli.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,22 @@ def test_version_option() -> None:
197197
assert 'datadog-slo-overrides' in result.output
198198

199199

200+
def test_run_missing_choice_value_lists_choices() -> None:
201+
"""A choice option given without a value reports the accepted values."""
202+
result = runner.invoke(app, ['run', '--strategy'])
203+
assert result.exit_code != 0
204+
normalized = ' '.join(result.output.split())
205+
assert 'Choose from' in normalized
206+
assert SKIP_IF_COVERED in normalized
207+
208+
209+
def test_run_missing_nonchoice_value_keeps_bare_message() -> None:
210+
"""A non-choice option given without a value keeps Click's plain message."""
211+
result = runner.invoke(app, ['run', '--start'])
212+
assert result.exit_code != 0
213+
assert 'Choose from' not in ' '.join(result.output.split())
214+
215+
200216
def test_load_direnv_env_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
201217
"""A successful ``direnv export json`` is parsed into the exported variables."""
202218
(tmp_path / '.envrc').write_text('export DD_API_KEY=k\n')

0 commit comments

Comments
 (0)