diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c9219f..47f1117 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,184 +1,209 @@ # Changelog -## 0.15.4 +## 0.16 + +### 0.16.0 + +- feat: Add support for `BinaryIO` and `TextIO` for representing preconfigured + file objects to be returned the caller. + +## 0.15 + +### 0.15.4 - feat: Support pydantic v1. -## 0.15.3 +### 0.15.3 - fix: Incorrect error message when using an invalid base class type. -## 0.15.2 +### 0.15.2 - fix: Process `action` inference, taking into account `Optional`/`| None`. -## 0.15.1 +### 0.15.1 - feat: Support explicit context managers as invoke dependencies. -## 0.15.0 +### 0.15.0 - feat: Add docutils directive extension. -## 0.14.3 +## 0.14 + +### 0.14.3 - fix: Handle TypeError in mapping failures -## 0.14.2 +### 0.14.2 - fix: Default bool fields to `False` when omitted. -## 0.14.1 +### 0.14.1 - fix: zsh completion script error -## 0.14.0 +### 0.14.0 - feat: Support functions as interface for simple CLIs. -## 0.13.2 +## 0.13 + +### 0.13.2 - Support "discriminated unions" (i.e. unions which have type distinctions that affect how they're mapped.) -## 0.13.1 +### 0.13.1 - Prefer the field default (if set), if an `Env` is used, but no default is supplied. -## 0.13.0 +### 0.13.0 - Support `yield` in invoke dependencies to support context-manager-like dependencies (for example those which require cleanup). -## 0.12.1 +## 0.12 + +### 0.12.1 - When used in combination with `parse=...`, handle the "optional" part of `T | None` **before** `parse`. -## 0.12.0 +### 0.12.0 - Add `invoke_async` to support async invoke functions and dependencies -## 0.11.6 +## 0.11 + +### 0.11.6 - Disallow certain combinations of apparently incompatible annotations, i.e. sequences and scalars -## 0.11.5 +### 0.11.5 - Fix double dash following an invalid option (with num_args>0) -## 0.11.4 +### 0.11.4 - Fix num_args=-1 on options -## 0.11.3 +### 0.11.3 - Continue to parse docstrings without docstring_parser extra - Fix rendering issue with markdown in docstrings -## 0.11.2 +### 0.11.2 - Make docstring_parser dependency optional - Fix parser error if option followed unknown argument -## 0.11.1 +### 0.11.1 - (Hopefully) Configure rich properly to deal with line overflow when printing terminal escape codes. -## 0.11.0 +### 0.11.0 - Add option for explicit Output object, and add `error_format` option to allow customizing output formatting. -## 0.10.2 +## 0.10 + +### 0.10.2 - Disallow explicit `required=False` in combination with the lack of a field level default. -## 0.10.1 +### 0.10.1 - Fix regression resulting from `value_name`/`field_name` split. -## 0.10.0 +### 0.10.0 - Split Arg `value_name`/`field_name`. `value_name` controls help/error output naming. `field_name` controls the the destination field on the dataclass object. -## 0.9.3 +## 0.9 + +### 0.9.3 - Ensure output of missing required options is deterministically ordered - Output all required options when listing out missing required options - Fix ignore num_args=0 override -## 0.9.2 +### 0.9.2 - Invoke the specific callable subcommand instance being targeted. -## 0.9.1 +### 0.9.1 - Supply the parsed Command instance as an invoke dependency. -## 0.9.0 +### 0.9.0 - Change default backend to `cappa.parser.backend`. To opt into argparse backend, explicitly supply it with `backend=cappa.argparse.backend`. -## 0.8.9 +## 0.8 + +### 0.8.9 - Avoid mutating command when adding meta arguments - Avoid setting global NO_COLOR env var when disabling color -## 0.8.8 +### 0.8.8 - Clean up help text output formatting - Show rich-style help text when using argparse backend -## 0.8.7 +### 0.8.7 - Allow defining custom callable as an `action`. - Improve behavior consuming concatenated short arguments -## 0.8.6 +### 0.8.6 - Improve behavior consuming concatenated short arguments -## 0.8.5 +### 0.8.5 - Add metadata to package distribution -## 0.8.4 +### 0.8.4 - Loosen dependency version specifiers -## 0.8.3 +### 0.8.3 - Fix `Literal["one", "two"]` syntax vs `Literal["one"] | Literal["two"]` - Apply custom completions to already "valid" arguments values - Deduplicate the --completion helptext choices -## 0.8.2 +### 0.8.2 - The command's name should now always translate to the prog name - Explicitly provided argv should now **not** include the prog name -## 0.8.1 +### 0.8.1 - Correct the version long name when long=True. -## 0.8.0 +### 0.8.0 - Implement support for PEP-727 help text inference. -## 0.7.1 +## 0.7 + +### 0.7.1 - Provide clear error message when a version Arg is supplied without a name. - Documentation updates -## 0.7.0 +### 0.7.0 - Adds native cappa parser option vs argparse - Renames render option to "backend" diff --git a/docs/source/annotation.md b/docs/source/annotation.md index f99185d..a631a61 100644 --- a/docs/source/annotation.md +++ b/docs/source/annotation.md @@ -236,3 +236,58 @@ Supplying `foo bar` as the input value should produce `("foo", "bar")`, whereas `list[...]`, `tuple[...]`, `set[...]` all will coerce the parsed sequences of values into their corresponding type. The inner type will be mapped for each item in the sequence. + +### `typing.BinaryIO`/`typing.TextIO` + +[BinaryIO](typing.BinaryIO) and [TextIO](typing.TextIO) are used to produce an +open file handle to the file path given by the CLI input for that argument. + +This can be thought of as equivlent to `open("foo.py")`, given some +`cli --foo foo.py`, which is roughly equivalent to the +[FileType](https://docs.python.org/3/library/argparse.html#argparse.FileType) +feature from `argparse`. + +```python +@dataclass +class Args: + foo: typing.BinaryIO + + +args = cappa.parse(Args) +with args.foo: + print(args.foo.read()) +``` + +```{note} +The supported types do not map to concrete, instantiatable types. This is +important, because neither of these types would otherwise be valid type +annotations in the context of cappa's other inference rules. + +It's also important, because there are no concrete types which correspond +to the underlying types returned by `open()`, which would allow the distinction +between binary and text content. +``` + +#### Controlling `open(...)` options like `mode="w"` + +An un-`Annotated` IO type translates to `open()` with no additional +arguments, with the exception that `BinaryIO` infers `mode='b'`. + +In order to directly customize arguments like `mode`, `buffering`, `encoding`, +and `errors`, a [FileMode](cappa.FileMode) must be annotated on the input +argument. + +```python +import dataclasses +import typing +import cappa + +@dataclasses.dataclass +class Args: + foo: typing.Annotated[typing.BinaryIO, cappa.FileMode(mode='wb', encoding='utf-8')] + bar: typing.Annotated[typing.BinaryIO, cappa.Arg(short=True), cappa.FileMode(mode='wb')] +``` + +As shown, [FileMode](cappa.FileMode) is annotated much like a [Arg](cappa.Arg), +and can be used alongside one depending on the details of the argument in +question. diff --git a/docs/source/api.md b/docs/source/api.md index b3ffa09..7f56968 100644 --- a/docs/source/api.md +++ b/docs/source/api.md @@ -2,7 +2,7 @@ ```{eval-rst} .. autoapimodule:: cappa - :members: parse, invoke, invoke_async, collect, command, Command, Subcommand, Dep, Arg, ArgAction, Exit, Env, Completion, Output + :members: parse, invoke, invoke_async, collect, command, Command, Subcommand, Dep, Arg, ArgAction, Exit, Env, Completion, Output, FileMode ``` ```{eval-rst} diff --git a/docs/source/changelog.md b/docs/source/changelog.md new file mode 100644 index 0000000..6b2cd3d --- /dev/null +++ b/docs/source/changelog.md @@ -0,0 +1,4 @@ +```{include} ../../CHANGELOG.md +:relative-docs: docs/source/ +:relative-images: +``` diff --git a/docs/source/index.md b/docs/source/index.md index 7b90705..26453a8 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -52,4 +52,5 @@ Internals GitHub Repository PyPI +Changelog ``` diff --git a/pyproject.toml b/pyproject.toml index a882309..4754d74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cappa" -version = "0.15.4" +version = "0.16.0" description = "Declarative CLI argument parser." repository = "https://github.com/dancardin/cappa" diff --git a/src/cappa/__init__.py b/src/cappa/__init__.py index d5f9467..5804fe6 100644 --- a/src/cappa/__init__.py +++ b/src/cappa/__init__.py @@ -2,6 +2,7 @@ from cappa.command import Command from cappa.completion.types import Completion from cappa.env import Env +from cappa.file_io import FileMode from cappa.invoke import Dep from cappa.output import Exit, HelpExit, Output from cappa.subcommand import Subcommand, Subcommands @@ -21,6 +22,7 @@ "Dep", "Env", "Exit", + "FileMode", "HelpExit", "Output", "Subcommand", diff --git a/src/cappa/annotation.py b/src/cappa/annotation.py index 1d4fed6..ed3cfe8 100644 --- a/src/cappa/annotation.py +++ b/src/cappa/annotation.py @@ -6,6 +6,7 @@ from typing_inspect import get_origin, is_literal_type +from cappa.file_io import FileMode from cappa.typing import T, backend_type, is_none_type, is_subclass, is_union_type __all__ = [ @@ -32,7 +33,9 @@ ) -def parse_value(annotation: type) -> typing.Callable: +def parse_value( + annotation: type, extra_annotations: typing.Iterable[type] = () +) -> typing.Callable: """Create a value parser for the given annotation. Examples: @@ -65,6 +68,9 @@ def parse_value(annotation: type) -> typing.Callable: if is_subclass(origin, tuple): return parse_tuple(*type_args) + if is_subclass(origin, (typing.TextIO, typing.BinaryIO)): + return parse_file_io(origin, extra_annotations) + return origin @@ -178,6 +184,27 @@ def map_none(value): return map_none +def parse_file_io( + annotation: type, extra_annotations: typing.Iterable[type] +) -> typing.Callable: + def file_io_mapper(value: str): + try: + file_mode: FileMode = next( + typing.cast(FileMode, f) + for f in extra_annotations + if isinstance(f, FileMode) + ) + except StopIteration: + file_mode = FileMode() + + if issubclass(annotation, typing.BinaryIO): + file_mode.mode += "b" + + return file_mode(value) + + return file_io_mapper + + def detect_choices(origin: type, type_args: tuple[type, ...]) -> list[str] | None: if is_subclass(origin, enum.Enum): assert issubclass(origin, enum.Enum) diff --git a/src/cappa/arg.py b/src/cappa/arg.py index 190cd77..4a396a4 100644 --- a/src/cappa/arg.py +++ b/src/cappa/arg.py @@ -134,6 +134,8 @@ class Arg(typing.Generic[T]): field_name: str | MISSING = missing + annotations: list[typing.Type] = dataclasses.field(default_factory=list) + @classmethod def collect( cls, field: Field, type_hint: type, fallback_help: str | None = None @@ -159,7 +161,12 @@ def collect( field_name = infer_field_name(arg, field) default = infer_default(arg, field, annotation) - arg = dataclasses.replace(arg, field_name=field_name, default=default) + arg = dataclasses.replace( + arg, + field_name=field_name, + default=default, + annotations=object_annotation.other_annotations, + ) return arg.normalize(annotation, fallback_help=fallback_help) def normalize( @@ -488,7 +495,7 @@ def infer_parse(arg: Arg, annotation: type) -> Callable: return parse_optional(arg.parse) return arg.parse - return parse_value(annotation) + return parse_value(annotation, extra_annotations=arg.annotations) def infer_help( diff --git a/src/cappa/file_io.py b/src/cappa/file_io.py new file mode 100644 index 0000000..addf89f --- /dev/null +++ b/src/cappa/file_io.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import sys +from dataclasses import dataclass + +import cappa + + +@dataclass +class FileMode: + """Factory for creating file object types. + + Instances of FileType are typically passed as type= arguments to the + ArgumentParser add_argument() method. + + Arguments: + mode: The file mode to use to open the file. Passes directly through to builtin `open()`. + buffering: The file's desired buffer size. Passes directly through to builtin `open()`. + encoding: The file's encoding. Passes directly through to builtin `open()`. + errors: A string indicating how encoding and decoding errors are to + be handled. Passes directly through to builtin `open()`. + + error_code: The exit code to use when an error occurs. Defaults to 1. Note this is **not** + an `open()` argument. + """ + + mode: str = "r" + buffering: int = -1 + encoding: str | None = None + errors: str | None = None + + error_code: int = 1 + + def __call__(self, filename: str): + """Open the given `filename` and return the file handle. + + Supply "-" as the filename to read from stdin or write to stdout, + depending on the chosen `mode`. + """ + # the special argument "-" means sys.std{in,out} + if filename == "-": + if "r" in self.mode: + if "b" in self.mode: + return sys.stdin.buffer + return sys.stdin + + if any(c for c in self.mode if c in {"w", "a", "x"}): + if "b" in self.mode: + return sys.stdout.buffer + return sys.stdout + + raise cappa.Exit( + f"Invalid mode '{self.mode}' with supplied '-' file name.", + code=self.error_code, + ) + + try: + return open(filename, self.mode, self.buffering, self.encoding, self.errors) + except OSError as e: + raise cappa.Exit(f"Cannot open {filename}: {e}", code=self.error_code) diff --git a/src/cappa/typing.py b/src/cappa/typing.py index 28b8305..1fd077c 100644 --- a/src/cappa/typing.py +++ b/src/cappa/typing.py @@ -3,7 +3,7 @@ import sys import types import typing -from dataclasses import dataclass +from dataclasses import dataclass, field from inspect import cleandoc import typing_extensions @@ -34,6 +34,7 @@ class ObjectAnnotation(typing.Generic[T]): obj: T | None annotation: typing.Type doc: str | None = None + other_annotations: list[typing.Type] = field(default_factory=list) def find_type_annotation( @@ -42,18 +43,22 @@ def find_type_annotation( instance = None doc = None + other_annotations = [] if get_origin(type_hint) is Annotated: annotations = type_hint.__metadata__ type_hint = type_hint.__origin__ for annotation in annotations: - if isinstance(annotation, kind): - instance = annotation - break + is_instance = isinstance(annotation, kind) + is_kind = isinstance(annotation, type) and issubclass(annotation, kind) - if isinstance(annotation, type) and issubclass(annotation, kind): - instance = annotation() - break + if instance is None and (is_instance or is_kind): + instance = annotation + if is_kind: + instance = typing.cast(type, annotation)() + break + else: + other_annotations.append(annotation) if doc_type: for annotation in annotations: @@ -63,7 +68,9 @@ def find_type_annotation( else: typing_extensions.assert_never(doc_type) # type: ignore - return ObjectAnnotation(obj=instance, annotation=type_hint, doc=doc) + return ObjectAnnotation( + obj=instance, annotation=type_hint, doc=doc, other_annotations=other_annotations + ) def assert_type(value: typing.Any, typ: type[T]) -> T: diff --git a/tests/arg/test_file_io.py b/tests/arg/test_file_io.py new file mode 100644 index 0000000..596f21f --- /dev/null +++ b/tests/arg/test_file_io.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import io +from contextlib import contextmanager +from dataclasses import dataclass +from typing import BinaryIO, TextIO +from unittest.mock import mock_open, patch + +import cappa +import pytest +from typing_extensions import Annotated + +from tests.utils import backends, parse + + +@contextmanager +def file_content(content: str, mode: str = "r"): + if "b" in mode: + content = content.encode("utf-8") # type: ignore + + mock = mock_open(read_data=content) + with patch("builtins.open", new=mock): + yield mock + + for call in mock.call_args[::2]: + assert call[1] == mode + + +@contextmanager +def stdin(content: str): + with patch("sys.stdin", new=io.TextIOWrapper(io.BytesIO(content.encode("utf-8")))): + yield + + +@backends +def test_text_io_default(backend): + @dataclass + class Foo: + bar: TextIO + + with file_content("wat"): + test = parse(Foo, "foo.py", backend=backend) + + assert test.bar.read() == "wat" + + +@backends +def test_text_io(backend): + @dataclass + class Foo: + bar: Annotated[TextIO, cappa.FileMode(mode="r")] + + with file_content("wat"): + test = parse(Foo, "foo.py", backend=backend) + + assert test.bar.read() == "wat" + + +@backends +def test_text_io_write(backend): + @dataclass + class Foo: + bar: Annotated[BinaryIO, cappa.FileMode(mode="w")] + + with file_content("wat", mode="w"): + test = parse(Foo, "foo.py", backend=backend) + + test.bar.write("wat") + + +@backends +def test_binary_io_default(backend): + @dataclass + class Foo: + bar: BinaryIO + + with file_content("wat", mode="rb"): + test = parse(Foo, "foo.py", backend=backend) + + assert test.bar.read() == b"wat" + + +@backends +def test_binary_io(backend): + @dataclass + class Foo: + bar: Annotated[BinaryIO, cappa.FileMode(mode="rb")] + + with file_content("wat", mode="rb"): + test = parse(Foo, "foo.py", backend=backend) + + assert test.bar.read() == b"wat" + + +@backends +def test_binary_io_write(backend): + @dataclass + class Foo: + bar: Annotated[BinaryIO, cappa.FileMode(mode="wb")] + + with file_content("wat", mode="wb"): + test = parse(Foo, "foo.py", backend=backend) + + test.bar.write(b"wat") + + +@backends +def test_stdin(backend): + @dataclass + class Foo: + bar: Annotated[BinaryIO, cappa.FileMode(mode="r")] + + with stdin("wat"): + test = parse(Foo, "-", backend=backend) + assert test.bar.read() == "wat" + + +@backends +def test_stdin_binary(backend): + @dataclass + class Foo: + bar: Annotated[BinaryIO, cappa.FileMode(mode="rb")] + + with stdin("wat"): + test = parse(Foo, "-", backend=backend) + assert test.bar.read() == b"wat" + + +@backends +def test_stdout(backend, capsys): + @dataclass + class Foo: + bar: Annotated[BinaryIO, cappa.FileMode(mode="w")] + + test = parse(Foo, "-", backend=backend) + test.bar.write("wat") + + out = capsys.readouterr().out + assert out == "wat" + + +@backends +def test_stdout_binary(backend, capsys): + @dataclass + class Foo: + bar: Annotated[BinaryIO, cappa.FileMode(mode="wb")] + + test = parse(Foo, "-", backend=backend) + test.bar.write(b"wat") + + out = capsys.readouterr().out + assert out == "wat" + + +@backends +def test_invalid_mode_dash(backend): + @dataclass + class Foo: + bar: Annotated[BinaryIO, cappa.FileMode(mode="tb")] + + with pytest.raises(cappa.Exit) as e: + parse(Foo, "-", backend=backend) + + assert e.value.message == "Invalid mode 'tb' with supplied '-' file name." + + +@backends +def test_invalid_mode(backend): + @dataclass + class Foo: + bar: Annotated[BinaryIO, cappa.FileMode(mode="tb")] + + with pytest.raises(cappa.Exit) as e: + parse(Foo, "foo.py", backend=backend) + + assert ( + e.value.message + == "Invalid value for 'bar' with value 'foo.py': can't have text and binary mode at once" + ) + + +@backends +def test_open_oserror(backend): + @dataclass + class Foo: + bar: BinaryIO + + with pytest.raises(cappa.Exit) as e: + parse(Foo, "thisshouldneverexist.py", backend=backend) + + assert ( + e.value.message + == "Cannot open thisshouldneverexist.py: [Errno 2] No such file or directory: 'thisshouldneverexist.py'" + )