Skip to content

Commit 9527ba4

Browse files
authored
Add pip install --editable functionality (#101)
* WIP: `pip --editable` support * WIP: first complete try at `pip --editable` support * WIP: Add tests to `pip install --editable` support * WIP: small `pip install --editable` tweaks to `README.md` * Bump version to v0.2.19 * Revert `specstr(::PipPkgSpec)` to old short forum * Make local tests more Windows-friendly... maybe * Handle `file://` URLs with local installs * Add `editable` field to `CondaPkg.status` output * Remove commented-out test * less strict merging of editable flag * always include editable flag in pixispec if it's set * reuse existing tests for editable test * remove unneeded test modules * typo * null backend doesn't install so no point checking editability * keep readme brief [skip ci] * update changelog [skip ci] --------- Co-authored-by: Christopher Doris <github.com/cjdoris>
1 parent 116f962 commit 9527ba4

File tree

9 files changed

+131
-26
lines changed

9 files changed

+131
-26
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## Unreleased
4+
* Add `editable` property to pip packages, specifying whether to install them in
5+
editable mode.
6+
37
## 0.2.31 (2025-08-28)
48
* Bug fix: pip packages specified by file location are now correctly converted to "path"
59
installs with Pixi.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ These functions are intended to be used interactively when the Pkg REPL is not a
5757
- `rm(pkg)` removes a dependency or a vector of dependencies.
5858
- `add_channel(channel)` adds a channel.
5959
- `rm_channel(channel)` removes a channel.
60-
- `add_pip(pkg; version="")` adds/replaces a pip dependency.
60+
- `add_pip(pkg; version="", binary="", editable=false)` adds/replaces a pip dependency.
6161
- `rm_pip(pkg)` removes a pip dependency.
6262

6363
### CondaPkg.toml
@@ -90,6 +90,7 @@ some-local-package = "@ ./foo.zip"
9090
version = "~=2.1"
9191
extras = ["email", "timezone"]
9292
binary = "no" # or "only"
93+
editable = true
9394
```
9495

9596
## Access the Conda environment

src/PkgREPL.jl

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ $
2626
CondaPkg.PkgSpec(name, version = version, channel = channel, build = build)
2727
end
2828

29-
function parse_pip_pkg(x::String; binary::String = "")
29+
function parse_pip_pkg(x::String; binary::String = "", editable=false)
3030
m = match(
3131
r"""
3232
^
@@ -41,7 +41,7 @@ $
4141
name = m.captures[1]
4242
extras = split(something(m.captures[3], ""), ",", keepempty = false)
4343
version = something(m.captures[4], "")
44-
CondaPkg.PipPkgSpec(name, version = version, binary = binary, extras = extras)
44+
CondaPkg.PipPkgSpec(name, version = version, binary = binary, extras = extras, editable = editable)
4545
end
4646

4747
function parse_channel(x::String)
@@ -62,6 +62,13 @@ const binary_opt = Pkg.REPLMode.OptionDeclaration([
6262
:api => :binary => identity,
6363
])
6464

65+
const editable_opt = Pkg.REPLMode.OptionDeclaration([
66+
:name => "editable",
67+
:short_name => "e",
68+
:takes_arg => false,
69+
:api => :editable => true,
70+
])
71+
6572
### status
6673

6774
function status()
@@ -191,13 +198,13 @@ const channel_add_spec = Pkg.REPLMode.CommandSpec(
191198

192199
### pip_add
193200

194-
function pip_add(args; binary = "")
195-
CondaPkg.add([parse_pip_pkg(arg, binary = binary) for arg in args])
201+
function pip_add(args; binary = "", editable = false)
202+
CondaPkg.add([parse_pip_pkg(arg, binary = binary, editable = editable) for arg in args])
196203
end
197204

198205
const pip_add_help = Markdown.parse("""
199206
```
200-
conda pip_add [--binary={only|no}] pkg ...
207+
conda pip_add [--binary={only|no}] [--editable] pkg ...
201208
```
202209
203210
Add Pip packages to the environment.
@@ -209,6 +216,7 @@ pkg> conda pip_add build
209216
pkg> conda pip_add build~=0.7 # version range
210217
pkg> conda pip_add pydantic[email,timezone] # extras
211218
pkg> conda pip_add --binary=no nmslib # always build from source
219+
pkg> conda pip_add --editable nmslib@/path/to/package_source # install locally
212220
```
213221
""")
214222

@@ -219,7 +227,7 @@ const pip_add_spec = Pkg.REPLMode.CommandSpec(
219227
help = pip_add_help,
220228
description = "add Pip packages",
221229
arg_count = 0 => Inf,
222-
option_spec = [binary_opt],
230+
option_spec = [binary_opt, editable_opt],
223231
)
224232

225233
### rm

src/deps.jl

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ function parse_deps(toml)
8080
version = ""
8181
binary = ""
8282
extras = String[]
83+
editable = false
8384
if dep isa AbstractString
8485
version = _convert(String, dep)
8586
elseif dep isa AbstractDict
@@ -90,16 +91,18 @@ function parse_deps(toml)
9091
binary = _convert(String, v)
9192
elseif k == "extras"
9293
extras = _convert(Vector{String}, v)
94+
elseif k == "editable"
95+
editable = _convert(Bool, v)
9396
else
9497
error(
95-
"pip.deps keys must be 'version', 'extras' or 'binary', got '$k'",
98+
"pip.deps keys must be 'version', 'extras', 'binary' or 'editable', got '$k'",
9699
)
97100
end
98101
end
99102
else
100103
error("pip.deps must be String or Dict, got $(typeof(dep))")
101104
end
102-
pkg = PipPkgSpec(name, version = version, binary = binary, extras = extras)
105+
pkg = PipPkgSpec(name, version = version, binary = binary, extras = extras, editable = editable)
103106
push!(pip_packages, pkg)
104107
end
105108
end
@@ -231,6 +234,9 @@ function status(; io::IO = stderr)
231234
if !isempty(pkg.extras)
232235
push!(specparts, "[$(join(pkg.extras, ", "))]")
233236
end
237+
if pkg.editable
238+
push!(specparts, "editable")
239+
end
234240
isempty(specparts) ||
235241
printstyled(io, " (", join(specparts, ", "), ")", color = :light_black)
236242
println(io)
@@ -340,6 +346,9 @@ function add!(toml, pkg::PipPkgSpec)
340346
if !isempty(pkg.extras)
341347
dep["extras"] = pkg.extras
342348
end
349+
if pkg.editable
350+
dep["editable"] = pkg.editable
351+
end
343352
if issubset(keys(dep), ["version"])
344353
deps[pkg.name] = pkg.version
345354
else
@@ -405,7 +414,7 @@ Removes a channel from the current environment.
405414
rm_channel(channel::AbstractString; kw...) = rm(ChannelSpec(channel); kw...)
406415

407416
"""
408-
add_pip(pkg; version="", binary="", extras=[], resolve=true)
417+
add_pip(pkg; version="", binary="", extras=[], resolve=true, editable=false)
409418
410419
Adds a pip dependency to the current environment.
411420
@@ -414,8 +423,8 @@ Adds a pip dependency to the current environment.
414423
Use conda dependencies instead if at all possible. Pip does not handle version
415424
conflicts gracefully, so it is possible to get incompatible versions.
416425
"""
417-
add_pip(pkg::AbstractString; version = "", binary = "", extras = String[], kw...) =
418-
add(PipPkgSpec(pkg, version = version, binary = binary, extras = extras); kw...)
426+
add_pip(pkg::AbstractString; version = "", binary = "", extras = String[], editable = false, kw...) =
427+
add(PipPkgSpec(pkg, version = version, binary = binary, extras = extras, editable = editable); kw...)
419428

420429
"""
421430
rm_pip(pkg; resolve=true)

src/meta.jl

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ information about the most recent resolve.
44
"""
55

66
# increment whenever the metadata format changes
7-
const META_VERSION = 14
7+
const META_VERSION = 15
88

99
@kwdef mutable struct Meta
1010
timestamp::Float64
@@ -48,6 +48,9 @@ end
4848
function read_meta(io::IO, ::Type{Symbol})
4949
Symbol(read_meta(io, String))
5050
end
51+
function read_meta(io::IO, ::Type{Bool})
52+
read(io, Bool)
53+
end
5154
function read_meta(io::IO, ::Type{Vector{T}}) where {T}
5255
len = read(io, Int)
5356
ans = Vector{T}()
@@ -76,7 +79,8 @@ function read_meta(io::IO, ::Type{PipPkgSpec})
7679
version = read_meta(io, String)
7780
binary = read_meta(io, String)
7881
extras = read_meta(io, Vector{String})
79-
PipPkgSpec(name, version = version, binary = binary, extras = extras)
82+
editable = read_meta(io, Bool)
83+
PipPkgSpec(name, version = version, binary = binary, extras = extras, editable = editable)
8084
end
8185

8286
function write_meta(io::IO, meta::Meta)
@@ -102,6 +106,9 @@ end
102106
function write_meta(io::IO, x::Symbol)
103107
write_meta(io, String(x))
104108
end
109+
function write_meta(io::IO, x::Bool)
110+
write(io, x)
111+
end
105112
function write_meta(io::IO, x::Vector)
106113
write(io, convert(Int, length(x)))
107114
for item in x
@@ -125,4 +132,5 @@ function write_meta(io::IO, x::PipPkgSpec)
125132
write_meta(io, x.version)
126133
write_meta(io, x.binary)
127134
write_meta(io, x.extras)
135+
write_meta(io, x.editable)
128136
end

src/resolve.jl

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,7 @@ function _resolve_merge_pip_packages(packages)
518518
urls = String[]
519519
binary = ""
520520
extras = String[]
521+
editable = false
521522
for (fn, pkg) in pkgs
522523
@assert pkg.name == name
523524
if startswith(pkg.version, "@")
@@ -539,6 +540,7 @@ function _resolve_merge_pip_packages(packages)
539540
end
540541
end
541542
append!(extras, pkg.extras)
543+
editable |= pkg.editable
542544
end
543545
sort!(unique!(urls))
544546
sort!(unique!(versions))
@@ -555,7 +557,16 @@ function _resolve_merge_pip_packages(packages)
555557
"direct references ('@ ...') and version specifiers both given for pip package '$name'",
556558
)
557559
end
558-
push!(specs, PipPkgSpec(name, version = version, binary = binary, extras = extras))
560+
push!(
561+
specs,
562+
PipPkgSpec(
563+
name,
564+
version = version,
565+
binary = binary,
566+
extras = extras,
567+
editable = editable,
568+
),
569+
)
559570
end
560571
sort!(specs, by = x -> x.name)
561572
end
@@ -669,9 +680,16 @@ function _resolve_pip_install(io, pip_specs, load_path, backend)
669680
elseif spec.binary == "no"
670681
push!(args, "--no-binary", spec.name)
671682
end
683+
if spec.editable
684+
# remove the @ from the beginning of the path.
685+
url = replace(spec.version, r"@\s*" => "")
686+
push!(args, "--editable", url)
687+
end
672688
end
673689
for spec in pip_specs
674-
push!(args, specstr(spec))
690+
if !spec.editable
691+
push!(args, specstr(spec))
692+
end
675693
end
676694
vrb = _verbosity_flags()
677695
flags = vrb

src/spec.jl

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,22 +133,31 @@ struct PipPkgSpec
133133
version::String
134134
binary::String
135135
extras::Vector{String}
136-
function PipPkgSpec(name; version = "", binary = "", extras = String[])
136+
editable::Bool
137+
function PipPkgSpec(
138+
name;
139+
version = "",
140+
binary = "",
141+
extras = String[],
142+
editable = false,
143+
)
137144
name = validate_pip_pkg(name)
138145
version = validate_pip_version(version)
139146
binary = validate_pip_binary(binary)
140147
extras = validate_pip_extras(extras)
141-
new(name, version, binary, extras)
148+
validate_pip_editable(editable, version)
149+
new(name, version, binary, extras, editable)
142150
end
143151
end
144152

145153
Base.:(==)(x::PipPkgSpec, y::PipPkgSpec) =
146154
(x.name == y.name) &&
147155
(x.version == y.version) &&
148156
(x.binary == y.binary) &&
149-
(x.extras == y.extras)
157+
(x.extras == y.extras) &&
158+
(x.editable == y.editable)
150159
Base.hash(x::PipPkgSpec, h::UInt) =
151-
hash(x.extras, hash(x.binary, hash(x.version, hash(x.name, h))))
160+
hash(x.editable, hash(x.extras, hash(x.binary, hash(x.version, hash(x.name, h)))))
152161

153162
is_valid_pip_pkg(name) = occursin(r"^\s*[-_.A-Za-z0-9]+\s*$", name)
154163

@@ -233,6 +242,9 @@ function pixispec(x::PipPkgSpec)
233242
else
234243
spec["version"] = x.version == "" ? "*" : x.version
235244
end
245+
if x.editable
246+
spec["editable"] = true
247+
end
236248
if !isempty(x.extras)
237249
spec["extras"] = x.extras
238250
end
@@ -242,3 +254,10 @@ function pixispec(x::PipPkgSpec)
242254
spec
243255
end
244256
end
257+
258+
validate_pip_editable(editable, version) =
259+
if editable && !startswith(version, "@")
260+
error(
261+
"invalid pip version for editable install: must start with `@` but version is $(version)",
262+
)
263+
end

test/internals.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ end
2525
@test_throws Exception CondaPkg.PipPkgSpec("")
2626
@test_throws Exception CondaPkg.PipPkgSpec("foo!")
2727
@test_throws Exception CondaPkg.PipPkgSpec("foo", version = "1.2")
28+
@test_throws Exception CondaPkg.PipPkgSpec("foo", editable = true)
29+
@test_throws Exception CondaPkg.PipPkgSpec("foo", version = "1.2", editable = true)
2830
spec = CondaPkg.PipPkgSpec(" F...OO_-0 ", version = " @./SOME/Path ")
2931
@test spec.name == "f-oo-0"
3032
@test spec.version == "@./SOME/Path"
@@ -36,6 +38,8 @@ end
3638
CondaPkg.PkgSpec("foo", version = "=1.2.3", channel = "bar"),
3739
CondaPkg.ChannelSpec("fooo"),
3840
CondaPkg.PipPkgSpec("foooo", version = "==2.3.4"),
41+
CondaPkg.PipPkgSpec("bar", version = "@ /abs/path/somewhere", editable = true),
42+
CondaPkg.PipPkgSpec("baz", version = "@ ./rel/path/somewhere", editable = true)
3943
]
4044
for spec in specs
4145
io = IOBuffer()

test/main.jl

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,11 @@ end
164164
end
165165

166166
@testitem "pip install/remove local python package" begin
167-
@testset "file $file" for file in [
168-
"example-python-package",
169-
"example_python_package-1.0.0-py3-none-any.whl",
170-
"example_python_package-1.0.0.tar.gz",
167+
@testset "file $file $kwargs" for (file, kwargs) in [
168+
("example-python-package", NamedTuple()),
169+
("example_python_package-1.0.0-py3-none-any.whl", NamedTuple()),
170+
("example_python_package-1.0.0.tar.gz", NamedTuple()),
171+
("example-python-package", (editable = true,)),
171172
]
172173
include("setup.jl")
173174
CondaPkg.add("python", version = "==3.10.2")
@@ -180,14 +181,47 @@ end
180181

181182
# install package
182183
path = "./test/data/$file"
183-
CondaPkg.add_pip("example-python-package", version = "@$path")
184+
fullpath = abspath(dirname(CondaPkg.cur_deps_file()), path)
185+
@assert ispath(fullpath)
186+
editable = get(kwargs, :editable, false)
187+
CondaPkg.add_pip("example-python-package", version = "@$path"; kwargs...)
184188
@test occursin("example-python-package", status())
185-
@test occursin("(@$path)", status())
189+
if isempty(kwargs)
190+
@test occursin("(@$path)", status())
191+
else
192+
@test occursin("(@$path,", status())
193+
end
194+
@test occursin("editable", status()) == editable
186195
CondaPkg.withenv() do
187196
isnull || run(`python -c "import example_python_package"`)
188197
end
189198
@test occursin("v1.0.0", status()) == !isnull
190199

200+
# check editability
201+
if editable && !isnull
202+
@assert isdir(fullpath)
203+
added_path = joinpath(fullpath, "src", "example_python_package", "added.py")
204+
# check a particular submodule does not exist
205+
rm(added_path; force = true)
206+
CondaPkg.withenv() do
207+
@test_throws Exception run(
208+
`python -c "from example_python_package.added import foo"`,
209+
)
210+
end
211+
# now add it and check we can import it
212+
write(added_path, "foo = 12")
213+
CondaPkg.withenv() do
214+
run(`python -c "from example_python_package.added import foo"`)
215+
end
216+
# remove it again
217+
rm(added_path; force = true)
218+
CondaPkg.withenv() do
219+
@test_throws Exception run(
220+
`python -c "from example_python_package.added import foo"`,
221+
)
222+
end
223+
end
224+
191225
# remove package
192226
CondaPkg.rm_pip("example-python-package")
193227
@test !occursin("example-python-package", status())

0 commit comments

Comments
 (0)