Skip to content

Commit

Permalink
- Added support for postponed evaluation of annotations PEP 563 (#120).
Browse files Browse the repository at this point in the history
- Backport types in python<=3.9 to support PEP 585 and 604 for postponed evaluation of annotations (#120).
  • Loading branch information
mauvilsa committed Jun 6, 2023
1 parent 61804fb commit 9d58014
Show file tree
Hide file tree
Showing 34 changed files with 448 additions and 137 deletions.
47 changes: 32 additions & 15 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ jobs:
python3 -m jsonargparse_tests coverage xml coverage_py$py.xml
pip3 install $(ls ./dist/*.whl)[test,all]
python3 -m jsonargparse_tests coverage xml coverage_py${py}_all.xml
sed -i '/^from __future__ import annotations$/d' jsonargparse_tests/test_*.py
python3 -m jsonargparse_tests coverage xml coverage_py${py}_types.xml
- persist_to_workspace:
root: .
paths:
Expand All @@ -53,9 +55,26 @@ jobs:
docker:
- image: cimg/python:3.7
test-py36:
<<: *test-py38
docker:
- image: cimg/python:3.6
steps:
- attach_workspace:
at: .
- run:
name: Run unit tests
command: |
py=$(python3 --version | sed -r 's|.* 3\.([0-9]+)\..*|3.\1|')
sed -i '/^from __future__ import annotations$/d' jsonargparse_tests/test_*.py
virtualenv -p python3 venv$py
. venv$py/bin/activate
pip3 install $(ls ./dist/*.whl)[test-no-urls]
python3 -m jsonargparse_tests coverage xml coverage_py$py.xml
pip3 install $(ls ./dist/*.whl)[test,all]
python3 -m jsonargparse_tests coverage xml coverage_py${py}_all.xml
- persist_to_workspace:
root: .
paths:
- ./coverage_*.xml
codecov:
docker:
- image: cimg/python:3.8
Expand All @@ -66,28 +85,26 @@ jobs:
- run:
name: Code coverage
command: |
#for py in 3.6 3.7 3.8 3.9 3.10 3.11; do
for py in 3.6 3.7 3.8 3.9 3.10; do
bash <(curl -s https://codecov.io/bash) \
-Z \
-t $CODECOV_TOKEN_JSONARGPARSE \
-F py$py \
-f coverage_py${py}.xml
bash <(curl -s https://codecov.io/bash) \
-Z \
-t $CODECOV_TOKEN_JSONARGPARSE \
-F py${py}_all \
-f coverage_py${py}_all.xml
for py in 3.6 3.7 3.8 3.9 3.10 3.11; do
for suffix in "" "_all" "_types"; do
bash <(curl -s https://codecov.io/bash) \
-Z \
-t $CODECOV_TOKEN_JSONARGPARSE \
-F py${py}${suffix} \
-f coverage_py${py}${suffix}.xml
done
done
publish-pypi:
docker:
- image: mauvilsa/docker-twine:1.11.0
- image: cimg/python:3.10
steps:
- attach_workspace:
at: .
- run:
name: Publish Release on PyPI
command: twine upload --username __token__ --password "${PYPI_TOKEN}" ./dist/*.whl ./dist/*.tar.gz
command: |
pip3 install -U twine
twine upload --username __token__ --password "${PYPI_TOKEN}" ./dist/*.whl ./dist/*.tar.gz
workflows:
version: 2
Expand Down
36 changes: 36 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,42 @@ repos:
pass_filenames: false
verbose: true

- id: test-py36
name: test-py36
entry: bash -c '
if [ "$(which python3.6)" = "" ]; then
echo "$(tput setaf 6) Skipped, python 3.6 not found $(tput sgr0)";
else
TEST_DIR=$(mktemp -d -t _jsonargparse_tests_XXXXXX);
cleanup () { rm -rf "$TEST_DIR"; };
trap cleanup EXIT;
./setup.py bdist_wheel;
python3.6 -m venv "$TEST_DIR/venv36";
. "$TEST_DIR/venv36/bin/activate";
pip3 install "$(ls ./dist/*.whl | tail -n 1)[all,test,test-no-urls]";
pip3 install pytest pytest-subtests;
rsync -a jsonargparse_tests/*.py "$TEST_DIR";
cd "$TEST_DIR";
rm test_backports.py;
sed -i "/^from __future__ import annotations$/d" *.py;
pytest;
fi'
language: system
pass_filenames: false

- id: test-without-future-annotations
name: test-without-future-annotations
entry: bash -c '
TEST_DIR=$(mktemp -d -t _jsonargparse_tests_XXXXXX);
cleanup () { rm -rf "$TEST_DIR"; };
trap cleanup EXIT;
rsync -a jsonargparse_tests/*.py "$TEST_DIR";
cd "$TEST_DIR";
sed -i "/^from __future__ import annotations$/d" *.py;
pytest $TEST_DIR;'
language: system
pass_filenames: false

- id: doctest
name: sphinx-build -M doctest sphinx sphinx/_build sphinx/index.rst
entry: bash -c '
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ The semantic versioning only considers the public API as described in
paths are considered internals and can change in minor and patch releases.


v4.22.0 (2023-06-??)
--------------------

Added
^^^^^
- Support for postponed evaluation of annotations PEP `563
<https://peps.python.org/pep-0563/>`__ ``from __future__ import annotations``
(`#120 <https://github.com/omni-us/jsonargparse/issues/120>`__).
- Backport types in python<=3.9 to support PEP `585
<https://peps.python.org/pep-0585/>`__ and `604
<https://peps.python.org/pep-0604/>`__ for postponed evaluation of annotations
(`#120 <https://github.com/omni-us/jsonargparse/issues/120>`__).


v4.21.2 (2023-06-??)
--------------------

Expand Down
7 changes: 7 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,13 @@ Some notes about this support are:
nesting it is meant child types inside ``List``, ``Dict``, etc. There is no
limit in nesting depth.

- Postponed evaluation of types PEP `563 <https://peps.python.org/pep-0563/>`__
(i.e. ``from __future__ import annotations``) is supported. Also supported on
``python<=3.9`` are PEP `585 <https://peps.python.org/pep-0585/>`__ (i.e.
``list[<type>], dict[<type>], ...`` instead of ``List[<type>], Dict[<type>],
...``) and `604 <https://peps.python.org/pep-0604/>`__ (i.e. ``<type> |
<type>`` instead of ``Union[<type>, <type>]``).

- Fully supported types are: ``str``, ``bool`` (more details in
:ref:`boolean-arguments`), ``int``, ``float``, ``complex``,
``bytes``/``bytearray`` (Base64 encoding), ``List`` (more details in
Expand Down
138 changes: 123 additions & 15 deletions jsonargparse/_backports.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import ast
import inspect
import sys
import textwrap
import typing as T
from collections import namedtuple
from copy import deepcopy
from typing import Dict, FrozenSet, List, Set, Tuple, Type, Union

from .optionals import typing_extensions_import
from .util import unique

var_map = namedtuple("var_map", "name value")
none_map = var_map(name="NoneType", value=type(None))
union_map = var_map(name="Union", value=Union)
union_map = var_map(name="Union", value=T.Union)
pep585_map = {
"dict": var_map(name="Dict", value=Dict),
"frozenset": var_map(name="FrozenSet", value=FrozenSet),
"list": var_map(name="List", value=List),
"set": var_map(name="Set", value=Set),
"tuple": var_map(name="Tuple", value=Tuple),
"type": var_map(name="Type", value=Type),
"dict": var_map(name="Dict", value=T.Dict),
"frozenset": var_map(name="FrozenSet", value=T.FrozenSet),
"list": var_map(name="List", value=T.List),
"set": var_map(name="Set", value=T.Set),
"tuple": var_map(name="Tuple", value=T.Tuple),
"type": var_map(name="Type", value=T.Type),
}


class BackportTypeHints(ast.NodeTransformer):
_typing = __import__("typing")

def visit_Subscript(self, node: ast.Subscript) -> ast.Subscript:
if isinstance(node.value, ast.Name) and node.value.id in pep585_map:
value = self.new_name_load(pep585_map[node.value.id])
Expand All @@ -30,13 +34,13 @@ def visit_Subscript(self, node: ast.Subscript) -> ast.Subscript:
ctx=ast.Load(),
)

def visit_Constant(self, node: ast.Constant) -> Union[ast.Constant, ast.Name]:
def visit_Constant(self, node: ast.Constant) -> T.Union[ast.Constant, ast.Name]:
if node.value is None:
return self.new_name_load(none_map)
return node

def visit_BinOp(self, node: ast.BinOp) -> Union[ast.BinOp, ast.Subscript]:
out_node: Union[ast.BinOp, ast.Subscript] = node
def visit_BinOp(self, node: ast.BinOp) -> T.Union[ast.BinOp, ast.Subscript]:
out_node: T.Union[ast.BinOp, ast.Subscript] = node
if isinstance(node.op, ast.BitOr):
elts: list = []
self.append_union_elts(node.left, elts)
Expand Down Expand Up @@ -66,8 +70,112 @@ def new_name_load(self, var: var_map) -> ast.Name:
def backport(self, input_ast: ast.AST, exec_vars: dict) -> ast.AST:
for key, value in exec_vars.items():
if getattr(value, "__module__", "") == "collections.abc":
if hasattr(self._typing, key):
exec_vars[key] = getattr(self._typing, key)
if hasattr(T, key):
exec_vars[key] = getattr(T, key)
self.exec_vars = exec_vars
backport_ast = self.visit(deepcopy(input_ast))
return ast.fix_missing_locations(backport_ast)


class NamesVisitor(ast.NodeVisitor):
def visit_Name(self, node: ast.Name) -> None:
self.names_found.append(node.id)

def find(self, node: ast.AST) -> list:
self.names_found: T.List[str] = []
self.visit(node)
self.names_found = unique(self.names_found)
return self.names_found


def get_arg_type(arg_ast, aliases):
type_ast = ast.parse("___arg_type___ = 0")
type_ast.body[0].value = arg_ast.annotation
exec_vars = {}
bad_aliases = {}
add_asts = False
for name in NamesVisitor().find(arg_ast.annotation):
value = aliases[name]
if isinstance(value, tuple):
value = value[1]
if isinstance(value, Exception):
bad_aliases[name] = value
elif isinstance(value, ast.AST):
add_asts = True
else:
exec_vars[name] = value
if add_asts:
body = []
for name, (_, value) in aliases.items():
if isinstance(value, ast.AST):
body.append(ast.fix_missing_locations(value))
elif not isinstance(value, Exception):
exec_vars[name] = value
type_ast.body = body + type_ast.body
if "TypeAlias" not in exec_vars:
type_alias = typing_extensions_import("TypeAlias")
if type_alias:
exec_vars["TypeAlias"] = type_alias
if sys.version_info < (3, 10):
backporter = BackportTypeHints()
type_ast = backporter.backport(type_ast, exec_vars)
try:
exec(compile(type_ast, filename="<ast>", mode="exec"), exec_vars, exec_vars)
except NameError as ex:
ex_from = None
for name, alias_exception in bad_aliases.items():
if str(ex) == f"name '{name}' is not defined":
ex_from = alias_exception
break
raise ex from ex_from
return exec_vars["___arg_type___"]


def get_type_hints(obj: T.Any, globalns: T.Optional[dict] = None, localns: T.Optional[dict] = None) -> dict:
try:
return T.get_type_hints(obj, globalns, localns)
except Exception as ex1:
try:
source = textwrap.dedent(inspect.getsource(obj))
tree = ast.parse(source)
assert isinstance(tree, ast.Module) and len(tree.body) == 1
node = tree.body[0]

aliases = __builtins__.copy() # type: ignore
if globalns is None:
globalns = obj.__globals__
aliases.update(globalns)
if localns is not None:
aliases.update(localns)

types = {}
for arg_ast in node.args.args + node.args.kwonlyargs: # type: ignore
if not arg_ast.annotation:
continue
name = arg_ast.arg
types[name] = get_arg_type(arg_ast, aliases)

return types
except Exception as ex2:
raise type(ex1)(f"{repr(ex1)} + {repr(ex2)}") from ex2


def evaluate_postponed_annotations(params, component, logger):
if sys.version_info[:2] == (3, 6) or not (
params and any(isinstance(p.annotation, (str, T.ForwardRef)) for p in params)
):
return
try:
if sys.version_info < (3, 10):
types = get_type_hints(component)
else:
types = T.get_type_hints(component)
except Exception as ex:
logger.debug(f"Unable to evaluate types for {component}", exc_info=ex)
return
for param in params:
if param.name in types:
param_type = types[param.name]
if isinstance(param_type, T.ForwardRef):
param_type = param_type._evaluate(component.__globals__, {})
param.annotation = param_type
Loading

0 comments on commit 9d58014

Please sign in to comment.