diff --git a/docs/tools.rst b/docs/tools.rst index 90767d3..17dc98f 100644 --- a/docs/tools.rst +++ b/docs/tools.rst @@ -72,6 +72,22 @@ python package. Use the ``--write`` argument to write the file. +To exclude names exported by the compiled module, add one or more exclude +comments immediately after the autogenerated comment. Names are comma-separated: + +.. code-block:: python + + # autogenerated by 'semiwrap create-imports rpydemo rpydemo._rpydemo' + # - exclude InternalClass, internal_function + # - exclude OtherClass + from ._rpydemo import PublicClass + + __all__ = ["PublicClass"] + +The exclude comments are preserved when ``create-imports --write`` or +``update-init`` regenerates the imports. Names that are not exported by the +compiled module are ignored. + .. _update_init: update-init diff --git a/src/semiwrap/tool/create_imports.py b/src/semiwrap/tool/create_imports.py index 29bf223..e23b756 100644 --- a/src/semiwrap/tool/create_imports.py +++ b/src/semiwrap/tool/create_imports.py @@ -1,4 +1,3 @@ -import inspect import posixpath import subprocess import sys @@ -39,6 +38,39 @@ def _rel(self, base: str, compiled: str) -> str: def run(self, args): self.create(args.base, args.compiled, args.write, args.override_output_file) + def _find_excludes( + self, content: str, begin_statements: typing.Iterable[str] + ) -> typing.Tuple[typing.Set[str], typing.List[str]]: + for begin_stmt in begin_statements: + startidx = content.find(begin_stmt) + if startidx == -1: + continue + + line_end = content.find("\n", startidx) + if line_end == -1: + break + + excludes: typing.Set[str] = set() + exclude_lines = [] + line_start = line_end + 1 + while content.startswith("# - exclude ", line_start): + line_end = content.find("\n", line_start) + if line_end == -1: + line_end = len(content) + + line = content[line_start:line_end] + exclude_lines.append(line) + excludes.update( + name.strip() + for name in line[len("# - exclude ") :].split(",") + if name.strip() + ) + line_start = line_end + 1 + + return excludes, exclude_lines + + return set(), [] + def create( self, base: str, @@ -75,20 +107,8 @@ def create( begin_stmt = f"# autogenerated by 'semiwrap create-imports {base}" old_begin_stmt = f"# autogenerated by 'robotpy-build create-imports {base}" - stmt = inspect.cleandoc( - f""" - - {begin_stmt}{stmt_compiled}' - from {relimport} import {','.join(sorted(ctx.keys()))} - __all__ = ["{'", "'.join(sorted(ctx.keys()))}"] - - """ - ) - - content = subprocess.check_output( - [sys.executable, "-m", "black", "-", "-q"], input=stmt.encode("utf-8") - ).decode("utf-8") - + fcontent = orig_content = fname = None + exclude_lines: typing.List[str] = [] if write: fctx: typing.Dict[str, typing.Any] = {} exec(f"from {base} import __file__", {}, fctx) @@ -97,13 +117,54 @@ def create( with open(fname) as fp: fcontent = orig_content = fp.read() + excludes, exclude_lines = self._find_excludes( + fcontent, (begin_stmt, old_begin_stmt) + ) + for exclude in excludes: + ctx.pop(exclude, None) + + header = f"{begin_stmt}{stmt_compiled}'" + if exclude_lines: + header = "\n".join((header, *exclude_lines)) + names = sorted(ctx.keys()) + stmt_lines = [] + if names: + all_names = '", "'.join(names) + stmt_lines.append(f"from {relimport} import {','.join(names)}") + stmt_lines.append(f'__all__ = ["{all_names}"]') + else: + stmt_lines.append("__all__ = []") + stmt = "\n".join(stmt_lines) + "\n" + + formatted_stmt = subprocess.check_output( + [sys.executable, "-m", "black", "-", "-q"], input=stmt.encode("utf-8") + ).decode("utf-8") + content = f"{header}\n{formatted_stmt}" + + if write: + assert fcontent is not None + assert orig_content is not None + assert fname is not None + # Find the beginning statement idx = startidx = fcontent.find(begin_stmt) if startidx == -1: idx = startidx = fcontent.find(old_begin_stmt) if startidx != -1: - for to_find in ("from", "__all__", "[", "]", "\n"): + idx = fcontent.find("\n", idx) + if idx != -1: + idx += 1 + while fcontent.startswith("# - exclude ", idx): + idx = fcontent.find("\n", idx) + if idx == -1: + break + idx += 1 + + for to_find in ("__all__", "[", "]", "\n"): + if idx == -1: + startidx = -1 + break idx = fcontent.find(to_find, idx) if idx == -1: startidx = -1 diff --git a/tests/requirements.txt b/tests/requirements.txt index 39bb136..638abcf 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,4 +1,6 @@ build +black == 24.8.0; python_version < "3.9" +black == 25.1.0; python_version >= "3.9" pytest hatchling hatch-meson diff --git a/tests/test_create_imports.py b/tests/test_create_imports.py new file mode 100644 index 0000000..99a412a --- /dev/null +++ b/tests/test_create_imports.py @@ -0,0 +1,139 @@ +import sys + +from semiwrap.tool.create_imports import ImportCreator, UpdateInit + + +def test_create_preserves_exclude_directives_and_omits_exports(tmp_path, monkeypatch): + package = tmp_path / "exclude_directives_pkg" + package.mkdir() + exclude_line = "# - exclude Baz, Foo, Unknown" + " " + (package / "__init__.py").write_text( + f"""# autogenerated by 'robotpy-build create-imports exclude_directives_pkg exclude_directives_pkg._compiled' +# - exclude Foo, Bar +{exclude_line} +from ._compiled import Bar, Baz, Foo, FooBar, Keep + +__all__ = ["Bar", "Baz", "Foo", "FooBar", "Keep"] +""" + ) + (package / "_compiled.py").write_text( + """class Foo: + pass + +class Bar: + pass + +class Baz: + pass + +class FooBar: + pass + +class Keep: + pass +""" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + + try: + ImportCreator().create( + "exclude_directives_pkg", + "exclude_directives_pkg._compiled", + True, + None, + ) + finally: + for name in list(sys.modules): + if name == "exclude_directives_pkg" or name.startswith( + "exclude_directives_pkg." + ): + del sys.modules[name] + + assert (package / "__init__.py").read_text() == ( + f"""# autogenerated by 'semiwrap create-imports exclude_directives_pkg exclude_directives_pkg._compiled' +# - exclude Foo, Bar +{exclude_line} +from ._compiled import FooBar, Keep + +__all__ = ["FooBar", "Keep"] +""" + ) + + +def test_create_supports_excluding_every_export(tmp_path, monkeypatch): + package = tmp_path / "exclude_all_pkg" + package.mkdir() + (package / "__init__.py").write_text( + """# autogenerated by 'semiwrap create-imports exclude_all_pkg exclude_all_pkg._compiled' +# - exclude Foo +from ._compiled import Foo + +__all__ = ["Foo"] +""" + ) + (package / "_compiled.py").write_text( + """class Foo: + pass +""" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + + try: + creator = ImportCreator() + creator.create("exclude_all_pkg", "exclude_all_pkg._compiled", True, None) + creator.create("exclude_all_pkg", "exclude_all_pkg._compiled", True, None) + finally: + for name in list(sys.modules): + if name == "exclude_all_pkg" or name.startswith("exclude_all_pkg."): + del sys.modules[name] + + assert (package / "__init__.py").read_text() == ( + """# autogenerated by 'semiwrap create-imports exclude_all_pkg exclude_all_pkg._compiled' +# - exclude Foo +__all__ = [] +""" + ) + + +def test_update_init_applies_exclude_directives(tmp_path, monkeypatch): + (tmp_path / "pyproject.toml").write_text( + """[tool.semiwrap] +update_init = ["update_init_pkg update_init_pkg._compiled"] +""" + ) + package = tmp_path / "update_init_pkg" + package.mkdir() + (package / "__init__.py").write_text( + """# autogenerated by 'semiwrap create-imports update_init_pkg update_init_pkg._compiled' +# - exclude Internal +from ._compiled import Internal, Public + +__all__ = ["Internal", "Public"] +""" + ) + (package / "_compiled.py").write_text( + """class Internal: + pass + +class Public: + pass +""" + ) + monkeypatch.chdir(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + + try: + UpdateInit().run(None) + finally: + for name in list(sys.modules): + if name == "update_init_pkg" or name.startswith("update_init_pkg."): + del sys.modules[name] + + assert (package / "__init__.py").read_text() == ( + """# autogenerated by 'semiwrap create-imports update_init_pkg update_init_pkg._compiled' +# - exclude Internal +from ._compiled import Public + +__all__ = ["Public"] +""" + )