diff --git a/internal/conda/conda.go b/internal/conda/conda.go index 86cd608..add9156 100644 --- a/internal/conda/conda.go +++ b/internal/conda/conda.go @@ -10,6 +10,7 @@ import ( func init() { core.Register("conda", core.Manifest, &condaEnvParser{}, core.ExactMatch("environment.yml")) core.Register("conda", core.Manifest, &condaEnvParser{}, core.ExactMatch("environment.yaml")) + core.Register("conda", core.Lockfile, &condaLockParser{}, core.ExactMatch("conda-lock.yml")) } // condaEnvParser parses Conda environment.yml files. @@ -57,3 +58,83 @@ func parseCondaSpec(spec string) (name, version string) { } return name, version } + +// condaLockParser parses conda-lock.yml files. +type condaLockParser struct{} + +type condaLockFile struct { + Version int `yaml:"version"` + Package []condaLockPkg `yaml:"package"` +} + +type condaLockPkg struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Manager string `yaml:"manager"` + Platform string `yaml:"platform"` + URL string `yaml:"url"` + Hash condaLockHash `yaml:"hash"` + Category string `yaml:"category"` + Optional bool `yaml:"optional"` +} + +type condaLockHash struct { + MD5 string `yaml:"md5"` + SHA256 string `yaml:"sha256"` +} + +func (p *condaLockParser) Parse(filename string, content []byte) ([]core.Dependency, error) { + var lock condaLockFile + if err := yaml.Unmarshal(content, &lock); err != nil { + return nil, &core.ParseError{Filename: filename, Err: err} + } + + var deps []core.Dependency + seen := make(map[string]bool) + + for _, pkg := range lock.Package { + // Skip pip packages - they belong to pypi ecosystem + if pkg.Manager == "pip" { + continue + } + + // Deduplicate across platforms + if seen[pkg.Name] { + continue + } + seen[pkg.Name] = true + + scope := core.Runtime + if pkg.Category == "dev" { + scope = core.Development + } + + integrity := "" + if pkg.Hash.SHA256 != "" { + integrity = "sha256-" + pkg.Hash.SHA256 + } else if pkg.Hash.MD5 != "" { + integrity = "md5-" + pkg.Hash.MD5 + } + + // Extract channel URL from package URL + registryURL := "" + if strings.Contains(pkg.URL, "conda.anaconda.org") { + // Extract channel: https://conda.anaconda.org/conda-forge/linux-64/... + parts := strings.Split(pkg.URL, "/") + if len(parts) >= 4 { + registryURL = strings.Join(parts[:4], "/") + } + } + + deps = append(deps, core.Dependency{ + Name: pkg.Name, + Version: pkg.Version, + Scope: scope, + Integrity: integrity, + Direct: false, + RegistryURL: registryURL, + }) + } + + return deps, nil +} diff --git a/internal/conda/conda_test.go b/internal/conda/conda_test.go index 26a1583..778b03e 100644 --- a/internal/conda/conda_test.go +++ b/internal/conda/conda_test.go @@ -101,3 +101,64 @@ func TestCondaEnvironmentWithPip(t *testing.T) { t.Error("expected urllib3 (pip dep) to be excluded") } } + +func TestCondaLock(t *testing.T) { + content, err := os.ReadFile("../../testdata/conda/conda-lock.yml") + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + parser := &condaLockParser{} + deps, err := parser.Parse("conda-lock.yml", content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + // 4 conda packages (pip packages are excluded) + if len(deps) != 4 { + t.Fatalf("expected 4 dependencies, got %d", len(deps)) + } + + depMap := make(map[string]core.Dependency) + for _, d := range deps { + depMap[d.Name] = d + } + + // Verify conda packages + expected := []struct { + name string + version string + scope core.Scope + hasIntegrity bool + }{ + {"python", "3.11.0", core.Runtime, true}, + {"numpy", "1.24.3", core.Runtime, true}, + {"pandas", "2.0.1", core.Runtime, true}, + {"pytest", "7.3.1", core.Development, true}, + } + + for _, exp := range expected { + dep, ok := depMap[exp.name] + if !ok { + t.Errorf("expected %s dependency", exp.name) + continue + } + if dep.Version != exp.version { + t.Errorf("%s version = %q, want %q", exp.name, dep.Version, exp.version) + } + if dep.Scope != exp.scope { + t.Errorf("%s scope = %v, want %v", exp.name, dep.Scope, exp.scope) + } + if exp.hasIntegrity && dep.Integrity == "" { + t.Errorf("%s should have integrity hash", exp.name) + } + } + + // pip packages should be excluded + if _, ok := depMap["requests"]; ok { + t.Error("expected requests (pip package) to be excluded") + } + if _, ok := depMap["black"]; ok { + t.Error("expected black (pip package) to be excluded") + } +} diff --git a/testdata/conda/conda-lock.yml b/testdata/conda/conda-lock.yml new file mode 100644 index 0000000..b933dd4 --- /dev/null +++ b/testdata/conda/conda-lock.yml @@ -0,0 +1,90 @@ +# This lock file was generated by conda-lock (https://github.com/conda/conda-lock). DO NOT EDIT! +# +# A "lock file" contains a concrete list of package versions (with checksums) to be installed. +# +version: 1 +metadata: + content_hash: + linux-64: abc123def456 + channels: + - url: https://conda.anaconda.org/conda-forge + used_env_vars: [] + - url: https://conda.anaconda.org/pytorch + used_env_vars: [] + platforms: + - linux-64 + sources: + - environment.yml +package: + - name: python + version: 3.11.0 + manager: conda + platform: linux-64 + dependencies: + ld_impl_linux-64: '>=2.36.1' + libffi: '>=3.4' + url: https://conda.anaconda.org/conda-forge/linux-64/python-3.11.0-h1a5efe5_0.conda + hash: + md5: 1234567890abcdef1234567890abcdef + sha256: abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + category: main + optional: false + - name: numpy + version: 1.24.3 + manager: conda + platform: linux-64 + dependencies: + python: '>=3.8' + libblas: '>=3.9.0' + url: https://conda.anaconda.org/conda-forge/linux-64/numpy-1.24.3-py311h64a7726_0.conda + hash: + md5: fedcba0987654321fedcba0987654321 + sha256: 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef + category: main + optional: false + - name: pandas + version: 2.0.1 + manager: conda + platform: linux-64 + dependencies: + python: '>=3.8' + numpy: '>=1.21.0' + url: https://conda.anaconda.org/conda-forge/linux-64/pandas-2.0.1-py311h320fe9a_0.conda + hash: + md5: aabbccdd11223344aabbccdd11223344 + sha256: 5566778899aabbcc5566778899aabbcc5566778899aabbcc5566778899aabbcc + category: main + optional: false + - name: pytest + version: 7.3.1 + manager: conda + platform: linux-64 + dependencies: + python: '>=3.7' + url: https://conda.anaconda.org/conda-forge/noarch/pytest-7.3.1-pyhd8ed1ab_0.conda + hash: + md5: 1111222233334444555566667777888a + sha256: aaaabbbbccccddddeeeeffffaaaabbbbccccddddeeeeffffaaaabbbbccccdddd + category: dev + optional: false + - name: requests + version: 2.31.0 + manager: pip + platform: linux-64 + dependencies: {} + url: https://files.pythonhosted.org/packages/requests-2.31.0-py3-none-any.whl + hash: + sha256: 942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1 + category: main + optional: false + - name: black + version: 23.3.0 + manager: pip + platform: linux-64 + dependencies: + click: '>=8.0.0' + url: https://files.pythonhosted.org/packages/black-23.3.0-py3-none-any.whl + hash: + sha256: 1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940 + category: dev + optional: true