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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,29 @@ build-backend = "setuptools.build_meta"
# ...
```

Additional configuration
------------------------

You can pass additional configuration to Plux, either via the command line or your project `pyproject.toml`.

### Excluding Python packages during discovery

When [discovering entrypoints](#discovering-entrypoints), Plux will try importing your code to discover Plugins.
Some parts of your codebase might have side effects, or raise errors when imported outside a specific context like some database
migration scripts.

You can ignore those Python packages by specifying the `--exclude` flag to the entrypoints discovery commands (`python -m plux entrypoints` or `python setup.py plugins`).
The option takes a list of comma-separated values that can be paths or package names.

You can also specify those values in the `tool.plux` section of your `pyproject.toml`:

```toml
# ...

[tool.plux]
exclude = ["**/database/alembic*"]
```

Install
-------

Expand Down
1 change: 1 addition & 0 deletions plugin/entrypoint.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Deprecated bindings, use plux imports instead, but you shouldn't use the internals in the first place.
"""

from plux.build.setuptools import find_plugins
from plux.core.entrypoint import (
EntryPoint,
Expand Down
85 changes: 78 additions & 7 deletions plux/build/setuptools.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import shutil
import sys
import typing as t
from fnmatch import fnmatchcase
from importlib.util import find_spec
from pathlib import Path

import setuptools
Expand Down Expand Up @@ -39,20 +41,33 @@ def _ensure_dist_info(self, *args, **kwargs):
class plugins(InfoCommon, setuptools.Command):
description = "Discover plux plugins and store them in .egg_info"

user_options = [
# TODO
user_options: t.ClassVar[t.List[t.Tuple[str, str, str]]] = [
('exclude=', 'e', "a sequence of paths to exclude; '*' can be used as a wildcard in the names. 'foo.*' will exclude all subpackages of 'foo' (but not 'foo' itself)."),
# TODO: add more
]

egg_info: str

def initialize_options(self) -> None:
self.plux_json_path = None
self.exclude = None

def finalize_options(self) -> None:
self.plux_json_path = get_plux_json_path(self.distribution)
self.ensure_string_list('exclude')
if self.exclude is None:
self.exclude = []

# we merge the configuration from the CLI arguments with the configuration read from the `pyproject.toml`
# [tool.plux] section
project_config = read_plux_configuration(self.distribution)
file_exclude = project_config.get("exclude")
if file_exclude:
self.exclude = set(self.exclude) | set(file_exclude)
self.exclude = [_path_to_module(item) for item in self.exclude]

def run(self) -> None:
plugin_finder = PluginFromPackageFinder(DistributionPackageFinder(self.distribution))
plugin_finder = PluginFromPackageFinder(DistributionPackageFinder(self.distribution, exclude=self.exclude))
Copy link
Contributor Author

@bentsku bentsku Aug 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm actually not too sure if we should filter at the Package level or the Module level. Module level might be a bit more flexible, right now we have to ignore the full package and specifying a module name won't work.

It is currently more in line with setuptools.find_packages and the other PluginFinder, but doesn't exactly serve the same purpose.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't thought very long about, but I think either can work. This looks good!

ep = discover_entry_points(plugin_finder)

self.debug_print(f"writing discovered plugins into {self.plux_json_path}")
Expand Down Expand Up @@ -191,6 +206,34 @@ def get_plux_json_path(distribution):
return os.path.join(egg_info_dir, "plux.json")


def read_plux_configuration(distribution) -> dict:
"""
Try reading the [tool.plux] section of the `pyproject.toml` TOML file of the Distribution, and returns it as a
dictionary.
"""
if find_spec("tomllib"):
# the tomllib library is part of the standard library since 3.11
from tomllib import load as load_toml
elif find_spec("tomli"):
# setuptools vendors the tomli library in 3.10
from tomli import load as load_toml
else:
# if we cannot find a TOML lib, we do not return any configuration
return {}

dirs = distribution.package_dir
pyproject_base = (dirs or {}).get("", os.curdir)
pyproject_file = os.path.join(pyproject_base, "pyproject.toml")
if not os.path.exists(pyproject_file):
return {}

with open(pyproject_file, "rb") as file:
pyproject_config = load_toml(file)

tool_table = pyproject_config.get("tool", {})
return tool_table.get("plux", {})


def update_entrypoints(distribution, ep: EntryPointDict):
if distribution.entry_points is None:
distribution.entry_points = {}
Expand Down Expand Up @@ -223,8 +266,8 @@ def load_entry_points(
instead, acting as sort of a cache.

:param where: the file path to look for plugins (default, the current working dir)
:param exclude: the glob patterns to exclude
:param include: the glob patterns to include
:param exclude: the shell style wildcard patterns to exclude
:param include: the shell style wildcard patterns to include
Comment on lines +269 to +270
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as I copied the code for setuptools to find package, I was a bit surprised to see glob here. I checked and the documentation of setuptools mentions the following:

        'exclude' is a sequence of names to exclude; '*' can be used
        as a wildcard in the names.
        When finding packages, 'foo.*' will exclude all subpackages of 'foo'
        (but not 'foo' itself).

        'include' is a sequence of names to include.
        If it's specified, only the named items will be included.
        If it's not specified, all found items will be included.
        'include' can contain shell style wildcard patterns just like
        'exclude'.

So I've used the same wording

:param merge: a map of entry points that are always added
"""
should_read, meta_dir = _should_read_existing_egg_info()
Expand Down Expand Up @@ -375,6 +418,27 @@ def _to_filename(name):
return name.replace("-", "_")


def _path_to_module(path):
"""
Convert a path to a Python module to its module representation
Example: plux/core/test -> plux.core.test
"""
return ".".join(Path(path).with_suffix("").parts)


class _Filter:
"""
Given a list of patterns, create a callable that will be true only if
the input matches at least one of the patterns.
This is from `setuptools.discovery._Filter`
"""
def __init__(self, patterns: t.Iterable[str]):
self._patterns = patterns

def __call__(self, item: str):
return any(fnmatchcase(item, pat) for pat in self._patterns)


class _PackageFinder:
"""
Generate a list of Python packages. How these are generated depends on the implementation.
Expand All @@ -395,13 +459,17 @@ class DistributionPackageFinder(_PackageFinder):
then the ``[tool.setuptools.package.find]`` config will be interpreted, resolved, and then
``distribution.packages`` will contain the resolved packages. This already contains namespace packages
correctly if configured.
You can additionally pass a sequence of values to the `exclude` parameters to provide a list of Unix shell style
patterns that will be matched against the Python packages to exclude them from the resolved packages.
Wildcards are allowed in the patterns with '*'. 'foo.*' will exclude all subpackages of 'foo' (but not 'foo' itself)
"""

def __init__(self, distribution: Distribution):
def __init__(self, distribution: Distribution, exclude: t.Optional[t.Iterable[str]] = None):
Comment on lines -400 to +467
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we update the pydoc of _PackageFinder, to add a couple of sentences + example how the exclude works?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do! However, beware, I've only updated the DistributionPackageFinder, as the regular _PackageFinder and its subclass DefaultPackageFinder does already support exclude via setuptools.find_namespace_packages and setuptools.find_packages (however the CLI does not pass the where/exclude/include options, it has some default values that I did not want to change)

I've found that we are using the DistributionPackageFinder everywhere when building entrypoints, and are only using the DefaultPackageFinder with the PackagePathPluginFinder that is only used in the discover CLI command. I'm not entirely sure why it is not unified, and I did not find documentation of the discover CLI command, so I haven't touched this part of the codebase. It also doesn't seem as important.
I can open a follow-up PR to improve the discover command but not it is worth it at the moment?

self.distribution = distribution
self.exclude = _Filter(exclude or [])

def find_packages(self) -> t.Iterable[str]:
return self.distribution.packages
return self.filter_packages(self.distribution.packages)

@property
def path(self) -> str:
Expand All @@ -415,6 +483,9 @@ def path(self) -> str:
where = "."
return where

def filter_packages(self, packages: t.Iterable[str]) -> t.Iterable[str]:
return [item for item in packages if not self.exclude(item)]


class DefaultPackageFinder(_PackageFinder):
def __init__(self, where=".", exclude=(), include=("*",), namespace=True) -> None:
Expand Down
6 changes: 6 additions & 0 deletions plux/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def entrypoints(args):
dist = get_distribution_from_workdir(os.getcwd())

print("discovering plugins ...")
dist.command_options["plugins"] = {"exclude": ("command line", args.exclude)}
dist.run_command("plugins")

print(f"building {dist.get_name().replace('-', '_')}.egg-info...")
Expand Down Expand Up @@ -82,6 +83,11 @@ def main(argv=None):
generate_parser = subparsers.add_parser(
"entrypoints", help="Discover plugins and generate entry points"
)
generate_parser.add_argument(
"-e",
"--exclude",
help="a sequence of paths to exclude; '*' can be used as a wildcard in the names. 'foo.*' will exclude all subpackages of 'foo' (but not 'foo' itself).",
)
generate_parser.set_defaults(func=entrypoints)

# Subparser for the 'discover' subcommand
Expand Down
Empty file.
6 changes: 6 additions & 0 deletions tests/cli/projects/pyproject/mysrc/subpkg/plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from plugin import Plugin


class MyNestedPlugin(Plugin):
namespace = "plux.test.plugins"
name = "mynestedplugin"
Empty file.
6 changes: 6 additions & 0 deletions tests/cli/projects/setupcfg/mysrc/subpkg/plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from plugin import Plugin


class MyNestedPlugin(Plugin):
namespace = "plux.test.plugins"
name = "mynestedplugin"
82 changes: 80 additions & 2 deletions tests/cli/test_entrypoints.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os.path
import shutil
import sys
from pathlib import Path

import pytest

Expand All @@ -26,10 +26,88 @@ def test_entrypoints(project_name):

with open(os.path.join(project, "test_project.egg-info", "entry_points.txt"), "r") as f:
lines = [line.strip() for line in f.readlines() if line.strip()]
assert lines == ["[plux.test.plugins]", "myplugin = mysrc.plugins:MyPlugin"]
assert lines == [
"[plux.test.plugins]",
"mynestedplugin = mysrc.subpkg.plugins:MyNestedPlugin",
"myplugin = mysrc.plugins:MyPlugin",
]

# make sure that SOURCES.txt contain no absolute paths
with open(os.path.join(project, "test_project.egg-info", "SOURCES.txt"), "r") as f:
lines = [line.strip() for line in f.readlines() if line.strip()]
for line in lines:
assert not line.startswith("/")


@pytest.mark.parametrize("project_name", ["pyproject", "setupcfg"])
def test_entrypoints_exclude(project_name):
if project_name == "pyproject" and sys.version_info < (3, 10):
pytest.xfail("reading pyproject.toml requires Python 3.10 or above")

from plux.__main__ import main

project = os.path.join(os.path.dirname(__file__), "projects", project_name)
os.chdir(project)

sys.path.append(project)
try:
try:
main(["--workdir", project, "entrypoints", "--exclude", "*/subpkg*"])
except SystemExit:
pass
finally:
sys.path.remove(project)

with open(os.path.join(project, "test_project.egg-info", "entry_points.txt"), "r") as f:
lines = [line.strip() for line in f.readlines() if line.strip()]
assert lines == [
"[plux.test.plugins]",
"myplugin = mysrc.plugins:MyPlugin",
]

# make sure that SOURCES.txt contain no absolute paths
with open(os.path.join(project, "test_project.egg-info", "SOURCES.txt"), "r") as f:
lines = [line.strip() for line in f.readlines() if line.strip()]
for line in lines:
assert not line.startswith("/")


def test_entrypoints_exclude_from_pyproject_config(tmp_path):
if sys.version_info < (3, 10):
pytest.xfail("reading pyproject.toml requires Python 3.10 or above")

from plux.__main__ import main

src_project = os.path.join(os.path.dirname(__file__), "projects", "pyproject")
dest_project = os.path.join(str(tmp_path), "pyproject")

shutil.copytree(src_project, dest_project)

pyproject_toml_path = os.path.join(dest_project, "pyproject.toml")

with open(pyproject_toml_path, "a") as fp:
fp.write('\n[tool.plux]\nexclude = ["*.subpkg.*"]\n')

os.chdir(dest_project)

sys.path.append(dest_project)
try:
try:
main(["--workdir", dest_project, "entrypoints"])
except SystemExit:
pass
finally:
sys.path.remove(dest_project)

with open(os.path.join(dest_project, "test_project.egg-info", "entry_points.txt"), "r") as f:
lines = [line.strip() for line in f.readlines() if line.strip()]
assert lines == [
"[plux.test.plugins]",
"myplugin = mysrc.plugins:MyPlugin",
]

# make sure that SOURCES.txt contain no absolute paths
with open(os.path.join(dest_project, "test_project.egg-info", "SOURCES.txt"), "r") as f:
lines = [line.strip() for line in f.readlines() if line.strip()]
for line in lines:
assert not line.startswith("/")