From 6536e6621649e32c652d6c86635e326986684c15 Mon Sep 17 00:00:00 2001 From: Benjamin De Bernardi Date: Wed, 15 Oct 2025 16:01:19 +0200 Subject: [PATCH 1/5] ci(linter): add flake8 linter rule to disable direct access of os.environ --- hatch.toml | 3 + .../flake8_environ_rule/environ_checker.py | 155 ++++++++++++++++++ scripts/flake8_environ_rule/setup.py | 18 ++ setup.cfg | 3 + 4 files changed, 179 insertions(+) create mode 100644 scripts/flake8_environ_rule/environ_checker.py create mode 100644 scripts/flake8_environ_rule/setup.py diff --git a/hatch.toml b/hatch.toml index e46a08acacc..bb3b2283493 100644 --- a/hatch.toml +++ b/hatch.toml @@ -23,6 +23,8 @@ dependencies = [ "cmake-format==0.6.13", "ruamel.yaml==0.18.6", "ast-grep-cli==0.39.4", + "flake8>=7.0.0", + "flake8_environ_rule @ {root:uri}/scripts/flake8_environ_rule", ] [envs.lint.scripts] @@ -38,6 +40,7 @@ cformat_check = [ style = [ "black_check", "ruff check {args:.}", + "flake8 --select=ENV001 {args:ddtrace/}", "cython-lint {args:.}", "cformat_check", "cmakeformat_check", diff --git a/scripts/flake8_environ_rule/environ_checker.py b/scripts/flake8_environ_rule/environ_checker.py new file mode 100644 index 00000000000..b5c9ba78a43 --- /dev/null +++ b/scripts/flake8_environ_rule/environ_checker.py @@ -0,0 +1,155 @@ +""" +flake8 plugin to disallow os.environ use. Integrated into hatch lint environment +""" + +import ast +from typing import Iterator +from typing import Tuple +from typing import Type + + +class EnvironChecker: + """Flake8 checker for os.environ access patterns.""" + + name = "environ-access-checker" + version = "1.0.0" + + def __init__(self, tree: ast.AST) -> None: + self.tree = tree + self._os_names = set() + self._environ_names = set() + + def run(self) -> Iterator[Tuple[int, int, str, Type["EnvironChecker"]]]: + # STEP 1: Collect import information first + # This builds our _os_names and _environ_names sets to track all ways + # that os.environ might be referenced in this file + self._collect_imports() + + # STEP 2: Track seen violations to avoid duplicates + # Some AST nodes might trigger multiple violation checks, so we deduplicate + # using (line_number, column_offset, message) as the unique key + seen_violations = set() + + # STEP 3: Walk through every node in the AST and check for violations + for node in ast.walk(self.tree): + for violation in self._check_node(node): + # Create a unique key for this violation to prevent duplicates + key = (violation[0], violation[1], violation[2]) # line, col, message + if key not in seen_violations: + seen_violations.add(key) + yield violation + + def _collect_imports(self) -> None: + """Collect os and environ import names to track all possible access patterns.""" + for node in ast.walk(self.tree): + # IMPORT PATTERN 1: Standard module imports + # Handles: import os, import os as system_module + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "os": + # Store the name used to reference 'os' (could be aliased) + self._os_names.add(alias.asname or "os") + + # IMPORT PATTERN 2: Direct environ imports from os module + # Handles: from os import environ, from os import environ as env_vars + elif isinstance(node, ast.ImportFrom) and node.module == "os": + for alias in node.names: + if alias.name == "environ": + # Store the name used to reference 'environ' (could be aliased) + self._environ_names.add(alias.asname or "environ") + + def _check_node(self, node: ast.AST) -> Iterator[Tuple[int, int, str, Type["EnvironChecker"]]]: + """Check a single AST node for violations.""" + + # VIOLATION CHECK 1: Subscript access patterns + # Catches: os.environ["KEY"], os.environ.get("KEY"), environ["KEY"] + # Examples: value = os.environ["HOME"], config = environ["DEBUG"] + if isinstance(node, ast.Subscript): + if self._is_environ_access(node.value): + yield ( + node.lineno, + node.col_offset, + "ENV001 any access to os.environ is not allowed, use configuration utility instead", + type(self), + ) + + # VIOLATION CHECK 2: Method calls on os.environ + # Catches: os.environ.get(), os.environ.keys(), os.environ.items(), os.environ.update(), etc. + # Examples: os.environ.get("PATH"), os.environ.keys(), environ.items() + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + if self._is_environ_access(node.func.value): + yield ( + node.lineno, + node.col_offset, + "ENV001 any access to os.environ is not allowed, use configuration utility instead", + type(self), + ) + + # VIOLATION CHECK 3: Membership tests using 'in' operator + # Catches: "KEY" in os.environ, variable in os.environ + # Examples: if "HOME" in os.environ:, if key in environ: + elif isinstance(node, ast.Compare): + for op, comparator in zip(node.ops, node.comparators): + if isinstance(op, ast.In) and self._is_environ_access(comparator): + yield ( + node.lineno, + node.col_offset, + "ENV001 any access to os.environ is not allowed, use configuration utility instead", + type(self), + ) + + # VIOLATION CHECK 4: Iteration over os.environ + # Catches: for loops that iterate over os.environ or its methods + # Examples: for key in os.environ:, for k, v in os.environ.items(): + elif isinstance(node, ast.For): + if self._is_environ_access(node.iter): + yield ( + node.lineno, + node.col_offset, + "ENV001 any access to os.environ is not allowed, use configuration utility instead", + type(self), + ) + + # VIOLATION CHECK 5: Direct attribute access to os.environ + # Catches: direct references to os.environ object itself + # Examples: env_dict = os.environ, my_env = environ + elif isinstance(node, ast.Attribute): + if self._is_environ_attribute(node): + yield ( + node.lineno, + node.col_offset, + "ENV001 any access to os.environ is not allowed, use configuration utility instead", + type(self), + ) + + def _is_environ_attribute(self, node: ast.Attribute) -> bool: + """ + Check if node is os.environ attribute access. + + Detects patterns like: os.environ, system.environ (if imported as 'system') + Returns True when: + - node.value is a Name (like 'os') + - that name is in our tracked os import names + - the attribute being accessed is 'environ' + """ + return isinstance(node.value, ast.Name) and node.value.id in self._os_names and node.attr == "environ" + + def _is_environ_access(self, node: ast.AST) -> bool: + """ + Check if a node represents access to os.environ in any form. + + Handles two main access patterns: + 1. Direct environ usage: environ (from 'from os import environ') + 2. Attribute access: os.environ (from 'import os') + """ + # CASE 1: Direct environ name usage + # Matches: environ, env_vars (if imported as 'from os import environ as env_vars') + if isinstance(node, ast.Name) and node.id in self._environ_names: + return True + + # CASE 2: Attribute access pattern (os.environ) + # Matches: os.environ, system.environ (if imported as 'import os as system') + if isinstance(node, ast.Attribute): + return self._is_environ_attribute(node) + + return False diff --git a/scripts/flake8_environ_rule/setup.py b/scripts/flake8_environ_rule/setup.py new file mode 100644 index 00000000000..359179e796e --- /dev/null +++ b/scripts/flake8_environ_rule/setup.py @@ -0,0 +1,18 @@ +"""Setup for local flake8 plugin - integrated with hatch lint environment.""" + +from setuptools import setup + + +setup( + name="flake8-environ-rule", + version="1.0.0", + py_modules=["environ_checker"], + entry_points={ + "flake8.extension": [ + "ENV = environ_checker:EnvironChecker", + ], + }, + install_requires=[ + "flake8>=3.0.0", + ], +) diff --git a/setup.cfg b/setup.cfg index a50038950b4..a697f135628 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,3 +19,6 @@ asyncio_mode = auto [flake8] max-line-length = 120 +# Enable local environ checker +# ENV001: Complete ban on os.environ usage +extend-select = ENV001 From 53b21b8c229d54cc9add1c4dc6beda85ff6668e5 Mon Sep 17 00:00:00 2001 From: Benjamin De Bernardi Date: Sat, 8 Nov 2025 15:48:05 +0100 Subject: [PATCH 2/5] ci(linter): add os.getenv to linter rule for env --- .../flake8_environ_rule/environ_checker.py | 110 ++++++++++++------ scripts/flake8_environ_rule/tests/README.md | 30 +++++ .../tests/test_all_patterns.py | 82 +++++++++++++ 3 files changed, 184 insertions(+), 38 deletions(-) create mode 100644 scripts/flake8_environ_rule/tests/README.md create mode 100644 scripts/flake8_environ_rule/tests/test_all_patterns.py diff --git a/scripts/flake8_environ_rule/environ_checker.py b/scripts/flake8_environ_rule/environ_checker.py index b5c9ba78a43..345a81719d8 100644 --- a/scripts/flake8_environ_rule/environ_checker.py +++ b/scripts/flake8_environ_rule/environ_checker.py @@ -8,6 +8,10 @@ from typing import Type +environ_message = "ENV001 any access to os.environ is not allowed, use ddtrace.settings._env.environ instead" +getenv_message = "ENV001 any access os.getenv is not allowed, use ddtrace.settings._env.get_env instead" + + class EnvironChecker: """Flake8 checker for os.environ access patterns.""" @@ -18,11 +22,12 @@ def __init__(self, tree: ast.AST) -> None: self.tree = tree self._os_names = set() self._environ_names = set() + self._getenv_names = set() def run(self) -> Iterator[Tuple[int, int, str, Type["EnvironChecker"]]]: # STEP 1: Collect import information first - # This builds our _os_names and _environ_names sets to track all ways - # that os.environ might be referenced in this file + # This builds our _os_names, _environ_names, and _getenv_names sets to track all ways + # that os.environ and os.getenv might be referenced in this file self._collect_imports() # STEP 2: Track seen violations to avoid duplicates @@ -40,7 +45,7 @@ def run(self) -> Iterator[Tuple[int, int, str, Type["EnvironChecker"]]]: yield violation def _collect_imports(self) -> None: - """Collect os and environ import names to track all possible access patterns.""" + """Collect os, environ, and getenv import names to track all possible access patterns.""" for node in ast.walk(self.tree): # IMPORT PATTERN 1: Standard module imports # Handles: import os, import os as system_module @@ -50,13 +55,17 @@ def _collect_imports(self) -> None: # Store the name used to reference 'os' (could be aliased) self._os_names.add(alias.asname or "os") - # IMPORT PATTERN 2: Direct environ imports from os module + # IMPORT PATTERN 2: Direct imports from os module # Handles: from os import environ, from os import environ as env_vars + # Handles: from os import getenv, from os import getenv as get_env elif isinstance(node, ast.ImportFrom) and node.module == "os": for alias in node.names: if alias.name == "environ": # Store the name used to reference 'environ' (could be aliased) self._environ_names.add(alias.asname or "environ") + elif alias.name == "getenv": + # Store the name used to reference 'getenv' (could be aliased) + self._getenv_names.add(alias.asname or "getenv") def _check_node(self, node: ast.AST) -> Iterator[Tuple[int, int, str, Type["EnvironChecker"]]]: """Check a single AST node for violations.""" @@ -66,24 +75,19 @@ def _check_node(self, node: ast.AST) -> Iterator[Tuple[int, int, str, Type["Envi # Examples: value = os.environ["HOME"], config = environ["DEBUG"] if isinstance(node, ast.Subscript): if self._is_environ_access(node.value): - yield ( - node.lineno, - node.col_offset, - "ENV001 any access to os.environ is not allowed, use configuration utility instead", - type(self), - ) + yield (node.lineno, node.col_offset, environ_message, type(self)) # VIOLATION CHECK 2: Method calls on os.environ # Catches: os.environ.get(), os.environ.keys(), os.environ.items(), os.environ.update(), etc. # Examples: os.environ.get("PATH"), os.environ.keys(), environ.items() elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): if self._is_environ_access(node.func.value): - yield ( - node.lineno, - node.col_offset, - "ENV001 any access to os.environ is not allowed, use configuration utility instead", - type(self), - ) + yield (node.lineno, node.col_offset, environ_message, type(self)) + # VIOLATION CHECK 6: os.getenv() function calls + # Catches: os.getenv("KEY"), os.getenv("KEY", "default") + # Examples: value = os.getenv("HOME"), config = os.getenv("DEBUG", "false") + elif self._is_getenv_call(node): + yield (node.lineno, node.col_offset, getenv_message, type(self)) # VIOLATION CHECK 3: Membership tests using 'in' operator # Catches: "KEY" in os.environ, variable in os.environ @@ -91,36 +95,30 @@ def _check_node(self, node: ast.AST) -> Iterator[Tuple[int, int, str, Type["Envi elif isinstance(node, ast.Compare): for op, comparator in zip(node.ops, node.comparators): if isinstance(op, ast.In) and self._is_environ_access(comparator): - yield ( - node.lineno, - node.col_offset, - "ENV001 any access to os.environ is not allowed, use configuration utility instead", - type(self), - ) + yield (node.lineno, node.col_offset, environ_message, type(self)) # VIOLATION CHECK 4: Iteration over os.environ # Catches: for loops that iterate over os.environ or its methods # Examples: for key in os.environ:, for k, v in os.environ.items(): elif isinstance(node, ast.For): if self._is_environ_access(node.iter): - yield ( - node.lineno, - node.col_offset, - "ENV001 any access to os.environ is not allowed, use configuration utility instead", - type(self), - ) - - # VIOLATION CHECK 5: Direct attribute access to os.environ - # Catches: direct references to os.environ object itself - # Examples: env_dict = os.environ, my_env = environ + yield (node.lineno, node.col_offset, environ_message, type(self)) + + # VIOLATION CHECK 5: Direct attribute access to os.environ and os.getenv + # Catches: direct references to os.environ object itself and os.getenv function + # Examples: env_dict = os.environ, my_env = environ, getenv_func = os.getenv elif isinstance(node, ast.Attribute): if self._is_environ_attribute(node): - yield ( - node.lineno, - node.col_offset, - "ENV001 any access to os.environ is not allowed, use configuration utility instead", - type(self), - ) + yield (node.lineno, node.col_offset, environ_message, type(self)) + elif self._is_getenv_attribute(node): + yield (node.lineno, node.col_offset, getenv_message, type(self)) + + # VIOLATION CHECK 7: Direct getenv() function calls (imported directly) + # Catches: getenv("KEY"), get_env("KEY") (from 'from os import getenv as get_env') + # Examples: value = getenv("HOME"), config = get_env("DEBUG", "false") + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + if self._is_getenv_call(node): + yield (node.lineno, node.col_offset, getenv_message, type(self)) def _is_environ_attribute(self, node: ast.Attribute) -> bool: """ @@ -134,6 +132,18 @@ def _is_environ_attribute(self, node: ast.Attribute) -> bool: """ return isinstance(node.value, ast.Name) and node.value.id in self._os_names and node.attr == "environ" + def _is_getenv_attribute(self, node: ast.Attribute) -> bool: + """ + Check if node is os.getenv attribute access. + + Detects patterns like: os.getenv, system.getenv (if imported as 'system') + Returns True when: + - node.value is a Name (like 'os') + - that name is in our tracked os import names + - the attribute being accessed is 'getenv' + """ + return isinstance(node.value, ast.Name) and node.value.id in self._os_names and node.attr == "getenv" + def _is_environ_access(self, node: ast.AST) -> bool: """ Check if a node represents access to os.environ in any form. @@ -153,3 +163,27 @@ def _is_environ_access(self, node: ast.AST) -> bool: return self._is_environ_attribute(node) return False + + def _is_getenv_call(self, node: ast.Call) -> bool: + """ + Check if a function call node represents os.getenv() in any form. + + Handles two main call patterns: + 1. Direct getenv usage: getenv("KEY") (from 'from os import getenv') + 2. Attribute access: os.getenv("KEY") (from 'import os') + """ + # CASE 1: Direct getenv function call + # Matches: getenv("KEY"), get_env("KEY") (if imported as 'from os import getenv as get_env') + if isinstance(node.func, ast.Name) and node.func.id in self._getenv_names: + return True + + # CASE 2: Attribute access pattern (os.getenv) + # Matches: os.getenv("KEY"), system.getenv("KEY") (if imported as 'import os as system') + if isinstance(node.func, ast.Attribute): + return ( + isinstance(node.func.value, ast.Name) + and node.func.value.id in self._os_names + and node.func.attr == "getenv" + ) + + return False diff --git a/scripts/flake8_environ_rule/tests/README.md b/scripts/flake8_environ_rule/tests/README.md new file mode 100644 index 00000000000..8d866ba4d95 --- /dev/null +++ b/scripts/flake8_environ_rule/tests/README.md @@ -0,0 +1,30 @@ +# Tests for environ_checker flake8 plugin + +This directory contains test files for the `environ_checker` flake8 plugin. + +## test_all_patterns.py + +A dummy file containing examples of ALL environment variable access patterns that should be detected and flagged by the environ_checker plugin. + +### Usage + +```bash +# Run the test to verify all patterns are caught +hatch run lint:flake8 --select=ENV001 scripts/flake8_environ_rule/tests/test_all_patterns.py +``` + +### Expected Result + +All lines containing environment variable access should be flagged with ENV001 errors. The test covers: + +1. **Direct os.environ access patterns**: method calls (get, copy, clear, update, pop, setdefault, keys, values, items) +2. **Membership tests and iteration**: `in` operator, `for` loops +3. **Direct environ usage**: imported `environ` from os +4. **Aliased environ usage**: `from os import environ as env_dict` +5. **os.getenv function calls**: direct calls with and without defaults +6. **Direct getenv calls**: imported `getenv` from os +7. **Aliased getenv calls and direct attribute access**: including `os.getenv` attribute references + +### Pattern Count + +The test should detect exactly 28 violations across all the different access patterns. diff --git a/scripts/flake8_environ_rule/tests/test_all_patterns.py b/scripts/flake8_environ_rule/tests/test_all_patterns.py new file mode 100644 index 00000000000..0ff82ba779b --- /dev/null +++ b/scripts/flake8_environ_rule/tests/test_all_patterns.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +""" +Comprehensive test file for the environ_checker flake8 plugin. + +This file contains examples of ALL environment variable access patterns that should be +detected and flagged by the environ_checker plugin with ENV001 violations. + +Usage: + hatch run lint:flake8 --select=ENV001 scripts/flake8_environ_rule/tests/test_all_patterns.py + +Expected: All lines with environment variable access should be flagged with ENV001 errors. +""" + +import os +from os import environ +from os import environ as env_dict +from os import getenv +from os import getenv as get_env + + +# ============================================================================ +# SECTION 1: Direct os.environ access patterns +# ============================================================================ +value1 = os.environ["HOME"] # Subscript access +value2 = os.environ.get("PATH") # Method call +env_copy = os.environ.copy() # Method call +os.environ.clear() # Method call +os.environ.update({"NEW_VAR": "value"}) # Method call +removed = os.environ.pop("TEMP_VAR", None) # Method call +default_val = os.environ.setdefault("DEF", "default") # Method call +all_keys = os.environ.keys() # Method call +all_values = os.environ.values() # Method call +all_items = os.environ.items() # Method call + +# ============================================================================ +# SECTION 2: Membership tests and iteration +# ============================================================================ +if "HOME" in os.environ: # Membership test + pass + +# Iteration +for key in os.environ: # Iteration + pass +for k, v in os.environ.items(): # Iteration over method call + pass + +# ============================================================================ +# SECTION 3: Direct environ usage (imported from os) +# ============================================================================ +value3 = environ["USER"] # Direct environ subscript +value4 = environ.get("SHELL") # Direct environ method +env_keys = environ.keys() # Direct environ method + +# ============================================================================ +# SECTION 4: Aliased environ usage +# ============================================================================ +value5 = env_dict["HOME"] # Aliased environ subscript +value6 = env_dict.get("PATH") # Aliased environ method + +# ============================================================================ +# SECTION 5: os.getenv function calls +# ============================================================================ +value7 = os.getenv("DEBUG") # Direct os.getenv +value8 = os.getenv("TIMEOUT", "30") # Direct os.getenv with default + +# ============================================================================ +# SECTION 6: Direct getenv calls (imported from os) +# ============================================================================ +value9 = getenv("CONFIG") # Direct getenv +value10 = getenv("MODE", "production") # Direct getenv with default + +# ============================================================================ +# SECTION 7: Aliased getenv calls and direct attribute access +# ============================================================================ +value11 = get_env("LEVEL") # Aliased getenv +value12 = get_env("PORT", "8080") # Aliased getenv with default + +# Direct attribute access +env_ref = os.environ # Direct attribute access +getenv_ref = os.getenv # Direct attribute access (should this be caught?) + +print("All patterns tested") From a6fdc28311ea386bf88ce6dfaf848346e0d8d6ab Mon Sep 17 00:00:00 2001 From: Benjamin De Bernardi Date: Thu, 20 Nov 2025 18:08:06 +0100 Subject: [PATCH 3/5] feat(sg): add new sg linting rule for env --- .sg/rules/os-environ-usage.yml | 95 +++++++ .../os-environ-usage-snapshot.yml | 247 ++++++++++++++++++ .sg/tests/os-environ-usage-test.yml | 125 +++++++++ 3 files changed, 467 insertions(+) create mode 100644 .sg/rules/os-environ-usage.yml create mode 100644 .sg/tests/__snapshots__/os-environ-usage-snapshot.yml create mode 100644 .sg/tests/os-environ-usage-test.yml diff --git a/.sg/rules/os-environ-usage.yml b/.sg/rules/os-environ-usage.yml new file mode 100644 index 00000000000..a12178bee75 --- /dev/null +++ b/.sg/rules/os-environ-usage.yml @@ -0,0 +1,95 @@ +id: os-environ-usage +message: Use `ddtrace.settings._env.environ` instead of `os.environ`, or `ddtrace.settings._env.get_env()` instead of `os.getenv()` +severity: error +language: python +files: + - "ddtrace/**" +ignores: + - "ddtrace/settings/_env.py" +rule: + any: + # Match os.environ access patterns + - pattern: os.environ + - pattern: os.environ[$$$ARGS] + - pattern: os.environ.get($$$ARGS) + - pattern: os.environ.keys($$$ARGS) + - pattern: os.environ.values($$$ARGS) + - pattern: os.environ.items($$$ARGS) + - pattern: os.environ.copy($$$ARGS) + - pattern: os.environ.clear($$$ARGS) + - pattern: os.environ.update($$$ARGS) + - pattern: os.environ.pop($$$ARGS) + - pattern: os.environ.setdefault($$$ARGS) + + # Match os.getenv() calls + - pattern: os.getenv($$$ARGS) + + # Match direct imports and usage of environ + - pattern: from os import environ + - pattern: from os import environ as $ALIAS + - pattern: from os import $$$IMPORTS, environ + - pattern: from os import $$$IMPORTS, environ as $ALIAS + - pattern: from os import environ, $$$IMPORTS + - pattern: from os import environ as $ALIAS, $$$IMPORTS + + # Match direct imports and usage of getenv + - pattern: from os import getenv + - pattern: from os import getenv as $ALIAS + - pattern: from os import $$$IMPORTS, getenv + - pattern: from os import $$$IMPORTS, getenv as $ALIAS + - pattern: from os import getenv, $$$IMPORTS + - pattern: from os import getenv as $ALIAS, $$$IMPORTS + + # Match usage of imported environ (direct name access) + - pattern: environ[$$$ARGS] + has: + kind: module + pattern: | + from os import environ + - pattern: environ.get($$$ARGS) + has: + kind: module + pattern: | + from os import environ + - pattern: environ.keys($$$ARGS) + has: + kind: module + pattern: | + from os import environ + - pattern: environ.values($$$ARGS) + has: + kind: module + pattern: | + from os import environ + - pattern: environ.items($$$ARGS) + has: + kind: module + pattern: | + from os import environ + + # Match usage of imported getenv + - pattern: getenv($$$ARGS) + has: + kind: module + pattern: | + from os import getenv + +note: | + Direct access to os.environ or os.getenv is not allowed in this codebase. + + Instead, use the centralized environment variable helpers: + - For environ: `from ddtrace.settings._env import environ` + - For getenv: `from ddtrace.settings._env import get_env` + + Before: + import os + value = os.environ["HOME"] + debug = os.getenv("DEBUG", "false") + + After: + from ddtrace.settings._env import environ, get_env + value = environ["HOME"] + debug = get_env("DEBUG", "false") + + This ensures consistent environment variable handling across the codebase. + diff --git a/.sg/tests/__snapshots__/os-environ-usage-snapshot.yml b/.sg/tests/__snapshots__/os-environ-usage-snapshot.yml new file mode 100644 index 00000000000..df11f9d9cf4 --- /dev/null +++ b/.sg/tests/__snapshots__/os-environ-usage-snapshot.yml @@ -0,0 +1,247 @@ +id: os-environ-usage +snapshots: + from os import environ: + labels: + - source: from os import environ + style: primary + start: 0 + end: 22 + ? | + from os import environ + env_keys = environ.keys() + : labels: + - source: from os import environ + style: primary + start: 0 + end: 22 + ? | + from os import environ + value = environ.get("SHELL") + : labels: + - source: from os import environ + style: primary + start: 0 + end: 22 + ? | + from os import environ + value = environ["USER"] + : labels: + - source: from os import environ + style: primary + start: 0 + end: 22 + from os import environ as env_dict: + labels: + - source: from os import environ as env_dict + style: primary + start: 0 + end: 34 + ? | + from os import environ as env_dict + value = env_dict.get("PATH") + : labels: + - source: from os import environ as env_dict + style: primary + start: 0 + end: 34 + ? | + from os import environ as env_dict + value = env_dict["HOME"] + : labels: + - source: from os import environ as env_dict + style: primary + start: 0 + end: 34 + from os import environ, getenv: + labels: + - source: from os import environ, getenv + style: primary + start: 0 + end: 30 + from os import getenv: + labels: + - source: from os import getenv + style: primary + start: 0 + end: 21 + ? | + from os import getenv + value = getenv("CONFIG") + : labels: + - source: from os import getenv + style: primary + start: 0 + end: 21 + ? | + from os import getenv + value = getenv("MODE", "production") + : labels: + - source: from os import getenv + style: primary + start: 0 + end: 21 + from os import getenv as get_env_func: + labels: + - source: from os import getenv as get_env_func + style: primary + start: 0 + end: 37 + ? | + from os import getenv as get_env_func + value = get_env_func("LEVEL") + : labels: + - source: from os import getenv as get_env_func + style: primary + start: 0 + end: 37 + ? | + from os import getenv as get_env_func + value = get_env_func("PORT", "8080") + : labels: + - source: from os import getenv as get_env_func + style: primary + start: 0 + end: 37 + from os import getenv, environ: + labels: + - source: from os import getenv, environ + style: primary + start: 0 + end: 30 + from os import path, environ, getenv: + labels: + - source: from os import path, environ, getenv + style: primary + start: 0 + end: 36 + ? | + import os + all_items = os.environ.items() + : labels: + - source: os.environ.items() + style: primary + start: 22 + end: 40 + ? | + import os + all_keys = os.environ.keys() + : labels: + - source: os.environ.keys() + style: primary + start: 21 + end: 38 + ? | + import os + all_values = os.environ.values() + : labels: + - source: os.environ.values() + style: primary + start: 23 + end: 42 + ? | + import os + default_val = os.environ.setdefault("DEF", "default") + : labels: + - source: os.environ.setdefault("DEF", "default") + style: primary + start: 24 + end: 63 + ? | + import os + env_copy = os.environ.copy() + : labels: + - source: os.environ.copy() + style: primary + start: 21 + end: 38 + ? | + import os + env_ref = os.environ + : labels: + - source: os.environ + style: primary + start: 20 + end: 30 + ? | + import os + for k, v in os.environ.items(): + pass + : labels: + - source: os.environ.items() + style: primary + start: 22 + end: 40 + ? | + import os + for key in os.environ: + pass + : labels: + - source: os.environ + style: primary + start: 21 + end: 31 + ? | + import os + if "HOME" in os.environ: + pass + : labels: + - source: os.environ + style: primary + start: 23 + end: 33 + ? | + import os + os.environ.clear() + : labels: + - source: os.environ.clear() + style: primary + start: 10 + end: 28 + ? | + import os + os.environ.update({"NEW_VAR": "value"}) + : labels: + - source: 'os.environ.update({"NEW_VAR": "value"})' + style: primary + start: 10 + end: 49 + ? | + import os + removed = os.environ.pop("TEMP_VAR", None) + : labels: + - source: os.environ.pop("TEMP_VAR", None) + style: primary + start: 20 + end: 52 + ? | + import os + value = os.environ.get("PATH") + : labels: + - source: os.environ.get("PATH") + style: primary + start: 18 + end: 40 + ? | + import os + value = os.environ["HOME"] + : labels: + - source: os.environ["HOME"] + style: primary + start: 18 + end: 36 + ? | + import os + value = os.getenv("DEBUG") + : labels: + - source: os.getenv("DEBUG") + style: primary + start: 18 + end: 36 + ? | + import os + value = os.getenv("TIMEOUT", "30") + : labels: + - source: os.getenv("TIMEOUT", "30") + style: primary + start: 18 + end: 44 diff --git a/.sg/tests/os-environ-usage-test.yml b/.sg/tests/os-environ-usage-test.yml new file mode 100644 index 00000000000..4349066df01 --- /dev/null +++ b/.sg/tests/os-environ-usage-test.yml @@ -0,0 +1,125 @@ +id: os-environ-usage +valid: + # These should NOT trigger the rule (valid code) + - | + from ddtrace.settings._env import environ + value = environ["HOME"] + - | + from ddtrace.settings._env import get_env + debug = get_env("DEBUG", "false") + - | + from ddtrace.settings._env import environ, get_env + value = environ["HOME"] + debug = get_env("DEBUG", "false") + - | + # Using the centralized helpers is OK + from ddtrace.settings._env import environ + if "HOME" in environ: + print(environ["HOME"]) + +invalid: + # These should trigger the rule (errors) + + # Direct os.environ access patterns + - | + import os + value = os.environ["HOME"] + - | + import os + value = os.environ.get("PATH") + - | + import os + env_copy = os.environ.copy() + - | + import os + os.environ.clear() + - | + import os + os.environ.update({"NEW_VAR": "value"}) + - | + import os + removed = os.environ.pop("TEMP_VAR", None) + - | + import os + default_val = os.environ.setdefault("DEF", "default") + - | + import os + all_keys = os.environ.keys() + - | + import os + all_values = os.environ.values() + - | + import os + all_items = os.environ.items() + + # Membership tests and iteration + - | + import os + if "HOME" in os.environ: + pass + - | + import os + for key in os.environ: + pass + - | + import os + for k, v in os.environ.items(): + pass + + # os.getenv function calls + - | + import os + value = os.getenv("DEBUG") + - | + import os + value = os.getenv("TIMEOUT", "30") + + # Direct environ usage (imported from os) + - from os import environ + - | + from os import environ + value = environ["USER"] + - | + from os import environ + value = environ.get("SHELL") + - | + from os import environ + env_keys = environ.keys() + + # Aliased environ usage + - from os import environ as env_dict + - | + from os import environ as env_dict + value = env_dict["HOME"] + - | + from os import environ as env_dict + value = env_dict.get("PATH") + + # Direct getenv calls (imported from os) + - from os import getenv + - | + from os import getenv + value = getenv("CONFIG") + - | + from os import getenv + value = getenv("MODE", "production") + + # Aliased getenv calls + - from os import getenv as get_env_func + - | + from os import getenv as get_env_func + value = get_env_func("LEVEL") + - | + from os import getenv as get_env_func + value = get_env_func("PORT", "8080") + + # Mixed imports + - from os import environ, getenv + - from os import getenv, environ + - from os import path, environ, getenv + + # Direct attribute access + - | + import os + env_ref = os.environ + From 1c3ee1f318a2cd4abfafe1f91609ba944641fee8 Mon Sep 17 00:00:00 2001 From: Benjamin De Bernardi Date: Thu, 20 Nov 2025 18:09:00 +0100 Subject: [PATCH 4/5] chore(linter): remove flake8 env rule --- hatch.toml | 3 - .../flake8_environ_rule/environ_checker.py | 189 ------------------ scripts/flake8_environ_rule/setup.py | 18 -- scripts/flake8_environ_rule/tests/README.md | 30 --- .../tests/test_all_patterns.py | 82 -------- setup.cfg | 3 - 6 files changed, 325 deletions(-) delete mode 100644 scripts/flake8_environ_rule/environ_checker.py delete mode 100644 scripts/flake8_environ_rule/setup.py delete mode 100644 scripts/flake8_environ_rule/tests/README.md delete mode 100644 scripts/flake8_environ_rule/tests/test_all_patterns.py diff --git a/hatch.toml b/hatch.toml index bb3b2283493..e46a08acacc 100644 --- a/hatch.toml +++ b/hatch.toml @@ -23,8 +23,6 @@ dependencies = [ "cmake-format==0.6.13", "ruamel.yaml==0.18.6", "ast-grep-cli==0.39.4", - "flake8>=7.0.0", - "flake8_environ_rule @ {root:uri}/scripts/flake8_environ_rule", ] [envs.lint.scripts] @@ -40,7 +38,6 @@ cformat_check = [ style = [ "black_check", "ruff check {args:.}", - "flake8 --select=ENV001 {args:ddtrace/}", "cython-lint {args:.}", "cformat_check", "cmakeformat_check", diff --git a/scripts/flake8_environ_rule/environ_checker.py b/scripts/flake8_environ_rule/environ_checker.py deleted file mode 100644 index 345a81719d8..00000000000 --- a/scripts/flake8_environ_rule/environ_checker.py +++ /dev/null @@ -1,189 +0,0 @@ -""" -flake8 plugin to disallow os.environ use. Integrated into hatch lint environment -""" - -import ast -from typing import Iterator -from typing import Tuple -from typing import Type - - -environ_message = "ENV001 any access to os.environ is not allowed, use ddtrace.settings._env.environ instead" -getenv_message = "ENV001 any access os.getenv is not allowed, use ddtrace.settings._env.get_env instead" - - -class EnvironChecker: - """Flake8 checker for os.environ access patterns.""" - - name = "environ-access-checker" - version = "1.0.0" - - def __init__(self, tree: ast.AST) -> None: - self.tree = tree - self._os_names = set() - self._environ_names = set() - self._getenv_names = set() - - def run(self) -> Iterator[Tuple[int, int, str, Type["EnvironChecker"]]]: - # STEP 1: Collect import information first - # This builds our _os_names, _environ_names, and _getenv_names sets to track all ways - # that os.environ and os.getenv might be referenced in this file - self._collect_imports() - - # STEP 2: Track seen violations to avoid duplicates - # Some AST nodes might trigger multiple violation checks, so we deduplicate - # using (line_number, column_offset, message) as the unique key - seen_violations = set() - - # STEP 3: Walk through every node in the AST and check for violations - for node in ast.walk(self.tree): - for violation in self._check_node(node): - # Create a unique key for this violation to prevent duplicates - key = (violation[0], violation[1], violation[2]) # line, col, message - if key not in seen_violations: - seen_violations.add(key) - yield violation - - def _collect_imports(self) -> None: - """Collect os, environ, and getenv import names to track all possible access patterns.""" - for node in ast.walk(self.tree): - # IMPORT PATTERN 1: Standard module imports - # Handles: import os, import os as system_module - if isinstance(node, ast.Import): - for alias in node.names: - if alias.name == "os": - # Store the name used to reference 'os' (could be aliased) - self._os_names.add(alias.asname or "os") - - # IMPORT PATTERN 2: Direct imports from os module - # Handles: from os import environ, from os import environ as env_vars - # Handles: from os import getenv, from os import getenv as get_env - elif isinstance(node, ast.ImportFrom) and node.module == "os": - for alias in node.names: - if alias.name == "environ": - # Store the name used to reference 'environ' (could be aliased) - self._environ_names.add(alias.asname or "environ") - elif alias.name == "getenv": - # Store the name used to reference 'getenv' (could be aliased) - self._getenv_names.add(alias.asname or "getenv") - - def _check_node(self, node: ast.AST) -> Iterator[Tuple[int, int, str, Type["EnvironChecker"]]]: - """Check a single AST node for violations.""" - - # VIOLATION CHECK 1: Subscript access patterns - # Catches: os.environ["KEY"], os.environ.get("KEY"), environ["KEY"] - # Examples: value = os.environ["HOME"], config = environ["DEBUG"] - if isinstance(node, ast.Subscript): - if self._is_environ_access(node.value): - yield (node.lineno, node.col_offset, environ_message, type(self)) - - # VIOLATION CHECK 2: Method calls on os.environ - # Catches: os.environ.get(), os.environ.keys(), os.environ.items(), os.environ.update(), etc. - # Examples: os.environ.get("PATH"), os.environ.keys(), environ.items() - elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): - if self._is_environ_access(node.func.value): - yield (node.lineno, node.col_offset, environ_message, type(self)) - # VIOLATION CHECK 6: os.getenv() function calls - # Catches: os.getenv("KEY"), os.getenv("KEY", "default") - # Examples: value = os.getenv("HOME"), config = os.getenv("DEBUG", "false") - elif self._is_getenv_call(node): - yield (node.lineno, node.col_offset, getenv_message, type(self)) - - # VIOLATION CHECK 3: Membership tests using 'in' operator - # Catches: "KEY" in os.environ, variable in os.environ - # Examples: if "HOME" in os.environ:, if key in environ: - elif isinstance(node, ast.Compare): - for op, comparator in zip(node.ops, node.comparators): - if isinstance(op, ast.In) and self._is_environ_access(comparator): - yield (node.lineno, node.col_offset, environ_message, type(self)) - - # VIOLATION CHECK 4: Iteration over os.environ - # Catches: for loops that iterate over os.environ or its methods - # Examples: for key in os.environ:, for k, v in os.environ.items(): - elif isinstance(node, ast.For): - if self._is_environ_access(node.iter): - yield (node.lineno, node.col_offset, environ_message, type(self)) - - # VIOLATION CHECK 5: Direct attribute access to os.environ and os.getenv - # Catches: direct references to os.environ object itself and os.getenv function - # Examples: env_dict = os.environ, my_env = environ, getenv_func = os.getenv - elif isinstance(node, ast.Attribute): - if self._is_environ_attribute(node): - yield (node.lineno, node.col_offset, environ_message, type(self)) - elif self._is_getenv_attribute(node): - yield (node.lineno, node.col_offset, getenv_message, type(self)) - - # VIOLATION CHECK 7: Direct getenv() function calls (imported directly) - # Catches: getenv("KEY"), get_env("KEY") (from 'from os import getenv as get_env') - # Examples: value = getenv("HOME"), config = get_env("DEBUG", "false") - elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name): - if self._is_getenv_call(node): - yield (node.lineno, node.col_offset, getenv_message, type(self)) - - def _is_environ_attribute(self, node: ast.Attribute) -> bool: - """ - Check if node is os.environ attribute access. - - Detects patterns like: os.environ, system.environ (if imported as 'system') - Returns True when: - - node.value is a Name (like 'os') - - that name is in our tracked os import names - - the attribute being accessed is 'environ' - """ - return isinstance(node.value, ast.Name) and node.value.id in self._os_names and node.attr == "environ" - - def _is_getenv_attribute(self, node: ast.Attribute) -> bool: - """ - Check if node is os.getenv attribute access. - - Detects patterns like: os.getenv, system.getenv (if imported as 'system') - Returns True when: - - node.value is a Name (like 'os') - - that name is in our tracked os import names - - the attribute being accessed is 'getenv' - """ - return isinstance(node.value, ast.Name) and node.value.id in self._os_names and node.attr == "getenv" - - def _is_environ_access(self, node: ast.AST) -> bool: - """ - Check if a node represents access to os.environ in any form. - - Handles two main access patterns: - 1. Direct environ usage: environ (from 'from os import environ') - 2. Attribute access: os.environ (from 'import os') - """ - # CASE 1: Direct environ name usage - # Matches: environ, env_vars (if imported as 'from os import environ as env_vars') - if isinstance(node, ast.Name) and node.id in self._environ_names: - return True - - # CASE 2: Attribute access pattern (os.environ) - # Matches: os.environ, system.environ (if imported as 'import os as system') - if isinstance(node, ast.Attribute): - return self._is_environ_attribute(node) - - return False - - def _is_getenv_call(self, node: ast.Call) -> bool: - """ - Check if a function call node represents os.getenv() in any form. - - Handles two main call patterns: - 1. Direct getenv usage: getenv("KEY") (from 'from os import getenv') - 2. Attribute access: os.getenv("KEY") (from 'import os') - """ - # CASE 1: Direct getenv function call - # Matches: getenv("KEY"), get_env("KEY") (if imported as 'from os import getenv as get_env') - if isinstance(node.func, ast.Name) and node.func.id in self._getenv_names: - return True - - # CASE 2: Attribute access pattern (os.getenv) - # Matches: os.getenv("KEY"), system.getenv("KEY") (if imported as 'import os as system') - if isinstance(node.func, ast.Attribute): - return ( - isinstance(node.func.value, ast.Name) - and node.func.value.id in self._os_names - and node.func.attr == "getenv" - ) - - return False diff --git a/scripts/flake8_environ_rule/setup.py b/scripts/flake8_environ_rule/setup.py deleted file mode 100644 index 359179e796e..00000000000 --- a/scripts/flake8_environ_rule/setup.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Setup for local flake8 plugin - integrated with hatch lint environment.""" - -from setuptools import setup - - -setup( - name="flake8-environ-rule", - version="1.0.0", - py_modules=["environ_checker"], - entry_points={ - "flake8.extension": [ - "ENV = environ_checker:EnvironChecker", - ], - }, - install_requires=[ - "flake8>=3.0.0", - ], -) diff --git a/scripts/flake8_environ_rule/tests/README.md b/scripts/flake8_environ_rule/tests/README.md deleted file mode 100644 index 8d866ba4d95..00000000000 --- a/scripts/flake8_environ_rule/tests/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Tests for environ_checker flake8 plugin - -This directory contains test files for the `environ_checker` flake8 plugin. - -## test_all_patterns.py - -A dummy file containing examples of ALL environment variable access patterns that should be detected and flagged by the environ_checker plugin. - -### Usage - -```bash -# Run the test to verify all patterns are caught -hatch run lint:flake8 --select=ENV001 scripts/flake8_environ_rule/tests/test_all_patterns.py -``` - -### Expected Result - -All lines containing environment variable access should be flagged with ENV001 errors. The test covers: - -1. **Direct os.environ access patterns**: method calls (get, copy, clear, update, pop, setdefault, keys, values, items) -2. **Membership tests and iteration**: `in` operator, `for` loops -3. **Direct environ usage**: imported `environ` from os -4. **Aliased environ usage**: `from os import environ as env_dict` -5. **os.getenv function calls**: direct calls with and without defaults -6. **Direct getenv calls**: imported `getenv` from os -7. **Aliased getenv calls and direct attribute access**: including `os.getenv` attribute references - -### Pattern Count - -The test should detect exactly 28 violations across all the different access patterns. diff --git a/scripts/flake8_environ_rule/tests/test_all_patterns.py b/scripts/flake8_environ_rule/tests/test_all_patterns.py deleted file mode 100644 index 0ff82ba779b..00000000000 --- a/scripts/flake8_environ_rule/tests/test_all_patterns.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env python3 -""" -Comprehensive test file for the environ_checker flake8 plugin. - -This file contains examples of ALL environment variable access patterns that should be -detected and flagged by the environ_checker plugin with ENV001 violations. - -Usage: - hatch run lint:flake8 --select=ENV001 scripts/flake8_environ_rule/tests/test_all_patterns.py - -Expected: All lines with environment variable access should be flagged with ENV001 errors. -""" - -import os -from os import environ -from os import environ as env_dict -from os import getenv -from os import getenv as get_env - - -# ============================================================================ -# SECTION 1: Direct os.environ access patterns -# ============================================================================ -value1 = os.environ["HOME"] # Subscript access -value2 = os.environ.get("PATH") # Method call -env_copy = os.environ.copy() # Method call -os.environ.clear() # Method call -os.environ.update({"NEW_VAR": "value"}) # Method call -removed = os.environ.pop("TEMP_VAR", None) # Method call -default_val = os.environ.setdefault("DEF", "default") # Method call -all_keys = os.environ.keys() # Method call -all_values = os.environ.values() # Method call -all_items = os.environ.items() # Method call - -# ============================================================================ -# SECTION 2: Membership tests and iteration -# ============================================================================ -if "HOME" in os.environ: # Membership test - pass - -# Iteration -for key in os.environ: # Iteration - pass -for k, v in os.environ.items(): # Iteration over method call - pass - -# ============================================================================ -# SECTION 3: Direct environ usage (imported from os) -# ============================================================================ -value3 = environ["USER"] # Direct environ subscript -value4 = environ.get("SHELL") # Direct environ method -env_keys = environ.keys() # Direct environ method - -# ============================================================================ -# SECTION 4: Aliased environ usage -# ============================================================================ -value5 = env_dict["HOME"] # Aliased environ subscript -value6 = env_dict.get("PATH") # Aliased environ method - -# ============================================================================ -# SECTION 5: os.getenv function calls -# ============================================================================ -value7 = os.getenv("DEBUG") # Direct os.getenv -value8 = os.getenv("TIMEOUT", "30") # Direct os.getenv with default - -# ============================================================================ -# SECTION 6: Direct getenv calls (imported from os) -# ============================================================================ -value9 = getenv("CONFIG") # Direct getenv -value10 = getenv("MODE", "production") # Direct getenv with default - -# ============================================================================ -# SECTION 7: Aliased getenv calls and direct attribute access -# ============================================================================ -value11 = get_env("LEVEL") # Aliased getenv -value12 = get_env("PORT", "8080") # Aliased getenv with default - -# Direct attribute access -env_ref = os.environ # Direct attribute access -getenv_ref = os.getenv # Direct attribute access (should this be caught?) - -print("All patterns tested") diff --git a/setup.cfg b/setup.cfg index a697f135628..a50038950b4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,6 +19,3 @@ asyncio_mode = auto [flake8] max-line-length = 120 -# Enable local environ checker -# ENV001: Complete ban on os.environ usage -extend-select = ENV001 From 91d379aeba71868ed6ec43e8250c2e737e9a91ff Mon Sep 17 00:00:00 2001 From: Benjamin De Bernardi Date: Fri, 28 Nov 2025 15:33:01 +0100 Subject: [PATCH 5/5] chore(sg): update os-environ-usage rule with new import example --- .sg/rules/os-environ-usage.yml | 12 +++++------- .sg/tests/os-environ-usage-test.yml | 12 ++++++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.sg/rules/os-environ-usage.yml b/.sg/rules/os-environ-usage.yml index a12178bee75..df2c0386a67 100644 --- a/.sg/rules/os-environ-usage.yml +++ b/.sg/rules/os-environ-usage.yml @@ -1,5 +1,5 @@ id: os-environ-usage -message: Use `ddtrace.settings._env.environ` instead of `os.environ`, or `ddtrace.settings._env.get_env()` instead of `os.getenv()` +message: Use `ddtrace.settings._env` instead of `os` to access environment variables severity: error language: python files: @@ -77,9 +77,7 @@ rule: note: | Direct access to os.environ or os.getenv is not allowed in this codebase. - Instead, use the centralized environment variable helpers: - - For environ: `from ddtrace.settings._env import environ` - - For getenv: `from ddtrace.settings._env import get_env` + Instead, use the centralized environment variable helper `ddtrace.settings._env` Before: import os @@ -87,9 +85,9 @@ note: | debug = os.getenv("DEBUG", "false") After: - from ddtrace.settings._env import environ, get_env - value = environ["HOME"] - debug = get_env("DEBUG", "false") + from ddtrace.settings import _env + value = _env.environ["HOME"] + debug = _env.getenv("DEBUG", "false") This ensures consistent environment variable handling across the codebase. diff --git a/.sg/tests/os-environ-usage-test.yml b/.sg/tests/os-environ-usage-test.yml index 4349066df01..85f2f42bb53 100644 --- a/.sg/tests/os-environ-usage-test.yml +++ b/.sg/tests/os-environ-usage-test.yml @@ -1,16 +1,20 @@ id: os-environ-usage valid: # These should NOT trigger the rule (valid code) + - | + from ddtrace.setting import _env + debug = _env.getenv("DEBUG", "false") + value = _env.environ["HOME"] - | from ddtrace.settings._env import environ value = environ["HOME"] - | - from ddtrace.settings._env import get_env - debug = get_env("DEBUG", "false") + from ddtrace.settings._env import getenv + debug = getenv("DEBUG", "false") - | - from ddtrace.settings._env import environ, get_env + from ddtrace.settings._env import environ, getenv value = environ["HOME"] - debug = get_env("DEBUG", "false") + debug = getenv("DEBUG", "false") - | # Using the centralized helpers is OK from ddtrace.settings._env import environ