Skip to content

Commit 55dfdfc

Browse files
authored
Maintain original fn argument names when fn is def'ined (#1222)
Add support for a new metadata key `^:allow-unsafe-names` which can be applied to single arity functions to disable munging and generating globally safe parameter names. This enables support for using libraries such as FastAPI and others which depend on the raw Python parameter names of functions.
1 parent 4a1ab99 commit 55dfdfc

File tree

6 files changed

+108
-11
lines changed

6 files changed

+108
-11
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Changed
9+
* Single arity functions can be tagged with `^:allow-unsafe-names` to preserve their parameter names (#1212)
810

911
## [v0.3.7]
1012
### Fixed

docs/compiler.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,4 @@ The former can be configured via the environment variable ``BASILISP_USE_DEV_LOG
205205
.. code-block:: bash
206206
207207
export BASILISP_USE_DEV_LOGGER=true
208-
export BASILISP_LOGGING_LEVEL=INFO
208+
export BASILISP_LOGGING_LEVEL=INFO

docs/pyinterop.rst

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Basilisp features myriad options for interfacing with host Python code.
1010
Name Munging
1111
------------
1212

13-
Per Python's `PEP 8 naming conventions <https://www.python.org/dev/peps/pep-0008/#naming-conventions>`_, Python method and function names frequently use ``snake_case``.
13+
Per Python's `PEP 8 naming conventions <https://www.python.org/dev/peps/pep-0008/#naming-conventions>`_, Python method and function and parameter names frequently use ``snake_case``.
1414
Basilisp is certainly capable of reading ``snake_case`` names without any special affordance.
1515
However, Basilisp code (like many Lisps) tends to prefer ``kebab-case`` for word separation.
1616

@@ -22,6 +22,14 @@ When compiled, a ``kebab-case`` identifier always becomes a ``snake_case`` ident
2222

2323
The Basilisp compiler munges *all* unsafe Basilisp identifiers to safe Python identifiers, but other cases are unlikely to appear in standard Python interop usage.
2424

25+
.. note::
26+
27+
By default, the compiler munges function parameter names and makes them globally unique by appending a monotically increasing number suffix to support function inlining. To enable interop with Python libraries that rely on preserved parameter names, you can use the ```^:allow-unsafe-names`` metadata key to retain the (munged) parameter names. This flag only applies to single-arity Basilisp functions.
28+
29+
.. code-block::
30+
31+
(defn ^:allow-unsafe-names afun [a b] ...)
32+
2533
.. _python_builtins:
2634

2735
Python Builtins

src/basilisp/lang/compiler/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class SpecialForm:
3737

3838
SYM_ABSTRACT_META_KEY = kw.keyword("abstract")
3939
SYM_ABSTRACT_MEMBERS_META_KEY = kw.keyword("abstract-members")
40+
SYM_ALLOW_UNSAFE_NAMES_META_KEY = kw.keyword("allow-unsafe-names")
4041
SYM_ASYNC_META_KEY = kw.keyword("async")
4142
SYM_KWARGS_META_KEY = kw.keyword("kwargs")
4243
SYM_PRIVATE_META_KEY = kw.keyword("private")

src/basilisp/lang/compiler/generator.py

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
INTERFACE_KW,
3838
OPERATOR_ALIAS,
3939
REST_KW,
40+
SYM_ALLOW_UNSAFE_NAMES_META_KEY,
4041
SYM_DYNAMIC_META_KEY,
4142
SYM_REDEF_META_KEY,
4243
VAR_IS_PROTOCOL_META_KEY,
@@ -110,7 +111,7 @@
110111
ast_ClassDef,
111112
ast_FunctionDef,
112113
)
113-
from basilisp.lang.interfaces import IMeta, ISeq
114+
from basilisp.lang.interfaces import IMeta, IPersistentMap, ISeq
114115
from basilisp.lang.runtime import CORE_NS
115116
from basilisp.lang.runtime import NS_VAR_NAME as LISP_NS_VAR
116117
from basilisp.lang.runtime import BasilispModule, Var
@@ -649,6 +650,21 @@ def with_lineno_and_col(
649650
return with_lineno_and_col
650651

651652

653+
MetaNode = Union[Const, MapNode]
654+
655+
656+
def _is_allow_unsafe_names(fn_meta_node: Optional[MetaNode]) -> bool:
657+
"""Return True if the `fn_meta_node` has the meta key set to
658+
retain functio parameter names.
659+
660+
"""
661+
return (
662+
bool(fn_meta_node.form.val_at(SYM_ALLOW_UNSAFE_NAMES_META_KEY, False)) is True
663+
if fn_meta_node is not None and isinstance(fn_meta_node.form, IPersistentMap)
664+
else False
665+
)
666+
667+
652668
def _is_dynamic(v: Var) -> bool:
653669
"""Return True if the Var holds a value which should be compiled to a dynamic
654670
Var access."""
@@ -896,7 +912,9 @@ def _def_to_py_ast( # pylint: disable=too-many-locals
896912
assert node.init is not None # silence MyPy
897913
if node.init.op == NodeOp.FN:
898914
assert isinstance(node.init, Fn)
899-
def_ast = _fn_to_py_ast(ctx, node.init, def_name=defsym.name)
915+
def_ast = _fn_to_py_ast(
916+
ctx, node.init, def_name=defsym.name, meta_node=node.meta
917+
)
900918
is_defn = True
901919
elif (
902920
node.init.op == NodeOp.WITH_META
@@ -1622,9 +1640,6 @@ def _synthetic_do_to_py_ast(
16221640
)
16231641

16241642

1625-
MetaNode = Union[Const, MapNode]
1626-
1627-
16281643
def __fn_name(ctx: GeneratorContext, s: Optional[str]) -> str:
16291644
"""Generate a safe Python function name from a function name symbol.
16301645
@@ -1640,16 +1655,27 @@ def __fn_name(ctx: GeneratorContext, s: Optional[str]) -> str:
16401655

16411656

16421657
def __fn_args_to_py_ast(
1643-
ctx: GeneratorContext, params: Iterable[Binding], body: Do
1658+
ctx: GeneratorContext,
1659+
params: Iterable[Binding],
1660+
body: Do,
1661+
allow_unsafe_param_names: bool = True,
16441662
) -> tuple[list[ast.arg], Optional[ast.arg], list[ast.stmt], Iterable[PyASTNode]]:
1645-
"""Generate a list of Python AST nodes from function method parameters."""
1663+
"""Generate a list of Python AST nodes from function method parameters.
1664+
1665+
Parameter names are munged and modified to ensure global
1666+
uniqueness by default. If `allow_unsafe_param_names` is set to
1667+
True, the original munged parameter names are retained instead.
1668+
1669+
"""
16461670
fn_args, varg = [], None
16471671
fn_body_ast: list[ast.stmt] = []
16481672
fn_def_deps: list[PyASTNode] = []
16491673
for binding in params:
16501674
assert binding.init is None, ":fn nodes cannot have binding :inits"
16511675
assert varg is None, "Must have at most one variadic arg"
1652-
arg_name = genname(munge(binding.name))
1676+
arg_name = munge(binding.name)
1677+
if not allow_unsafe_param_names:
1678+
arg_name = genname(arg_name)
16531679

16541680
arg_tag: Optional[ast.expr]
16551681
if (
@@ -1786,8 +1812,14 @@ def __single_arity_fn_to_py_ast( # pylint: disable=too-many-locals
17861812
sym.symbol(lisp_fn_name), py_fn_name, LocalType.FN
17871813
)
17881814

1815+
# check if we should preserve the original parameter names
1816+
allow_unsafe_param_names = _is_allow_unsafe_names(meta_node)
1817+
17891818
fn_args, varg, fn_body_ast, fn_def_deps = __fn_args_to_py_ast(
1790-
ctx, method.params, method.body
1819+
ctx,
1820+
method.params,
1821+
method.body,
1822+
allow_unsafe_param_names=allow_unsafe_param_names,
17911823
)
17921824
meta_deps, meta_decorators = __fn_meta(ctx, meta_node)
17931825

tests/basilisp/compiler_test.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7186,3 +7186,57 @@ def test_yield_as_coroutine_with_multiple_yields(
71867186
assert kw.keyword("coroutine-value") == state.deref()
71877187
assert None is next(coro, None)
71887188
assert kw.keyword("done") == state.deref()
7189+
7190+
7191+
def test_defn_argument_names(lcompile: CompileFn):
7192+
# By default, function parameter names are made globally unique.
7193+
#
7194+
# For example, notice how defn generate parameter names in the
7195+
# pattern <parameter-name>_<monotonically-increasing-number> to
7196+
# ensure global uniqueness.
7197+
code = """
7198+
(defn test_dfn0 [a b] 5)
7199+
"""
7200+
fvar = lcompile(code)
7201+
args = list(inspect.signature(fvar.deref()).parameters.keys())
7202+
assert all(
7203+
re.fullmatch(p, s) for p, s in zip([r"a_\d+", r"b_\d+"], args)
7204+
), f"unexpected argument names {args}"
7205+
7206+
code = """
7207+
(defn ^:allow-unsafe-names test_dfn1a [a b] 5)
7208+
"""
7209+
fvar = lcompile(code)
7210+
args = list(inspect.signature(fvar.deref()).parameters.keys())
7211+
assert args == ["a", "b"]
7212+
7213+
code = """
7214+
(defn ^{:allow-unsafe-names false} test_dfn1b [a b] 5)
7215+
"""
7216+
fvar = lcompile(code)
7217+
args = list(inspect.signature(fvar.deref()).parameters.keys())
7218+
assert all(
7219+
re.fullmatch(p, s) for p, s in zip([r"a_\d+", r"b_\d+"], args)
7220+
), f"unexpected argument names {args}"
7221+
7222+
code = """
7223+
(def ^:allow-unsafe-names test_dfn2 (fn [a b & c] 5))
7224+
"""
7225+
fvar = lcompile(code)
7226+
args = list(inspect.signature(fvar.deref()).parameters.keys())
7227+
assert args == ["a", "b", "c"]
7228+
7229+
code = """
7230+
(def test_dfn3 ^:allow-unsafe-names (fn [a b & c] 5))
7231+
"""
7232+
fvar = lcompile(code)
7233+
args = list(inspect.signature(fvar.deref()).parameters.keys())
7234+
assert args == ["a", "b", "c"]
7235+
7236+
code = """
7237+
^{:kwargs :collect}
7238+
(defn ^:allow-unsafe-names test_dfn4 [a b {:as xyz}] 5)
7239+
"""
7240+
fvar = lcompile(code)
7241+
args = list(inspect.signature(fvar.deref()).parameters.keys())
7242+
assert args == ["a", "b", "xyz"]

0 commit comments

Comments
 (0)