Skip to content

Commit 57675ab

Browse files
committed
Merge branch 'main' into 4.0-breaking-changes
2 parents 7c9c8b1 + 81acf6d commit 57675ab

File tree

15 files changed

+270
-65
lines changed

15 files changed

+270
-65
lines changed

ddtrace/_trace/span.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,12 @@ def finished(self, value: bool) -> None:
276276
If the span is already finished and a truthy value is provided
277277
no action will occur.
278278
"""
279+
deprecate(
280+
prefix="The finished setter is deprecated",
281+
message="""Use the finish() method to finish a span.""",
282+
category=DDTraceDeprecationWarning,
283+
removal_version="4.0.0",
284+
)
279285
if value:
280286
if not self.finished:
281287
self.duration_ns = Time.time_ns() - self.start_ns
@@ -767,7 +773,7 @@ def _set_link_or_append_pointer(self, link: Union[SpanLink, _SpanPointer]) -> No
767773
except ValueError:
768774
self._links.append(link)
769775

770-
def finish_with_ancestors(self) -> None:
776+
def _finish_with_ancestors(self) -> None:
771777
"""Finish this span along with all (accessible) ancestors of this span.
772778
773779
This method is useful if a sudden program shutdown is required and finishing
@@ -778,6 +784,15 @@ def finish_with_ancestors(self) -> None:
778784
span.finish()
779785
span = span._parent
780786

787+
@removals.remove(removal_version="4.0.0")
788+
def finish_with_ancestors(self) -> None:
789+
"""Finish this span along with all (accessible) ancestors of this span.
790+
791+
This method is useful if a sudden program shutdown is required and finishing
792+
the trace is desired.
793+
"""
794+
self._finish_with_ancestors()
795+
781796
def __enter__(self) -> "Span":
782797
return self
783798

ddtrace/appsec/_iast/__init__.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def wrapped_function(wrapped, instance, args, kwargs):
4646
_IAST_TO_BE_LOADED = True
4747
_iast_propagation_enabled = False
4848
_fork_handler_registered = False
49+
_iast_in_pytest_mode = False
4950

5051

5152
def _disable_iast_after_fork():
@@ -87,6 +88,15 @@ def _disable_iast_after_fork():
8788
from ddtrace.appsec._iast._iast_request_context_base import is_iast_request_enabled
8889
from ddtrace.appsec._iast._taint_tracking._context import clear_all_request_context_slots
8990

91+
# In pytest mode, always disable IAST in child processes to avoid segfaults
92+
# when tests create multiprocesses (e.g., for testing fork behavior)
93+
if _iast_in_pytest_mode:
94+
log.debug("IAST fork handler: Pytest mode detected, disabling IAST in child process")
95+
clear_all_request_context_slots()
96+
IAST_CONTEXT.set(None)
97+
asm_config._iast_enabled = False
98+
return
99+
90100
if not is_iast_request_enabled():
91101
# No active context - this is an early fork (web framework worker)
92102
# IAST can be safely initialized fresh in this child process
@@ -168,6 +178,7 @@ def enable_iast_propagation():
168178
"""Add IAST AST patching in the ModuleWatchdog"""
169179
# DEV: These imports are here to avoid _ast.ast_patching import in the top level
170180
# because they are slow and affect serverless startup time
181+
171182
if asm_config._iast_propagation_enabled:
172183
from ddtrace.appsec._iast._ast.ast_patching import _should_iast_patch
173184
from ddtrace.appsec._iast._loader import _exec_iast_patched_module
@@ -183,19 +194,30 @@ def enable_iast_propagation():
183194

184195

185196
def _iast_pytest_activation():
186-
global _iast_propagation_enabled
187-
if _iast_propagation_enabled:
197+
"""Configure IAST settings for pytest execution.
198+
199+
This function sets up IAST configuration but does NOT create a request context.
200+
Request contexts should be created per-test or per-request to avoid threading issues.
201+
202+
Also sets a global flag to indicate we're in pytest mode, which ensures IAST is
203+
disabled in forked child processes to prevent segfaults when tests use multiprocessing.
204+
"""
205+
global _iast_in_pytest_mode
206+
207+
if not asm_config._iast_enabled:
188208
return
189-
os.environ["DD_IAST_ENABLED"] = os.environ.get("DD_IAST_ENABLED") or "1"
209+
210+
# Mark that we're running in pytest mode
211+
# This flag is checked by the fork handler to disable IAST in child processes
212+
_iast_in_pytest_mode = True
213+
190214
os.environ["DD_IAST_REQUEST_SAMPLING"] = os.environ.get("DD_IAST_REQUEST_SAMPLING") or "100.0"
191215
os.environ["_DD_APPSEC_DEDUPLICATION_ENABLED"] = os.environ.get("_DD_APPSEC_DEDUPLICATION_ENABLED") or "false"
192216
os.environ["DD_IAST_VULNERABILITIES_PER_REQUEST"] = os.environ.get("DD_IAST_VULNERABILITIES_PER_REQUEST") or "1000"
193-
os.environ["DD_IAST_MAX_CONCURRENT_REQUESTS"] = os.environ.get("DD_IAST_MAX_CONCURRENT_REQUESTS") or "1000"
194217

195218
asm_config._iast_request_sampling = 100.0
196219
asm_config._deduplication_enabled = False
197220
asm_config._iast_max_vulnerabilities_per_requests = 1000
198-
asm_config._iast_max_concurrent_requests = 1000
199221
oce.reconfigure()
200222

201223

ddtrace/contrib/internal/aws_lambda/patch.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def _crash_flush(self, _, __):
103103

104104
current_span = tracer.current_span()
105105
if current_span is not None:
106-
current_span.finish_with_ancestors()
106+
current_span._finish_with_ancestors()
107107

108108
def _remove_alarm_signal(self):
109109
"""Removes the handler set for the signal `SIGALRM`."""

ddtrace/internal/packages.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,11 @@ def _root_module(path: Path) -> str:
142142
pass
143143

144144
# Bazel runfiles support: we assume that these paths look like
145-
# /some/path.runfiles/.../site-packages/<root_module>/...
146-
if any(p.suffix == ".runfiles" for p in path.parents):
147-
for s in path.parents:
148-
if s.parent.name == "site-packages":
149-
return s.name
145+
# /some/path.runfiles/<distribution_name>/site-packages/<root_module>/...
146+
# /usr/local/runfiles/<distribution_name>/site-packages/<root_module>/...
147+
for s in path.parents:
148+
if s.parent.name == "site-packages":
149+
return s.name
150150

151151
msg = f"Could not find root module for path {path}"
152152
raise ValueError(msg)

ddtrace/internal/settings/asm.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ class ASMConfig(DDConfig):
142142
+ r"ey[I-L][\w=-]+\.ey[I-L][\w=-]+(\.[\w.+\/=-]+)?|[\-]{5}BEGIN[a-z\s]+PRIVATE\sKEY"
143143
+ r"[\-]{5}[^\-]+[\-]{5}END[a-z\s]+PRIVATE\sKEY|ssh-rsa\s*[a-z0-9\/\.+]{100,}",
144144
)
145+
# We never use `asm_config._iast_max_concurrent_requests` directly,
146+
# but we define it so it can be reported through telemetry, since it’s used from the C files.
145147
_iast_max_concurrent_requests = DDConfig.var(
146148
int,
147149
IAST.DD_IAST_MAX_CONCURRENT_REQUESTS,

lib-injection/sources/requirements.csv

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
Dependency,Version Specifier,Python Version
2-
bytecode,>=0.17.0,python_version>='3.14.0'
3-
bytecode,>=0.16.0,python_version>='3.13.0'
4-
bytecode,>=0.15.1,python_version~='3.12.0'
5-
bytecode,>=0.14.0,python_version~='3.11.0'
6-
bytecode,>=0.13.0,python_version<'3.11'
2+
bytecode,">=0.17.0,<1",python_version>='3.14.0'
3+
bytecode,">=0.16.0,<1",python_version>='3.13.0'
4+
bytecode,">=0.15.1,<1",python_version~='3.12.0'
5+
bytecode,">=0.14.0,<1",python_version~='3.11.0'
6+
bytecode,">=0.13.0,<1",python_version<'3.11'
77
envier,~=0.6.1,
88
opentelemetry-api,">=1,<2",
99
wrapt,">=1,<3",

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ classifiers = [
3535
"Programming Language :: Python :: 3.14",
3636
]
3737
dependencies = [
38-
"bytecode>=0.17.0; python_version>='3.14.0'",
39-
"bytecode>=0.16.0; python_version>='3.13.0'",
40-
"bytecode>=0.15.1; python_version~='3.12.0'",
41-
"bytecode>=0.14.0; python_version~='3.11.0'",
42-
"bytecode>=0.13.0; python_version<'3.11'",
38+
"bytecode>=0.17.0,<1; python_version>='3.14.0'",
39+
"bytecode>=0.16.0,<1; python_version>='3.13.0'",
40+
"bytecode>=0.15.1,<1; python_version~='3.12.0'",
41+
"bytecode>=0.14.0,<1; python_version~='3.11.0'",
42+
"bytecode>=0.13.0,<1; python_version<'3.11'",
4343
"envier~=0.6.1",
4444
"opentelemetry-api>=1,<2",
4545
"wrapt>=1,<3",
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
deprecations:
3+
- |
4+
tracing: ``Span.finished`` setter is deprecated, use ``Span.finish()`` method instead.
5+
- |
6+
tracing: ``Span.finish_with_ancestors()`` is deprecated with no alternative.

requirements.csv

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
Dependency,Version Specifier,Python Version
2-
bytecode,>=0.17.0,python_version>='3.14.0'
3-
bytecode,>=0.16.0,python_version>='3.13.0'
4-
bytecode,>=0.15.1,python_version~='3.12.0'
5-
bytecode,>=0.14.0,python_version~='3.11.0'
6-
bytecode,>=0.13.0,python_version<'3.11'
2+
bytecode,">=0.17.0,<1",python_version>='3.14.0'
3+
bytecode,">=0.16.0,<1",python_version>='3.13.0'
4+
bytecode,">=0.15.1,<1",python_version~='3.12.0'
5+
bytecode,">=0.14.0,<1",python_version~='3.11.0'
6+
bytecode,">=0.13.0,<1",python_version<'3.11'
77
envier,~=0.6.1,
88
opentelemetry-api,">=1,<2",
99
wrapt,">=1,<3",

scripts/check-dependency-bounds

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#!/usr/bin/env scripts/uv-run-script
2+
# -*- mode: python -*-
3+
# /// script
4+
# dependencies = [
5+
# "packaging>=23.1,<24",
6+
# ]
7+
# ///
8+
"""
9+
Validate that all project dependencies have well-defined version ranges.
10+
11+
This script checks that dependencies in pyproject.toml have both lower and upper
12+
bounds to prevent unexpected breaking changes from transitive dependencies.
13+
14+
Acceptable formats:
15+
- >=X.Y,<X.Z (explicit bounds)
16+
- ~=X.Y.Z (compatible release with at least x.y format, implies upper bound)
17+
18+
Unacceptable formats:
19+
- >=X.Y (open-ended, no upper bound)
20+
- <X.Y (only upper bound)
21+
- ~=X (only single version component, too open-ended)
22+
- package (no version specified)
23+
"""
24+
25+
import sys
26+
import tomllib
27+
from pathlib import Path
28+
from typing import Dict, List, Tuple
29+
30+
from packaging.specifiers import SpecifierSet
31+
32+
33+
def load_pyproject() -> Dict:
34+
"""Load and parse pyproject.toml from the current directory."""
35+
pyproject_path = Path("pyproject.toml")
36+
if not pyproject_path.exists():
37+
print(f"Error: {pyproject_path} not found")
38+
sys.exit(1)
39+
40+
with open(pyproject_path, "rb") as f:
41+
return tomllib.load(f)
42+
43+
44+
def has_lower_bound(spec_set: SpecifierSet) -> bool:
45+
"""Check if specifier set has a lower bound."""
46+
for spec in spec_set:
47+
operator = spec.operator
48+
if operator in (">=", ">", "~=", "=="):
49+
return True
50+
return False
51+
52+
53+
def has_upper_bound(spec_set: SpecifierSet) -> bool:
54+
"""Check if specifier set has an upper bound."""
55+
for spec in spec_set:
56+
operator = spec.operator
57+
if operator in ("<", "<=", "~=", "=="):
58+
return True
59+
return False
60+
61+
62+
def is_well_defined(spec_string: str) -> Tuple[bool, str]:
63+
"""
64+
Check if a version specifier is well-defined.
65+
66+
Returns:
67+
Tuple of (is_valid, reason_if_invalid)
68+
"""
69+
# Handle empty string (no version specified)
70+
if not spec_string.strip():
71+
return False, "no version specified"
72+
73+
try:
74+
spec_set = SpecifierSet(spec_string)
75+
except Exception as e:
76+
return False, f"invalid specifier: {e}"
77+
78+
# Check for lower bound
79+
if not has_lower_bound(spec_set):
80+
return False, "missing lower bound (no >=, >, ~=, or ==)"
81+
82+
# Check for upper bound
83+
if not has_upper_bound(spec_set):
84+
return False, "missing upper bound (no <, <=, ~=, or ==)"
85+
86+
# If using compatible release (~=), ensure it has at least x.y format
87+
for spec in spec_set:
88+
if spec.operator == "~=":
89+
version_parts = spec.version.split(".")
90+
if len(version_parts) < 2:
91+
return False, f"compatible release ~= requires at least x.y format (got ~={spec.version})"
92+
93+
return True, ""
94+
95+
96+
def check_dependencies(data: Dict) -> List[str]:
97+
"""
98+
Check all project dependencies for well-defined version ranges.
99+
100+
Returns:
101+
List of error messages for violations found
102+
"""
103+
violations = []
104+
105+
# Check project.dependencies
106+
if "project" in data and "dependencies" in data["project"]:
107+
violations.extend(
108+
check_dependency_section(
109+
data["project"]["dependencies"],
110+
"project.dependencies",
111+
)
112+
)
113+
114+
# Check project.optional-dependencies
115+
if "project" in data and "optional-dependencies" in data["project"]:
116+
optional_deps = data["project"]["optional-dependencies"]
117+
for group_name, deps in optional_deps.items():
118+
violations.extend(
119+
check_dependency_section(
120+
deps,
121+
f"project.optional-dependencies[{group_name}]",
122+
)
123+
)
124+
125+
return violations
126+
127+
128+
def check_dependency_section(deps: List[str], section_name: str) -> List[str]:
129+
"""Check a single dependency section for violations."""
130+
violations = []
131+
132+
for dep_line in deps:
133+
# Parse package name and version specifier
134+
# Format: "package_name[extras]version_spec" or just "package_name"
135+
parts = dep_line.split(";", 1) # Split on environment marker
136+
dep_spec = parts[0].strip()
137+
138+
# Extract package name and version specifier
139+
# Find where the version specifier starts (first operator: >, <, =, ~, !)
140+
package_name = ""
141+
version_spec = ""
142+
143+
for i, char in enumerate(dep_spec):
144+
if char in (">=", "<", "=", "~", "!", ">", "<") or dep_spec[i:].startswith((">=", "<=", "~=")):
145+
package_name = dep_spec[:i].strip()
146+
version_spec = dep_spec[i:].strip()
147+
break
148+
else:
149+
# No version specifier found
150+
package_name = dep_spec.strip()
151+
version_spec = ""
152+
153+
# Remove environment markers from package name if present
154+
if "[" in package_name:
155+
package_name = package_name.split("[")[0]
156+
157+
is_valid, reason = is_well_defined(version_spec)
158+
159+
if not is_valid:
160+
violations.append(
161+
f"{section_name}: '{package_name}' has {reason} (specifier: '{version_spec}')"
162+
)
163+
164+
return violations
165+
166+
167+
def main() -> int:
168+
"""Main entry point."""
169+
data = load_pyproject()
170+
violations = check_dependencies(data)
171+
172+
if violations:
173+
print("❌ Dependency validation failed:\n")
174+
for violation in violations:
175+
print(f" {violation}")
176+
print("\nAll dependencies must have both lower and upper bounds.")
177+
print("Acceptable formats: >=X.Y,<X.Z or ~=X.Y.Z (compatible release)")
178+
return 1
179+
180+
print("✅ All dependencies have well-defined version ranges")
181+
return 0
182+
183+
184+
if __name__ == "__main__":
185+
sys.exit(main())

0 commit comments

Comments
 (0)