Skip to content

Commit b005c5e

Browse files
authored
CHORE: Exclude diagnostic LOG statements from coverage metrics (#556)
### Work Item / Issue Reference <!-- IMPORTANT: Please follow the PR template guidelines below. For mssql-python maintainers: Insert your ADO Work Item ID below For external contributors: Insert Github Issue number below Only one reference is required - either GitHub issue OR ADO Work Item. --> <!-- mssql-python maintainers: ADO Work Item --> > [AB#44903](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/44903) <!-- External contributors: GitHub Issue --> > GitHub Issue: #<ISSUE_NUMBER> ------------------------------------------------------------------- ### Summary <!-- Insert your summary of changes below. Minimum 10 characters required. --> This pull request introduces a new workflow for improving code coverage reporting by automatically joining multi-line `LOG()` macro calls into single lines before coverage analysis. This makes it easier to exclude logging statements from LCOV coverage results without modifying the original source code. The changes include a new helper script, updates to the build script to use this helper during coverage builds, and adjustments to the coverage merge process to exclude `LOG` statements. **Coverage build improvements:** * Added a new script `eng/scripts/join_logs_for_coverage.py` that joins multi-line `LOG()` macro calls into single lines in `.cpp` and `.hpp` files, simplifying LCOV filtering for logging statements. This script operates on temporary copies of the source code and does not modify the originals. * Updated `mssql_python/pybind/build.sh` to invoke the new helper script during coverage builds. The script backs up original source files, processes them to join `LOG` statements, and restores the originals after the build, ensuring the source code remains unchanged. **Coverage reporting adjustments:** * Modified `generate_codecov.sh` to use LCOV's `--omit-lines` option with a regex that matches all `LOG` macro calls (e.g., `LOG`, `LOG_ERROR`, `LOG_WARNING`) during the coverage merge step, ensuring these statements are excluded from the final coverage report. <!-- ### PR Title Guide > For feature requests FEAT: (short-description) > For non-feature requests like test case updates, config updates , dependency updates etc CHORE: (short-description) > For Fix requests FIX: (short-description) > For doc update requests DOC: (short-description) > For Formatting, indentation, or styling update STYLE: (short-description) > For Refactor, without any feature changes REFACTOR: (short-description) > For performance improvements PERF: (short-description) > For release related changes, without any feature changes RELEASE: #<RELEASE_VERSION> (short-description) ### Contribution Guidelines External contributors: - Create a GitHub issue first: https://github.com/microsoft/mssql-python/issues/new - Link the GitHub issue in the "GitHub Issue" section above - Follow the PR title format and provide a meaningful summary mssql-python maintainers: - Create an ADO Work Item following internal processes - Link the ADO Work Item in the "ADO Work Item" section above - Follow the PR title format and provide a meaningful summary -->
1 parent 79cae39 commit b005c5e

4 files changed

Lines changed: 285 additions & 9 deletions

File tree

.gitignore

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ build/
4848

4949
# wheel files
5050
*.whl
51+
52+
# Coverage reports and artifacts
53+
.coverage
54+
coverage.json
55+
coverage*.xml
56+
htmlcov/
57+
unified-coverage/
58+
*.profraw
59+
*.profdata
60+
*.info
5161
*.tar.gz
5262
*.zip
5363

@@ -66,3 +76,11 @@ mssql_py_core/
6676

6777
# learning files
6878
learnings/
79+
80+
# Local development and experimental scripts (not part of the PR)
81+
add_platform_exclusions.py
82+
add_lcov_exclusions.py
83+
fix_multiline_log_exclusions.py
84+
test_pyodbc_decimal.py
85+
run_coverage_docker.ps1
86+
TRIAGE_REPORT_*.md
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Join multi-line LOG() calls onto single lines for LCOV coverage filtering.
4+
5+
This script is used only during coverage builds to simplify LOG statement exclusion.
6+
It doesn't modify the original source files - it works on copies during the build.
7+
Adjacent string literals are concatenated at compile time, so runtime behavior is identical.
8+
9+
Uses a proper C++ tokenizer to handle string literals, character literals, and comments
10+
correctly, avoiding issues with unbalanced parentheses or semicolons in strings.
11+
"""
12+
13+
import re
14+
import sys
15+
from pathlib import Path
16+
17+
18+
_LOG_MACRO_PATTERN = re.compile(r'\bLOG[A-Z_]*\s*\(')
19+
20+
21+
def _find_log_macro_open(line: str):
22+
"""Return the index of the opening parenthesis for a LOG-like macro, if present."""
23+
match = _LOG_MACRO_PATTERN.search(line)
24+
if not match:
25+
return None
26+
return match.end() - 1
27+
28+
29+
def _find_log_statement_end(lines, start_line, open_paren_index):
30+
"""Find the line index where the LOG macro call closes, ignoring literals/comments.
31+
32+
This properly handles:
33+
- String literals: LOG("unbalanced (", x);
34+
- Character literals: LOG(')', code);
35+
- Line comments: LOG("msg", x); // comment with )
36+
- Block comments: LOG("msg" /* comment ) */, x);
37+
"""
38+
depth = 0
39+
in_string = False
40+
in_char = False
41+
in_block_comment = False
42+
escape = False
43+
44+
for line_index in range(start_line, len(lines)):
45+
line = lines[line_index]
46+
i = open_paren_index if line_index == start_line else 0
47+
in_line_comment = False
48+
49+
while i < len(line):
50+
ch = line[i]
51+
nxt = line[i + 1] if i + 1 < len(line) else ''
52+
53+
# Line comments consume rest of line
54+
if in_line_comment:
55+
break
56+
57+
# Inside block comment - only look for */
58+
if in_block_comment:
59+
if ch == '*' and nxt == '/':
60+
in_block_comment = False
61+
i += 2
62+
continue
63+
i += 1
64+
continue
65+
66+
# Inside string literal - handle escapes
67+
if in_string:
68+
if escape:
69+
escape = False
70+
elif ch == '\\':
71+
escape = True
72+
elif ch == '"':
73+
in_string = False
74+
i += 1
75+
continue
76+
77+
# Inside character literal - handle escapes
78+
if in_char:
79+
if escape:
80+
escape = False
81+
elif ch == '\\':
82+
escape = True
83+
elif ch == "'":
84+
in_char = False
85+
i += 1
86+
continue
87+
88+
# Check for comment starts
89+
if ch == '/' and nxt == '/':
90+
in_line_comment = True
91+
break
92+
if ch == '/' and nxt == '*':
93+
in_block_comment = True
94+
i += 2
95+
continue
96+
97+
# Check for literal starts
98+
if ch == '"':
99+
in_string = True
100+
escape = False
101+
i += 1
102+
continue
103+
if ch == "'":
104+
in_char = True
105+
escape = False
106+
i += 1
107+
continue
108+
109+
# Count parentheses depth outside of literals/comments
110+
if ch == '(':
111+
depth += 1
112+
elif ch == ')':
113+
depth -= 1
114+
if depth == 0:
115+
return line_index
116+
117+
i += 1
118+
119+
return None
120+
121+
122+
def join_log_statements(content: str) -> str:
123+
"""Join multi-line LOG macro calls onto a single line using proper C++ tokenization."""
124+
lines = content.split('\n')
125+
result = []
126+
i = 0
127+
128+
while i < len(lines):
129+
line = lines[i]
130+
131+
# Check if this line contains a LOG macro start
132+
open_paren_index = _find_log_macro_open(line)
133+
if open_paren_index is not None:
134+
# Find where the LOG statement ends, respecting C++ syntax
135+
end_index = _find_log_statement_end(lines, i, open_paren_index)
136+
if end_index is not None and end_index > i:
137+
# Multi-line LOG statement found - join it
138+
full_statement = lines[i]
139+
for join_index in range(i + 1, end_index + 1):
140+
full_statement += ' ' + lines[join_index].strip()
141+
result.append(full_statement)
142+
i = end_index + 1
143+
continue
144+
145+
# Not a LOG statement or single-line LOG - keep as is
146+
result.append(line)
147+
i += 1
148+
149+
return '\n'.join(result)
150+
151+
152+
def process_file(filepath: Path) -> None:
153+
"""Process a single C++ source file."""
154+
try:
155+
with open(filepath, 'r', encoding='utf-8') as f:
156+
content = f.read()
157+
158+
modified = join_log_statements(content)
159+
160+
with open(filepath, 'w', encoding='utf-8') as f:
161+
f.write(modified)
162+
163+
print(f"[INFO] Processed: {filepath}")
164+
except Exception as e:
165+
print(f"[ERROR] Failed to process {filepath}: {e}", file=sys.stderr)
166+
sys.exit(1)
167+
168+
169+
def main():
170+
"""Process all .cpp and .hpp files in the pybind directory."""
171+
if len(sys.argv) > 1:
172+
# Process specific directory passed as argument
173+
base_dir = Path(sys.argv[1])
174+
else:
175+
# Default to current directory
176+
base_dir = Path.cwd()
177+
178+
if not base_dir.exists():
179+
print(f"[ERROR] Directory not found: {base_dir}", file=sys.stderr)
180+
sys.exit(1)
181+
182+
# Find all C++ source and header files (*.cpp, *.h, *.hpp)
183+
# Note: using '*.h*' pattern to match both .h and .hpp extensions
184+
cpp_files = list(base_dir.rglob('*.cpp')) + list(base_dir.rglob('*.h*'))
185+
186+
if not cpp_files:
187+
print(f"[WARNING] No .cpp or .hpp files found in {base_dir}")
188+
return
189+
190+
print(f"[INFO] Processing {len(cpp_files)} C++ files in {base_dir}")
191+
for filepath in cpp_files:
192+
# Skip cmake-generated files in build directory
193+
if 'build' in filepath.parts:
194+
continue
195+
process_file(filepath)
196+
197+
print(f"[SUCCESS] Joined LOG statements in {len(cpp_files)} files")
198+
199+
200+
if __name__ == '__main__':
201+
main()

generate_codecov.sh

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,43 +74,61 @@ fi
7474

7575
echo "[INFO] Using pybind module: $PYBIND_SO"
7676

77-
# Export C++ coverage, excluding Python headers, pybind11, and system includes
77+
# Export C++ coverage, excluding Python headers, pybind11, system includes, and vendored deps
7878
llvm-cov export "$PYBIND_SO" \
7979
-instr-profile=default.profdata \
8080
-ignore-filename-regex='(python3\.[0-9]+|cpython|pybind11|/usr/include/|/usr/lib/|build/_deps/)' \
8181
--skip-functions \
8282
-format=lcov > cpp-coverage.info
8383

84-
# Note: LCOV exclusion markers (LCOV_EXCL_LINE) should be added to source code
85-
# to exclude LOG() statements from coverage. However, for automated exclusion
86-
# of all LOG lines without modifying source code, we can use geninfo's --omit-lines
87-
# feature during the merge step (see below).
84+
# Note: LCOV exclusion markers (LCOV_EXCL_LINE) are processed below
8885

8986
echo "==================================="
9087
echo "[STEP 4] Merging Python + C++ coverage"
9188
echo "==================================="
9289

93-
# Merge LCOV reports (ignore inconsistencies in Python LCOV export)
94-
echo "[ACTION] Merging Python and C++ coverage"
95-
lcov -a python-coverage.info -a cpp-coverage.info -o total.info \
90+
# Merge LCOV reports and filter LOG statements using --omit-lines
91+
# The --omit-lines option excludes lines matching the regex from coverage
92+
# Since we joined multi-line LOGs during build, they're now on single lines
93+
echo "[ACTION] Merging Python and C++ coverage with LOG exclusion"
94+
lcov -a python-coverage.info -a cpp-coverage.info -o total-unfiltered.info \
95+
--omit-lines '\bLOG[A-Z_]*\s*\(' \
9696
--ignore-errors inconsistent,corrupt
9797

98+
echo "[INFO] Coverage merged with LOG statements excluded"
99+
98100
# Defense-in-depth: drop any vendored third-party sources pulled in via CMake
99101
# FetchContent (e.g. simdutf). The llvm-cov ignore-filename-regex above is the
100102
# primary filter; this catches anything that slips through future deps.
101103
echo "[ACTION] Removing vendored third-party sources from merged coverage"
102-
lcov --remove total.info '*/build/_deps/*' -o total.info \
104+
lcov --remove total-unfiltered.info '*/build/_deps/*' -o total.info \
103105
--ignore-errors inconsistent,unused
104106

105107
# Normalize paths so everything starts from mssql_python/
106108
echo "[ACTION] Normalizing paths in LCOV report"
107109
sed -i "s|$(pwd)/||g" total.info
108110

109111
# Generate full HTML report
112+
echo "[ACTION] Generating HTML coverage report"
110113
genhtml total.info \
111114
--output-directory unified-coverage \
112115
--quiet \
113116
--title "Unified Coverage Report"
114117

115118
# Generate Cobertura XML (for Azure DevOps Code Coverage tab)
116119
lcov_cobertura total.info --output coverage.xml
120+
121+
echo "==================================="
122+
echo "[STEP 5] Cleanup"
123+
echo "==================================="
124+
125+
# Restore original source files if they were backed up during coverage build
126+
BACKUP_FILE="mssql_python/pybind/.source_backup_coverage.tar.gz"
127+
if [ -f "$BACKUP_FILE" ]; then
128+
echo "[ACTION] Restoring original source files from backup"
129+
(cd mssql_python/pybind && tar -xzf .source_backup_coverage.tar.gz)
130+
rm -f "$BACKUP_FILE"
131+
echo "[INFO] Original source files restored"
132+
fi
133+
134+
echo "[INFO] Coverage report generation complete"

mssql_python/pybind/build.sh

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,45 @@ COVERAGE_MODE=false
3131
if [[ "${1:-}" == "codecov" || "${1:-}" == "--coverage" ]]; then
3232
COVERAGE_MODE=true
3333
echo "[MODE] Enabling Clang coverage instrumentation"
34+
35+
# For coverage builds, join multi-line LOG statements to simplify LCOV filtering
36+
# Original source is backed up and must be restored by generate_codecov.sh after analysis
37+
echo "[ACTION] Preparing source for coverage build (joining LOG statements)"
38+
39+
# Save current directory
40+
ORIGINAL_DIR=$(pwd)
41+
42+
# Create backup using tar to preserve directory structure
43+
BACKUP_FILE="${ORIGINAL_DIR}/.source_backup_coverage.tar.gz"
44+
echo "[INFO] Creating backup of source files"
45+
tar -czf "$BACKUP_FILE" --exclude='build' --exclude='.source_backup*' \
46+
$(find . -maxdepth 2 -type f \( -name "*.cpp" -o -name "*.h*" \) -o -type d -name connection) 2>/dev/null || true
47+
48+
if [[ ! -f "$BACKUP_FILE" ]]; then
49+
echo "[ERROR] Failed to create source backup"
50+
exit 1
51+
fi
52+
53+
# Join LOG statements using the helper script
54+
SCRIPT_PATH="${ORIGINAL_DIR}/../../eng/scripts/join_logs_for_coverage.py"
55+
if [[ -f "$SCRIPT_PATH" ]]; then
56+
python3 "$SCRIPT_PATH" "$ORIGINAL_DIR"
57+
if [[ $? -eq 0 ]]; then
58+
echo "[SUCCESS] LOG statements joined for coverage build"
59+
echo "[INFO] Original source backed up to $BACKUP_FILE"
60+
echo "[IMPORTANT] Run 'tar -xzf $BACKUP_FILE' in $(pwd) to restore after coverage analysis"
61+
else
62+
echo "[ERROR] Failed to join LOG statements"
63+
# Restore backup and exit
64+
tar -xzf "$BACKUP_FILE" 2>/dev/null
65+
rm -f "$BACKUP_FILE"
66+
exit 1
67+
fi
68+
else
69+
echo "[WARNING] join_logs_for_coverage.py not found at $SCRIPT_PATH"
70+
echo "[WARNING] Continuing with original source (LOG filtering may be incomplete)"
71+
rm -f "$BACKUP_FILE" # No need for backup if not joining
72+
fi
3473
fi
3574

3675
# Get Python version from active interpreter

0 commit comments

Comments
 (0)