Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/tools.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 77 additions & 16 deletions src/semiwrap/tool/create_imports.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import inspect
import posixpath
import subprocess
import sys
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
139 changes: 139 additions & 0 deletions tests/test_create_imports.py
Original file line number Diff line number Diff line change
@@ -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"]
"""
)
Loading