diff --git a/buildtools/cli.go b/buildtools/cli.go index 5d0dcf1f1..5f24d11ae 100644 --- a/buildtools/cli.go +++ b/buildtools/cli.go @@ -2187,7 +2187,7 @@ func pythonCmd(c *cli.Context, projectType project.ProjectType) error { workingDir, err := os.Getwd() if err != nil { log.Warn("Failed to get working directory, skipping build info collection: " + err.Error()) - } else if err := buildinfo.GetPoetryBuildInfo(workingDir, buildConfiguration, deployerRepo); err != nil { + } else if err := buildinfo.GetPoetryBuildInfo(workingDir, buildConfiguration, deployerRepo, cmdName, poetryArgs); err != nil { log.Warn("Failed to collect Poetry build info: " + err.Error()) } else { buildNumber, err := buildConfiguration.GetBuildNumber() diff --git a/go.mod b/go.mod index 3494c68db..8531d3486 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/buger/jsonparser v1.2.0 github.com/gocarina/gocsv v0.0.0-20260523204920-c264028e67ea github.com/jfrog/archiver/v3 v3.6.3 - github.com/jfrog/build-info-go v1.13.1-0.20260528065004-80409c046540 + github.com/jfrog/build-info-go v1.13.1-0.20260609173330-a8a3ed3919af github.com/jfrog/gofrog v1.7.6 github.com/jfrog/jfrog-cli-application v1.0.2-0.20260608074325-4de652aef752 github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260609101705-321f68d15a6d @@ -248,7 +248,7 @@ require ( // replace github.com/jfrog/jfrog-cli-artifactory => github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260608132618-003a2af3b8a3 -// replace github.com/jfrog/build-info-go => github.com/jfrog/build-info-go v1.13.1-0.20260428071432-1e9d9a1991ad +// replace github.com/jfrog/build-info-go => github.com/jfrog/build-info-go v1.13.1-0.20260603044611-897a097a7031 // replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260604085947-7c110b77b4b4 diff --git a/go.sum b/go.sum index 3f6a8a423..2bc71850e 100644 --- a/go.sum +++ b/go.sum @@ -394,8 +394,8 @@ github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jfrog/archiver/v3 v3.6.3 h1:hkAmPjBw393tPmQ07JknLNWFNZjXdy2xFEnOW9wwOxI= github.com/jfrog/archiver/v3 v3.6.3/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= -github.com/jfrog/build-info-go v1.13.1-0.20260528065004-80409c046540 h1:yJjTgSfmsBx9Q6/iiJxXQ/m0KZfFjNx8nNzaRLCM7z4= -github.com/jfrog/build-info-go v1.13.1-0.20260528065004-80409c046540/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= +github.com/jfrog/build-info-go v1.13.1-0.20260609173330-a8a3ed3919af h1:ubWSqnwIip7pdr6PybXiynb3rUVwdNBYEGnb23JA0L4= +github.com/jfrog/build-info-go v1.13.1-0.20260609173330-a8a3ed3919af/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= github.com/jfrog/froggit-go v1.22.0 h1:eeN5F8sOUo+h2cXkzArAu4nvSdjkDTAZtgqwrct70qg= github.com/jfrog/froggit-go v1.22.0/go.mod h1:wRDryqyp3oe+eHgME2mpnEQmO8XBECIPagFwj0nHmdI= github.com/jfrog/go-mockhttp v0.3.1 h1:/wac8v4GMZx62viZmv4wazB5GNKs+GxawuS1u3maJH8= diff --git a/poetry_test.go b/poetry_test.go index 82c8bc204..c6632143e 100644 --- a/poetry_test.go +++ b/poetry_test.go @@ -711,8 +711,8 @@ func setPoetryHTTPBasicAuth(t *testing.T) func() { // `go test` process even when several FlexPack-mode tests invoke // buildJfBinaryAndAddToPath sequentially. var ( - jfBinaryOnce sync.Once - jfBinaryDir string + jfBinaryOnce sync.Once + jfBinaryDir string jfBinaryBuildErr error ) diff --git a/utils/buildinfo/buildinfo.go b/utils/buildinfo/buildinfo.go index a8a501e8d..357a9aad6 100644 --- a/utils/buildinfo/buildinfo.go +++ b/utils/buildinfo/buildinfo.go @@ -1,6 +1,7 @@ package buildinfo import ( + "encoding/json" "fmt" "net/url" "os" @@ -44,7 +45,9 @@ func GetBuildInfoForPackageManager(pkgManager, workingDir string, buildConfigura switch pkgManager { case "poetry": - return GetPoetryBuildInfo(workingDir, buildConfiguration, "") // Empty deployer repo - will use from pyproject.toml + // No cmd/args context available from this entry point — falls back to + // the existing lock-file-driven behaviour (no installed-set filtering). + return GetPoetryBuildInfo(workingDir, buildConfiguration, "", "", nil) // Empty deployer repo - will use from pyproject.toml case "mvn", "maven": // Maven FlexPack is handled directly in jfrog-cli-artifactory Maven command return GetBuildInfoForUploadedArtifacts("", buildConfiguration) @@ -61,8 +64,12 @@ func GetBuildInfoForPackageManager(pkgManager, workingDir string, buildConfigura } } -// GetPoetryBuildInfo collects build info for Poetry projects -func GetPoetryBuildInfo(workingDir string, buildConfiguration *buildUtils.BuildConfiguration, deployerRepo string) error { +// GetPoetryBuildInfo collects build info for Poetry projects. +// +// cmdName and args describe the poetry sub-command that just ran (e.g. "install", +// "--only", "main"). They are used to decide whether to query poetry for the +// group-filtered installed set +func GetPoetryBuildInfo(workingDir string, buildConfiguration *buildUtils.BuildConfiguration, deployerRepo, cmdName string, args []string) error { log.Debug("Collecting Poetry build info from directory: " + workingDir) log.Debug("Deployer repository: " + deployerRepo) @@ -102,7 +109,16 @@ func GetPoetryBuildInfo(workingDir string, buildConfiguration *buildUtils.BuildC } log.Info(fmt.Sprintf("Using repository for artifacts: %s", artifactRepo)) - err = collectPoetryBuildInfo(workingDir, buildName, buildNumber, serverDetails, repoConfig.TargetRepo(), artifactRepo, buildConfiguration) + // For venv-modifying commands (install/add/remove/update/sync), capture the + // group-filtered set from `poetry show`, forwarding the same + // --only/--with/--without flags that were passed to the poetry command so + // build-info reflects exactly the groups that were installed. + var installed map[string]string + if poetryModifiesVenv(cmdName) { + installed = poetryInstalledPackages(workingDir, args) + } + + err = collectPoetryBuildInfo(workingDir, buildName, buildNumber, serverDetails, repoConfig.TargetRepo(), artifactRepo, installed, buildConfiguration) if err != nil { log.Warn("Enhanced Poetry collection failed, falling back to standard method: " + err.Error()) err = saveBuildInfo(serverDetails, artifactRepo, "", buildConfiguration) @@ -359,14 +375,20 @@ func CreateAqlQueryForSearch(repo, file string) string { return fmt.Sprintf(itemsPart, repo, file) } -// collectPoetryBuildInfo collects Poetry dependencies and artifacts for build info -func collectPoetryBuildInfo(workingDir, buildName, buildNumber string, serverDetails *config.ServerDetails, _ string, artifactRepo string, buildConfiguration *buildUtils.BuildConfiguration) error { +// collectPoetryBuildInfo collects Poetry dependencies and artifacts for build info. +// +// installedPackages, when non-nil, is the group-filtered set captured from +// `poetry show` (with the install command's --only/--with/--without flags +// forwarded). Only lockfile entries present in this map are included in +// build-info, which is how --only/--without/--with are honoured. +func collectPoetryBuildInfo(workingDir, buildName, buildNumber string, serverDetails *config.ServerDetails, _ string, artifactRepo string, installedPackages map[string]string, buildConfiguration *buildUtils.BuildConfiguration) error { log.Debug("Initializing Poetry dependency collection...") // Create Poetry configuration config := flexpack.PoetryConfig{ WorkingDirectory: workingDir, IncludeDevDependencies: false, // Match standard behavior + InstalledPackages: installedPackages, } // Create Poetry instance @@ -799,3 +821,112 @@ func extractRepoNameFromPypiURL(urlStr string) string { return "" } + +// poetryModifiesVenv reports whether the named poetry sub-command actually +// installs/uninstalls packages into the venv. Only for these commands does +// querying poetry for the group-filtered installed set make sense — for +// lock/build/publish etc. the installed state is unrelated to the operation. +func poetryModifiesVenv(cmdName string) bool { + switch cmdName { + case "install", "add", "remove", "update", "sync": + return true + } + return false +} + +// poetryShowPackage is the shape of one entry in `poetry show --format json`. +// poetry show emits more fields (installed_status, description, ...) but only +// name and version are needed here. +type poetryShowPackage struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// poetryShowGroupArgs extracts the dependency-group filter flags +// (--only/--with/--without and their values) from the poetry command args so +// they can be forwarded verbatim to `poetry show`. Other install flags are not +// valid for `poetry show` and are intentionally dropped. +// +// includeAll is true when the args request every group (e.g. --all-groups), in +// which case no `poetry show` filtering is needed — the legacy include-all +// behaviour already produces the correct result. +func poetryShowGroupArgs(args []string) (groupArgs []string, includeAll bool) { + groupFlags := map[string]bool{"--only": true, "--with": true, "--without": true} + for i := 0; i < len(args); i++ { + a := args[i] + if a == "--all-groups" { + return nil, true + } + // --flag=value form + if eq := strings.IndexByte(a, '='); eq > 0 { + if groupFlags[a[:eq]] { + groupArgs = append(groupArgs, a) + } + continue + } + // --flag value form + if groupFlags[a] { + groupArgs = append(groupArgs, a) + if i+1 < len(args) { + groupArgs = append(groupArgs, args[i+1]) + i++ + } + } + } + return groupArgs, false +} + +// poetryInstalledPackages runs `poetry show --format json` inside workingDir, +// forwarding the dependency-group filter flags (--only/--with/--without) that +// were passed to the poetry command. poetry show resolves the activated groups +// via its own solver, so the result is exactly the set produced by the +// group-filtered install — honouring --only/--without/--with. +// +// Returns the set as normalised name → version. Normalisation matches PEP 503 +// (lowercase, runs of [-_.] collapsed to "-"), the same normalisation applied +// to lockfile names by PoetryFlexPack. Returns nil on any error so the caller +// falls back to lock-file-driven resolution (no regression). +func poetryInstalledPackages(workingDir string, args []string) map[string]string { + groupArgs, includeAll := poetryShowGroupArgs(args) + if includeAll { + // Every group requested → legacy include-all behaviour is already correct. + return nil + } + cmdArgs := append([]string{"show", "--format", "json"}, groupArgs...) + cmd := exec.Command("poetry", cmdArgs...) + cmd.Dir = workingDir + out, err := cmd.Output() + if err != nil { + log.Debug("Poetry build-info: 'poetry show' failed, falling back to lock-file resolution: " + err.Error()) + return nil + } + var list []poetryShowPackage + if err := json.Unmarshal(out, &list); err != nil { + log.Debug("Poetry build-info: failed to parse 'poetry show' output: " + err.Error()) + return nil + } + installed := make(map[string]string, len(list)) + for _, p := range list { + installed[normalizePoetryPipName(p.Name)] = p.Version + } + return installed +} + +func normalizePoetryPipName(name string) string { + lower := strings.ToLower(name) + var b strings.Builder + b.Grow(len(lower)) + prevSep := false + for _, r := range lower { + if r == '-' || r == '_' || r == '.' { + if !prevSep && b.Len() > 0 { + b.WriteByte('-') + } + prevSep = true + continue + } + b.WriteRune(r) + prevSep = false + } + return strings.TrimSuffix(b.String(), "-") +} diff --git a/utils/buildinfo/buildinfo_test.go b/utils/buildinfo/buildinfo_test.go new file mode 100644 index 000000000..aab14cb54 --- /dev/null +++ b/utils/buildinfo/buildinfo_test.go @@ -0,0 +1,90 @@ +package buildinfo + +import ( + "reflect" + "testing" +) + +// TestPoetryShowGroupArgs verifies that only the dependency-group filter flags +// (--only/--with/--without) are forwarded to `poetry show`, that both the +// "--flag value" and "--flag=value" forms are handled, and that --all-groups +// short-circuits to include-all (legacy behaviour). +func TestPoetryShowGroupArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantGroupArgs []string + wantIncludeAll bool + }{ + { + name: "only main, space form", + args: []string{"--only", "main", "--no-root"}, + wantGroupArgs: []string{"--only", "main"}, + }, + { + name: "only main, equals form", + args: []string{"--only=main", "--no-root"}, + wantGroupArgs: []string{"--only=main"}, + }, + { + name: "without dev", + args: []string{"--without", "dev"}, + wantGroupArgs: []string{"--without", "dev"}, + }, + { + name: "with optional, multiple group flags", + args: []string{"--with", "docs", "--without", "dev"}, + wantGroupArgs: []string{"--with", "docs", "--without", "dev"}, + }, + { + name: "all-groups short-circuits to include-all", + args: []string{"--all-groups"}, + wantGroupArgs: nil, + wantIncludeAll: true, + }, + { + name: "no group flags drops everything else", + args: []string{"--no-root", "--compile", "-E", "extra"}, + wantGroupArgs: nil, + }, + { + name: "trailing group flag without value is forwarded as-is", + args: []string{"--only"}, + wantGroupArgs: []string{"--only"}, + }, + { + name: "empty args", + args: nil, + wantGroupArgs: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotGroupArgs, gotIncludeAll := poetryShowGroupArgs(tt.args) + if gotIncludeAll != tt.wantIncludeAll { + t.Errorf("includeAll = %v, want %v", gotIncludeAll, tt.wantIncludeAll) + } + if !reflect.DeepEqual(gotGroupArgs, tt.wantGroupArgs) { + t.Errorf("groupArgs = %#v, want %#v", gotGroupArgs, tt.wantGroupArgs) + } + }) + } +} + +// TestNormalizePoetryPipName verifies PEP 503 normalisation: lowercase with runs +// of [-_.] collapsed to a single "-" and no leading/trailing separator. +func TestNormalizePoetryPipName(t *testing.T) { + tests := map[string]string{ + "Requests": "requests", + "ruamel.yaml": "ruamel-yaml", + "my__weird..name": "my-weird-name", + "Already-Normalised": "already-normalised", + "typing_extensions": "typing-extensions", + } + for in, want := range tests { + if got := normalizePoetryPipName(in); got != want { + t.Errorf("normalizePoetryPipName(%q) = %q, want %q", in, got, want) + } + } +}