Skip to content

Commit 4cd8356

Browse files
authored
add support for pyproject.toml for dependency resolution (#1701)
* add support for pyproject.toml for dependency resolution * lock pyproject deps automatically * chore: format * add a new claude command * handle the case sensitive paths * fix new mock path
1 parent da6ff2d commit 4cd8356

31 files changed

Lines changed: 1060 additions & 170 deletions

File tree

.claude/commands/format-fix.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
model: claude-sonnet-4-6
3+
---
4+
5+
Run `make format` and automatically fix all formatting issues in the codebase.
6+
7+
Follow these steps:
8+
9+
1. First, run the format command to identify issues:
10+
```
11+
make format
12+
```
13+
14+
2. Check for any formatting or linting errors in the output.
15+
16+
3. If issues are detected, fix them automatically:
17+
- Import ordering issues: Reorganize imports according to Go conventions
18+
- Code formatting: Fix indentation, spacing, and line length issues
19+
- Style violations: Fix naming conventions and other style issues
20+
- Simple lint errors: Apply straightforward fixes
21+
22+
4. After making fixes, re-run `make format` to verify all issues are resolved.
23+
24+
5. If any issues require manual intervention:
25+
- Show the specific errors that couldn't be auto-fixed
26+
- Provide guidance on how to resolve them
27+
- Re-run validation after manual fixes
28+
29+
6. Show a summary of what was fixed and confirm the codebase passes formatting checks.

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,10 @@ venv
154154
logs/runs
155155
logs/exports
156156
.claude
157+
!.claude/commands/
157158
bruin
158159

159160
__debug_bin*
160-
.claude
161161
test-glossary.yml
162162
.venv
163163
logs/queries

Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ endif
2020

2121
JQ_REL_PATH = jq --arg prefix "$$(pwd)" 'walk(if type == "object" and has("path") and (.path | type == "string") then .path |= (if . == $$prefix then "integration-tests" elif startswith($$prefix + "/") then .[($$prefix | length + 1):] elif startswith($$prefix) then .[($$prefix | length):] elif startswith("integration-tests/") then .[16:] else . end) else . end)'
2222

23-
.PHONY: all clean test build build-no-duckdb format pre-commit refresh-integration-expectations integration-test-cloud validate-links
23+
.PHONY: all clean test build build-no-duckdb format pre-commit refresh-integration-expectations integration-test-cloud validate-links tools-update
2424
all: clean deps test build
2525

2626
deps:
@@ -97,6 +97,12 @@ format: lint-python
9797
go tool golangci-lint run --timeout 10m60s --build-tags="no_duckdb_arrow" ./... & \
9898
wait
9999

100+
tools-update:
101+
go get github.com/daixiang0/gci@latest
102+
go get github.com/golangci/golangci-lint/cmd/golangci-lint@latest
103+
go get mvdan.cc/gofumpt@latest
104+
@go mod tidy
105+
100106
lint-python:
101107
uv pip install --system sqlglot
102108
@echo "$(OK_COLOR)==> Running Python formatting with ruff...$(NO_COLOR)"

cmd/internal.go

Lines changed: 92 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,11 +1464,13 @@ type LockDependenciesResult struct {
14641464
RequirementsPath string
14651465
PythonVersion string
14661466
InputPath string
1467+
DependencyType python.DependencyType
1468+
ProjectRoot string
14671469
}
14681470

1469-
// FindRequirementsPath finds the requirements.txt path for the given input.
1470-
// It handles both direct requirements.txt files and Python assets.
1471-
func (l *LockDependenciesCommand) FindRequirementsPath(inputPath string) (*LockDependenciesResult, error) {
1471+
// FindDependencyPath finds the dependency configuration for the given input.
1472+
// It handles direct requirements.txt files, pyproject.toml projects, and Python assets.
1473+
func (l *LockDependenciesCommand) FindDependencyPath(inputPath string) (*LockDependenciesResult, error) {
14721474
// Get absolute path
14731475
absolutePath, err := filepath.Abs(inputPath)
14741476
if err != nil {
@@ -1481,53 +1483,67 @@ func (l *LockDependenciesCommand) FindRequirementsPath(inputPath string) (*LockD
14811483
return nil, errors2.Wrap(err, "failed to find repository")
14821484
}
14831485

1484-
var requirementsTxt string
1485-
14861486
// Check if input is a requirements.txt file directly
14871487
if strings.HasSuffix(inputPath, "requirements.txt") {
1488-
requirementsTxt = absolutePath
1489-
} else {
1490-
// Parse the asset
1491-
asset, err := l.builder.CreateAssetFromFile(absolutePath, nil)
1492-
if err != nil {
1493-
return nil, errors2.Wrap(err, "failed to parse asset")
1494-
}
1488+
return &LockDependenciesResult{
1489+
RequirementsPath: absolutePath,
1490+
InputPath: absolutePath,
1491+
DependencyType: python.DependencyTypeRequirementsTxt,
1492+
ProjectRoot: filepath.Dir(absolutePath),
1493+
}, nil
1494+
}
14951495

1496-
if asset == nil {
1497-
return nil, errors2.New("file is not a valid asset")
1498-
}
1496+
// Check if input is a pyproject.toml file directly
1497+
if strings.HasSuffix(inputPath, "pyproject.toml") {
1498+
return &LockDependenciesResult{
1499+
InputPath: absolutePath,
1500+
DependencyType: python.DependencyTypePyproject,
1501+
ProjectRoot: filepath.Dir(absolutePath),
1502+
}, nil
1503+
}
14991504

1500-
// Check if asset is a Python asset
1501-
if asset.Type != pipeline.AssetTypePython && !strings.HasSuffix(asset.ExecutableFile.Path, ".py") {
1502-
return nil, errors2.New("asset is not a Python asset")
1503-
}
1505+
// Parse the asset
1506+
asset, err := l.builder.CreateAssetFromFile(absolutePath, nil)
1507+
if err != nil {
1508+
return nil, errors2.Wrap(err, "failed to parse asset")
1509+
}
15041510

1505-
// Find requirements.txt
1506-
moduleFinder := &python.ModulePathFinder{}
1507-
requirementsTxt, err = moduleFinder.FindRequirementsTxtInPath(repo.Path, &asset.ExecutableFile)
1508-
if err != nil {
1509-
var noReqsError *python.NoRequirementsFoundError
1510-
if errors.As(err, &noReqsError) {
1511-
return nil, errors2.New("no requirements.txt found for this asset")
1512-
}
1513-
return nil, errors2.Wrap(err, "failed to find requirements.txt")
1514-
}
1511+
if asset == nil {
1512+
return nil, errors2.New("file is not a valid asset")
1513+
}
1514+
1515+
// Check if asset is a Python asset
1516+
if asset.Type != pipeline.AssetTypePython && !strings.HasSuffix(asset.ExecutableFile.Path, ".py") {
1517+
return nil, errors2.New("asset is not a Python asset")
1518+
}
1519+
1520+
// Find dependency configuration
1521+
moduleFinder := &python.ModulePathFinder{}
1522+
depConfig, err := moduleFinder.FindDependencyConfig(repo.Path, &asset.ExecutableFile)
1523+
if err != nil {
1524+
return nil, errors2.Wrap(err, "failed to find dependency configuration")
1525+
}
1526+
1527+
if depConfig.Type == python.DependencyTypeNone {
1528+
return nil, errors2.New("no dependency configuration found for this asset (no requirements.txt or pyproject.toml)")
15151529
}
15161530

15171531
return &LockDependenciesResult{
1518-
RequirementsPath: requirementsTxt,
1532+
RequirementsPath: depConfig.RequirementsTxt,
15191533
InputPath: absolutePath,
1534+
DependencyType: depConfig.Type,
1535+
ProjectRoot: depConfig.ProjectRoot,
15201536
}, nil
15211537
}
15221538

15231539
// LockAssetDependencies returns a CLI command that locks Python dependencies for an asset.
1524-
// It finds the asset's requirements file, creates/uses a uv environment, and updates the
1525-
// requirements file with locked versions using uv pip compile.
1540+
// It finds the asset's dependency configuration and locks versions using either
1541+
// uv pip compile (for requirements.txt) or uv lock (for pyproject.toml).
15261542
func LockAssetDependencies() *cli.Command {
15271543
return &cli.Command{
15281544
Name: "lock-asset-dependencies",
15291545
Usage: "Lock Python dependencies for a Bruin asset using uv",
1530-
ArgsUsage: "[path to the asset definition or requirements.txt]",
1546+
ArgsUsage: "[path to the asset definition, requirements.txt, or pyproject.toml]",
15311547
Flags: []cli.Flag{
15321548
&cli.StringFlag{
15331549
Name: "python-version",
@@ -1560,26 +1576,19 @@ func LockAssetDependencies() *cli.Command {
15601576
return cli.Exit("", 1)
15611577
}
15621578

1563-
// Use the refactored command to find requirements path
1579+
// Find dependency configuration
15641580
cmd := &LockDependenciesCommand{
15651581
builder: DefaultPipelineBuilder,
15661582
}
15671583

1568-
result, err := cmd.FindRequirementsPath(assetPath)
1584+
result, err := cmd.FindDependencyPath(assetPath)
15691585
if err != nil {
15701586
printErrorJSON(err)
15711587
return cli.Exit("", 1)
15721588
}
15731589

15741590
result.PythonVersion = pythonVersion
15751591

1576-
// Find the git repo for working directory
1577-
repo, err := git.FindRepoFromPath(result.InputPath)
1578-
if err != nil {
1579-
printErrorJSON(errors2.Wrap(err, "failed to find repository"))
1580-
return cli.Exit("", 1)
1581-
}
1582-
15831592
// Ensure uv is installed
15841593
uvChecker := &uv.Checker{}
15851594
uvBinaryPath, err := uvChecker.EnsureUvInstalled(ctx)
@@ -1588,36 +1597,66 @@ func LockAssetDependencies() *cli.Command {
15881597
return cli.Exit("", 1)
15891598
}
15901599

1591-
// Run uv pip compile to lock dependencies
1592-
// uv pip compile requirements.txt -o requirements.txt --python-version X.Y --no-header
1593-
execCmd := exec.CommandContext(ctx, uvBinaryPath, "pip", "compile", result.RequirementsPath, "-o", result.RequirementsPath, "--python-version", pythonVersion, "--quiet", "--no-header") //nolint:gosec
1594-
execCmd.Dir = repo.Path
1600+
var execCmd *exec.Cmd
1601+
switch result.DependencyType { //nolint:exhaustive
1602+
case python.DependencyTypePyproject:
1603+
// uv lock --python <version>
1604+
execCmd = exec.CommandContext(ctx, uvBinaryPath, "lock", "--python", pythonVersion) //nolint:gosec
1605+
execCmd.Dir = result.ProjectRoot
1606+
1607+
case python.DependencyTypeRequirementsTxt:
1608+
// uv pip compile requirements.txt -o requirements.txt --python-version X.Y --no-header
1609+
execCmd = exec.CommandContext(ctx, uvBinaryPath, "pip", "compile", result.RequirementsPath, "-o", result.RequirementsPath, "--python-version", pythonVersion, "--quiet", "--no-header") //nolint:gosec
1610+
execCmd.Dir = result.ProjectRoot
1611+
1612+
default:
1613+
printErrorJSON(errors2.New("no supported dependency configuration found"))
1614+
return cli.Exit("", 1)
1615+
}
1616+
15951617
execCmd.Stdout = os.Stdout
15961618
execCmd.Stderr = os.Stderr
15971619

15981620
err = execCmd.Run()
15991621
if err != nil {
1600-
printErrorJSON(errors2.Wrap(err, "failed to lock dependencies with uv pip compile"))
1622+
if result.DependencyType == python.DependencyTypePyproject {
1623+
printErrorJSON(errors2.Wrap(err, "failed to lock dependencies with uv lock"))
1624+
} else {
1625+
printErrorJSON(errors2.Wrap(err, "failed to lock dependencies with uv pip compile"))
1626+
}
16011627
return cli.Exit("", 1)
16021628
}
16031629

16041630
// Output result
16051631
switch output {
16061632
case outputFormatPlain:
1607-
fmt.Printf("Successfully locked dependencies in %s\n", result.RequirementsPath)
1633+
if result.DependencyType == python.DependencyTypePyproject {
1634+
fmt.Printf("Successfully locked dependencies in %s\n", filepath.Join(result.ProjectRoot, "uv.lock"))
1635+
} else {
1636+
fmt.Printf("Successfully locked dependencies in %s\n", result.RequirementsPath)
1637+
}
16081638
case "json":
16091639
type jsonResponse struct {
1610-
RequirementsPath string `json:"requirements_path"`
1640+
RequirementsPath string `json:"requirements_path,omitempty"`
1641+
LockFilePath string `json:"lock_file_path,omitempty"`
16111642
PythonVersion string `json:"python_version"`
16121643
AssetPath string `json:"asset_path"`
1644+
DependencyType string `json:"dependency_type"`
16131645
Success bool `json:"success"`
16141646
}
16151647

16161648
finalOutput := jsonResponse{
1617-
RequirementsPath: result.RequirementsPath,
1618-
PythonVersion: pythonVersion,
1619-
AssetPath: result.InputPath,
1620-
Success: true,
1649+
PythonVersion: pythonVersion,
1650+
AssetPath: result.InputPath,
1651+
Success: true,
1652+
}
1653+
1654+
if result.DependencyType == python.DependencyTypePyproject {
1655+
finalOutput.DependencyType = "pyproject"
1656+
finalOutput.LockFilePath = filepath.Join(result.ProjectRoot, "uv.lock")
1657+
} else {
1658+
finalOutput.DependencyType = "requirements_txt"
1659+
finalOutput.RequirementsPath = result.RequirementsPath
16211660
}
16221661

16231662
jsonData, err := json.Marshal(finalOutput)

cmd/internal_test.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ func normalize(s string) string {
205205
return strings.Join(out, "\n")
206206
}
207207

208-
func TestLockDependenciesCommand_FindRequirementsPath(t *testing.T) {
208+
func TestLockDependenciesCommand_FindDependencyPath(t *testing.T) {
209209
t.Parallel()
210210

211211
tests := []struct {
@@ -255,41 +255,41 @@ func TestLockDependenciesCommand_FindRequirementsPath(t *testing.T) {
255255
builder: DefaultPipelineBuilder,
256256
}
257257

258-
result, err := cmd.FindRequirementsPath(tt.inputPath)
258+
result, err := cmd.FindDependencyPath(tt.inputPath)
259259

260260
if tt.wantErr {
261261
if err == nil {
262-
t.Errorf("FindRequirementsPath() expected error, got nil")
262+
t.Errorf("FindDependencyPath() expected error, got nil")
263263
return
264264
}
265265
if tt.wantErrContain != "" && !strings.Contains(err.Error(), tt.wantErrContain) {
266-
t.Errorf("FindRequirementsPath() error = %v, want error containing %q", err, tt.wantErrContain)
266+
t.Errorf("FindDependencyPath() error = %v, want error containing %q", err, tt.wantErrContain)
267267
}
268268
return
269269
}
270270

271271
if err != nil {
272-
t.Errorf("FindRequirementsPath() unexpected error: %v", err)
272+
t.Errorf("FindDependencyPath() unexpected error: %v", err)
273273
return
274274
}
275275

276276
if result == nil {
277-
t.Error("FindRequirementsPath() returned nil result")
277+
t.Error("FindDependencyPath() returned nil result")
278278
return
279279
}
280280

281281
if tt.wantReqSuffix != "" && !strings.HasSuffix(result.RequirementsPath, tt.wantReqSuffix) {
282-
t.Errorf("FindRequirementsPath() RequirementsPath = %v, want suffix %v", result.RequirementsPath, tt.wantReqSuffix)
282+
t.Errorf("FindDependencyPath() RequirementsPath = %v, want suffix %v", result.RequirementsPath, tt.wantReqSuffix)
283283
}
284284

285285
if result.InputPath == "" {
286-
t.Error("FindRequirementsPath() InputPath should not be empty")
286+
t.Error("FindDependencyPath() InputPath should not be empty")
287287
}
288288
})
289289
}
290290
}
291291

292-
func TestLockDependenciesCommand_FindRequirementsPath_PythonAssetWithoutRequirements(t *testing.T) {
292+
func TestLockDependenciesCommand_FindDependencyPath_PythonAssetWithoutDependencies(t *testing.T) {
293293
t.Parallel()
294294

295295
// Create a temp directory with a Python asset but no requirements.txt
@@ -317,13 +317,13 @@ print("hello")
317317
builder: DefaultPipelineBuilder,
318318
}
319319

320-
_, err := cmd.FindRequirementsPath(pyPath)
320+
_, err := cmd.FindDependencyPath(pyPath)
321321
if err == nil {
322-
t.Error("FindRequirementsPath() expected error for Python asset without requirements.txt, got nil")
322+
t.Error("FindDependencyPath() expected error for Python asset without dependencies, got nil")
323323
return
324324
}
325325

326-
if !strings.Contains(err.Error(), "no requirements.txt found") {
327-
t.Errorf("FindRequirementsPath() error = %v, want error containing 'no requirements.txt found'", err)
326+
if !strings.Contains(err.Error(), "no dependency configuration found") {
327+
t.Errorf("FindDependencyPath() error = %v, want error containing 'no dependency configuration found'", err)
328328
}
329329
}

0 commit comments

Comments
 (0)