From ef079d5e08e97c5df2e91b098ff691ebe3788b54 Mon Sep 17 00:00:00 2001 From: "Zack M. Davis" Date: Sun, 15 Mar 2015 14:59:54 -0700 Subject: [PATCH 1/3] implement keyword-only arguments Python 3 supports keyword-only arguments as described in the immortal PEP 3102. This commit implements keyword-only argument support for Hy using a `&kwonly` lambda-list-keyword with semantics analogous how `&optional` arguments are handled: `&kwonly` arguments are either a symbol, in which case the keyword argument so named is mandatory, or a two-element list, the first of which is the symbolic name of the keyword argument and the second of which is its default value if not supplied. If Hy is running under Python 2, attempting to use `&kwonly` args will raise a HyTypeError. This effort is with the aim of resolving #453. --- hy/compiler.py | 45 ++++++++++++++++++++++++---- tests/native_tests/py3_only_tests.hy | 26 ++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/hy/compiler.py b/hy/compiler.py index e20964b..6da7215 100644 --- a/hy/compiler.py +++ b/hy/compiler.py @@ -485,11 +485,13 @@ class HyASTCompiler(object): def _parse_lambda_list(self, exprs): """ Return FunctionDef parameter values from lambda list.""" - ll_keywords = ("&rest", "&optional", "&key", "&kwargs") + ll_keywords = ("&rest", "&optional", "&key", "&kwonly", "&kwargs") ret = Result() args = [] defaults = [] varargs = None + kwonlyargs = [] + kwonlydefaults = [] kwargs = None lambda_keyword = None @@ -506,6 +508,8 @@ class HyASTCompiler(object): lambda_keyword = expr elif expr == "&key": lambda_keyword = expr + elif expr == "&kwonly": + lambda_keyword = expr elif expr == "&kwargs": lambda_keyword = expr else: @@ -554,6 +558,24 @@ class HyASTCompiler(object): args.append(k) ret += self.compile(v) defaults.append(ret.force_expr) + elif lambda_keyword == "&kwonly": + if not PY3: + raise HyTypeError(expr, + "keyword-only arguments are only " + "available under Python 3") + if isinstance(expr, HyList): + if len(expr) != 2: + raise HyTypeError(expr, + "keyword-only args should be bare " + "names or 2-item lists") + k, v = expr + kwonlyargs.append(k) + ret += self.compile(v) + kwonlydefaults.append(ret.force_expr) + else: + k = expr + kwonlyargs.append(k) + kwonlydefaults.append(None) elif lambda_keyword == "&kwargs": if kwargs: raise HyTypeError(expr, @@ -561,7 +583,7 @@ class HyASTCompiler(object): "&kwargs argument") kwargs = str(expr) - return ret, args, defaults, varargs, kwargs + return ret, args, defaults, varargs, kwonlyargs, kwonlydefaults, kwargs def _storeize(self, name, func=None): """Return a new `name` object with an ast.Store() context""" @@ -2026,7 +2048,9 @@ class HyASTCompiler(object): if not isinstance(arglist, HyList): raise HyTypeError(expression, "First argument to (fn) must be a list") - ret, args, defaults, stararg, kwargs = self._parse_lambda_list(arglist) + + (ret, args, defaults, stararg, + kwonlyargs, kwonlydefaults, kwargs) = self._parse_lambda_list(arglist) for i, arg in enumerate(args): if isinstance(arg, HyList): # Destructuring argument @@ -2049,6 +2073,11 @@ class HyASTCompiler(object): lineno=x.start_line, col_offset=x.start_column) for x in args] + kwonlyargs = [ast.arg(arg=ast_str(x), annotation=None, + lineno=x.start_line, + col_offset=x.start_column) + for x in kwonlyargs] + # XXX: Beware. Beware. This wasn't put into the parse lambda # list because it's really just an internal parsing thing. @@ -2065,12 +2094,18 @@ class HyASTCompiler(object): lineno=x.start_line, col_offset=x.start_column) for x in args] + if PY3: + kwonlyargs = [ast.Name(arg=ast_str(x), id=ast_str(x), + ctx=ast.Param(), lineno=x.start_line, + col_offset=x.start_column) + for x in kwonlyargs] + args = ast.arguments( args=args, vararg=stararg, kwarg=kwargs, - kwonlyargs=[], - kw_defaults=[], + kwonlyargs=kwonlyargs, + kw_defaults=kwonlydefaults, defaults=defaults) body = self._compile_branch(expression) diff --git a/tests/native_tests/py3_only_tests.hy b/tests/native_tests/py3_only_tests.hy index 9be88f1..1ad58a7 100644 --- a/tests/native_tests/py3_only_tests.hy +++ b/tests/native_tests/py3_only_tests.hy @@ -10,3 +10,29 @@ (try (raise ValueError :from NameError) (except [e [ValueError]] (assert (= (type (. e __cause__)) NameError))))) + + +(defn test-kwonly [] + "NATIVE: test keyword-only arguments" + ;; keyword-only with default works + (let [[kwonly-foo-default-false (fn [&kwonly [foo false]] foo)]] + (assert (= (apply kwonly-foo-default-false) false)) + (assert (= (apply kwonly-foo-default-false [] {"foo" true}) true))) + ;; keyword-only without default ... + (let [[kwonly-foo-no-default (fn [&kwonly foo] foo)] + [attempt-to-omit-default (try + (kwonly-foo-no-default) + (catch [e [Exception]] e))]] + ;; works + (assert (= (apply kwonly-foo-no-default [] {"foo" "quux"}) "quux")) + ;; raises TypeError with appropriate message if not supplied + (assert (isinstance attempt-to-omit-default TypeError)) + (assert (in "missing 1 required keyword-only argument: 'foo'" + (. attempt-to-omit-default args [0])))) + ;; keyword-only with other arg types works + (let [[function-of-various-args + (fn [a b &rest args &kwonly foo &kwargs kwargs] + (, a b args foo kwargs))]] + (assert (= (apply function-of-various-args + [1 2 3 4] {"foo" 5 "bar" 6 "quux" 7}) + (, 1 2 (, 3 4) 5 {"bar" 6 "quux" 7}))))) From 4b00a84f9f0dbf9333e0a88ef7f28d210649f3cf Mon Sep 17 00:00:00 2001 From: "Zack M. Davis" Date: Sun, 15 Mar 2015 17:22:48 -0700 Subject: [PATCH 2/3] documentation for &kwonly --- docs/language/api.rst | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/language/api.rst b/docs/language/api.rst index 40c8000..625ebc0 100644 --- a/docs/language/api.rst +++ b/docs/language/api.rst @@ -441,6 +441,41 @@ Parameters may have the following keywords in front of them: => (zig-zag-sum 1 2 3 4 5 6) -3 +&kwonly + .. versionadded:: 0.12.0 + + Parameters that can only be called as keywords. Mandatory + keyword-only arguments are declared with the argument's name; + optional keyword-only arguments are declared as a two-element list + containing the argument name followed by the default value (as + with `&optional` above). + + .. code-block:: clj + + => (defn compare [a b &kwonly keyfn [reverse false]] + ... (let [[result (keyfn a b)]] + ... (if (not reverse) + ... result + ... (- result)))) + => (apply compare ["lisp" "python"] + ... {"keyfn" (fn [x y] + ... (reduce - (map (fn [s] (ord (first s))) [x y])))}) + -4 + => (apply compare ["lisp" "python"] + ... {"keyfn" (fn [x y] + ... (reduce - (map (fn [s] (ord (first s))) [x y]))) + ... "reverse" true}) + 4 + + .. code-block:: python + + => (compare "lisp" "python") + Traceback (most recent call last): + File "", line 1, in + TypeError: compare() missing 1 required keyword-only argument: 'keyfn' + + Availability: Python 3. + .. _defn-alias / defun-alias: defn-alias / defun-alias From 8a03e922c1a9f3be265e6326ce2d4b2aac3c9322 Mon Sep 17 00:00:00 2001 From: "Zack M. Davis" Date: Sat, 21 Mar 2015 13:43:28 -0700 Subject: [PATCH 3/3] AST test coverage for &kwonly; `cant_compile` returns exception object Much like how `can_compile` returns the compilation result, which some tests make use of, it may be useful for for `cant_compile` to return the exception object that it caught, for more specific assertions. --- tests/compilers/test_ast.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/compilers/test_ast.py b/tests/compilers/test_ast.py index 7c9fefa..7ef5451 100644 --- a/tests/compilers/test_ast.py +++ b/tests/compilers/test_ast.py @@ -54,11 +54,13 @@ def cant_compile(expr): # error, otherwise it's a compiler bug. assert isinstance(e.expression, HyObject) assert e.message + return e except HyCompileError as e: # Anything that can't be compiled should raise a user friendly # error, otherwise it's a compiler bug. assert isinstance(e.exception, HyTypeError) assert e.traceback + return e def test_ast_bad_type(): @@ -441,10 +443,31 @@ def test_lambda_list_keywords_kwargs(): cant_compile("(fn (x &kwargs xs &kwargs ys) (list x xs ys))") +def test_lambda_list_keywords_kwonly(): + """Ensure we can compile functions with &kwonly if we're on Python + 3, or fail with an informative message on Python 2.""" + kwonly_demo = "(fn [&kwonly a [b 2]] (print a b))" + if PY3: + code = can_compile(kwonly_demo) + for i, kwonlyarg_name in enumerate(('a', 'b')): + assert kwonlyarg_name == code.body[0].args.kwonlyargs[i].arg + assert code.body[0].args.kw_defaults[0] is None + assert code.body[0].args.kw_defaults[1].n == 2 + else: + exception = cant_compile(kwonly_demo) + assert isinstance(exception, HyTypeError) + message, = exception.args + assert message == ("keyword-only arguments are only " + "available under Python 3") + + def test_lambda_list_keywords_mixed(): """ Ensure we can mix them up.""" can_compile("(fn (x &rest xs &kwargs kw) (list x xs kw))") cant_compile("(fn (x &rest xs &fasfkey {bar \"baz\"}))") + if PY3: + can_compile("(fn [x &rest xs &kwargs kwxs &kwonly kwoxs]" + " (list x xs kwxs kwoxs))") def test_ast_unicode_strings():