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 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/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(): 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})))))