All right, that's another mastodon thread on this. And here's the code I added and updated tonight. With this, I should be ready to switch the protocol usages from the old to the new and vet them out, and to update the existing protocol implementations.
def resolve_path(path: pathlib.Path) -> pathlib.Path: return path.resolve() @attr.dataclass(frozen=True) class Environment: root: pathlib.Path = attr.field(converter=resolve_path) def bin(self, cmd: str) -> _io.Input[pathlib.Path]: return _io.Input(self.root / "bin" / cmd) class Combinable(typing.Protocol): """Protocol for classes whose instances can be combined.""" def combine(self: TCombinable, __other: TCombinable) -> TCombinable: """Combine two instances of the same type.""" class EnvironmentArgs(typing.Protocol): """Protocol for setting up an environment.""" def setup_environment( self, __root: pathlib.Path ) -> _requirements.Requirements[Environment]: ... class InstallerArgs(Combinable): """Protocol for installing software in an environment.""" def resolve( self, environment: Environment, cmd: str ) -> _requirements.Requirements[_io.Input[pathlib.Path]]: ... def install( env_part: PathWith[EnvironmentArgs], installer_part: PathWith[InstallerArgs], cmd: str, ) -> _requirements.Requirements[_io.Input[pathlib.Path]]: root = installer_part.path / env_part.path environment = yield from env_part.val.setup_environment(root) return (yield from installer_part.val.resolve(environment, cmd)) TCombinable = typing.TypeVar("TCombinable", bound=Combinable) ... @attr.dataclass(frozen=True) class PathWith(typing.Generic[T_co]): path: pathlib.Path = attr.field(validator=_path_is_well_behaved) val: T_co def combine( self: PathWith[TCombinable], __other: PathWith[TCombinable] ) -> PathWith[TCombinable]: """Combine two PathWith instances with compatible wrapped values.""" return PathWith( self.path / __other.path, self.val.combine(__other.val) )
Let's lay this out.
I wrote a simple wrapper around pathlib.Path.resolve because mypy complained when I used the method itself as a converter.
The convert is used by the Environment class I added to represent the environment that commands are run from. It has a fairly obvious method there, to create Input[Path] objects from a command name.
Next up, the protocols. These could end up needing to change when I actually implement them, but they're... probably fine. I might end up coming up with a sensible combine() implementation for EnvironmentArgs instances, or at least some of them, but I'm just not going to bother for now.
The idea of these bits is to factor out the unique logic of the different protocol implementations, and stick them together with the general flow of "set up environment, then install stuff in environment". There is one insight that I didn't have the perspective to realize before today:
Just because two parts of the implementation are coupled at the moment, that does not mean that specifying "what one part requires" and "what one part provides" separately is a duplication. I'm currently expecting both implementations to specify pip independently. This way, if hypothetical implementations that work off of some other installer are created, then putting together mismatched implementations will fail at configuration time, so the user will get immediate feedback rather than cryptic file-not-found errors.
Lastly, if the wrapped value in a PathWith implements Combinable, then so does the PathWith. This will be important later.
All right then. I'm excited to see where this leads me, but right now I'm mostly tired.