Skip to content

Commit 2c21efa

Browse files
feat(ci): add 3.14/3.15 base images and scenario exclude for downstream gate
Add RE2-safe list-scenarios -exclude and ci.yml test_scenarios_exclude so wheel-only Python scenarios are not silently skipped. Ship Python 3.14/3.15 base images, dd-trace-py STS trigger policy, and a minimal migration gate index README; scenario workloads land in follow-up PRs.
1 parent 8233b0e commit 2c21efa

11 files changed

Lines changed: 195 additions & 15 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Policy for: .github/workflows/prof-correctness.yml in DataDog/dd-trace-py
2+
# Allows dd-trace-py GitHub Actions to trigger prof-correctness workflows
3+
# when profiling code changes, to run correctness tests against just-built wheels.
4+
issuer: https://token.actions.githubusercontent.com
5+
subject_pattern: repo:DataDog/dd-trace-py:.*
6+
7+
claim_pattern:
8+
job_workflow_ref: DataDog/dd-trace-py/.github/workflows/prof-correctness.yml@refs/heads/.*
9+
ref_type: branch
10+
11+
permissions:
12+
actions: write

.github/workflows/ci.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,15 @@ jobs:
5858
python:
5959
uses: ./.github/workflows/test.yml
6060
with:
61-
test_scenarios: python.*
61+
test_scenarios: 'python.*'
62+
# Wheel-only scenarios need DDTRACE_INSTALL_URL (dd-trace-py downstream
63+
# gate), so they can't run here against PyPI ddtrace: every *_3.15.
64+
# python_downstream_gate is an index README, not a runnable scenario.
65+
# Go's regexp is RE2 (no negative lookahead), so this is an explicit
66+
# exclude rather than a lookahead baked into test_scenarios.
67+
# Additional wheel-only 3.14 dirs (e.g. live_heap) are added to this list
68+
# when those scenarios land.
69+
test_scenarios_exclude: '_3\.15$|^python_downstream_gate$'
6270
secrets: inherit
6371
full_host:
6472
uses: ./.github/workflows/test.yml

.github/workflows/test.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ on:
99
required: false
1010
type: string
1111
default: '.*'
12+
test_scenarios_exclude:
13+
description: 'A regexp dropping scenarios matched by test_scenarios (RE2, unanchored)'
14+
required: false
15+
type: string
16+
default: ''
1217
ddtrace_install_url:
1318
description: 'URL to a ddtrace install script (e.g. from S3 builds)'
1419
required: false
@@ -35,7 +40,7 @@ jobs:
3540
# - free local-daemon layer caching: buildBaseImages runs once per chunk,
3641
# and the chunk's scenarios reuse the built base image.
3742
run: |
38-
matrix=$(go run ./cmd/list-scenarios -pattern '${{ inputs.test_scenarios }}' -chunk-size 3)
43+
matrix=$(go run ./cmd/list-scenarios -pattern '${{ inputs.test_scenarios }}' -exclude '${{ inputs.test_scenarios_exclude }}' -chunk-size 3)
3944
echo "scenarios=$matrix" >> "$GITHUB_OUTPUT"
4045
4146
docker-scenarios:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ data/*
66
__pycache__/
77

88
.idea
9+
.vscode
910
gems.locked
1011

1112
.DS_Store

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,27 @@ a step to analyze your results and match it against your expectation:
3636
You need to provide a JSON file with your expectations and a path to where to
3737
find the pprof files.
3838
39+
## Downstream from dd-trace-py
40+
41+
dd-trace-py triggers this repo after building wheels for a commit. The
42+
[`downstream-python.yml`](.github/workflows/downstream-python.yml) workflow
43+
installs ddtrace from the S3 wheel for that SHA (`DDTRACE_INSTALL_URL`) and
44+
runs selected Python scenarios.
45+
46+
**Triggers (non-blocking):**
47+
48+
| Source | When | Where to see results |
49+
|--------|------|----------------------|
50+
| **GitLab** `prof-correctness` job | After `upload all`; profiling path changes on `main` or MR | [prof-correctness Actions](https://github.com/DataDog/prof-correctness/actions/workflows/downstream-python.yml) — filter by commit SHA |
51+
| **GitHub** `.github/workflows/prof-correctness.yml` in dd-trace-py | Profiling path changes on PR/push; `workflow_dispatch` | Same Actions page |
52+
53+
Both use [dd-octo-sts](https://github.com/DataDog/dd-octo-sts-action) (`dd-trace-py.gitlab.trigger-ci` / `dd-trace-py.github.trigger-ci`) to call `gh workflow run downstream-python.yml`.
54+
55+
**Inputs:**
56+
57+
- `dd_trace_py_commit_sha` — commit to test (required)
58+
- `test_scenarios` — regexp passed to `TEST_SCENARIOS` (see the [3.14/3.15 migration gate index](scenarios/python_downstream_gate/README.md); the downstream workflow default alone is `python.*`)
59+
3960
## Creating new tests
4061

4162
### Define the dockerfile

base_images/Dockerfile.python-3.14

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM python:3.14 AS base
2+
3+
ARG DDTRACE_INSTALL_URL=""
4+
RUN if [ -n "$DDTRACE_INSTALL_URL" ]; then \
5+
curl -fsSL "$DDTRACE_INSTALL_URL" | bash; \
6+
fi
7+
8+
ENV DD_PROFILING_ENABLED=true
9+
ENV DD_TRACE_ENABLED=false
10+
ENV DD_PROFILING_OUTPUT_PPROF="/app/data/profiles"

base_images/Dockerfile.python-3.15

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM python:3.15.0b1 AS base
2+
3+
ARG DDTRACE_INSTALL_URL=""
4+
RUN if [ -n "$DDTRACE_INSTALL_URL" ]; then \
5+
curl -fsSL "$DDTRACE_INSTALL_URL" | bash; \
6+
fi
7+
8+
ENV DD_PROFILING_ENABLED=true
9+
ENV DD_TRACE_ENABLED=false
10+
ENV DD_PROFILING_OUTPUT_PPROF="/app/data/profiles"

cmd/list-scenarios/main.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// Usage:
1414
//
1515
// go run ./cmd/list-scenarios -pattern 'python.*' -chunk-size 3
16+
// go run ./cmd/list-scenarios -pattern 'python.*' -exclude '_3\.15$' -chunk-size 3
1617
package main
1718

1819
import (
@@ -39,7 +40,7 @@ type matrixEntry struct {
3940
Names string `json:"names"`
4041
}
4142

42-
func run(pattern, scenariosDir string, chunkSize int) ([]matrixEntry, error) {
43+
func run(pattern, exclude, scenariosDir string, chunkSize int) ([]matrixEntry, error) {
4344
// Anchor the user pattern so e.g. "python" doesn't accidentally match
4445
// "python_basic_idle_3.10". The non-capturing group preserves precedence of
4546
// any alternation inside the user pattern.
@@ -48,21 +49,38 @@ func run(pattern, scenariosDir string, chunkSize int) ([]matrixEntry, error) {
4849
return nil, fmt.Errorf("invalid -pattern: %w", err)
4950
}
5051

52+
// Optional exclusion, applied to names that matched -pattern. Unlike
53+
// -pattern this is NOT anchored, so a suffix like `_3\.15$` drops every
54+
// name ending in that version. Go's regexp is RE2 (no lookahead), so
55+
// "match python but not the wheel-only variants" must be expressed as a
56+
// separate exclude rather than a negative lookahead in -pattern.
57+
var excludeRe *regexp.Regexp
58+
if exclude != "" {
59+
excludeRe, err = regexp.Compile(exclude)
60+
if err != nil {
61+
return nil, fmt.Errorf("invalid -exclude: %w", err)
62+
}
63+
}
64+
5165
entries, err := os.ReadDir(scenariosDir)
5266
if err != nil {
5367
return nil, fmt.Errorf("read %s: %w", scenariosDir, err)
5468
}
5569

5670
var names []string
5771
for _, e := range entries {
58-
if e.IsDir() && re.MatchString(e.Name()) {
59-
names = append(names, e.Name())
72+
if !e.IsDir() || !re.MatchString(e.Name()) {
73+
continue
74+
}
75+
if excludeRe != nil && excludeRe.MatchString(e.Name()) {
76+
continue
6077
}
78+
names = append(names, e.Name())
6179
}
6280
sort.Strings(names)
6381

6482
if len(names) == 0 {
65-
return nil, fmt.Errorf("no scenarios matched pattern %q in %s", pattern, scenariosDir)
83+
return nil, fmt.Errorf("no scenarios matched pattern %q (exclude %q) in %s", pattern, exclude, scenariosDir)
6684
}
6785

6886
// Pack into chunks of at most chunkSize, preserving sorted order.
@@ -88,6 +106,7 @@ func run(pattern, scenariosDir string, chunkSize int) ([]matrixEntry, error) {
88106

89107
func main() {
90108
pattern := flag.String("pattern", "", "regex selecting scenario directory names (anchored as ^pattern$)")
109+
exclude := flag.String("exclude", "", "regex dropping matched names (unanchored, RE2); e.g. '_3\\.15$'")
91110
scenariosDir := flag.String("scenarios-dir", "scenarios", "path to the scenarios directory")
92111
chunkSize := flag.Int("chunk-size", 3, "max scenarios per matrix entry")
93112
flag.Parse()
@@ -103,7 +122,7 @@ func main() {
103122
}
104123

105124
abs, _ := filepath.Abs(*scenariosDir)
106-
out, err := run(*pattern, *scenariosDir, *chunkSize)
125+
out, err := run(*pattern, *exclude, *scenariosDir, *chunkSize)
107126
if err != nil {
108127
fmt.Fprintf(os.Stderr, "error: %v (resolved scenarios dir: %s)\n", err, abs)
109128
os.Exit(1)

cmd/list-scenarios/main_test.go

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"path/filepath"
77
"reflect"
88
"regexp"
9+
"strings"
910
"testing"
1011
)
1112

@@ -37,7 +38,7 @@ func TestRun_ChunksAlphabetically(t *testing.T) {
3738
"node_heap", // must be filtered out by the pattern
3839
})
3940

40-
got, err := run("python.*", root, 3)
41+
got, err := run("python.*", "", root, 3)
4142
if err != nil {
4243
t.Fatal(err)
4344
}
@@ -62,7 +63,7 @@ func TestRun_ChunksAlphabetically(t *testing.T) {
6263
func TestRun_SingleChunkWhenSmall(t *testing.T) {
6364
root := mkScenarios(t, []string{"dotnet_wall", "dotnet_alloc"})
6465

65-
got, err := run("dotnet.*", root, 3)
66+
got, err := run("dotnet.*", "", root, 3)
6667
if err != nil {
6768
t.Fatal(err)
6869
}
@@ -87,7 +88,7 @@ func TestRun_AnchoringRejectsSubstringMatches(t *testing.T) {
8788
"python_cpu_sleep_sync_3.12",
8889
})
8990

90-
got, err := run("python_cpu", root, 3)
91+
got, err := run("python_cpu", "", root, 3)
9192
if err != nil {
9293
t.Fatal(err)
9394
}
@@ -101,7 +102,7 @@ func TestRun_ExactChunkSizeBoundary(t *testing.T) {
101102
root := mkScenarios(t, []string{
102103
"a", "b", "c", "d", "e", "f",
103104
})
104-
got, err := run(".*", root, 3)
105+
got, err := run(".*", "", root, 3)
105106
if err != nil {
106107
t.Fatal(err)
107108
}
@@ -113,10 +114,46 @@ func TestRun_ExactChunkSizeBoundary(t *testing.T) {
113114
}
114115
}
115116

117+
func TestRun_ExcludeDropsMatchedNames(t *testing.T) {
118+
// Mirrors the CI python gate: run everything python except the wheel-only
119+
// variants (every *_3.15 plus python_live_heap_3.14).
120+
root := mkScenarios(t, []string{
121+
"python_cpu",
122+
"python_lock_3.14",
123+
"python_lock_3.15",
124+
"python_mem_domain_3.14",
125+
"python_mem_domain_3.15",
126+
"python_live_heap_3.14",
127+
"python_live_heap_3.15",
128+
})
129+
130+
got, err := run("python.*", `_3\.15$|^python_live_heap_3\.14$`, root, 3)
131+
if err != nil {
132+
t.Fatal(err)
133+
}
134+
135+
var names []string
136+
for _, e := range got {
137+
names = append(names, e.Names)
138+
}
139+
joined := strings.Join(names, ", ")
140+
want := "python_cpu, python_lock_3.14, python_mem_domain_3.14"
141+
if joined != want {
142+
t.Fatalf("excluded set wrong:\n got %q\nwant %q", joined, want)
143+
}
144+
}
145+
146+
func TestRun_InvalidExcludeIsError(t *testing.T) {
147+
root := mkScenarios(t, []string{"python_cpu"})
148+
if _, err := run("python.*", "[invalid", root, 3); err == nil {
149+
t.Fatal("expected error on invalid exclude regex")
150+
}
151+
}
152+
116153
func TestRun_NoMatchIsError(t *testing.T) {
117154
root := mkScenarios(t, []string{"python_cpu"})
118155

119-
_, err := run("ruby.*", root, 3)
156+
_, err := run("ruby.*", "", root, 3)
120157
if err == nil {
121158
t.Fatal("expected error when no scenarios match")
122159
}
@@ -125,7 +162,7 @@ func TestRun_NoMatchIsError(t *testing.T) {
125162
func TestRun_InvalidPatternIsError(t *testing.T) {
126163
root := mkScenarios(t, []string{"python_cpu"})
127164

128-
if _, err := run("[invalid", root, 3); err == nil {
165+
if _, err := run("[invalid", "", root, 3); err == nil {
129166
t.Fatal("expected error on invalid regex")
130167
}
131168
}
@@ -140,7 +177,7 @@ func TestRun_RegexMatchesIntendedDirAndOnlyThat(t *testing.T) {
140177
"python_cpu_sleep_sync_3.12",
141178
"python_basic_idle_3.11",
142179
})
143-
got, err := run("python.*", root, 3)
180+
got, err := run("python.*", "", root, 3)
144181
if err != nil {
145182
t.Fatal(err)
146183
}
@@ -175,7 +212,7 @@ func TestRun_RegexMatchesIntendedDirAndOnlyThat(t *testing.T) {
175212
// verifies it round-trips to the expected shape.
176213
func TestRun_OutputIsValidJSON(t *testing.T) {
177214
root := mkScenarios(t, []string{"a", "b", "c", "d"})
178-
got, err := run(".*", root, 3)
215+
got, err := run(".*", "", root, 3)
179216
if err != nil {
180217
t.Fatal(err)
181218
}

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ ignore = [
5050
"**/__init__.py" = ["D104"] # missing docstring in public package
5151
"**/test_*.py" = ["S101", "D"] # allow assert in tests, skip docstrings
5252
"**/tests/**/*.py" = ["S101", "D"] # allow assert in tests, skip docstrings
53+
# The exceptions scenario deliberately raises + swallows an exception as its
54+
# profiled workload; the explicit string-literal raise and try/except/pass are
55+
# the point of the test, not style smells.
56+
"scenarios/python_exceptions_*/main.py" = ["EM101", "SIM105"]
5357

5458
[tool.ruff.lint.pydocstyle]
5559
convention = "google"

0 commit comments

Comments
 (0)