Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,6 @@ venv.bak/

# don't ignore build package
!plux/build
!tests/build

plux.ini
100 changes: 98 additions & 2 deletions plux/build/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
import importlib
import inspect
import logging
import os
import pkgutil
import typing as t
from fnmatch import fnmatchcase
from pathlib import Path
from types import ModuleType
import os
import pkgutil

from plux import PluginFinder, PluginSpecResolver, PluginSpec

Expand Down Expand Up @@ -56,6 +57,101 @@ def path(self) -> str:
raise NotImplementedError


class SimplePackageFinder(PackageFinder):
"""
A package finder that uses a heuristic to find python packages within a given path. It iterates over all
subdirectories in the path and returns every directory that contains a ``__init__.py`` file. It will include the
root package in the list of results, so if your tree looks like this::

mypkg
├── __init__.py
├── subpkg1
│ ├── __init__.py
│ └── nested_subpkg1
│ └── __init__.py
└── subpkg2
└── __init__.py

and you instantiate SimplePackageFinder("mypkg"), it will return::

[
"mypkg",
"mypkg.subpkg1",
"mypkg.subpkg2",
"mypkg.subpkg1.nested_subpkg1,
]

If the root is not a package, say if you have a ``src/`` layout, and you pass "src/mypkg" as ``path`` it will omit
everything in the preceding path that's not a package.
"""

def __init__(self, path: str):
self._path = path

@property
def path(self) -> str:
return self._path

def find_packages(self) -> t.Iterable[str]:
"""
Find all Python packages in the given path.

Returns a list of package names in the format "pkg", "pkg.subpkg", etc.
"""
path = self.path
if not os.path.isdir(path):
return []

result = []

# Get the absolute path to handle relative paths correctly
abs_path = os.path.abspath(path)

# Check if the root directory is a package
root_is_package = self._looks_like_package(abs_path)

# Walk through the directory tree
for root, dirs, files in os.walk(abs_path):
# Skip directories that don't look like packages
if not self._looks_like_package(root):
continue

# Determine the base directory for relative path calculation
# If the root is not a package, we use the root directory itself as the base
# This ensures we don't include the root directory name in the package names
if root_is_package:
base_dir = os.path.dirname(abs_path)
else:
base_dir = abs_path

# Convert the path to a module name
rel_path = os.path.relpath(root, base_dir)
if rel_path == ".":
# If we're at the root and it's a package, use the directory name
rel_path = os.path.basename(abs_path)

# Skip invalid package names (those containing dots in the path)
if "." in os.path.basename(rel_path):
continue

module_name = self._path_to_module(rel_path)
result.append(module_name)

# Sort the results for consistent output
return sorted(result)

def _looks_like_package(self, path: str) -> bool:
return os.path.exists(os.path.join(path, "__init__.py"))

@staticmethod
def _path_to_module(path: str):
"""
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 PluginFromPackageFinder(PluginFinder):
"""
Finds Plugins from packages that are resolved by the given ``PackageFinder``. Under the hood this uses a
Expand Down
135 changes: 133 additions & 2 deletions plux/build/hatchling.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,137 @@
import logging
import os
import typing as t
from pathlib import Path

from hatchling.builders.config import BuilderConfig
from hatchling.builders.wheel import WheelBuilder

from plux.build.config import EntrypointBuildMode
from plux.build.discovery import PackageFinder, Filter, MatchAllFilter, SimplePackageFinder
from plux.build.project import Project

LOG = logging.getLogger(__name__)


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 HatchlingPackageFinder(PackageFinder):
"""
Uses hatchling's BuilderConfig abstraction to enumerate packages.

TODO: this might not be 100% correct and needs more thorough testing with different scenarios.
"""

builder_config: BuilderConfig
exclude: Filter
include: Filter

def __init__(
self,
builder_config: BuilderConfig,
exclude: list[str] | None = None,
include: list[str] | None = None,
):
self.builder_config = builder_config
self.exclude = Filter(exclude or [])
self.include = Filter(include) if include else MatchAllFilter()

def find_packages(self) -> t.Iterable[str]:
"""
Hatchling-specific algorithm to find packages. Unlike setuptools, hatchling does not provide a package discovery
and only provides a config, so this implements our own heuristic to detect packages and namespace packages.

:return: An Iterable of Packages
"""
# packages in hatch are defined as file system paths, whereas find_packages expects modules
package_paths = list(self.builder_config.packages)

# unlike setuptools, hatch does not return all subpackages by default. instead, these are
# top-level package paths, so we need to recurse and use the ``__init__.py`` heuristic to find
# packages.
all_packages = []
for relative_package_path in package_paths:
package_name = os.path.basename(relative_package_path)

package_path = os.path.join(
self.path, relative_package_path
) # build package path within sources root
if not os.path.isdir(package_path):
continue

is_namespace_package = not os.path.exists(os.path.join(package_path, "__init__.py"))
found_packages = SimplePackageFinder(package_path).find_packages()

if is_namespace_package:
# If it's a namespace package, we need to do two things. First, we include it explicitly as a
# top-level package to the list of found packages. Second, since``SimplePackageFinder`` will not
# consider it a package, it will only return subpackages, so we need to prepend the namespace package
# as a namespace to the package names.
all_packages.append(package_name)
found_packages = [f"{package_name}.{found_package}" for found_package in found_packages]

all_packages.extend(found_packages)

# now do the filtering like the plux PackageFinder API expects
packages = self.filter_packages(all_packages)

# de-duplicate and sort
return sorted(set(packages))

@property
def path(self) -> str:
if not self.builder_config.sources:
where = self.builder_config.root
else:
if self.builder_config.sources[""]:
where = self.builder_config.sources[""]
else:
LOG.warning("plux doesn't know how to resolve multiple sources directories")
where = self.builder_config.root

return where

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


class HatchlingProject(Project):
# TODO: implement me
pass
def __init__(self, workdir: str = None):
super().__init__(workdir)

if self.config.entrypoint_build_mode != EntrypointBuildMode.MANUAL:
raise NotImplementedError(
"Hatchling integration currently only works with entrypoint_build_mode=manual"
)

# FIXME it's unclear whether this will really hold for all configs. most configs we assume will build a wheel,
# and any associated package configuration will come from there. so currently we make the wheel build config
# the source of truth, but we should revisit this once we know more about hatch build configurations.
self.builder = WheelBuilder(workdir)

def create_package_finder(self) -> PackageFinder:
return HatchlingPackageFinder(
self.hatchling_config,
exclude=self.config.exclude,
include=self.config.include,
)

@property
def hatchling_config(self) -> BuilderConfig:
return self.builder.config

def find_plux_index_file(self) -> Path:
# TODO: extend as soon as we support EntryPointBuildMode = build-hook
return Path(self.hatchling_config.root, self.config.entrypoint_static_file)

def find_entry_point_file(self) -> Path:
# TODO: we'll assume that `pip install -e .` is used, and therefore the entrypoints file will be in the
# .dist-info metadata directory
raise NotImplementedError
5 changes: 4 additions & 1 deletion plux/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

def _get_build_backend() -> str | None:
# TODO: should read this from the project configuration instead somehow.
return "hatchling"
try:
import setuptools # noqa

Expand Down Expand Up @@ -45,7 +46,9 @@ def _load_project(args: argparse.Namespace) -> Project:

return SetuptoolsProject(workdir)
elif backend == "hatchling":
raise NotImplementedError("Hatchling is not yet supported as build backend")
from plux.build.hatchling import HatchlingProject

return HatchlingProject(workdir)
else:
raise RuntimeError(
"No supported build backend found. Plux needs either setuptools or hatchling to work."
Expand Down
Empty file added tests/build/__init__.py
Empty file.
Loading
Loading