Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

python3Packages.mkPythonEditablePackage: init #339228

Merged
merged 1 commit into from
Sep 11, 2024
Merged
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
44 changes: 44 additions & 0 deletions doc/languages-frameworks/python.section.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,50 @@ mkPythonMetaPackage {
}
```

#### `mkPythonEditablePackage` function {#mkpythoneditablepackage-function}

When developing Python packages it's common to install packages in [editable mode](https://setuptools.pypa.io/en/latest/userguide/development_mode.html).
Like `mkPythonMetaPackage` this function exists to create an otherwise empty package, but also containing a pointer to an impure location outside the Nix store that can be changed without rebuilding.

The editable root is passed as a string. Normally `.pth` files contains absolute paths to the mutable location. This isn't always ergonomic with Nix, so environment variables are expanded at runtime.
This means that a shell hook setting up something like a `$REPO_ROOT` variable can be used as the relative package root.

As an implementation detail, the [PEP-518](https://peps.python.org/pep-0518/) `build-system` specified won't be used, but instead the editable package will be built using [hatchling](https://pypi.org/project/hatchling/).
The `build-system`'s provided will instead become runtime dependencies of the editable package.

Note that overriding packages deeper in the dependency graph _can_ work, but it's not the primary use case and overriding existing packages can make others break in unexpected ways.

adisbladis marked this conversation as resolved.
Show resolved Hide resolved
``` nix
{ pkgs ? import <nixpkgs> { } }:

let
pyproject = pkgs.lib.importTOML ./pyproject.toml;

myPython = pkgs.python.override {
self = myPython;
packageOverrides = pyfinal: pyprev: {
# An editable package with a script that loads our mutable location
my-editable = pyfinal.mkPythonEditablePackage {
# Inherit project metadata from pyproject.toml
pname = pyproject.project.name;
inherit (pyproject.project) version;

# The editable root passed as a string
root = "$REPO_ROOT/src"; # Use environment variable expansion at runtime
adisbladis marked this conversation as resolved.
Show resolved Hide resolved

# Inject a script (other PEP-621 entrypoints are also accepted)
inherit (pyproject.project) scripts;
};
};
};

pythonEnv = testPython.withPackages (ps: [ ps.my-editable ]);

in pkgs.mkShell {
packages = [ pythonEnv ];
}
```

#### `python.buildEnv` function {#python.buildenv-function}

Python environments can be created using the low-level `pkgs.buildEnv` function.
Expand Down
99 changes: 99 additions & 0 deletions pkgs/development/interpreters/python/editable.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
{
buildPythonPackage,
lib,
hatchling,
tomli-w,
}:
{
pname,
version,

# Editable root as string.
# Environment variables will be expanded at runtime using os.path.expandvars.
root,

# Arguments passed on verbatim to buildPythonPackage
derivationArgs ? { },

# Python dependencies
dependencies ? [ ],
optional-dependencies ? { },

# PEP-518 build-system https://peps.python.org/pep-518
build-system ? [ ],

# PEP-621 entry points https://peps.python.org/pep-0621/#entry-points
scripts ? { },
gui-scripts ? { },
entry-points ? { },

passthru ? { },
meta ? { },
}:

# Create a PEP-660 (https://peps.python.org/pep-0660/) editable package pointing to an impure location outside the Nix store.
# The primary use case of this function is to enable local development workflows where the local package is installed into a virtualenv-like environment using withPackages.

assert lib.isString root;
let
# In editable mode build-system's are considered to be runtime dependencies.
dependencies' = dependencies ++ build-system;

pyproject = {
# PEP-621 project table
project = {
name = pname;
inherit
version
scripts
gui-scripts
entry-points
;
dependencies = map lib.getName dependencies';
optional-dependencies = lib.mapAttrs (_: lib.getName) optional-dependencies;
};

# Allow empty package
tool.hatch.build.targets.wheel.bypass-selection = true;

# Include our editable pointer file in build
tool.hatch.build.targets.wheel.force-include."_${pname}.pth" = "_${pname}.pth";

# Build editable package using hatchling
build-system = {
requires = [ "hatchling" ];
build-backend = "hatchling.build";
};
};

in
buildPythonPackage (
{
inherit
pname
version
optional-dependencies
passthru
meta
;
dependencies = dependencies';

pyproject = true;

unpackPhase = ''
python -c "import json, tomli_w; print(tomli_w.dumps(json.load(open('$pyprojectContentsPath'))))" > pyproject.toml
echo 'import os.path, sys; sys.path.insert(0, os.path.expandvars("${root}"))' > _${pname}.pth
'';

build-system = [ hatchling ];
}
// derivationArgs
// {
# Note: Using formats.toml generates another intermediary derivation that needs to be built.
# We inline the same functionality for better UX.
nativeBuildInputs = (derivationArgs.nativeBuildInputs or [ ]) ++ [ tomli-w ];
pyprojectContents = builtins.toJSON pyproject;
passAsFile = [ "pyprojectContents" ];
preferLocalBuild = true;
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ let

removePythonPrefix = lib.removePrefix namePrefix;

mkPythonEditablePackage = callPackage ./editable.nix { };

mkPythonMetaPackage = callPackage ./meta-package.nix { };

# Convert derivation to a Python module.
Expand Down Expand Up @@ -99,7 +101,7 @@ in {
inherit buildPythonPackage buildPythonApplication;
inherit hasPythonModule requiredPythonModules makePythonPath disabled disabledIf;
inherit toPythonModule toPythonApplication;
inherit mkPythonMetaPackage;
inherit mkPythonMetaPackage mkPythonEditablePackage;

python = toPythonModule python;

Expand Down
39 changes: 38 additions & 1 deletion pkgs/development/interpreters/python/tests.nix
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,43 @@ let
}
);

# Test editable package support
editableTests = let
testPython = python.override {
self = testPython;
packageOverrides = pyfinal: pyprev: {
# An editable package with a script that loads our mutable location
my-editable = pyfinal.mkPythonEditablePackage {
pname = "my-editable";
version = "0.1.0";
root = "$NIX_BUILD_TOP/src"; # Use environment variable expansion at runtime
# Inject a script
scripts = {
my-script = "my_editable.main:main";
};
};
};
};


in {
editable-script = runCommand "editable-test" {
nativeBuildInputs = [ (testPython.withPackages (ps: [ ps.my-editable ])) ];
} ''
mkdir -p src/my_editable

cat > src/my_editable/main.py << EOF
def main():
print("hello mutable")
EOF

test "$(my-script)" == "hello mutable"
adisbladis marked this conversation as resolved.
Show resolved Hide resolved
test "$(python -c 'import sys; print(sys.path[1])')" == "$NIX_BUILD_TOP/src"

touch $out
'';
};

# Tests to ensure overriding works as expected.
overrideTests = let
extension = self: super: {
Expand Down Expand Up @@ -192,4 +229,4 @@ let
'';
};

in lib.optionalAttrs (stdenv.hostPlatform == stdenv.buildPlatform ) (environmentTests // integrationTests // overrideTests // condaTests)
in lib.optionalAttrs (stdenv.hostPlatform == stdenv.buildPlatform ) (environmentTests // integrationTests // overrideTests // condaTests // editableTests)