Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
f6ee0c8
Add mechanism to read pyproject.toml specs
cottsay Nov 30, 2022
5170d47
Implement PEP 621 identification and augmentation
cottsay Nov 30, 2022
f863eae
Implement PEP 517 hook caller
cottsay Nov 30, 2022
ab45092
Implement decoration extension for enhancing hook calls
cottsay Nov 30, 2022
7bf6c51
Implement mechanism for reading python METADATA files
cottsay Nov 30, 2022
7ad21f5
Implement PEP 517 identification and augmentation
cottsay Nov 30, 2022
772a78e
Implement basic mechanism for installing Python wheels
cottsay Nov 30, 2022
89a83c0
Implement PEP 517 build task
cottsay Nov 30, 2022
b41a6d5
Implement test task for python.project packages
cottsay Nov 30, 2022
ca43054
Update README.rst to reflect current status
cottsay Nov 30, 2022
bd35220
Add support for in-tree build backends
cottsay Dec 1, 2022
04345c0
Add support for setuptools script directory override
cottsay Dec 1, 2022
74968bc
Fix installation of data files from wheels
cottsay Dec 1, 2022
65c07f9
More robust wheel directory creation
cottsay Dec 1, 2022
ab5b6e3
Add a PEP 517 identification fallback for legacy setuptools
cottsay Dec 1, 2022
98f164d
Implement RECORD file installation for wheels
cottsay Dec 1, 2022
03572cc
Add missing test dependency on pytest-asyncio
cottsay Dec 2, 2022
977e9c5
Forward subprocess stderr in hook tests
cottsay Dec 2, 2022
4614fa9
Forward handles instead of fds on Windows
cottsay Dec 2, 2022
62e608d
Add 'INSTALLER' file to dist-info
cottsay Dec 5, 2022
986ad19
Add hooks and scripts to RECORD file
cottsay Dec 5, 2022
31906fe
Bump colcon-core dependency version
cottsay Mar 27, 2023
a845829
Cache metadata using getter
cottsay Mar 29, 2023
31fed82
Return dist-info directory path from wheel installation
cottsay Mar 29, 2023
50b6bea
Implement hook listing functionality
cottsay Mar 29, 2023
59668e1
Implement PEP 660 editable installs
cottsay Mar 29, 2023
5a1d4a4
Add usage instructions
cottsay Mar 30, 2023
bb175cd
Invoke hook subprocess as script instead of module
cottsay May 19, 2023
767bf43
Add more tests, make the prototype easier to use
cottsay Jun 5, 2023
78d3daa
Close event loops to avoid ResourceWarning
cottsay Jun 6, 2023
4a109bd
Suppress an upstream deprecation
cottsay Jun 6, 2023
fc4e67b
Set the thread's event loop
cottsay Jun 6, 2023
95f266c
Update test requirements
cottsay Jun 6, 2023
d469873
Python 3.6 compatibility
cottsay Jun 6, 2023
21e7d1d
Let the Python 3.6 tests fail for now
cottsay Jun 6, 2023
d0b03c6
Deal with older setuptools versions
cottsay Jun 9, 2023
f613ef9
Use colcon's new_event_loop for async benchmarking
cottsay Jun 9, 2023
7b9acc9
Drop dependency on pytest-asyncio
cottsay Jun 9, 2023
8d761b7
Revert "Let the Python 3.6 tests fail for now"
cottsay Jun 9, 2023
b8ed4e1
How did that get there?
cottsay Jun 9, 2023
dd75630
Add test for build task
cottsay Jun 10, 2023
882a617
Use importlib.metadata to find and uninstall packages
cottsay Jun 10, 2023
8f94b2d
Specifically set thread's event loop prior to benchmarking
cottsay Jun 10, 2023
6e8fcd1
Significantly more robust package removal
cottsay Jun 11, 2023
2c04b4b
Add a slightly hacky way to remove egg-info
cottsay Jun 12, 2023
40a938f
Add (new) usage instructions
cottsay Jun 12, 2023
10cb5b1
...make that a code block
cottsay Jun 12, 2023
d3b72c4
Fixes for Windows
cottsay Jun 13, 2023
e258edb
fixup!
cottsay Jun 18, 2023
8357134
Fix rebuild of packages without top_level.txt
cottsay Jun 27, 2023
5abc103
Fake setuptools options so that test verb works
cottsay Jul 17, 2023
fb123bc
Drop partial hook methods, rally around 'call_hook'
cottsay Aug 29, 2023
a948e97
Quiet pydocstyle warnings during flake8 testing
cottsay Aug 29, 2023
9beeffd
Address a new deprecation in colcon-core
cottsay Aug 29, 2023
bd32ba0
Fix spelling test
cottsay Aug 29, 2023
6a80a77
Suppress pkg_resources deprecation in flake8_import_order
cottsay Aug 29, 2023
257c9fa
Fix priority on ament package augmentation
cottsay Aug 30, 2023
ad96f10
Fix setuptools escape hatch for earlier than v64
cottsay Aug 31, 2023
d48d683
Suppress an irrelevant informational message from setuptools
cottsay Aug 31, 2023
5b2d7e4
Handle hook call without explicit environment vars
cottsay Sep 5, 2023
35206d0
Add markers to linter tests, move linter imports
cottsay Jun 24, 2026
6d0c159
Switch to logger.warning instead of deprecated logger.warn
cottsay Jun 24, 2026
4720cea
Stop treating warnings as errors in tests
cottsay Jun 24, 2026
4eb3cdd
Switch to SPDX license identifier in setup.cfg
cottsay Jun 24, 2026
d07281a
Add a top-level .gitignore
cottsay Jun 24, 2026
be3e6e5
Update target Ubuntu/Debian suites
cottsay Jun 24, 2026
9d6f929
Add '+upstream' suffix to published deb version
cottsay Jun 24, 2026
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
/dist/
/*.egg-info/
/.coverage
/COLCON_IGNORE
.*.swp
__pycache__/
21 changes: 21 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,24 @@ Extensions for `colcon-core <https://github.com/colcon/colcon-core>`_ to work wi

**:warning: This is a prototype package, and isn't ready for widespread use.**
**Please continue using the Python package support in** ``colcon-core`` **and** ``colcon-python-setup-py`` **until these extensions are ready.**

TODO
----
* Graceful and informational error handling
* More tests

Idiosyncrasies
--------------
* For setuptools-based packages, symlink installs do not install data_files.
* For setuptools-based packages, symlink installs always print warnings to stderr with no good way to suppress them.
* For poetry-based packages, dependencies expressed in groups (including 'test') are not discovered (use 'test' extra as a workaround).

Using This Prototype
--------------------

.. code-block::

$ mkdir -p ~/colcon_pyproject_ws/src && cd ~/colcon_pyproject_ws
$ git clone https://github.com/colcon/colcon-python-project.git -b devel src/colcon-python-project
$ colcon build
$ . install/local_setup.sh
Empty file.
41 changes: 41 additions & 0 deletions colcon_python_project/argument_parser/python_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright 2023 Open Source Robotics Foundation, Inc.
# Licensed under the Apache License, Version 2.0

import os

from colcon_core.argument_parser import ArgumentParserDecoratorExtensionPoint
from colcon_core.plugin_system import satisfies_version

try:
from colcon_core.extension_point \
import EXTENSION_BLOCKLIST_ENVIRONMENT_VARIABLE as BLOCK_VAR
except ImportError:
from colcon_core.entry_point \
import EXTENSION_BLOCKLIST_ENVIRONMENT_VARIABLE as BLOCK_VAR


class PythonProjectArgumentParserDecorator(
ArgumentParserDecoratorExtensionPoint
):
"""Disable extensions which conflict with colcon-python-project."""

def __init__(self): # noqa: D107
super().__init__()
satisfies_version(
ArgumentParserDecoratorExtensionPoint.EXTENSION_POINT_VERSION,
'^1.0')

def decorate_argument_parser(self, *, parser): # noqa: D102
blocks = []
if os.environ.get(BLOCK_VAR.name, None):
blocks = os.environ[BLOCK_VAR.name].split(os.pathsep)

if 'colcon_core.package_identification.python' not in blocks:
blocks.append('colcon_core.package_identification.python')

if 'colcon_core.package_identification.python_setup_py' not in blocks:
blocks.append('colcon_core.package_identification.python_setup_py')

os.environ[BLOCK_VAR.name] = os.pathsep.join(blocks)

return parser
138 changes: 138 additions & 0 deletions colcon_python_project/hook_caller/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Copyright 2022 Open Source Robotics Foundation, Inc.
# Licensed under the Apache License, Version 2.0

from contextlib import AbstractContextManager
import os
import pickle
import sys

from colcon_core.subprocess import run
from colcon_python_project.hook_caller import _call_hook
from colcon_python_project.hook_caller import _list_hooks
from colcon_python_project.spec import load_and_cache_spec


class _SubprocessTransport(AbstractContextManager):

def __enter__(self):
self.child_in, self.parent_out = os.pipe()
self.parent_in, self.child_out = os.pipe()

try:
import msvcrt
except ImportError:
os.set_inheritable(self.child_in, True)
self.pass_in = self.child_in
os.set_inheritable(self.child_out, True)
self.pass_out = self.child_out
else:
self.pass_in = msvcrt.get_osfhandle(self.child_in)
os.set_handle_inheritable(self.pass_in, True)
self.pass_out = msvcrt.get_osfhandle(self.child_out)
os.set_handle_inheritable(self.pass_out, True)

return self

def __exit__(self, exc_type, exc_value, traceback):
os.close(self.parent_out)
os.close(self.parent_in)
os.close(self.child_out)
os.close(self.child_in)


class AsyncHookCaller:
"""Calls PEP 517 style hooks asynchronously in a new process."""

def __init__(
self, backend_name, *, project_path=None, env=None,
stdout_callback=None, stderr_callback=None,
):
"""
Initialize a new AsyncHookCaller.

:param backend_name: The name of the PEP 517 build backend.
:param project_path: Path to the project's root directory.
:param env: Environment variables to use when invoking hooks.
:param stdout_callback: Callback for stdout from the hook invocation.
:param stderr_callback: Callback for stderr from the hook invocation.
"""
self._backend_name = backend_name
self._project_path = str(project_path) if project_path else None
self._env = dict(env if env is not None else os.environ)
self._stdout_callback = stdout_callback
self._stderr_callback = stderr_callback

@property
def backend_name(self):
"""Get the name of the backend to call hooks on."""
return self._backend_name

@property
def env(self):
"""Get the environment variables to use when invoking hooks."""
return self._env

async def list_hooks(self):
"""
Call into the backend to list implemented hooks.

This function lists all callable methods on the backend, which may
include more than just the hook names.

:returns: List of hook names.
"""
args = [
sys.executable, _list_hooks.__file__,
self._backend_name]
process = await run(
args, None, self._stderr_callback,
cwd=self._project_path, env=self.env,
capture_output=True)
process.check_returncode()
hook_names = [
line.strip().decode() for line in process.stdout.splitlines()]
return [
hook for hook in hook_names if hook and not hook.startswith('_')]

async def call_hook(self, hook_name, **kwargs):
"""
Call the given hook with given arguments.

:param hook_name: Name of the hook to call.
"""
with _SubprocessTransport() as transport:
args = [
sys.executable, _call_hook.__file__,
self._backend_name, hook_name,
str(transport.pass_in), str(transport.pass_out)]
with os.fdopen(os.dup(transport.parent_out), 'wb') as f:
pickle.dump(kwargs, f)
have_callbacks = self._stdout_callback or self._stderr_callback
process = await run(
args, self._stdout_callback, self._stderr_callback,
cwd=self._project_path, env=self.env, close_fds=False,
capture_output=not have_callbacks)
process.check_returncode()
with os.fdopen(os.dup(transport.parent_in), 'rb') as f:
res = pickle.load(f)
return res


def get_hook_caller(desc, **kwargs):
"""
Create a new AsyncHookCaller instance for a package descriptor.

:param desc: The package descriptor
"""
spec = load_and_cache_spec(desc)
backend_path = spec['build-system'].get('backend-path')
if backend_path:
# TODO(cottsay): This isn't *technically* the beginning of sys.path
# as PEP 517 calls for, but it's pretty darn close.
kwargs['env'] = dict(kwargs.get('env', os.environ))
pythonpath = kwargs['env'].get('PYTHONPATH', '')
kwargs['env']['PYTHONPATH'] = os.pathsep.join(
backend_path + ([pythonpath] if pythonpath else []))
return AsyncHookCaller(
spec['build-system']['build-backend'],
project_path=desc.path, **kwargs)
29 changes: 29 additions & 0 deletions colcon_python_project/hook_caller/_call_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright 2022 Open Source Robotics Foundation, Inc.
# Licensed under the Apache License, Version 2.0

from importlib import import_module
import os
import pickle
import sys


if __name__ == '__main__':
backend_name, hook_name, child_in, child_out = sys.argv[1:]
try:
import msvcrt
except ImportError:
pass
else:
child_in = msvcrt.open_osfhandle(int(child_in), os.O_RDONLY)
child_out = msvcrt.open_osfhandle(int(child_out), 0)
if ':' in backend_name:
backend_module_name, backend_object_name = backend_name.split(':', 2)
backend_module = import_module(backend_module_name)
backend = getattr(backend_module, backend_object_name)
else:
backend = import_module(backend_name)
with os.fdopen(int(child_in), 'rb') as f:
kwargs = pickle.load(f) or {}
res = getattr(backend, hook_name)(**kwargs)
with os.fdopen(int(child_out), 'wb') as f:
pickle.dump(res, f)
19 changes: 19 additions & 0 deletions colcon_python_project/hook_caller/_list_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright 2023 Open Source Robotics Foundation, Inc.
# Licensed under the Apache License, Version 2.0

from importlib import import_module
import sys


if __name__ == '__main__':
backend_name = sys.argv[1]
if ':' in backend_name:
backend_module_name, backend_object_name = backend_name.split(':', 2)
backend_module = import_module(backend_module_name)
backend = getattr(backend_module, backend_object_name)
else:
backend = import_module(backend_name)

for attr in dir(backend):
if callable(getattr(backend, attr)):
print(attr)
134 changes: 134 additions & 0 deletions colcon_python_project/hook_caller_decorator/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Copyright 2022 Open Source Robotics Foundation, Inc.
# Licensed under the Apache License, Version 2.0

import traceback

from colcon_core.logging import colcon_logger
from colcon_core.plugin_system import instantiate_extensions
from colcon_core.plugin_system import order_extensions_by_priority
from colcon_python_project.hook_caller import get_hook_caller

logger = colcon_logger.getChild(__name__)


class HookCallerDecoratorExtensionPoint:
"""
The interface for PEP 517 hook caller decorator extensions.

For each instance the attribute `HOOK_CALLER_DECORATOR_NAME` is being
set to the basename of the entry point registering the extension.
"""

"""The version of the hook caller decorator extension interface."""
EXTENSION_POINT_VERSION = '1.0'

"""The default priority of hook caller decorator extensions."""
PRIORITY = 100

def decorate_hook_caller(self, *, hook_caller):
"""
Decorate a hook caller to perform additional functionality.

This method must be overridden in a subclass.

:param hook_caller: The hook caller
:returns: A decorator
"""
raise NotImplementedError()


def get_hook_caller_extensions():
"""
Get the available hook caller decorator extensions.

The extensions are ordered by their priority and entry point name.

:rtype: OrderedDict
"""
extensions = instantiate_extensions(__name__)
for name, extension in extensions.items():
extension.HOOK_CALLER_DECORATOR_NAME = name
return order_extensions_by_priority(extensions)


def decorate_hook_caller(hook_caller):
"""
Decorate the hook caller using hook caller decorator extensions.

:param hook_caller: The hook caller

:returns: The decorated parser
"""
extensions = get_hook_caller_extensions()
for extension in extensions.values():
logger.log(
1, 'decorate_hook_caller() %s',
extension.HOOK_CALLER_DECORATOR_NAME)
try:
decorated_hook_caller = extension.decorate_hook_caller(
hook_caller=hook_caller)
assert hasattr(decorated_hook_caller, 'call_hook'), \
'decorate_hook_caller() should return something to call hooks'
except Exception as e: # noqa: F841
# catch exceptions raised in decorator extension
exc = traceback.format_exc()
logger.error(
'Exception in hook caller decorator extension '
f"'{extension.HOOK_CALLER_DECORATOR_NAME}': {e}\n{exc}")
# skip failing extension, continue with next one
else:
hook_caller = decorated_hook_caller

return hook_caller


def get_decorated_hook_caller(desc, **kwargs):
"""
Create and decorate a hook caller instance for a package descriptor.

:param desc: The package descriptor
"""
hook_caller = get_hook_caller(desc, **kwargs)
decorated_hook_caller = decorate_hook_caller(hook_caller)
return decorated_hook_caller


# TODO(cottsay): Promote to colcon_core and use in ArgumentParserDecorator
class GenericDecorator:
"""A generic class decorator."""

def __init__(self, decoree, **kwargs):
"""
Create a new decorated class instance.

:param decoree: The instance to decorate
:param **kwargs: The keyword arguments are set as attributes on this
instance
"""
assert '_decoree' not in kwargs
kwargs['_decoree'] = decoree
for k, v in kwargs.items():
self.__dict__[k] = v

def __getattr__(self, name):
"""
Get an attribute from this decorator if it exists or the decoree.

:param str name: The name of the attribute
:returns: The attribute value
"""
return getattr(self.__dict__['_decoree'], name)

def __setattr__(self, name, value):
"""
Set an attribute value on this decorator if it exists or the decoree.

:param str name: The name of the attribute
:param value: The attribute value
"""
# overwrite existing attribute
if name in self.__dict__:
self.__dict__[name] = value
return
# set attribute on decoree
setattr(self.__dict__['_decoree'], name, value)
Loading
Loading