Skip to content

Commit 9cb1a5e

Browse files
committed
Introduce experimental fetchComposerDepsImpure
`fetchComposerDeps` works okay but since the fetching runs at evaluation time, it grinds Nix evaluator to a halt. As a bonus, we have a finer control over what is fetched so we can limit it to a shallow fetch. This patch adds an alternative implementation that moves the work to build time based on the experimental `impure-derivations` feature. Unfortunately, the feature needs to be enabled **in the daemon** and can only be used by other impure derivations. The repo derivation also will not be cached by Nix so everything will need to be re-fetched for every build.
1 parent 0d3f787 commit 9cb1a5e

File tree

7 files changed

+223
-2
lines changed

7 files changed

+223
-2
lines changed

.github/workflows/main.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ jobs:
1313

1414
- name: Install Nix
1515
uses: cachix/install-nix-action@v17
16+
with:
17+
extra_nix_config: |
18+
# `flakes` and `nix-command` for convenience
19+
# `impure-derivations` needed for testing `fetchComposerDepsImpure`
20+
# and it is not sufficient to enable with CLI flag: https://github.com/NixOS/nix/issues/6478
21+
# `ca-derivations` required by `impure-derivations`
22+
experimental-features = flakes nix-command ca-derivations impure-derivations
1623
1724
- name: Run integration tests
1825
run: ./run-tests.sh

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ This is a function that, for given source, returns a derivation with a Composer
8484

8585
- Either `lockFile` containing an explicit path to `composer.lock` file, or `src`, which is the source directory/derivation containing the file.
8686

87+
### `c4.fetchComposerDepsImpure`
88+
89+
This function has the same API as [`c4.fetchComposerDeps`]]#c4fetchcomposerdeps) but it fetches the dependencies at build time. There are, however, significant downsides:
90+
91+
- It requires [enabling an experimental `impure-derivations`](https://nixos.org/manual/nix/stable/contributing/experimental-features.html#impure-derivations) feature [**in the daemon**](https://github.com/NixOS/nix/issues/6478)
92+
- It can only be used by other impure derivations.
93+
- The dependencies will be re-fetched with every build.
94+
8795
### `c4.composerSetupHook`
8896

8997
This is a [setup hook](https://nixos.org/manual/nixpkgs/stable/#ssec-setup-hooks). By adding it to `nativeBuildInputs` of a Nixpkgs derivation, the following hooks will be automatically enabled.
@@ -102,10 +110,12 @@ It is controlled by the following environment variables (pass them to the deriva
102110
- It requires `composer.lock` to exist.
103111
- It currently only supports downloading packages from Git.
104112
- When the lockfile comes from a source derivation rather then a local repository, Nix’s [import from derivation](https://nixos.wiki/wiki/Import_From_Derivation) mechanism will be used, inheriting all problems of IFD. Notably, it cannot be used in Nixpkgs.
105-
- We download the sources at evaluation time so it will block evaluation, this is especially painful since Nix currently does not support parallel evaluation.
106-
- Nix’s fetchers will fetch the full Git ref, which will take a long time for heavy repos like https://github.com/phpstan/phpstan.
113+
- We download the sources at evaluation time so it will block evaluation, this is especially painful since Nix currently does not support parallel evaluation. 👋
114+
- Nix’s fetchers will fetch the full Git ref, which will take a long time for heavy repos like https://github.com/phpstan/phpstan. 👋
107115
- It might be somewhat slower than generated Nix files (e.g. [composer2nix]) since the Nix values need to be constructed from scratch every time.
108116

117+
👋 You can use the [`c4.fetchComposerDepsImpure`](#c4fetchcomposerdepsimpure) to move the work to build time and more efficient fetching but it has [other downsides](#c4fetchcomposerdepsimpure).
118+
109119
For more information look at Nicolas’s _[An overview of language support in Nix][nixcon-language-support-overview]_ presentation from NixCon 2019.
110120

111121
## How does it work?

overlay.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ prev:
1414
./src/composer-setup-hook.sh;
1515

1616
fetchComposerDeps = prev.callPackage ./src/fetch-deps.nix { };
17+
fetchComposerDepsImpure = prev.callPackage ./src/fetch-deps-impure.nix { };
1718
};
1819
}

run-tests.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
#!/usr/bin/env bash
22
set -x -o errexit
33

4+
nix develop --no-write-lock-file ./tests#python -c black --check --diff src/composer-create-repository.py
5+
nix develop --no-write-lock-file ./tests#python -c mypy --strict src/composer-create-repository.py
6+
7+
nix build -L --no-write-lock-file --extra-experimental-features impure-derivations ./tests#composer-impure
8+
nix build -L --no-write-lock-file --extra-experimental-features impure-derivations ./tests#grav-impure
9+
nix build -L --no-write-lock-file --extra-experimental-features impure-derivations ./tests#non-head-rev-impure
10+
411
nix build -L --no-write-lock-file ./tests#composer
512
nix build -L --no-write-lock-file ./tests#grav
613
nix build -L --no-write-lock-file ./tests#non-head-rev

src/composer-create-repository.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#!/usr/bin/env python3
2+
from pathlib import Path
3+
from typing import cast, NotRequired, TypedDict
4+
import argparse
5+
import json
6+
import shutil
7+
import subprocess
8+
9+
Source = TypedDict(
10+
"Source",
11+
{
12+
"type": str,
13+
"url": str,
14+
"reference": str,
15+
},
16+
)
17+
18+
19+
class Package(TypedDict):
20+
name: str
21+
version: str
22+
source: NotRequired[Source]
23+
dist: Source
24+
25+
26+
def clone_git_repo(url: str, rev: str, clone_target_path: Path) -> None:
27+
subprocess.check_call(
28+
["git", "init"],
29+
cwd=clone_target_path,
30+
)
31+
subprocess.check_call(
32+
["git", "fetch", url, rev, "--depth", "1"],
33+
cwd=clone_target_path,
34+
)
35+
subprocess.check_call(
36+
["git", "reset", "--hard", "FETCH_HEAD"],
37+
cwd=clone_target_path,
38+
)
39+
40+
41+
def fetch_composer_package(package: Package, clone_target_path: Path) -> None:
42+
assert (
43+
"source" in package and package["source"]["type"] == "git"
44+
), f"Package “{package['name']}” does not have source of type “git”."
45+
46+
clone_git_repo(
47+
url=package["source"]["url"],
48+
rev=package["source"]["reference"],
49+
clone_target_path=clone_target_path,
50+
)
51+
52+
# Clean up git directory to ensure reproducible output
53+
shutil.rmtree(clone_target_path / ".git")
54+
55+
56+
def make_package(
57+
package: Package,
58+
clone_target_path: Path,
59+
) -> tuple[str, dict[str, Package]]:
60+
assert (
61+
package["source"]["reference"] == package["dist"]["reference"]
62+
), f"Package “{package['name']}” has a mismatch between “reference” keys of “dist” and “source” keys."
63+
64+
# While Composer repositories only really require `name`, `version` and `source`/`dist` fields,
65+
# we will use the original contents of the package’s entry from `composer.lock`, modifying just the sources.
66+
# Package entries in Composer repositories correspond to `composer.json` files [1]
67+
# and Composer appears to use them when regenerating the lockfile.
68+
# If we just used the minimal info, stuff like `autoloading` or `bin` programs would be broken.
69+
#
70+
# We cannot use `source` since Composer does not support path sources:
71+
# "PathDownloader" is a dist type downloader and can not be used to download source
72+
#
73+
# [1]: https://getcomposer.org/doc/05-repositories.md#packages>
74+
75+
# Copy the Package so that we do not mutate the original.
76+
package = cast(Package, dict(package))
77+
package.pop("source", None)
78+
package["dist"] = {
79+
"type": "path",
80+
"url": str(clone_target_path / package["name"] / package["version"]),
81+
"reference": package["dist"]["reference"],
82+
}
83+
84+
return (
85+
package["name"],
86+
{
87+
package["version"]: package,
88+
},
89+
)
90+
91+
92+
def main(
93+
lockfile_path: Path,
94+
output_path: Path,
95+
) -> None:
96+
# We are generating a repository of type Composer
97+
# https://getcomposer.org/doc/05-repositories.md#composer
98+
with open(lockfile_path) as lockfile:
99+
lock = json.load(lockfile)
100+
repo_path = output_path / "repo"
101+
102+
# We always need to fetch dev dependencies so that `composer update --lock` can update the config.
103+
packages_to_install = lock["packages"] + lock["packages-dev"]
104+
105+
for package in packages_to_install:
106+
clone_target_path = repo_path / package["name"] / package["version"]
107+
clone_target_path.mkdir(parents=True)
108+
fetch_composer_package(package, clone_target_path)
109+
110+
repo_manifest = {
111+
"packages": {
112+
package_name: metadata
113+
for package_name, metadata in [
114+
make_package(package, repo_path) for package in packages_to_install
115+
]
116+
}
117+
}
118+
with open(output_path / "packages.json", "w") as repo_manifest_file:
119+
json.dump(
120+
repo_manifest,
121+
repo_manifest_file,
122+
indent=4,
123+
)
124+
125+
126+
if __name__ == "__main__":
127+
parser = argparse.ArgumentParser(
128+
description="Generate composer repository for offline fetching"
129+
)
130+
parser.add_argument(
131+
"lockfile_path",
132+
help="Path to a composer lockfile",
133+
)
134+
parser.add_argument(
135+
"output_path",
136+
help="Output path to store the repository in",
137+
)
138+
139+
args = parser.parse_args()
140+
141+
main(
142+
lockfile_path=Path(args.lockfile_path),
143+
output_path=Path(args.output_path),
144+
)

src/fetch-deps-impure.nix

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
runCommand,
3+
lib,
4+
python311,
5+
git,
6+
cacert,
7+
}:
8+
9+
{
10+
src ? null,
11+
lockFile ? null,
12+
}:
13+
14+
assert lib.assertMsg ((src == null) != (lockFile == null)) "Either “src” or “lockFile” attribute needs to be provided.";
15+
16+
let
17+
lockPath = if lockFile != null then lockFile else "${src}/composer.lock";
18+
in
19+
# We are generating a repository of type Composer
20+
# https://getcomposer.org/doc/05-repositories.md#composer
21+
runCommand "repo" {
22+
__impure = true;
23+
24+
nativeBuildInputs = [
25+
python311
26+
git
27+
cacert
28+
];
29+
} ''
30+
python3 "${./composer-create-repository.py}" ${lib.escapeShellArg lockPath} "$out"
31+
''

tests/flake.nix

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,31 @@
1212
c4.overlays.default
1313
];
1414
};
15+
16+
impurify =
17+
pkg:
18+
(pkg.override (prev: {
19+
c4 = prev.c4 // {
20+
fetchComposerDeps = prev.c4.fetchComposerDepsImpure;
21+
};
22+
})).overrideAttrs (attrs: {
23+
# Impure derivations can only be built by other impure derivations.
24+
__impure = true;
25+
});
1526
in
1627
{
1728
packages.x86_64-linux.composer = pkgs.callPackage ./composer { };
29+
packages.x86_64-linux.composer-impure = impurify self.packages.x86_64-linux.composer;
1830
packages.x86_64-linux.grav = pkgs.callPackage ./grav { };
31+
packages.x86_64-linux.grav-impure = impurify self.packages.x86_64-linux.grav;
1932
packages.x86_64-linux.non-head-rev = pkgs.callPackage ./non-head-rev { };
33+
packages.x86_64-linux.non-head-rev-impure = impurify self.packages.x86_64-linux.non-head-rev;
34+
35+
devShells.x86_64-linux.python = pkgs.mkShell {
36+
nativeBuildInputs = [
37+
pkgs.python311.pkgs.black
38+
pkgs.python311.pkgs.mypy
39+
];
40+
};
2041
};
2142
}

0 commit comments

Comments
 (0)