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 go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26.3
require (
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/klauspost/cpuid/v2 v2.3.0
github.com/pelletier/go-toml/v2 v2.3.1
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
Expand All @@ -19,7 +20,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions internal/constants/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const ManifestVersion = "1"
var ManifestPath = filepath.Join(PlanDirectory, "manifest.txt")

const TestOptimizationManifestFileEnvVar = "TEST_OPTIMIZATION_MANIFEST_FILE"
const DDTestOptimizationManifestFileEnvVar = "DD_TEST_OPTIMIZATION_MANIFEST_FILE"

// Runner layout paths.
var RunnerDirectory = filepath.Join(PlanDirectory, "runner")
Expand All @@ -36,3 +37,4 @@ var HTTPCacheDir = filepath.Join(PlanDirectory, "cache", "http")

// Platform specific output file paths
var RubyEnvOutputPath = filepath.Join(PlanDirectory, "ruby_env.json")
var PythonEnvOutputPath = filepath.Join(PlanDirectory, "python_env.json")
116 changes: 116 additions & 0 deletions internal/framework/pytest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package framework

import (
"context"
"log/slog"
"maps"
"strings"

"github.com/DataDog/ddtest/internal/discovery"
"github.com/DataDog/ddtest/internal/ext"
"github.com/DataDog/ddtest/internal/settings"
"github.com/DataDog/ddtest/internal/testoptimization"
)

const (
// pytestDefaultPattern is used when no config file specifies testpaths/python_files.
// Matches both pytest conventions (test_*.py and *_test.py) everywhere in the tree.
pytestDefaultPattern = "**/{test_*,*_test}.py"
)

type PyTest struct {
executor ext.CommandExecutor
commandOverride []string
platformEnv map[string]string
}

func NewPytest() *PyTest {
return &PyTest{
executor: &ext.DefaultCommandExecutor{},
commandOverride: loadCommandOverride(),
platformEnv: make(map[string]string),
}
}

func (p *PyTest) SetPlatformEnv(platformEnv map[string]string) {
p.platformEnv = platformEnv
}

func (p *PyTest) GetPlatformEnv() map[string]string {
return p.platformEnv
}

func (p *PyTest) Name() string {
return "pytest"
}

// TestPattern returns the glob pattern used to discover pytest test files.
// Priority: explicit --tests-location flag > pytest config file > built-in default.
// Multiple testpaths or python_files from config are collapsed into brace-expansion
// syntax that doublestar handles natively, e.g. {tests,src}/**/{test_*,*_test}.py.
func (p *PyTest) TestPattern() string {
if custom := settings.GetTestsLocation(); custom != "" {
return custom
}

cfg := loadPytestConfig()

filePatterns := cfg.PythonFiles
if len(filePatterns) == 0 {
filePatterns = []string{"{test_*,*_test}.py"}
}
filePart := braceExpand(filePatterns)

if len(cfg.Testpaths) == 0 {
return "**/" + filePart
}
return braceExpand(cfg.Testpaths) + "/**/" + filePart
}

func (p *PyTest) DiscoverTests(ctx context.Context, testFiles discovery.TestFileSet) ([]testoptimization.Test, error) {
discovery.Cleanup()

if testFiles.Empty() {
return []testoptimization.Test{}, nil
}

args := []string{"-m", "pytest"}

if testFiles.UseExplicitFiles() {
args = append(args, testFiles.ExplicitFiles...)
} else {
// pytest has no --pattern flag; resolve the glob pattern to explicit files
files, err := discovery.DiscoverTestFiles(testFiles.Pattern, "")
if err != nil {
return nil, err
}
if len(files) == 0 {
return []testoptimization.Test{}, nil
}
slog.Info("Constraining pytest test discovery", "pattern", testFiles.Pattern, "fileCount", len(files))
args = append(args, files...)
}

return discovery.DiscoverTests(ctx, p.executor, "python", args, p.platformEnv)
}

// braceExpand collapses a list into a single glob token.
// A single item is returned as-is; multiple items are wrapped: {a,b,c}.
func braceExpand(items []string) string {
if len(items) == 1 {
return items[0]
}
return "{" + strings.Join(items, ",") + "}"
}

func (p *PyTest) RunTests(ctx context.Context, testFiles []string, envMap map[string]string) error {
command := "python"
args := []string{"-m", "pytest"}
slog.Info("Running tests with command", "command", command, "args", args)
args = append(args, testFiles...)

mergedEnv := make(map[string]string)
maps.Copy(mergedEnv, p.platformEnv)
maps.Copy(mergedEnv, envMap)
return p.executor.Run(ctx, command, args, mergedEnv)
}
133 changes: 133 additions & 0 deletions internal/framework/pytest_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package framework

import (
"bufio"
"bytes"
"os"
"strings"

"github.com/pelletier/go-toml/v2"
)

// pytestConfig holds the subset of pytest config relevant for test file discovery.
type pytestConfig struct {
Testpaths []string
PythonFiles []string
}

// loadPytestConfig reads testpaths and python_files from the first pytest config
// file found, checking in pytest's own precedence order.
// Returns a zero-value config when no file is found or no relevant keys are set.
func loadPytestConfig() pytestConfig {
if data, err := os.ReadFile("pytest.ini"); err == nil {
if cfg, ok := parsePytestIni(data, "pytest"); ok {
return cfg
}
}
if data, err := os.ReadFile("pyproject.toml"); err == nil {
if cfg, ok := parsePyprojectToml(data); ok {
return cfg
}
}
if data, err := os.ReadFile("tox.ini"); err == nil {
if cfg, ok := parsePytestIni(data, "pytest"); ok {
return cfg
}
}
if data, err := os.ReadFile("setup.cfg"); err == nil {
if cfg, ok := parsePytestIni(data, "tool:pytest"); ok {
return cfg
}
}
return pytestConfig{}
}

// parsePytestIni extracts testpaths and python_files from an INI-format config.
// section is the section name to look for (e.g. "pytest" or "tool:pytest").
// Values may be space-separated on the same line, or newline-indented continuations.
func parsePytestIni(data []byte, section string) (pytestConfig, bool) {
var cfg pytestConfig
inSection := false
var currentKey string
var currentValues []string

flush := func() {
switch currentKey {
case "testpaths":
cfg.Testpaths = currentValues
case "python_files":
cfg.PythonFiles = currentValues
}
currentKey = ""
currentValues = nil
}

scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)

if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";") {
continue
}

if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
flush()
inSection = trimmed[1:len(trimmed)-1] == section
continue
}

if !inSection {
continue
}

// Continuation lines are indented
if line[0] == ' ' || line[0] == '\t' {
currentValues = append(currentValues, strings.Fields(trimmed)...)
continue
}

idx := strings.IndexByte(trimmed, '=')
if idx < 0 {
continue
}
flush()
key := strings.TrimSpace(trimmed[:idx])
value := strings.TrimSpace(trimmed[idx+1:])
if key == "testpaths" || key == "python_files" {
currentKey = key
if value != "" {
currentValues = strings.Fields(value)
}
}
}
flush()

return cfg, len(cfg.Testpaths) > 0 || len(cfg.PythonFiles) > 0
}

type pyprojectTomlFile struct {
Tool struct {
Pytest struct {
IniOptions struct {
Testpaths []string `toml:"testpaths"`
PythonFiles []string `toml:"python_files"`
} `toml:"ini_options"`
} `toml:"pytest"`
} `toml:"tool"`
}

func parsePyprojectToml(data []byte) (pytestConfig, bool) {
var parsed pyprojectTomlFile
if err := toml.Unmarshal(data, &parsed); err != nil {
return pytestConfig{}, false
}
opts := parsed.Tool.Pytest.IniOptions
if len(opts.Testpaths) == 0 && len(opts.PythonFiles) == 0 {
return pytestConfig{}, false
}
return pytestConfig{
Testpaths: opts.Testpaths,
PythonFiles: opts.PythonFiles,
}, true
}
Loading
Loading