diff --git a/README.md b/README.md index 7fbfe39..e4c1c9b 100644 --- a/README.md +++ b/README.md @@ -41,16 +41,16 @@ Running `python square.py --num 2` will print `The square of your number is 4.0. Tap requires Python 3.10+ -To install Tap from PyPI run: +To install Tap from PyPI run: -``` +```shell pip install typed-argument-parser ```
To install Tap from source, run the following commands: -``` +```shell git clone https://github.com/swansonk14/typed-argument-parser.git cd typed-argument-parser pip install -e . @@ -61,7 +61,7 @@ pip install -e .
To develop this package, install development requirements (in a virtual environment): -``` +```shell python -m pip install -e ".[dev]" ``` @@ -69,7 +69,7 @@ Use [`flake8`](https://github.com/PyCQA/flake8) linting. To run tests, run: -``` +```shell pytest ``` @@ -77,35 +77,53 @@ pytest ## Table of Contents -* [Installation](#installation) -* [Table of Contents](#table-of-contents) -* [Tap is Python-native](#tap-is-python-native) -* [Tap features](#tap-features) - + [Arguments](#arguments) - + [Tap help](#tap-help) - + [Configuring arguments](#configuring-arguments) - - [Adding special argument behavior](#adding-special-argument-behavior) - - [Adding subparsers](#adding-subparsers) - + [Types](#types) - + [Argument processing](#argument-processing) - + [Processing known args](#processing-known-args) - + [Subclassing](#subclassing) - + [Printing](#printing) - + [Reproducibility](#reproducibility) - + [Saving and loading arguments](#saving-and-loading-arguments) - + [Loading from configuration files](#loading-from-configuration-files) -* [tapify](#tapify) - + [Examples](#examples) - - [Function](#function) - - [Class](#class) - - [Dataclass](#dataclass) - + [tapify help](#tapify-help) - + [Command line vs explicit arguments](#command-line-vs-explicit-arguments) - + [Known args](#known-args) -* [Convert to a `Tap` class](#convert-to-a-tap-class) - + [`to_tap_class` examples](#to_tap_class-examples) - - [Simple](#simple) - - [Complex](#complex) +- [Typed Argument Parser (Tap)](#typed-argument-parser-tap) + - [Installation](#installation) + - [Table of Contents](#table-of-contents) + - [Tap is Python-native](#tap-is-python-native) + - [Tap features](#tap-features) + - [Arguments](#arguments) + - [Tap help](#tap-help) + - [Configuring arguments](#configuring-arguments) + - [Adding special argument behavior](#adding-special-argument-behavior) + - [Adding subparsers](#adding-subparsers) + - [Types](#types) + - [`str`, `int`, and `float`](#str-int-and-float) + - [`bool`](#bool) + - [`Optional`](#optional) + - [`List`](#list) + - [`Set`](#set) + - [`Tuple`](#tuple) + - [`Literal`](#literal) + - [`Union`](#union) + - [Complex Types](#complex-types) + - [Ignore Attribute](#ignore-attribute) + - [Argument processing](#argument-processing) + - [Processing known args](#processing-known-args) + - [Subclassing](#subclassing) + - [Printing](#printing) + - [Reproducibility](#reproducibility) + - [Reproducibility info](#reproducibility-info) + - [Conversion Tap to and from dictionaries](#conversion-tap-to-and-from-dictionaries) + - [Saving and loading arguments](#saving-and-loading-arguments) + - [Save](#save) + - [Load](#load) + - [Load from dict](#load-from-dict) + - [Loading from configuration files](#loading-from-configuration-files) + - [tapify](#tapify) + - [Examples](#examples) + - [Function](#function) + - [Class](#class) + - [Dataclass](#dataclass) + - [Pydantic](#pydantic) + - [tapify help](#tapify-help) + - [Command line vs explicit arguments](#command-line-vs-explicit-arguments) + - [Known args](#known-args) + - [Explicit boolean arguments](#explicit-boolean-arguments) + - [Convert to a `Tap` class](#convert-to-a-tap-class) + - [`to_tap_class` examples](#to_tap_class-examples) + - [Simple](#simple) + - [Complex](#complex) ## Tap is Python-native @@ -361,6 +379,33 @@ args = Args().parse_args('--aged_person Tapper,27'.split()) print(f'{args.aged_person.name} is {args.aged_person.age}') # Tapper is 27 ``` +### Ignore Attribute + +Sometimes you may want to define attributes that should not be parsed as command line arguments, but you still want to type them. +This can be achieved by using `TapIgnore`. + +```python +from tap import Tap, TapIgnore + +class MyTap(Tap): + # Regular arguments (will be parsed) + package: str + stars: int = 5 + + # Ignored attributes (will not be parsed) + internal_counter: TapIgnore[int] = 0 + +args = MyTap().parse_args(["--help"]) +``` + +```txt +usage: ipython --package PACKAGE [--stars STARS] [-h] + +options: + --package PACKAGE (str, required) + --stars STARS (int, default=5) + -h, --help show this help message and exit +``` ### Argument processing @@ -863,7 +908,7 @@ Running `python person.py --name Jesse --age 1` prints `My name is Jesse.` follo ### Explicit boolean arguments -Tapify supports explicit specification of boolean arguments (see [bool](#bool) for more details). By default, `explicit_bool=False` and it can be set with `tapify(..., explicit_bool=True)`. +Tapify supports explicit specification of boolean arguments (see [bool](#bool) for more details). By default, `explicit_bool=False` and it can be set with `tapify(..., explicit_bool=True)`. ## Convert to a `Tap` class @@ -903,7 +948,7 @@ if __name__ == "__main__": Running `python main.py --package tap` will print `Project instance: package='tap' is_cool=True stars=5`. -### Complex +#### Complex The general pattern is: diff --git a/src/tap/__init__.py b/src/tap/__init__.py index c4b36c7..aa63d8a 100644 --- a/src/tap/__init__.py +++ b/src/tap/__init__.py @@ -5,13 +5,16 @@ __version__ = "1.11.0" from argparse import ArgumentError, ArgumentTypeError + from tap.tap import Tap from tap.tapify import tapify, to_tap_class +from tap.utils import TapIgnore __all__ = [ "ArgumentError", "ArgumentTypeError", "Tap", + "TapIgnore", "tapify", "to_tap_class", "__version__", diff --git a/src/tap/tap.py b/src/tap/tap.py index 1cd480e..d363662 100644 --- a/src/tap/tap.py +++ b/src/tap/tap.py @@ -8,7 +8,22 @@ from pprint import pformat from shlex import quote, split from types import MethodType, UnionType -from typing import Any, Callable, Iterable, List, Optional, Sequence, Set, Tuple, TypeVar, Union, get_type_hints +from typing import ( + Annotated, + Any, + Callable, + Iterable, + List, + Optional, + Sequence, + Set, + Tuple, + TypeVar, + Union, + get_type_hints, + get_origin as typing_get_origin, + get_args as typing_get_args, +) from typing_inspect import is_literal_type from tap.utils import ( @@ -23,6 +38,7 @@ type_to_str, get_literals, boolean_type, + _TapIgnoreMarker, TupleTypeEnforcer, define_python_object_encoder, as_python_object, @@ -92,6 +108,7 @@ def __init__( # Get annotations from self and all super classes up through tap self._annotations = self._get_annotations() + self._annotations_with_extras = self._get_annotations(include_extras=True) # Set the default description to be the docstring kwargs.setdefault("description", self.__doc__) @@ -300,10 +317,21 @@ def add_argument(self, *name_or_flags, **kwargs) -> None: variable = get_argument_name(*name_or_flags).replace("-", "_") self.argument_buffer[variable] = (name_or_flags, kwargs) + def _is_ignored_argument(self, variable: str, annotations: Optional[dict[str, Any]] = None) -> bool: + annotations = self._annotations_with_extras if annotations is None else annotations + if variable in annotations: + var_type = annotations[variable] + if typing_get_origin(var_type) is Annotated and _TapIgnoreMarker in typing_get_args(var_type): + return True + return False + def _add_arguments(self) -> None: """Add arguments to self in the order they are defined as class variables (so the help string is in order).""" # Add class variables (in order) for variable in self.class_variables: + if self._is_ignored_argument(variable): + continue + if variable in self.argument_buffer: name_or_flags, kwargs = self.argument_buffer[variable] self._add_argument(*name_or_flags, **kwargs) @@ -313,6 +341,8 @@ def _add_arguments(self) -> None: # Add any arguments that were added manually in configure but aren't class variables (in order) for variable, (name_or_flags, kwargs) in self.argument_buffer.items(): if variable not in self.class_variables: + if self._is_ignored_argument(variable): + continue self._add_argument(*name_or_flags, **kwargs) def process_args(self) -> None: @@ -483,7 +513,7 @@ def parse_args( return self @classmethod - def _get_from_self_and_super(cls, extract_func: Callable[[type], dict]) -> dict[str, Any] | dict: + def _get_from_self_and_super(cls, extract_func: Callable[[type], dict]) -> dict[str, Any]: """Returns a dictionary mapping variable names to values. Variables and values are extracted from classes using key starting @@ -531,11 +561,16 @@ def _get_class_dict(self) -> dict[str, Any]: return class_dict - def _get_annotations(self) -> dict[str, Any]: - """Returns a dictionary mapping variable names to their type annotations.""" - return self._get_from_self_and_super(extract_func=lambda super_class: dict(get_type_hints(super_class))) + def _get_annotations(self, include_extras: bool = False) -> dict[str, Any]: + """ + Returns a dictionary mapping variable names to their type annotations. + Keep Annotations and other extras if include_extras is True. + """ + return self._get_from_self_and_super( + extract_func=lambda super_class: dict(get_type_hints(super_class, include_extras=include_extras)) + ) - def _get_class_variables(self) -> dict: + def _get_class_variables(self, exclude_tap_ignores: bool = True) -> dict: """Returns a dictionary mapping class variables names to their additional information.""" class_variable_names = {**self._get_annotations(), **self._get_class_dict()}.keys() @@ -560,7 +595,13 @@ def _get_class_variables(self) -> dict: class_variables = {} for variable in class_variable_names: class_variables[variable] = {"comment": ""} - + if exclude_tap_ignores: + extra_annotations = self._get_annotations(include_extras=True) + return { + var: data + for var, data in class_variables.items() + if not self._is_ignored_argument(var, extra_annotations) + } return class_variables def _get_argument_names(self) -> set[str]: diff --git a/src/tap/utils.py b/src/tap/utils.py index e77c972..9a5c09b 100644 --- a/src/tap/utils.py +++ b/src/tap/utils.py @@ -1,17 +1,20 @@ -from argparse import ArgumentParser, ArgumentTypeError import ast -from base64 import b64encode, b64decode import inspect -from io import StringIO -from json import JSONEncoder import os import pickle import re import subprocess +import sys import textwrap import tokenize +import warnings +from argparse import ArgumentParser, ArgumentTypeError +from base64 import b64decode, b64encode +from io import StringIO +from json import JSONEncoder from types import UnionType from typing import ( + Annotated, Any, Callable, Generator, @@ -19,10 +22,12 @@ Iterator, Literal, Optional, + TypeAlias, + TypeVar, ) -from typing_inspect import get_args as typing_inspect_get_args, get_origin as typing_inspect_get_origin -import warnings +from typing_inspect import get_args as typing_inspect_get_args +from typing_inspect import get_origin as typing_inspect_get_origin NO_CHANGES_STATUS = """nothing to commit, working tree clean""" PRIMITIVES = (str, int, float, bool) @@ -594,3 +599,26 @@ def get_args(tp: Any) -> tuple[type, ...]: return tp.__args__ return typing_inspect_get_args(tp) + + +_T = TypeVar("_T") + + +class _TapIgnoreMarker: + """Internal marker that if present in a type annotation indicates that the argument should be ignored.""" + + +# TODO: Python 3.12 turn this into a TypeAliasType for better IDE tooltips +TapIgnore: TypeAlias = Annotated[_T, _TapIgnoreMarker] +""" +Type annotation to indicate that an argument should be ignored by Tap and not be added as an argument. + +Usage: + from tap import Tap, TapIgnore + + class Args(Tap): + a: int + + # TapIgnore is generic and preserves the type of the ignored attribute + e: TapIgnore[int] = 5 +""" diff --git a/tests/test_tap_ignore.py b/tests/test_tap_ignore.py new file mode 100644 index 0000000..1ebcb66 --- /dev/null +++ b/tests/test_tap_ignore.py @@ -0,0 +1,109 @@ +import unittest +from typing import Annotated +from tap import Tap, TapIgnore + + +class TapIgnoreTests(unittest.TestCase): + def test_tap_ignore(self): + class Args(Tap): + a: int + b: TapIgnore[int] = 2 + c: Annotated[int, "metadata"] = 3 + d: Annotated[TapIgnore[int], "metadata"] = 4 + e: TapIgnore[Annotated[int, "metadata"]] = 5 + + args = Args().parse_args(["--a", "1"]) + + self.assertEqual(args.a, 1) + self.assertEqual(args.b, 2) + self.assertEqual(args.c, 3) + self.assertEqual(args.d, 4) + self.assertEqual(args.e, 5) + + # Check that b is not in the help message (indirectly checking it's not an argument) + # Or check _actions + + actions = {a.dest for a in args._actions} + self.assertIn("a", actions) + self.assertNotIn("b", actions) + self.assertIn("c", actions) + self.assertNotIn("d", actions) + self.assertNotIn("e", actions) + self.assertNotIn("b", args.class_variables) + self.assertNotIn("d", args.class_variables) + self.assertNotIn("e", args.class_variables) + + def test_tap_ignore_no_default(self): + class Args(Tap): + a: int + b: TapIgnore[int] + + # If b is ignored, it shouldn't be required by argparse + # But if it has no default, accessing it might raise AttributeError if not set? + # Tap doesn't set it if it's not in arguments. + + args = Args().parse_args(["--a", "1"]) + self.assertEqual(args.a, 1) + + # b should not be set + with self.assertRaises(AttributeError): + _ = args.b + + def test_tap_ignore_annotated_unwrapping(self): + class Args(Tap): + a: Annotated[int, "some metadata"] + + args = Args().parse_args(["--a", "1"]) + self.assertEqual(args.a, 1) + + def test_tap_ignore_subclass(self): + class BaseArgs(Tap): + base_keep: int + base_ignore: TapIgnore[str] = "ignore_me" + + class SubArgs(BaseArgs): + sub_keep: float + sub_ignore: TapIgnore[bool] = True + + args = SubArgs().parse_args(["--base_keep", "1", "--sub_keep", "2.5"]) + + self.assertEqual(args.base_keep, 1) + self.assertEqual(args.base_ignore, "ignore_me") + self.assertEqual(args.sub_keep, 2.5) + self.assertEqual(args.sub_ignore, True) + + actions = {a.dest for a in args._actions} + self.assertIn("base_keep", actions) + self.assertNotIn("base_ignore", actions) + self.assertIn("sub_keep", actions) + self.assertNotIn("sub_ignore", actions) + self.assertNotIn("b", args.class_variables) + self.assertNotIn("d", args.class_variables) + self.assertNotIn("e", args.class_variables) + + def test_tap_ignore_subclass_override(self): + # Case 1: Override ignored with argument + class Base1(Tap): + a: TapIgnore[int] = 1 + + class Sub1(Base1): + a: int = 2 + + args1 = Sub1().parse_args([]) + self.assertEqual(args1.a, 2) + self.assertIn("a", {a.dest for a in args1._actions}) + + # Case 2: Override argument with ignored + class Base2(Tap): + b: int = 3 + + class Sub2(Base2): + b: TapIgnore[int] = 4 + + args2 = Sub2().parse_args([]) + self.assertEqual(args2.b, 4) + self.assertNotIn("b", {a.dest for a in args2._actions}) + + +if __name__ == "__main__": + unittest.main()