Implement interface for calling PEP 517 hooks - #732
Conversation
This implementation is copied nearly verbatim from the prototype colcon_python_project package.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #732 +/- ##
==========================================
+ Coverage 87.21% 87.42% +0.21%
==========================================
Files 69 73 +4
Lines 4106 4270 +164
Branches 709 726 +17
==========================================
+ Hits 3581 3733 +152
- Misses 414 425 +11
- Partials 111 112 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| extend_ignore=[ | ||
| 'D100', 'D101', 'D102', 'D103', 'D104', 'D105', 'D106', 'D107', | ||
| ], |
There was a problem hiding this comment.
I added D106 to this list. Evidently we've never had a nested class definition in the test sources, but it makes sense to suppress the documentation requirement there as well.
|
This PR provides the low-level AsyncHookCaller tool. For the high-level integration, is the plan to develop PEP 517 package support within the |
KmoM88
left a comment
There was a problem hiding this comment.
LGTM overall. This are just some comments to discuss and get more context about this feature. Great Job!
| 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( |
There was a problem hiding this comment.
Question about the performance of the process-per-hook design, specifically in the new AsyncHookCaller.call_hook method in colcon_core/python_project/hook_caller/__init__.py.
The pattern of spawning a new process for isolation is similar, as far as I understand, to how colcon already handles setup.py. However, PEP 517 build can involve more, smaller hook calls per package.
Given that the number of subprocess invocations per package could increase, is there any concern this might become a noticeable performance issue in very large workspaces? Just curious about your thoughts on the scalability of this pattern.
There was a problem hiding this comment.
It is absolutely a problem, and one of my biggest critiques of PEP 517. In the worst case, we may actually need to build a project just to determine what the name of the package is. :(
Still, a worst-case scenario will only make three subprocess invocations during a typical colcon operation:
- During package identification
- During package build to detect if editable installs can be performed by the backend
- During package build to actually build the wheel
In reality, we'll rarely do (1). For ROS packages, we'll have package.xml to facilitate metadata discovery. For (2), we could probably move to a fallback-style behavior where an editable install is attempted without first looking for the hooks and then ensure that it's communicated properly back to colcon that the hook failed because it doesn't exist, so that colcon can fall back to a traditional wheel instead of an editable one.
tl;dr - process invocations are indeed slow, but I think we can mitigate the fallout. In the end, it's more important to conform to PEP 517:
Frontends should call each hook in a fresh subprocess, so that backends are free to change process global state (such as environment variables or the working directory). A Python library will be provided which frontends can use to easily call hooks this way.
| 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) |
There was a problem hiding this comment.
I think there is a potential deadlock issue here related to IPC handling within the event loop.
Because pickle.dump is synchronous, blocking call, writing to the file descriptor can block the entire event loop if the payload exceeds the OS pipe buffer.
While this might not affect basic use cases with small payloads, it is possible to freeze in complex bigger scenarios (large dependency list or extensive config_settings).
Has a fully non-blocking IPC approach using asyncio streams been considered to make this more robust for advanced use cases? Maybe this is over-optimizing and can be considered if an issue is found and asyncio would remove the low level OS pipes from the previous comment.
There was a problem hiding this comment.
Hmm, that's a good point. I see some options to consider:
- Do the synchronous pickling separately from writing to the socket, the latter of which could be done asynchronously
- Move the pickle-and-write operation to happen after we attempt to spawn the subprocess
- Put a
BufferedWriterbetween the pickle operation and the pipe to the subprocess.
Thoughts?
There was a problem hiding this comment.
After moving from Pickle to JSON, the read/write operations got separated from the (de)serialization anyway, so I went ahead and did (1) in 70c8019.
There was a problem hiding this comment.
Oof, actually it looks like that won't work on Windows. Reverted.
There was a problem hiding this comment.
I just pushed the write to a background thread in 0727c07, which should improve this scenario.
| from colcon_core.subprocess import run | ||
|
|
||
|
|
||
| class _SubprocessTransport(AbstractContextManager): |
There was a problem hiding this comment.
I'm curious about the _SubprocessTransport class here. Manually handling low-level OS pipes and platform-specific inheritance logic (like msvcrt) can be brittle. In colcon_core/subprocess.py the subprocess management is done using asyncio.create_subprocess_exec if I'm not wrong. Wouldn't be posible to simplify the IPC by using asyncio? Leveraging asyncio.create_subprocess_exec with stdin=asyncio.subprocess.PIPE and stdout=asyncio.subprocess.PIPE directly could align better with existing implementation and address the possible deadlock concern in one of the next comments.
There was a problem hiding this comment.
This is a great question. The problem with using stdin/stdout is that PEP 517 doesn't forbid backends from using stdout for normal console messages, so we can't use it as a data pipeline. The purpose of this class is really just to set up additional pipes which behave very similar to stdin/stdout already do, but with a dedicated purpose.
There was a problem hiding this comment.
Oh, I see. Thank you for the clarification.
| capture_output=not have_callbacks) | ||
| process.check_returncode() | ||
| with os.fdopen(os.dup(transport.parent_in), 'rb') as f: | ||
| res = pickle.load(f) |
There was a problem hiding this comment.
About the use of pickle for deserializing the hook's return value. I understand that PEP 517 operates on a trust model, but there is still a potential for remote code execution if a compromised build backend returns a malicious payload. Given that the standard PEP 517 hooks return simple, serializable data types (like strings and lists), would it be an improvement to use a safer serialization format?
Using the json library would completely mitigate this risk without adding new dependencies. Alternatively, if more complex objects are a future concern, a library like jsonpickle could also work (though it would add a new dependency). This might be a great way to make the IPC mechanism even more robust. What are your thoughts on this? Is something we should worry about at the moment?
There was a problem hiding this comment.
Looking at the hooks defined today, they all seem to accept and return only Python primitives. I'm willing to bet that Pickle will be more performant, but we can't limit Pickle to only Python primitives, so switching to another serializer will give us that security (indirectly).
I'll give JSON a shot.
There was a problem hiding this comment.
Updated to use JSON in fb31156. It was a pretty clean change.
cottsay
left a comment
There was a problem hiding this comment.
Thanks for giving this such a thorough look!
| from colcon_core.subprocess import run | ||
|
|
||
|
|
||
| class _SubprocessTransport(AbstractContextManager): |
There was a problem hiding this comment.
This is a great question. The problem with using stdin/stdout is that PEP 517 doesn't forbid backends from using stdout for normal console messages, so we can't use it as a data pipeline. The purpose of this class is really just to set up additional pipes which behave very similar to stdin/stdout already do, but with a dedicated purpose.
| 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( |
There was a problem hiding this comment.
It is absolutely a problem, and one of my biggest critiques of PEP 517. In the worst case, we may actually need to build a project just to determine what the name of the package is. :(
Still, a worst-case scenario will only make three subprocess invocations during a typical colcon operation:
- During package identification
- During package build to detect if editable installs can be performed by the backend
- During package build to actually build the wheel
In reality, we'll rarely do (1). For ROS packages, we'll have package.xml to facilitate metadata discovery. For (2), we could probably move to a fallback-style behavior where an editable install is attempted without first looking for the hooks and then ensure that it's communicated properly back to colcon that the hook failed because it doesn't exist, so that colcon can fall back to a traditional wheel instead of an editable one.
tl;dr - process invocations are indeed slow, but I think we can mitigate the fallout. In the end, it's more important to conform to PEP 517:
Frontends should call each hook in a fresh subprocess, so that backends are free to change process global state (such as environment variables or the working directory). A Python library will be provided which frontends can use to easily call hooks this way.
| 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) |
There was a problem hiding this comment.
Hmm, that's a good point. I see some options to consider:
- Do the synchronous pickling separately from writing to the socket, the latter of which could be done asynchronously
- Move the pickle-and-write operation to happen after we attempt to spawn the subprocess
- Put a
BufferedWriterbetween the pickle operation and the pipe to the subprocess.
Thoughts?
| capture_output=not have_callbacks) | ||
| process.check_returncode() | ||
| with os.fdopen(os.dup(transport.parent_in), 'rb') as f: | ||
| res = pickle.load(f) |
There was a problem hiding this comment.
Looking at the hooks defined today, they all seem to accept and return only Python primitives. I'm willing to bet that Pickle will be more performant, but we can't limit Pickle to only Python primitives, so switching to another serializer will give us that security (indirectly).
I'll give JSON a shot.
We only need to send primitive Python types across this channel today, so JSON should be safer (though less efficient). Assisted-by: Gemini 3.5 Flash
Turns out asynchronous I/O on anonymous pipes just isn't possible on Windows. This reverts commit 70c8019.
Works around the possible deadlock when the pipe buffer gets filled before the consuming process is started.
KmoM88
left a comment
There was a problem hiding this comment.
Thanks so much for your patience and for being so thorough in addressing all the feedback.
No more comments on my side, LGTM. Happy to approve and continue with next phases to adopt PEP 517.
This implementation is copied nearly verbatim from the prototype colcon_python_project package.
This implementation is copied nearly verbatim from the prototype
colcon_python_projectpackage.Here's the spec for PEP 517: https://peps.python.org/pep-0517/
This is the central component for enabling colcon to act as a "build frontend" and the Python package build process will be built around this infrastructure. Additionally, non-PEP 518 packages will extract package metadata during the identification and augmentation phases of the colcon workflow.
To help visualize where this fits in the overall standards-based Python build picture, this hook caller interface will invoke the appropriate backend hook (e.x.
build_wheel) for a package to build a wheel file, and then colcon will install that wheel (after uninstalling any previous wheels for that package) into the install space.The decoration infrastructure allows us to add backend-specific augmentations to hook invocations. Though this interface should allow colcon to support arbitrary backends, we'll implement decorators for "helping" the backends to support broader scenarios (like symlink installs) and optimize the overall process. For example, we'll need to augment
setuptoolsbuilds in order to maintain feature parity with colcon's existing setuptools integration. We'll also use that decorator to move temporary artifacts out of the package source directory.