Skip to content

Commit 27a9e8b

Browse files
committed
Fixed the issue where path dependencies were not relative to the manifest file, instead they were relative the run directory
1 parent 9519e50 commit 27a9e8b

4 files changed

Lines changed: 330 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ All notable changes to ``FastSandPM`` will be documented in this file.
66
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
77
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

9+
0.2.0
10+
----------------------------------------------------------------------
11+
12+
### Fixed
13+
- Path dependencies are now resolved to absolute paths relative to the manifest file's directory
14+
when loading via `get_manifest()`. Previously, relative paths were left as-is which could cause
15+
issues when the working directory differed from the manifest location.
16+
917
0.1.0
1018
----------------------------------------------------------------------
1119

src/fastsandpm/manifest.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,13 @@
5757
PlainValidator,
5858
RootModel,
5959
ValidationError,
60+
ValidationInfo,
6061
WithJsonSchema,
6162
field_validator,
6263
model_validator,
6364
)
6465

65-
from fastsandpm.dependencies.requirements import ConcreteRequirement
66+
from fastsandpm.dependencies.requirements import ConcreteRequirement, PathRequirement
6667
from fastsandpm.registries import Registries
6768
from fastsandpm.versioning import LibraryVersion
6869

@@ -404,6 +405,48 @@ def parse_optional_dependencies(cls, data: Any) -> Any:
404405
data["optional_dependencies"] = new_opt_deps
405406
return data
406407

408+
@model_validator(mode="after")
409+
def _resolve_path_requirement_paths(self, info: ValidationInfo) -> Manifest:
410+
"""Resolve relative paths in PathRequirements to absolute paths.
411+
412+
Creates a new Manifest with all relative path dependencies resolved
413+
relative to the manifest file's directory.
414+
415+
Args:
416+
manifest: The parsed Manifest object.
417+
manifest_dir: The directory containing the manifest file.
418+
419+
Returns:
420+
A new Manifest with resolved path dependencies.
421+
"""
422+
if isinstance(info.context, dict) and "manifest_dir" in info.context:
423+
manifest_dir = pathlib.Path(info.context["manifest_dir"])
424+
else:
425+
# No manifest directory context provided (e.g., loading from bytes)
426+
# Keep relative paths as-is
427+
return self
428+
429+
def resolve_dep(dep: ConcreteRequirement) -> ConcreteRequirement:
430+
"""Resolve paths in a single dependency."""
431+
if isinstance(dep, PathRequirement) and not dep.path.is_absolute():
432+
resolved_path = (manifest_dir / dep.path).resolve()
433+
return dep.model_copy(update={"path": resolved_path})
434+
return dep
435+
436+
# Resolve paths in required dependencies
437+
new_deps = Dependencies([resolve_dep(dep) for dep in self.dependencies])
438+
439+
# Resolve paths in optional dependencies
440+
new_opt_deps: dict[str, Dependencies] = {}
441+
for group_name, deps in self.optional_dependencies.items():
442+
new_opt_deps[group_name] = Dependencies([resolve_dep(dep) for dep in deps])
443+
444+
# Create new manifest with resolved paths
445+
self.dependencies = new_deps
446+
self.optional_dependencies = new_opt_deps
447+
448+
return self
449+
407450

408451
#: The default manifest filename
409452
MANIFEST_FILENAME = "proj.toml"
@@ -413,13 +456,14 @@ def get_manifest(path: os.PathLike) -> Manifest:
413456
"""Load and parse a manifest from a repository path.
414457
415458
Looks for a `proj.toml` file in the specified directory, parses it,
416-
and returns a Manifest object.
459+
and returns a Manifest object. Relative paths in path dependencies are
460+
resolved to absolute paths relative to the manifest file's directory.
417461
418462
Args:
419463
path: Path to the repository directory containing the proj.toml file.
420464
421465
Returns:
422-
The parsed Manifest object.
466+
The parsed Manifest object with resolved path dependencies.
423467
424468
Raises:
425469
ManifestNotFoundError: If the proj.toml file does not exist at the path.
@@ -454,10 +498,13 @@ def get_manifest(path: os.PathLike) -> Manifest:
454498

455499
# Parse the data into a Manifest object
456500
try:
457-
return Manifest.model_validate(data)
501+
manifest = Manifest.model_validate(data, context={"manifest_dir": path.resolve()})
458502
except ValidationError as e:
459503
raise ManifestParseError(path, str(e)) from e
460504

505+
# Resolve relative paths in path dependencies
506+
return manifest
507+
461508

462509
def get_manifest_from_bytes(content: bytes, source: str = "<bytes>") -> Manifest:
463510
"""Parse a manifest from raw bytes content.

tests/manifest/test_get_manifest.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,11 @@ def test_get_manifest_with_git_dependencies(self, tmp_path: pathlib.Path) -> Non
173173
assert dep5.version == DirectVersionSpecifier(LibraryVersion("1.0.0"))
174174

175175
def test_get_manifest_with_path_dependencies(self, tmp_path: pathlib.Path) -> None:
176-
"""Test loading manifest with path dependencies."""
176+
"""Test loading manifest with path dependencies.
177+
178+
Relative paths should be resolved to absolute paths relative to
179+
the manifest file's directory.
180+
"""
177181
manifest_content = """
178182
[package]
179183
name = "with-path-deps"
@@ -192,11 +196,15 @@ def test_get_manifest_with_path_dependencies(self, tmp_path: pathlib.Path) -> No
192196

193197
local = manifest.dependencies.get_by_name("local_dep")
194198
assert isinstance(local, PathRequirement)
195-
assert local.path == pathlib.Path("./local_utils")
199+
# Relative paths should be resolved to absolute paths
200+
assert local.path.is_absolute()
201+
assert local.path == (tmp_path / "local_utils").resolve()
196202

197203
parent = manifest.dependencies.get_by_name("parent_dep")
198204
assert isinstance(parent, PathRequirement)
199-
assert parent.path == pathlib.Path("../sibling_project")
205+
# Relative paths should be resolved to absolute paths
206+
assert parent.path.is_absolute()
207+
assert parent.path == (tmp_path / ".." / "sibling_project").resolve()
200208

201209
def test_get_manifest_with_mixed_dependencies(self, tmp_path: pathlib.Path) -> None:
202210
"""Test loading manifest with mixed dependency types."""
@@ -539,3 +547,37 @@ def test_get_manifest_from_bytes_with_git_dependencies(self) -> None:
539547
assert git_dep is not None
540548
assert isinstance(git_dep, BranchGitRequirement)
541549
assert git_dep.branch == "main"
550+
551+
def test_get_manifest_from_bytes_with_path_dependencies(self) -> None:
552+
"""Test parsing manifest with path dependencies from bytes.
553+
554+
When loading from bytes without a file context, relative paths
555+
should remain as-is (not resolved to absolute paths).
556+
"""
557+
content = b"""
558+
[package]
559+
name = "path-deps-pkg"
560+
version = "1.0.0"
561+
description = "Package with path dependencies"
562+
563+
[dependencies]
564+
local_dep = {path = "./local_utils"}
565+
parent_dep = {path = "../sibling"}
566+
"""
567+
manifest = get_manifest_from_bytes(content)
568+
569+
assert len(manifest.dependencies) == 2
570+
571+
local_dep = manifest.dependencies.get_by_name("local_dep")
572+
assert local_dep is not None
573+
assert isinstance(local_dep, PathRequirement)
574+
# Paths should remain relative when loading from bytes
575+
assert local_dep.path == pathlib.Path("./local_utils")
576+
assert not local_dep.path.is_absolute()
577+
578+
parent_dep = manifest.dependencies.get_by_name("parent_dep")
579+
assert parent_dep is not None
580+
assert isinstance(parent_dep, PathRequirement)
581+
# Paths should remain relative when loading from bytes
582+
assert parent_dep.path == pathlib.Path("../sibling")
583+
assert not parent_dep.path.is_absolute()

0 commit comments

Comments
 (0)