Add package dependencies system and project settings page
This commit is contained in:
723
backend/app/dependencies.py
Normal file
723
backend/app/dependencies.py
Normal file
@@ -0,0 +1,723 @@
|
||||
"""
|
||||
Dependency management module for artifact dependencies.
|
||||
|
||||
Handles:
|
||||
- Parsing orchard.ensure files
|
||||
- Storing dependencies in the database
|
||||
- Querying dependencies and reverse dependencies
|
||||
- Dependency resolution with topological sorting
|
||||
- Circular dependency detection
|
||||
- Conflict detection
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from typing import List, Dict, Any, Optional, Set, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from .models import (
|
||||
Project,
|
||||
Package,
|
||||
Artifact,
|
||||
Tag,
|
||||
ArtifactDependency,
|
||||
PackageVersion,
|
||||
)
|
||||
from .schemas import (
|
||||
EnsureFileContent,
|
||||
EnsureFileDependency,
|
||||
DependencyResponse,
|
||||
ArtifactDependenciesResponse,
|
||||
DependentInfo,
|
||||
ReverseDependenciesResponse,
|
||||
ResolvedArtifact,
|
||||
DependencyResolutionResponse,
|
||||
DependencyConflict,
|
||||
PaginationMeta,
|
||||
)
|
||||
|
||||
|
||||
class DependencyError(Exception):
|
||||
"""Base exception for dependency errors."""
|
||||
pass
|
||||
|
||||
|
||||
class CircularDependencyError(DependencyError):
|
||||
"""Raised when a circular dependency is detected."""
|
||||
def __init__(self, cycle: List[str]):
|
||||
self.cycle = cycle
|
||||
super().__init__(f"Circular dependency detected: {' -> '.join(cycle)}")
|
||||
|
||||
|
||||
class DependencyConflictError(DependencyError):
|
||||
"""Raised when conflicting dependency versions are detected."""
|
||||
def __init__(self, conflicts: List[DependencyConflict]):
|
||||
self.conflicts = conflicts
|
||||
super().__init__(f"Dependency conflicts detected: {len(conflicts)} conflict(s)")
|
||||
|
||||
|
||||
class DependencyNotFoundError(DependencyError):
|
||||
"""Raised when a dependency cannot be resolved."""
|
||||
def __init__(self, project: str, package: str, constraint: str):
|
||||
self.project = project
|
||||
self.package = package
|
||||
self.constraint = constraint
|
||||
super().__init__(f"Dependency not found: {project}/{package}@{constraint}")
|
||||
|
||||
|
||||
class InvalidEnsureFileError(DependencyError):
|
||||
"""Raised when the ensure file is invalid."""
|
||||
pass
|
||||
|
||||
|
||||
class DependencyDepthExceededError(DependencyError):
|
||||
"""Raised when dependency resolution exceeds max depth."""
|
||||
def __init__(self, max_depth: int):
|
||||
self.max_depth = max_depth
|
||||
super().__init__(f"Dependency resolution exceeded maximum depth of {max_depth}")
|
||||
|
||||
|
||||
# Safety limits to prevent DoS attacks
|
||||
MAX_DEPENDENCY_DEPTH = 50 # Maximum levels of nested dependencies
|
||||
MAX_DEPENDENCIES_PER_ARTIFACT = 200 # Maximum direct dependencies per artifact
|
||||
|
||||
|
||||
def parse_ensure_file(content: bytes) -> EnsureFileContent:
|
||||
"""
|
||||
Parse an orchard.ensure file.
|
||||
|
||||
Args:
|
||||
content: Raw bytes of the ensure file
|
||||
|
||||
Returns:
|
||||
Parsed EnsureFileContent
|
||||
|
||||
Raises:
|
||||
InvalidEnsureFileError: If the file is invalid YAML or has wrong structure
|
||||
"""
|
||||
try:
|
||||
data = yaml.safe_load(content.decode('utf-8'))
|
||||
except yaml.YAMLError as e:
|
||||
raise InvalidEnsureFileError(f"Invalid YAML: {e}")
|
||||
except UnicodeDecodeError as e:
|
||||
raise InvalidEnsureFileError(f"Invalid encoding: {e}")
|
||||
|
||||
if data is None:
|
||||
return EnsureFileContent(dependencies=[])
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise InvalidEnsureFileError("Ensure file must be a YAML dictionary")
|
||||
|
||||
dependencies = []
|
||||
deps_data = data.get('dependencies', [])
|
||||
|
||||
if not isinstance(deps_data, list):
|
||||
raise InvalidEnsureFileError("'dependencies' must be a list")
|
||||
|
||||
# Safety limit: prevent DoS through excessive dependencies
|
||||
if len(deps_data) > MAX_DEPENDENCIES_PER_ARTIFACT:
|
||||
raise InvalidEnsureFileError(
|
||||
f"Too many dependencies: {len(deps_data)} exceeds maximum of {MAX_DEPENDENCIES_PER_ARTIFACT}"
|
||||
)
|
||||
|
||||
for i, dep in enumerate(deps_data):
|
||||
if not isinstance(dep, dict):
|
||||
raise InvalidEnsureFileError(f"Dependency {i} must be a dictionary")
|
||||
|
||||
project = dep.get('project')
|
||||
package = dep.get('package')
|
||||
version = dep.get('version')
|
||||
tag = dep.get('tag')
|
||||
|
||||
if not project:
|
||||
raise InvalidEnsureFileError(f"Dependency {i} missing 'project'")
|
||||
if not package:
|
||||
raise InvalidEnsureFileError(f"Dependency {i} missing 'package'")
|
||||
if not version and not tag:
|
||||
raise InvalidEnsureFileError(
|
||||
f"Dependency {i} must have either 'version' or 'tag'"
|
||||
)
|
||||
if version and tag:
|
||||
raise InvalidEnsureFileError(
|
||||
f"Dependency {i} cannot have both 'version' and 'tag'"
|
||||
)
|
||||
|
||||
dependencies.append(EnsureFileDependency(
|
||||
project=project,
|
||||
package=package,
|
||||
version=version,
|
||||
tag=tag,
|
||||
))
|
||||
|
||||
return EnsureFileContent(dependencies=dependencies)
|
||||
|
||||
|
||||
def validate_dependencies(
|
||||
db: Session,
|
||||
dependencies: List[EnsureFileDependency],
|
||||
) -> List[str]:
|
||||
"""
|
||||
Validate that all dependency projects exist.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
dependencies: List of dependencies to validate
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if all valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
for dep in dependencies:
|
||||
project = db.query(Project).filter(Project.name == dep.project).first()
|
||||
if not project:
|
||||
errors.append(f"Project '{dep.project}' not found")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def store_dependencies(
|
||||
db: Session,
|
||||
artifact_id: str,
|
||||
dependencies: List[EnsureFileDependency],
|
||||
) -> List[ArtifactDependency]:
|
||||
"""
|
||||
Store dependencies for an artifact.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
artifact_id: The artifact ID that has these dependencies
|
||||
dependencies: List of dependencies to store
|
||||
|
||||
Returns:
|
||||
List of created ArtifactDependency objects
|
||||
"""
|
||||
created = []
|
||||
|
||||
for dep in dependencies:
|
||||
artifact_dep = ArtifactDependency(
|
||||
artifact_id=artifact_id,
|
||||
dependency_project=dep.project,
|
||||
dependency_package=dep.package,
|
||||
version_constraint=dep.version,
|
||||
tag_constraint=dep.tag,
|
||||
)
|
||||
db.add(artifact_dep)
|
||||
created.append(artifact_dep)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def get_artifact_dependencies(
|
||||
db: Session,
|
||||
artifact_id: str,
|
||||
) -> List[DependencyResponse]:
|
||||
"""
|
||||
Get all dependencies for an artifact.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
artifact_id: The artifact ID
|
||||
|
||||
Returns:
|
||||
List of DependencyResponse objects
|
||||
"""
|
||||
deps = db.query(ArtifactDependency).filter(
|
||||
ArtifactDependency.artifact_id == artifact_id
|
||||
).all()
|
||||
|
||||
return [DependencyResponse.from_orm_model(dep) for dep in deps]
|
||||
|
||||
|
||||
def get_reverse_dependencies(
|
||||
db: Session,
|
||||
project_name: str,
|
||||
package_name: str,
|
||||
page: int = 1,
|
||||
limit: int = 50,
|
||||
) -> ReverseDependenciesResponse:
|
||||
"""
|
||||
Get all artifacts that depend on a given package.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
project_name: Target project name
|
||||
package_name: Target package name
|
||||
page: Page number (1-indexed)
|
||||
limit: Results per page
|
||||
|
||||
Returns:
|
||||
ReverseDependenciesResponse with dependents and pagination
|
||||
"""
|
||||
# Query dependencies that point to this project/package
|
||||
query = db.query(ArtifactDependency).filter(
|
||||
ArtifactDependency.dependency_project == project_name,
|
||||
ArtifactDependency.dependency_package == package_name,
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
deps = query.offset(offset).limit(limit).all()
|
||||
|
||||
dependents = []
|
||||
for dep in deps:
|
||||
# Get artifact info to find the project/package/version
|
||||
artifact = db.query(Artifact).filter(Artifact.id == dep.artifact_id).first()
|
||||
if not artifact:
|
||||
continue
|
||||
|
||||
# Find which package this artifact belongs to via tags or versions
|
||||
tag = db.query(Tag).filter(Tag.artifact_id == dep.artifact_id).first()
|
||||
if tag:
|
||||
pkg = db.query(Package).filter(Package.id == tag.package_id).first()
|
||||
if pkg:
|
||||
proj = db.query(Project).filter(Project.id == pkg.project_id).first()
|
||||
if proj:
|
||||
# Get version if available
|
||||
version_record = db.query(PackageVersion).filter(
|
||||
PackageVersion.artifact_id == dep.artifact_id,
|
||||
PackageVersion.package_id == pkg.id,
|
||||
).first()
|
||||
|
||||
dependents.append(DependentInfo(
|
||||
artifact_id=dep.artifact_id,
|
||||
project=proj.name,
|
||||
package=pkg.name,
|
||||
version=version_record.version if version_record else None,
|
||||
constraint_type="version" if dep.version_constraint else "tag",
|
||||
constraint_value=dep.version_constraint or dep.tag_constraint,
|
||||
))
|
||||
|
||||
total_pages = (total + limit - 1) // limit
|
||||
|
||||
return ReverseDependenciesResponse(
|
||||
project=project_name,
|
||||
package=package_name,
|
||||
dependents=dependents,
|
||||
pagination=PaginationMeta(
|
||||
page=page,
|
||||
limit=limit,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
has_more=page < total_pages,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_dependency_to_artifact(
|
||||
db: Session,
|
||||
project_name: str,
|
||||
package_name: str,
|
||||
version: Optional[str],
|
||||
tag: Optional[str],
|
||||
) -> Optional[Tuple[str, str, int]]:
|
||||
"""
|
||||
Resolve a dependency constraint to an artifact ID.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
project_name: Project name
|
||||
package_name: Package name
|
||||
version: Version constraint (exact)
|
||||
tag: Tag constraint
|
||||
|
||||
Returns:
|
||||
Tuple of (artifact_id, resolved_version_or_tag, size) or None if not found
|
||||
"""
|
||||
# Get project and package
|
||||
project = db.query(Project).filter(Project.name == project_name).first()
|
||||
if not project:
|
||||
return None
|
||||
|
||||
package = db.query(Package).filter(
|
||||
Package.project_id == project.id,
|
||||
Package.name == package_name,
|
||||
).first()
|
||||
if not package:
|
||||
return None
|
||||
|
||||
if version:
|
||||
# Look up by version
|
||||
pkg_version = db.query(PackageVersion).filter(
|
||||
PackageVersion.package_id == package.id,
|
||||
PackageVersion.version == version,
|
||||
).first()
|
||||
if pkg_version:
|
||||
artifact = db.query(Artifact).filter(
|
||||
Artifact.id == pkg_version.artifact_id
|
||||
).first()
|
||||
if artifact:
|
||||
return (artifact.id, version, artifact.size)
|
||||
|
||||
# Also check if there's a tag with this exact name
|
||||
tag_record = db.query(Tag).filter(
|
||||
Tag.package_id == package.id,
|
||||
Tag.name == version,
|
||||
).first()
|
||||
if tag_record:
|
||||
artifact = db.query(Artifact).filter(
|
||||
Artifact.id == tag_record.artifact_id
|
||||
).first()
|
||||
if artifact:
|
||||
return (artifact.id, version, artifact.size)
|
||||
|
||||
if tag:
|
||||
# Look up by tag
|
||||
tag_record = db.query(Tag).filter(
|
||||
Tag.package_id == package.id,
|
||||
Tag.name == tag,
|
||||
).first()
|
||||
if tag_record:
|
||||
artifact = db.query(Artifact).filter(
|
||||
Artifact.id == tag_record.artifact_id
|
||||
).first()
|
||||
if artifact:
|
||||
return (artifact.id, tag, artifact.size)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _detect_package_cycle(
|
||||
db: Session,
|
||||
project_name: str,
|
||||
package_name: str,
|
||||
target_project: str,
|
||||
target_package: str,
|
||||
visiting: Set[str],
|
||||
visited: Set[str],
|
||||
path: List[str],
|
||||
) -> Optional[List[str]]:
|
||||
"""
|
||||
Detect cycles at the package level using DFS.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
project_name: Current project being visited
|
||||
package_name: Current package being visited
|
||||
target_project: The project we're checking for cycles back to
|
||||
target_package: The package we're checking for cycles back to
|
||||
visiting: Set of package keys currently in the recursion stack
|
||||
visited: Set of fully processed package keys
|
||||
path: Current path for cycle reporting
|
||||
|
||||
Returns:
|
||||
Cycle path if detected, None otherwise
|
||||
"""
|
||||
pkg_key = f"{project_name}/{package_name}"
|
||||
|
||||
# Check if we've reached the target package (cycle detected)
|
||||
if project_name == target_project and package_name == target_package:
|
||||
return path + [pkg_key]
|
||||
|
||||
if pkg_key in visiting:
|
||||
# Unexpected internal cycle
|
||||
return None
|
||||
|
||||
if pkg_key in visited:
|
||||
return None
|
||||
|
||||
visiting.add(pkg_key)
|
||||
path.append(pkg_key)
|
||||
|
||||
# Get the package and find any artifacts with dependencies
|
||||
project = db.query(Project).filter(Project.name == project_name).first()
|
||||
if project:
|
||||
package = db.query(Package).filter(
|
||||
Package.project_id == project.id,
|
||||
Package.name == package_name,
|
||||
).first()
|
||||
if package:
|
||||
# Find all artifacts in this package via tags
|
||||
tags = db.query(Tag).filter(Tag.package_id == package.id).all()
|
||||
artifact_ids = {t.artifact_id for t in tags}
|
||||
|
||||
# Get dependencies from all artifacts in this package
|
||||
for artifact_id in artifact_ids:
|
||||
deps = db.query(ArtifactDependency).filter(
|
||||
ArtifactDependency.artifact_id == artifact_id
|
||||
).all()
|
||||
|
||||
for dep in deps:
|
||||
cycle = _detect_package_cycle(
|
||||
db,
|
||||
dep.dependency_project,
|
||||
dep.dependency_package,
|
||||
target_project,
|
||||
target_package,
|
||||
visiting,
|
||||
visited,
|
||||
path,
|
||||
)
|
||||
if cycle:
|
||||
return cycle
|
||||
|
||||
path.pop()
|
||||
visiting.remove(pkg_key)
|
||||
visited.add(pkg_key)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_circular_dependencies(
|
||||
db: Session,
|
||||
artifact_id: str,
|
||||
new_dependencies: List[EnsureFileDependency],
|
||||
project_name: Optional[str] = None,
|
||||
package_name: Optional[str] = None,
|
||||
) -> Optional[List[str]]:
|
||||
"""
|
||||
Check if adding the new dependencies would create a circular dependency.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
artifact_id: The artifact that will have these dependencies
|
||||
new_dependencies: Dependencies to be added
|
||||
project_name: Project name (optional, will try to look up from tag if not provided)
|
||||
package_name: Package name (optional, will try to look up from tag if not provided)
|
||||
|
||||
Returns:
|
||||
Cycle path if detected, None otherwise
|
||||
"""
|
||||
# First, get the package info for this artifact to build path labels
|
||||
if project_name and package_name:
|
||||
current_path = f"{project_name}/{package_name}"
|
||||
else:
|
||||
# Try to look up from tag
|
||||
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
||||
if not artifact:
|
||||
return None
|
||||
|
||||
# Find package for this artifact
|
||||
tag = db.query(Tag).filter(Tag.artifact_id == artifact_id).first()
|
||||
if not tag:
|
||||
return None
|
||||
|
||||
package = db.query(Package).filter(Package.id == tag.package_id).first()
|
||||
if not package:
|
||||
return None
|
||||
|
||||
project = db.query(Project).filter(Project.id == package.project_id).first()
|
||||
if not project:
|
||||
return None
|
||||
|
||||
current_path = f"{project.name}/{package.name}"
|
||||
|
||||
# Extract target project and package from current_path
|
||||
if "/" in current_path:
|
||||
target_project, target_package = current_path.split("/", 1)
|
||||
else:
|
||||
return None
|
||||
|
||||
# For each new dependency, check if it would create a cycle back to our package
|
||||
for dep in new_dependencies:
|
||||
# Check if this dependency (transitively) depends on us at the package level
|
||||
visiting: Set[str] = set()
|
||||
visited: Set[str] = set()
|
||||
path: List[str] = [current_path]
|
||||
|
||||
# Check from the dependency's package
|
||||
cycle = _detect_package_cycle(
|
||||
db,
|
||||
dep.project,
|
||||
dep.package,
|
||||
target_project,
|
||||
target_package,
|
||||
visiting,
|
||||
visited,
|
||||
path,
|
||||
)
|
||||
if cycle:
|
||||
return cycle
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_dependencies(
|
||||
db: Session,
|
||||
project_name: str,
|
||||
package_name: str,
|
||||
ref: str,
|
||||
base_url: str,
|
||||
) -> DependencyResolutionResponse:
|
||||
"""
|
||||
Resolve all dependencies for an artifact recursively.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
project_name: Project name
|
||||
package_name: Package name
|
||||
ref: Tag or version reference
|
||||
base_url: Base URL for download URLs
|
||||
|
||||
Returns:
|
||||
DependencyResolutionResponse with all resolved artifacts
|
||||
|
||||
Raises:
|
||||
DependencyNotFoundError: If a dependency cannot be resolved
|
||||
CircularDependencyError: If circular dependencies are detected
|
||||
DependencyConflictError: If conflicting versions are required
|
||||
"""
|
||||
# Resolve the initial artifact
|
||||
project = db.query(Project).filter(Project.name == project_name).first()
|
||||
if not project:
|
||||
raise DependencyNotFoundError(project_name, package_name, ref)
|
||||
|
||||
package = db.query(Package).filter(
|
||||
Package.project_id == project.id,
|
||||
Package.name == package_name,
|
||||
).first()
|
||||
if not package:
|
||||
raise DependencyNotFoundError(project_name, package_name, ref)
|
||||
|
||||
# Try to find artifact by tag or version
|
||||
resolved = _resolve_dependency_to_artifact(
|
||||
db, project_name, package_name, ref, ref
|
||||
)
|
||||
if not resolved:
|
||||
raise DependencyNotFoundError(project_name, package_name, ref)
|
||||
|
||||
root_artifact_id, root_version, root_size = resolved
|
||||
|
||||
# Track resolved artifacts and their versions
|
||||
resolved_artifacts: Dict[str, ResolvedArtifact] = {}
|
||||
# Track version requirements for conflict detection
|
||||
version_requirements: Dict[str, List[Dict[str, Any]]] = {} # pkg_key -> [(version, required_by)]
|
||||
# Track visiting/visited for cycle detection
|
||||
visiting: Set[str] = set()
|
||||
visited: Set[str] = set()
|
||||
# Resolution order (topological)
|
||||
resolution_order: List[str] = []
|
||||
|
||||
def _resolve_recursive(
|
||||
artifact_id: str,
|
||||
proj_name: str,
|
||||
pkg_name: str,
|
||||
version_or_tag: str,
|
||||
size: int,
|
||||
required_by: Optional[str],
|
||||
depth: int = 0,
|
||||
):
|
||||
"""Recursively resolve dependencies with cycle/conflict detection."""
|
||||
# Safety limit: prevent DoS through deeply nested dependencies
|
||||
if depth > MAX_DEPENDENCY_DEPTH:
|
||||
raise DependencyDepthExceededError(MAX_DEPENDENCY_DEPTH)
|
||||
|
||||
pkg_key = f"{proj_name}/{pkg_name}"
|
||||
|
||||
# Cycle detection (at artifact level)
|
||||
if artifact_id in visiting:
|
||||
# Build cycle path
|
||||
raise CircularDependencyError([pkg_key, pkg_key])
|
||||
|
||||
# Conflict detection - check if we've seen this package before with a different version
|
||||
if pkg_key in version_requirements:
|
||||
existing_versions = {r["version"] for r in version_requirements[pkg_key]}
|
||||
if version_or_tag not in existing_versions:
|
||||
# Conflict detected - same package, different version
|
||||
requirements = version_requirements[pkg_key] + [
|
||||
{"version": version_or_tag, "required_by": required_by}
|
||||
]
|
||||
raise DependencyConflictError([
|
||||
DependencyConflict(
|
||||
project=proj_name,
|
||||
package=pkg_name,
|
||||
requirements=[
|
||||
{
|
||||
"version": r["version"],
|
||||
"required_by": [{"path": r["required_by"]}] if r["required_by"] else []
|
||||
}
|
||||
for r in requirements
|
||||
],
|
||||
)
|
||||
])
|
||||
# Same version already resolved - skip
|
||||
if artifact_id in visited:
|
||||
return
|
||||
|
||||
if artifact_id in visited:
|
||||
return
|
||||
|
||||
visiting.add(artifact_id)
|
||||
|
||||
# Track version requirement
|
||||
if pkg_key not in version_requirements:
|
||||
version_requirements[pkg_key] = []
|
||||
version_requirements[pkg_key].append({
|
||||
"version": version_or_tag,
|
||||
"required_by": required_by,
|
||||
})
|
||||
|
||||
# Get dependencies
|
||||
deps = db.query(ArtifactDependency).filter(
|
||||
ArtifactDependency.artifact_id == artifact_id
|
||||
).all()
|
||||
|
||||
# Resolve each dependency first (depth-first)
|
||||
for dep in deps:
|
||||
resolved_dep = _resolve_dependency_to_artifact(
|
||||
db,
|
||||
dep.dependency_project,
|
||||
dep.dependency_package,
|
||||
dep.version_constraint,
|
||||
dep.tag_constraint,
|
||||
)
|
||||
|
||||
if not resolved_dep:
|
||||
constraint = dep.version_constraint or dep.tag_constraint
|
||||
raise DependencyNotFoundError(
|
||||
dep.dependency_project,
|
||||
dep.dependency_package,
|
||||
constraint,
|
||||
)
|
||||
|
||||
dep_artifact_id, dep_version, dep_size = resolved_dep
|
||||
_resolve_recursive(
|
||||
dep_artifact_id,
|
||||
dep.dependency_project,
|
||||
dep.dependency_package,
|
||||
dep_version,
|
||||
dep_size,
|
||||
pkg_key,
|
||||
depth + 1,
|
||||
)
|
||||
|
||||
visiting.remove(artifact_id)
|
||||
visited.add(artifact_id)
|
||||
|
||||
# Add to resolution order (dependencies before dependents)
|
||||
resolution_order.append(artifact_id)
|
||||
|
||||
# Store resolved artifact info
|
||||
resolved_artifacts[artifact_id] = ResolvedArtifact(
|
||||
artifact_id=artifact_id,
|
||||
project=proj_name,
|
||||
package=pkg_name,
|
||||
version=version_or_tag,
|
||||
size=size,
|
||||
download_url=f"{base_url}/api/v1/project/{proj_name}/{pkg_name}/+/{version_or_tag}",
|
||||
)
|
||||
|
||||
# Start resolution from root
|
||||
_resolve_recursive(
|
||||
root_artifact_id,
|
||||
project_name,
|
||||
package_name,
|
||||
root_version,
|
||||
root_size,
|
||||
None,
|
||||
)
|
||||
|
||||
# Build response in topological order
|
||||
resolved_list = [resolved_artifacts[aid] for aid in resolution_order]
|
||||
total_size = sum(r.size for r in resolved_list)
|
||||
|
||||
return DependencyResolutionResponse(
|
||||
requested={
|
||||
"project": project_name,
|
||||
"package": package_name,
|
||||
"ref": ref,
|
||||
},
|
||||
resolved=resolved_list,
|
||||
total_size=total_size,
|
||||
artifact_count=len(resolved_list),
|
||||
)
|
||||
@@ -117,6 +117,9 @@ class Artifact(Base):
|
||||
tags = relationship("Tag", back_populates="artifact")
|
||||
uploads = relationship("Upload", back_populates="artifact")
|
||||
versions = relationship("PackageVersion", back_populates="artifact")
|
||||
dependencies = relationship(
|
||||
"ArtifactDependency", back_populates="artifact", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
@@ -507,3 +510,54 @@ class PackageHistory(Base):
|
||||
Index("idx_package_history_changed_at", "changed_at"),
|
||||
Index("idx_package_history_package_changed_at", "package_id", "changed_at"),
|
||||
)
|
||||
|
||||
|
||||
class ArtifactDependency(Base):
|
||||
"""Dependency declared by an artifact on another package.
|
||||
|
||||
Each artifact can declare dependencies on other packages, specifying either
|
||||
an exact version or a tag. This enables recursive dependency resolution.
|
||||
"""
|
||||
|
||||
__tablename__ = "artifact_dependencies"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
artifact_id = Column(
|
||||
String(64),
|
||||
ForeignKey("artifacts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
dependency_project = Column(String(255), nullable=False)
|
||||
dependency_package = Column(String(255), nullable=False)
|
||||
version_constraint = Column(String(255), nullable=True)
|
||||
tag_constraint = Column(String(255), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
|
||||
# Relationship to the artifact that declares this dependency
|
||||
artifact = relationship("Artifact", back_populates="dependencies")
|
||||
|
||||
__table_args__ = (
|
||||
# Exactly one of version_constraint or tag_constraint must be set
|
||||
CheckConstraint(
|
||||
"(version_constraint IS NOT NULL AND tag_constraint IS NULL) OR "
|
||||
"(version_constraint IS NULL AND tag_constraint IS NOT NULL)",
|
||||
name="check_constraint_type",
|
||||
),
|
||||
# Each artifact can only depend on a specific project/package once
|
||||
Index(
|
||||
"idx_artifact_dependencies_artifact_id",
|
||||
"artifact_id",
|
||||
),
|
||||
Index(
|
||||
"idx_artifact_dependencies_target",
|
||||
"dependency_project",
|
||||
"dependency_package",
|
||||
),
|
||||
Index(
|
||||
"idx_artifact_dependencies_unique",
|
||||
"artifact_id",
|
||||
"dependency_project",
|
||||
"dependency_package",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ from fastapi import (
|
||||
Cookie,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import StreamingResponse, RedirectResponse
|
||||
from fastapi.responses import StreamingResponse, RedirectResponse, PlainTextResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, and_, func, text
|
||||
from typing import List, Optional, Literal
|
||||
@@ -47,6 +47,7 @@ from .models import (
|
||||
User,
|
||||
AccessPermission,
|
||||
PackageVersion,
|
||||
ArtifactDependency,
|
||||
)
|
||||
from .schemas import (
|
||||
ProjectCreate,
|
||||
@@ -120,8 +121,28 @@ from .schemas import (
|
||||
OIDCLoginResponse,
|
||||
PackageVersionResponse,
|
||||
PackageVersionDetailResponse,
|
||||
ArtifactDependenciesResponse,
|
||||
DependencyResponse,
|
||||
ReverseDependenciesResponse,
|
||||
DependencyResolutionResponse,
|
||||
CircularDependencyError as CircularDependencyErrorSchema,
|
||||
DependencyConflictError as DependencyConflictErrorSchema,
|
||||
)
|
||||
from .metadata import extract_metadata
|
||||
from .dependencies import (
|
||||
parse_ensure_file,
|
||||
validate_dependencies,
|
||||
store_dependencies,
|
||||
get_artifact_dependencies,
|
||||
get_reverse_dependencies,
|
||||
check_circular_dependencies,
|
||||
resolve_dependencies,
|
||||
InvalidEnsureFileError,
|
||||
CircularDependencyError,
|
||||
DependencyConflictError,
|
||||
DependencyNotFoundError,
|
||||
DependencyDepthExceededError,
|
||||
)
|
||||
from .config import get_settings
|
||||
from .checksum import (
|
||||
ChecksumMismatchError,
|
||||
@@ -144,6 +165,18 @@ def sanitize_filename(filename: str) -> str:
|
||||
return re.sub(r'[\r\n"]', "", filename)
|
||||
|
||||
|
||||
def read_ensure_file(ensure_file: UploadFile) -> bytes:
|
||||
"""Read the content of an ensure file upload.
|
||||
|
||||
Args:
|
||||
ensure_file: The uploaded ensure file
|
||||
|
||||
Returns:
|
||||
Raw bytes content of the file
|
||||
"""
|
||||
return ensure_file.file.read()
|
||||
|
||||
|
||||
def build_content_disposition(filename: str) -> str:
|
||||
"""Build a Content-Disposition header value with proper encoding.
|
||||
|
||||
@@ -2272,6 +2305,7 @@ def upload_artifact(
|
||||
package_name: str,
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
ensure: Optional[UploadFile] = File(None, description="Optional orchard.ensure file with dependencies"),
|
||||
tag: Optional[str] = Form(None),
|
||||
version: Optional[str] = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -2303,6 +2337,26 @@ def upload_artifact(
|
||||
- `throughput_mbps`: Upload throughput in MB/s
|
||||
- `deduplicated`: True if content already existed
|
||||
|
||||
**Dependencies (orchard.ensure file):**
|
||||
Optionally include an `ensure` file to declare dependencies for this artifact.
|
||||
The file must be valid YAML with the following format:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
- project: some-project
|
||||
package: some-lib
|
||||
version: "1.2.3" # Exact version (mutually exclusive with tag)
|
||||
|
||||
- project: another-project
|
||||
package: another-lib
|
||||
tag: stable # Tag reference (mutually exclusive with version)
|
||||
```
|
||||
|
||||
**Dependency validation:**
|
||||
- Each dependency must specify either `version` or `tag`, not both
|
||||
- Referenced projects must exist (packages are not validated at upload time)
|
||||
- Circular dependencies are rejected at upload time
|
||||
|
||||
**Example (curl):**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/v1/project/myproject/mypackage/upload" \\
|
||||
@@ -2311,6 +2365,15 @@ def upload_artifact(
|
||||
-F "tag=v1.0.0"
|
||||
```
|
||||
|
||||
**Example with dependencies (curl):**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/v1/project/myproject/mypackage/upload" \\
|
||||
-H "Authorization: Bearer <api-key>" \\
|
||||
-F "file=@myfile.tar.gz" \\
|
||||
-F "ensure=@orchard.ensure" \\
|
||||
-F "tag=v1.0.0"
|
||||
```
|
||||
|
||||
**Example (Python requests):**
|
||||
```python
|
||||
import requests
|
||||
@@ -2611,6 +2674,45 @@ def upload_artifact(
|
||||
f"ref_count={artifact.ref_count}, saved_bytes={saved_bytes}"
|
||||
)
|
||||
|
||||
# Process ensure file if provided
|
||||
dependencies_stored = []
|
||||
if ensure:
|
||||
try:
|
||||
ensure_content = read_ensure_file(ensure)
|
||||
parsed_ensure = parse_ensure_file(ensure_content)
|
||||
|
||||
if parsed_ensure.dependencies:
|
||||
# Validate dependencies (projects must exist)
|
||||
validation_errors = validate_dependencies(db, parsed_ensure.dependencies)
|
||||
if validation_errors:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid ensure file: {'; '.join(validation_errors)}"
|
||||
)
|
||||
|
||||
# Check for circular dependencies
|
||||
cycle = check_circular_dependencies(
|
||||
db, storage_result.sha256, parsed_ensure.dependencies,
|
||||
project_name=project_name, package_name=package_name
|
||||
)
|
||||
if cycle:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Circular dependency detected: {' -> '.join(cycle)}"
|
||||
)
|
||||
|
||||
# Store dependencies
|
||||
dependencies_stored = store_dependencies(
|
||||
db, storage_result.sha256, parsed_ensure.dependencies
|
||||
)
|
||||
logger.info(
|
||||
f"Stored {len(dependencies_stored)} dependencies for artifact "
|
||||
f"{storage_result.sha256[:12]}..."
|
||||
)
|
||||
|
||||
except InvalidEnsureFileError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid ensure file: {e}")
|
||||
|
||||
# Audit log
|
||||
_log_audit(
|
||||
db,
|
||||
@@ -6508,3 +6610,324 @@ def factory_reset(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Factory reset failed: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dependency Management Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/artifact/{artifact_id}/dependencies",
|
||||
response_model=ArtifactDependenciesResponse,
|
||||
tags=["dependencies"],
|
||||
)
|
||||
def get_artifact_dependencies_endpoint(
|
||||
artifact_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Get all dependencies for an artifact by its ID.
|
||||
|
||||
Returns the list of packages this artifact depends on.
|
||||
"""
|
||||
# Verify artifact exists
|
||||
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
||||
if not artifact:
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
|
||||
deps = get_artifact_dependencies(db, artifact_id)
|
||||
|
||||
return ArtifactDependenciesResponse(
|
||||
artifact_id=artifact_id,
|
||||
dependencies=deps,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/project/{project_name}/{package_name}/+/{ref}/dependencies",
|
||||
response_model=ArtifactDependenciesResponse,
|
||||
tags=["dependencies"],
|
||||
)
|
||||
def get_dependencies_by_ref(
|
||||
project_name: str,
|
||||
package_name: str,
|
||||
ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Get dependencies for an artifact by project/package/ref.
|
||||
|
||||
The ref can be a tag name or version.
|
||||
"""
|
||||
# Check project access (handles private project authorization)
|
||||
project = check_project_access(db, project_name, current_user, "read")
|
||||
|
||||
package = db.query(Package).filter(
|
||||
Package.project_id == project.id,
|
||||
Package.name == package_name,
|
||||
).first()
|
||||
if not package:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
|
||||
# Try to resolve ref to an artifact
|
||||
artifact_id = None
|
||||
|
||||
# Try as tag first
|
||||
tag = db.query(Tag).filter(
|
||||
Tag.package_id == package.id,
|
||||
Tag.name == ref,
|
||||
).first()
|
||||
if tag:
|
||||
artifact_id = tag.artifact_id
|
||||
|
||||
# Try as version if not found as tag
|
||||
if not artifact_id:
|
||||
version_record = db.query(PackageVersion).filter(
|
||||
PackageVersion.package_id == package.id,
|
||||
PackageVersion.version == ref,
|
||||
).first()
|
||||
if version_record:
|
||||
artifact_id = version_record.artifact_id
|
||||
|
||||
# Try as artifact ID prefix
|
||||
if not artifact_id and len(ref) >= 8:
|
||||
artifact = db.query(Artifact).filter(
|
||||
Artifact.id.like(f"{ref}%")
|
||||
).first()
|
||||
if artifact:
|
||||
artifact_id = artifact.id
|
||||
|
||||
if not artifact_id:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Reference '{ref}' not found in {project_name}/{package_name}"
|
||||
)
|
||||
|
||||
deps = get_artifact_dependencies(db, artifact_id)
|
||||
|
||||
return ArtifactDependenciesResponse(
|
||||
artifact_id=artifact_id,
|
||||
dependencies=deps,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/project/{project_name}/{package_name}/+/{ref}/ensure",
|
||||
response_class=PlainTextResponse,
|
||||
tags=["dependencies"],
|
||||
)
|
||||
def get_ensure_file(
|
||||
project_name: str,
|
||||
package_name: str,
|
||||
ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Get the orchard.ensure file content for an artifact.
|
||||
|
||||
Returns the dependencies in YAML format that can be used as an ensure file.
|
||||
"""
|
||||
# Check project access
|
||||
project = check_project_access(db, project_name, current_user, "read")
|
||||
|
||||
package = db.query(Package).filter(
|
||||
Package.project_id == project.id,
|
||||
Package.name == package_name,
|
||||
).first()
|
||||
if not package:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
|
||||
# Resolve ref to artifact
|
||||
artifact_id = None
|
||||
|
||||
# Try as tag first
|
||||
tag = db.query(Tag).filter(
|
||||
Tag.package_id == package.id,
|
||||
Tag.name == ref,
|
||||
).first()
|
||||
if tag:
|
||||
artifact_id = tag.artifact_id
|
||||
|
||||
# Try as version
|
||||
if not artifact_id:
|
||||
version = db.query(PackageVersion).filter(
|
||||
PackageVersion.package_id == package.id,
|
||||
PackageVersion.version == ref,
|
||||
).first()
|
||||
if version:
|
||||
artifact_id = version.artifact_id
|
||||
|
||||
# Try as artifact ID prefix
|
||||
if not artifact_id and len(ref) >= 8:
|
||||
artifact = db.query(Artifact).filter(
|
||||
Artifact.id.like(f"{ref}%")
|
||||
).first()
|
||||
if artifact:
|
||||
artifact_id = artifact.id
|
||||
|
||||
if not artifact_id:
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
|
||||
# Get artifact details
|
||||
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
||||
|
||||
# Get version info if available
|
||||
version_record = db.query(PackageVersion).filter(
|
||||
PackageVersion.package_id == package.id,
|
||||
PackageVersion.artifact_id == artifact_id,
|
||||
).first()
|
||||
version_str = version_record.version if version_record else None
|
||||
|
||||
# Get dependencies
|
||||
deps = get_artifact_dependencies(db, artifact_id)
|
||||
|
||||
# Build YAML content with full format
|
||||
lines = []
|
||||
|
||||
# Header comment
|
||||
lines.append(f"# orchard.ensure - Generated from {project_name}/{package_name}@{ref}")
|
||||
lines.append(f"# Artifact: {artifact_id}")
|
||||
if version_str:
|
||||
lines.append(f"# Version: {version_str}")
|
||||
lines.append(f"# Generated: {datetime.now(timezone.utc).isoformat()}")
|
||||
lines.append("")
|
||||
|
||||
# Top-level project
|
||||
lines.append(f"project: {project_name}")
|
||||
lines.append("")
|
||||
|
||||
# Projects section
|
||||
lines.append("projects:")
|
||||
lines.append(f" - name: {project_name}")
|
||||
|
||||
if deps:
|
||||
lines.append(" dependencies:")
|
||||
for dep in deps:
|
||||
# Determine if cross-project dependency
|
||||
is_cross_project = dep.project != project_name
|
||||
|
||||
lines.append(f" - package: {dep.package}")
|
||||
if is_cross_project:
|
||||
lines.append(f" project: {dep.project} # Cross-project dependency")
|
||||
if dep.version:
|
||||
lines.append(f" version: \"{dep.version}\"")
|
||||
elif dep.tag:
|
||||
lines.append(f" tag: {dep.tag}")
|
||||
# Suggest a path based on package name
|
||||
lines.append(f" path: {dep.package}/")
|
||||
else:
|
||||
lines.append(" dependencies: []")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return PlainTextResponse(
|
||||
"\n".join(lines),
|
||||
media_type="text/yaml",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/project/{project_name}/{package_name}/reverse-dependencies",
|
||||
response_model=ReverseDependenciesResponse,
|
||||
tags=["dependencies"],
|
||||
)
|
||||
def get_package_reverse_dependencies(
|
||||
project_name: str,
|
||||
package_name: str,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Get packages that depend on this package (reverse dependencies).
|
||||
|
||||
Returns a paginated list of artifacts that declare a dependency on this package.
|
||||
"""
|
||||
# Check project access (handles private project authorization)
|
||||
project = check_project_access(db, project_name, current_user, "read")
|
||||
|
||||
package = db.query(Package).filter(
|
||||
Package.project_id == project.id,
|
||||
Package.name == package_name,
|
||||
).first()
|
||||
if not package:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
|
||||
return get_reverse_dependencies(db, project_name, package_name, page, limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/project/{project_name}/{package_name}/+/{ref}/resolve",
|
||||
response_model=DependencyResolutionResponse,
|
||||
tags=["dependencies"],
|
||||
)
|
||||
def resolve_artifact_dependencies(
|
||||
project_name: str,
|
||||
package_name: str,
|
||||
ref: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Resolve all dependencies for an artifact recursively.
|
||||
|
||||
Returns a flat list of all artifacts needed, in topological order
|
||||
(dependencies before dependents). Includes download URLs for each artifact.
|
||||
|
||||
**Error Responses:**
|
||||
- 404: Artifact or dependency not found
|
||||
- 409: Circular dependency or version conflict detected
|
||||
"""
|
||||
# Check project access (handles private project authorization)
|
||||
check_project_access(db, project_name, current_user, "read")
|
||||
|
||||
# Build base URL for download links
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
|
||||
try:
|
||||
return resolve_dependencies(db, project_name, package_name, ref, base_url)
|
||||
except DependencyNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Dependency not found: {e.project}/{e.package}@{e.constraint}"
|
||||
)
|
||||
except CircularDependencyError as e:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "circular_dependency",
|
||||
"message": str(e),
|
||||
"cycle": e.cycle,
|
||||
}
|
||||
)
|
||||
except DependencyConflictError as e:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "dependency_conflict",
|
||||
"message": str(e),
|
||||
"conflicts": [
|
||||
{
|
||||
"project": c.project,
|
||||
"package": c.package,
|
||||
"requirements": c.requirements,
|
||||
}
|
||||
for c in e.conflicts
|
||||
],
|
||||
}
|
||||
)
|
||||
except DependencyDepthExceededError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "dependency_depth_exceeded",
|
||||
"message": str(e),
|
||||
"max_depth": e.max_depth,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -916,3 +916,140 @@ class ProjectWithAccessResponse(ProjectResponse):
|
||||
"""Project response with user's access level"""
|
||||
user_access_level: Optional[str] = None
|
||||
|
||||
|
||||
# Artifact Dependency schemas
|
||||
class DependencyCreate(BaseModel):
|
||||
"""Schema for creating a dependency"""
|
||||
project: str
|
||||
package: str
|
||||
version: Optional[str] = None
|
||||
tag: Optional[str] = None
|
||||
|
||||
@field_validator('version', 'tag')
|
||||
@classmethod
|
||||
def validate_constraint(cls, v, info):
|
||||
return v
|
||||
|
||||
def model_post_init(self, __context):
|
||||
"""Validate that exactly one of version or tag is set"""
|
||||
if self.version is None and self.tag is None:
|
||||
raise ValueError("Either 'version' or 'tag' must be specified")
|
||||
if self.version is not None and self.tag is not None:
|
||||
raise ValueError("Cannot specify both 'version' and 'tag'")
|
||||
|
||||
|
||||
class DependencyResponse(BaseModel):
|
||||
"""Schema for dependency response"""
|
||||
id: UUID
|
||||
artifact_id: str
|
||||
project: str
|
||||
package: str
|
||||
version: Optional[str] = None
|
||||
tag: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@classmethod
|
||||
def from_orm_model(cls, dep) -> "DependencyResponse":
|
||||
"""Create from ORM model with field mapping"""
|
||||
return cls(
|
||||
id=dep.id,
|
||||
artifact_id=dep.artifact_id,
|
||||
project=dep.dependency_project,
|
||||
package=dep.dependency_package,
|
||||
version=dep.version_constraint,
|
||||
tag=dep.tag_constraint,
|
||||
created_at=dep.created_at,
|
||||
)
|
||||
|
||||
|
||||
class ArtifactDependenciesResponse(BaseModel):
|
||||
"""Response containing all dependencies for an artifact"""
|
||||
artifact_id: str
|
||||
dependencies: List[DependencyResponse]
|
||||
|
||||
|
||||
class DependentInfo(BaseModel):
|
||||
"""Information about an artifact that depends on a package"""
|
||||
artifact_id: str
|
||||
project: str
|
||||
package: str
|
||||
version: Optional[str] = None
|
||||
constraint_type: str # 'version' or 'tag'
|
||||
constraint_value: str
|
||||
|
||||
|
||||
class ReverseDependenciesResponse(BaseModel):
|
||||
"""Response containing packages that depend on a given package"""
|
||||
project: str
|
||||
package: str
|
||||
dependents: List[DependentInfo]
|
||||
pagination: PaginationMeta
|
||||
|
||||
|
||||
class EnsureFileDependency(BaseModel):
|
||||
"""Dependency entry from orchard.ensure file"""
|
||||
project: str
|
||||
package: str
|
||||
version: Optional[str] = None
|
||||
tag: Optional[str] = None
|
||||
|
||||
@field_validator('version', 'tag')
|
||||
@classmethod
|
||||
def validate_constraint(cls, v, info):
|
||||
return v
|
||||
|
||||
def model_post_init(self, __context):
|
||||
"""Validate that exactly one of version or tag is set"""
|
||||
if self.version is None and self.tag is None:
|
||||
raise ValueError("Either 'version' or 'tag' must be specified")
|
||||
if self.version is not None and self.tag is not None:
|
||||
raise ValueError("Cannot specify both 'version' and 'tag'")
|
||||
|
||||
|
||||
class EnsureFileContent(BaseModel):
|
||||
"""Parsed content of orchard.ensure file"""
|
||||
dependencies: List[EnsureFileDependency] = []
|
||||
|
||||
|
||||
class ResolvedArtifact(BaseModel):
|
||||
"""A resolved artifact in the dependency tree"""
|
||||
artifact_id: str
|
||||
project: str
|
||||
package: str
|
||||
version: Optional[str] = None
|
||||
tag: Optional[str] = None
|
||||
size: int
|
||||
download_url: str
|
||||
|
||||
|
||||
class DependencyResolutionResponse(BaseModel):
|
||||
"""Response from dependency resolution endpoint"""
|
||||
requested: Dict[str, str] # project, package, ref
|
||||
resolved: List[ResolvedArtifact]
|
||||
total_size: int
|
||||
artifact_count: int
|
||||
|
||||
|
||||
class DependencyConflict(BaseModel):
|
||||
"""Details about a dependency conflict"""
|
||||
project: str
|
||||
package: str
|
||||
requirements: List[Dict[str, Any]] # version/tag and required_by info
|
||||
|
||||
|
||||
class DependencyConflictError(BaseModel):
|
||||
"""Error response for dependency conflicts"""
|
||||
error: str = "dependency_conflict"
|
||||
message: str
|
||||
conflicts: List[DependencyConflict]
|
||||
|
||||
|
||||
class CircularDependencyError(BaseModel):
|
||||
"""Error response for circular dependencies"""
|
||||
error: str = "circular_dependency"
|
||||
message: str
|
||||
cycle: List[str] # List of "project/package" strings showing the cycle
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import hashlib
|
||||
import logging
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import Project, Package, Artifact, Tag, Upload, PackageVersion
|
||||
from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency
|
||||
from .storage import get_storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -123,6 +123,17 @@ TEST_ARTIFACTS = [
|
||||
},
|
||||
]
|
||||
|
||||
# Dependencies to create (source artifact -> dependency)
|
||||
# Format: (source_project, source_package, source_version, dep_project, dep_package, version_constraint, tag_constraint)
|
||||
TEST_DEPENDENCIES = [
|
||||
# ui-components v1.1.0 depends on design-tokens v1.0.0
|
||||
("frontend-libs", "ui-components", "1.1.0", "frontend-libs", "design-tokens", "1.0.0", None),
|
||||
# auth-lib v1.0.0 depends on common-utils v2.0.0
|
||||
("backend-services", "auth-lib", "1.0.0", "backend-services", "common-utils", "2.0.0", None),
|
||||
# auth-lib v1.0.0 also depends on design-tokens (stable tag)
|
||||
("backend-services", "auth-lib", "1.0.0", "frontend-libs", "design-tokens", None, "latest"),
|
||||
]
|
||||
|
||||
|
||||
def is_database_empty(db: Session) -> bool:
|
||||
"""Check if the database has any projects."""
|
||||
@@ -240,6 +251,40 @@ def seed_database(db: Session) -> None:
|
||||
db.add(tag)
|
||||
tag_count += 1
|
||||
|
||||
db.flush()
|
||||
|
||||
# Create dependencies
|
||||
dependency_count = 0
|
||||
for dep_data in TEST_DEPENDENCIES:
|
||||
src_project, src_package, src_version, dep_project, dep_package, version_constraint, tag_constraint = dep_data
|
||||
|
||||
# Find the source artifact by looking up its version
|
||||
src_pkg = package_map.get((src_project, src_package))
|
||||
if not src_pkg:
|
||||
logger.warning(f"Source package not found: {src_project}/{src_package}")
|
||||
continue
|
||||
|
||||
# Find the artifact for this version
|
||||
src_version_record = db.query(PackageVersion).filter(
|
||||
PackageVersion.package_id == src_pkg.id,
|
||||
PackageVersion.version == src_version,
|
||||
).first()
|
||||
|
||||
if not src_version_record:
|
||||
logger.warning(f"Source version not found: {src_project}/{src_package}@{src_version}")
|
||||
continue
|
||||
|
||||
# Create the dependency
|
||||
dependency = ArtifactDependency(
|
||||
artifact_id=src_version_record.artifact_id,
|
||||
dependency_project=dep_project,
|
||||
dependency_package=dep_package,
|
||||
version_constraint=version_constraint,
|
||||
tag_constraint=tag_constraint,
|
||||
)
|
||||
db.add(dependency)
|
||||
dependency_count += 1
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Created {artifact_count} artifacts, {tag_count} tags, and {version_count} versions")
|
||||
logger.info(f"Created {artifact_count} artifacts, {tag_count} tags, {version_count} versions, and {dependency_count} dependencies")
|
||||
logger.info("Database seeding complete")
|
||||
|
||||
1080
backend/tests/test_dependencies.py
Normal file
1080
backend/tests/test_dependencies.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user