2626import tomllib
2727from dataclasses import dataclass
2828from datetime import datetime
29+ from enum import Enum
2930from importlib .metadata import PackageNotFoundError
3031from importlib .metadata import version as package_version
3132from pathlib import Path
33+ from typing import Any
3234from zoneinfo import ZoneInfo , ZoneInfoNotFoundError
3335
3436import niquests
3537import typer
38+ import typer .core
39+ from typer ._click .core import Context
3640
3741__author__ = 'Yorick Hoorneman <yhoorneman@schubergphilis.com>'
3842__docformat__ = 'google'
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.
6280DEFAULT_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+
745798def _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 )
774827def 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 ,
0 commit comments