Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion buildtools/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
4 changes: 2 additions & 2 deletions poetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
143 changes: 137 additions & 6 deletions utils/buildinfo/buildinfo.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package buildinfo

import (
"encoding/json"
"fmt"
"net/url"
"os"
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(), "-")
}
90 changes: 90 additions & 0 deletions utils/buildinfo/buildinfo_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading