Show HN: Modshim – A new alternative to monkey-patching in Python (github.com)

109 points by joouha ↗ HN
I've invented a new alternative to forking / vendoring / monkey-patching packages in Python.

It's a bit like OverlayFS for Python modules - it allows you write modifications for a target module (lower) in a new module (upper), and have these combined in a new virtual module (mount).

It works by rewriting imports using AST transformations, then running both the lower and upper module's code in the new Python module.

This prevents polluting the global namespace when monkey-patching, and means if you want to make changes to a third-party package, you don't have to take on the maintenance burden of forking, you can package and distribute just your changes.

16 comments

[ 3.2 ms ] story [ 33.1 ms ] thread
This is a nice system. I wonder if modshim can be used to or extended to do hot reload.
I ported the clojure bond library over to python. It’s not quite as neat as the original, but IMO the pseudo-FP style is much more ergonomic.

https://github.com/epgui/pybond

Monkey-patching is altering the code at runtime, not the source code, so from the "alternative to forking and modifying" part it doesn't sound like an alternative to that.

Edit: okay Readme is clear on it and the description does make sense, the short description here just confused me.

The is really awesome and an original and clean solution to a dirty old problem. Kudos.
This feels too much like breaking the guarantee sticker of a vendor code, and if your vendor pushes updates weekly, or daily, you are stuck pushing updates to your shimmed code, which officially becomes "unnamed fork". Even for tests, let's say that they changed an input type, I don't see an improvement in my workflow: I still need to update my "unnamed fork". At least with a fork I get to see the whole git history, including my contributions, and testing with monkey patching helps me create clear setUp and tearDown steps.

When you have a scalpel, you give it to operating doctors during the operation, not to 5 year olds on the street.

The import limitation seems to make this not useful for me. Usually when I am monkeypatching, it's because some code I do not control has a (possibly dynamic) import of the "buggy" module under another name, so I need to make my changes visible under the original name.

If I control all the imports I can usually subclass things myself just fine.

What Python versions have you tested on, OP? Good license choice, by the way.
I've tested it works from 3.9 to 3.14
Of these:

> * Fix bugs in third-party libraries without forking

> * Modify the behavior of existing functions

> * Add new features or options to existing classes

> * Test alternative implementations in an isolated way

only the last sounds close to something you might actually want to do, and then only as a throwaway thing

If you want to change a library, fork it. If you want to change the behavior of existing functions, don't or at least fork first. If you want to add new features to a class, write a new class, or again, at least fork first

For context: one of the several projects I'm working on right now is an automated extraction system for literate-code-style documentation in python. This isn't the place nor time to talk about the why of it (especially compared to other existing similar solutions). The important thing is the how: it uses a temporary import hook to stub out all module imports, allowing the docs generator to process each module independently at runtime, track imports between them, etc. At the end of the process, it also cleans itself up nicely.

Point being, it's a lot of really complicated fiddling with the python import system. And a lesson I have learned is that messing around with import internals in python is extremely tricky to get right. Furthermore, trying to coordinate correctly between modules that do and don't get modified my the hook is very finicky. Not to mention that supply side attacks on the import system itself could be a terrifying attack vector that would be absurdly difficult to detect.

All this to say, I'm not a big fan of monkeypatching, but I know exactly how it behaves, its edge cases, and what to expect if I do it. It is, after all, pretty standard practice to patch things during python unit tests. And even with all its warts, I would prefer patching to import fiddling any day of the week and twice on Sunday.

Feedback for the author: you need to explain the "why" of your project more thoroughly. I'm sure you had a good reason to strike out in this direction, and maybe this is a super elegant solution. But you've failed to explain to me under what circumstances I might also encounter the same problems with patching that you've encountered, in order to explain to me why the risk of an import hook is justified.

Let me explain what inspired me to create modshim:

I've written a Jupyter client for the terminal (euporie), for which I've had to employ monkey-patching of various third-party packages to achieve my goals and avoid forking those packages. For example, I've added terminal graphics support & HTML/CSS rendering to prompt-toolkit (a Python TUI library), and I've changed aiohttp to not raise errors on non-200 http responses. These are things the upstream package maintainers do not want to maintain or will not implement, and likewise I do not want to maintain forks of these packages.

So far I've got away with monkey-patching, but recently I implemented a kernel for euporie which runs on the local interpreter (the same interpreter as the application itself). This means that my patches are exposed to the end user in a REPL, resulting in potentially unexpected behaviour for users when using certain 3rd party packages in Python through euporie. Modshim will allow me to keep my patched versions isolated from the end user.

Additionally, I would like to publish some of my patches to prompt_toolkit as a new package extending prompt_toolkit, as I think they would be useful to others building TUI applications. However, the changes required need to be deeply integrated to work, which would mean forking prompt_toolkit (something I'd like to avoid). modshim will make it possible for me to publish just my modifications.

Perhaps it's a somewhat niche use-case, and modshim is not something most Python users would ever need to use. I just thought it was something novel enough to be of interest to other HN users.

> messing around with import internals in python is extremely tricky to get right

This is true! modshim has been the most complicated thing I've written by some way!

This is the wrong direction. I can say this having written a monkeypatching management library in Ruby a long time ago.
This is interesting, and I'll try to remember to give this a go next time I'm tempted to patch something from the standard library, but...

The README mentions 3 scenarios that this might be preferred over, but not the fourth which I regularly do: Create my own functions/classes that are composed from the unchanged modules. E.g. a request_with_retries function which adds retry logic to requests without the need to monkey patch. I regularly use decorators as well to add things like retries.

For more complex scenarios Modshim might win out, as mentioned in the understated section of the README "Benefits of this Approach":

> Internal Reference Rewriting: This example demonstrates modshim's most powerful feature. By replacing requests.sessions.Session, we automatically upgraded top-level functions like requests.get() because their internal references to Session are redirected to our new class.

> Preservation of the Original Module: The original requests package is not altered. Code in other parts of an application that imports requests directly will continue to use the original Session object without any retry logic, preventing unintended side-effects.

What I think this means is Modshim lets you really get in to the guts of a module (monkey-patch style, giving you god-like powers), while limiting the damage.

I wish a Python package manager would support patching dependencies, like e.g. Cargo allows: https://doc.rust-lang.org/cargo/reference/overriding-depende...

It's much cleaner than monkey patching, and it will more likely detect if an update conflicts with your patching.

I've used it by packaging everything through nix, but that can be cumbersome.