1+ name : Python Bindings - Unit Tests & Coverage
2+
3+ on :
4+ pull_request :
5+ branches :
6+ - ' **'
7+ paths :
8+ - ' bindings/sysman/python/**'
9+ push :
10+ branches :
11+ - main
12+ - master
13+ - python_bindings
14+ paths :
15+ - ' bindings/sysman/python/**'
16+ workflow_dispatch :
17+
18+ env :
19+ PYTHON_VERSION : ' 3.10'
20+
21+ jobs :
22+ unit-tests-linux :
23+ name : Linux - Unit Tests & Coverage Analysis
24+ runs-on : ubuntu-latest
25+ defaults :
26+ run :
27+ working-directory : bindings/sysman/python
28+ permissions :
29+ contents : read
30+ pull-requests : write # For PR comments
31+ checks : write # For check results
32+
33+ steps :
34+ - name : Checkout code
35+ uses : actions/checkout@v4
36+ with :
37+ fetch-depth : 0 # Full history for better coverage comparison
38+
39+ - name : Set up Python ${{ env.PYTHON_VERSION }}
40+ uses : actions/setup-python@v5
41+ with :
42+ python-version : ${{ env.PYTHON_VERSION }}
43+ cache : ' pip'
44+
45+ - name : Install dependencies
46+ run : |
47+ python -m pip install --upgrade pip
48+ pip install pytest pytest-cov pytest-html pytest-xdist
49+ pip install coverage[toml]
50+ # Linting, formatting, and type checking tools
51+ pip install flake8 black isort mypy
52+
53+ # Install any project-specific dependencies if requirements.txt exists
54+ if [ -f requirements.txt ]; then
55+ pip install -r requirements.txt
56+ fi
57+
58+ - name : Code Quality Checks
59+ run : |
60+ echo "Running code quality checks..."
61+
62+ # Check code formatting with black
63+ echo "Checking code formatting..."
64+ black --check --diff source/ test/ || {
65+ echo "❌ Code formatting issues found. Run 'black source/ test/' to fix."
66+ exit 1
67+ }
68+
69+ # Check import sorting
70+ echo "Checking import sorting..."
71+ isort --check-only --diff source/ test/ || {
72+ echo "❌ Import sorting issues found. Run 'isort source/ test/' to fix."
73+ exit 1
74+ }
75+
76+ # Run linting
77+ echo "Running flake8 linting..."
78+ flake8 source/ test/ || {
79+ echo "❌ Linting issues found. Check flake8 output above."
80+ exit 1
81+ }
82+
83+ # Run type checking
84+ echo "Running mypy type checking..."
85+ mypy source/ --ignore-missing-imports --no-strict-optional || {
86+ echo "❌ Type checking issues found. Check mypy output above."
87+ exit 1
88+ }
89+
90+ echo "✅ All code quality checks passed!"
91+
92+ - name : Run Unit Tests with Coverage
93+ run : |
94+ # Run tests with coverage and generate multiple report formats
95+ python -m pytest test/unit_tests/ \
96+ --cov=source \
97+ --cov-report=term-missing \
98+ --cov-report=html:htmlcov \
99+ --cov-report=xml:coverage.xml \
100+ --cov-report=json:coverage.json \
101+ --junit-xml=test-results.xml \
102+ --html=test-report.html \
103+ --self-contained-html \
104+ -v \
105+ --tb=short \
106+ --durations=10
107+
108+ - name : Extract Coverage Percentage
109+ id : coverage
110+ run : |
111+ # Extract coverage percentage from JSON report
112+ COVERAGE_PCT=$(python -c 'import json; data = json.load(open("coverage.json")); print("{:.1f}".format(data["totals"]["percent_covered"]))')
113+ echo "coverage_pct=$COVERAGE_PCT" >> $GITHUB_OUTPUT
114+ echo "Current Coverage: $COVERAGE_PCT%"
115+
116+ - name : Get Baseline Coverage from Target Branch
117+ id : baseline
118+ continue-on-error : true
119+ run : |
120+ # ============================================================================
121+ # TEMPORARY: First Baseline Handling
122+ # TODO: Remove the special handling below after first PR merge to master
123+ # Once master has test coverage baseline, this graceful fallback is no longer needed
124+ # ============================================================================
125+ SKIP_BASELINE_ON_MISSING=true # Set to false after first merge
126+
127+ # Get the target branch (base of the PR or parent commit for push events)
128+ if [ "${{ github.event_name }}" == "pull_request" ]; then
129+ TARGET_BRANCH="${{ github.event.pull_request.base.ref }}"
130+ echo "Pull request detected - comparing against base branch: $TARGET_BRANCH"
131+ else
132+ # For push events, compare against the parent commit
133+ TARGET_BRANCH="${{ github.ref_name }}"
134+ echo "Push event detected - comparing against parent commit on branch: $TARGET_BRANCH"
135+ # Use HEAD~1 to get the previous commit
136+ git checkout HEAD~1 2>/dev/null || {
137+ if [ "$SKIP_BASELINE_ON_MISSING" = true ]; then
138+ echo "⚠️ No parent commit found (possibly first commit). Skipping baseline comparison."
139+ echo "baseline_coverage=0" >> $GITHUB_OUTPUT
140+ exit 0
141+ else
142+ echo "❌ No parent commit found"
143+ exit 1
144+ fi
145+ }
146+ fi
147+
148+ echo "Target branch/commit: $TARGET_BRANCH"
149+
150+ # Checkout target branch/commit to get baseline coverage
151+ if [ "${{ github.event_name }}" == "pull_request" ]; then
152+ git fetch origin $TARGET_BRANCH
153+ git checkout origin/$TARGET_BRANCH 2>/dev/null || {
154+ if [ "$SKIP_BASELINE_ON_MISSING" = true ]; then
155+ echo "⚠️ Could not checkout target branch. Skipping baseline comparison."
156+ echo "baseline_coverage=0" >> $GITHUB_OUTPUT
157+ exit 0
158+ else
159+ echo "❌ Could not checkout target branch: $TARGET_BRANCH"
160+ exit 1
161+ fi
162+ }
163+ fi
164+
165+ # Check if tests exist in baseline
166+ if [ ! -d "bindings/sysman/python/test/unit_tests" ]; then
167+ if [ "$SKIP_BASELINE_ON_MISSING" = true ]; then
168+ echo "⚠️ No unit tests found in baseline. This is likely the first commit with tests."
169+ echo "Skipping baseline comparison."
170+ echo "baseline_coverage=0" >> $GITHUB_OUTPUT
171+ exit 0
172+ else
173+ echo "❌ No unit tests found in baseline branch"
174+ exit 1
175+ fi
176+ fi
177+
178+ # Install dependencies and run tests to get baseline coverage
179+ python -m pip install --upgrade pip
180+ pip install pytest pytest-cov coverage[toml]
181+
182+ # Run tests with coverage for baseline
183+ python -m pytest test/unit_tests/ \
184+ --cov=source \
185+ --cov-report=json:baseline-coverage.json \
186+ -q || {
187+ if [ "$SKIP_BASELINE_ON_MISSING" = true ]; then
188+ echo "⚠️ Baseline tests failed or not found. Skipping baseline comparison."
189+ echo "baseline_coverage=0" >> $GITHUB_OUTPUT
190+ exit 0
191+ else
192+ echo "❌ Baseline tests failed on $TARGET_BRANCH"
193+ echo "The target branch has broken tests. Fix the baseline before merging."
194+ exit 1
195+ fi
196+ }
197+
198+ # Extract baseline coverage
199+ BASELINE_COVERAGE=$(python -c 'import json; data = json.load(open("baseline-coverage.json")); print("{:.1f}".format(data["totals"]["percent_covered"]))')
200+ echo "baseline_coverage=$BASELINE_COVERAGE" >> $GITHUB_OUTPUT
201+ echo "Baseline Coverage: $BASELINE_COVERAGE%"
202+
203+ # Switch back to PR branch
204+ git checkout ${{ github.sha }}
205+
206+ - name : Check Coverage Threshold
207+ run : |
208+ CURRENT_COVERAGE="${{ steps.coverage.outputs.coverage_pct }}"
209+ BASELINE_COVERAGE="${{ steps.baseline.outputs.baseline_coverage }}"
210+
211+ echo "Current Coverage: $CURRENT_COVERAGE%"
212+ echo "Baseline Coverage: $BASELINE_COVERAGE%"
213+
214+ # ============================================================================
215+ # TEMPORARY: First Baseline Handling
216+ # TODO: Remove this section after first PR merge to master
217+ # After first merge, baseline should always exist and this check is unnecessary
218+ # ============================================================================
219+ # If baseline is 0, this is the first commit with tests - always pass
220+ if [ "$BASELINE_COVERAGE" == "0" ] || [ -z "$BASELINE_COVERAGE" ]; then
221+ echo "✅ No baseline coverage found (first commit with tests)."
222+ echo "Current coverage: $CURRENT_COVERAGE%"
223+ echo "Establishing baseline for future comparisons."
224+ exit 0
225+ fi
226+ # ============================================================================
227+ # END TEMPORARY SECTION
228+ # ============================================================================
229+
230+ # Use awk for floating point comparison (more portable than bc)
231+ if [ $(echo "$CURRENT_COVERAGE >= $BASELINE_COVERAGE" | awk '{print ($1 >= $3)}') -eq 1 ]; then
232+ DELTA=$(echo "$CURRENT_COVERAGE - $BASELINE_COVERAGE" | awk '{printf "%.1f", $1 - $3}')
233+ echo "✅ Coverage check passed: $CURRENT_COVERAGE% >= $BASELINE_COVERAGE% (Δ ${DELTA}%)"
234+ else
235+ REGRESSION=$(echo "$BASELINE_COVERAGE - $CURRENT_COVERAGE" | awk '{printf "%.1f", $1 - $3}')
236+ echo "❌ Coverage regression detected!"
237+ echo "Current coverage ($CURRENT_COVERAGE%) is below baseline coverage ($BASELINE_COVERAGE%)"
238+ echo "Regression: -${REGRESSION}%"
239+ echo "This would cause coverage to regress from the baseline."
240+ echo "Please add tests to maintain or improve coverage."
241+ exit 1
242+ fi
243+
244+ - name : Coverage Summary
245+ if : always()
246+ run : |
247+ echo "## 📊 Test Coverage Report" >> $GITHUB_STEP_SUMMARY
248+ echo "" >> $GITHUB_STEP_SUMMARY
249+ echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
250+ echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
251+ echo "| Current Coverage | ${{ steps.coverage.outputs.coverage_pct }}% |" >> $GITHUB_STEP_SUMMARY
252+ echo "| Target Branch Coverage | ${{ steps.baseline.outputs.baseline_coverage }}% |" >> $GITHUB_STEP_SUMMARY
253+
254+ THRESHOLD="${{ steps.baseline.outputs.baseline_coverage }}"
255+ if [ $(echo "${{ steps.coverage.outputs.coverage_pct }} >= $THRESHOLD" | awk '{print ($1 >= $3)}') -eq 1 ]; then
256+ echo "| Status | ✅ PASSED |" >> $GITHUB_STEP_SUMMARY
257+ else
258+ echo "| Status | ❌ FAILED |" >> $GITHUB_STEP_SUMMARY
259+ fi
260+ echo "" >> $GITHUB_STEP_SUMMARY
261+
262+ # Add detailed coverage report
263+ echo "### Detailed Coverage Report" >> $GITHUB_STEP_SUMMARY
264+ echo '```' >> $GITHUB_STEP_SUMMARY
265+ python -m coverage report >> $GITHUB_STEP_SUMMARY
266+ echo '```' >> $GITHUB_STEP_SUMMARY
267+
268+ - name : Comment PR with Coverage
269+ if : github.event_name == 'pull_request'
270+ uses : actions/github-script@v7
271+ with :
272+ script : |
273+ const coverage = '${{ steps.coverage.outputs.coverage_pct }}';
274+ const baselineCoverage = '${{ steps.baseline.outputs.baseline_coverage }}';
275+ const passed = parseFloat(coverage) >= parseFloat(baselineCoverage);
276+ const improvement = parseFloat(coverage) - parseFloat(baselineCoverage);
277+
278+ const body = `## 📊 Unit Tests & Coverage Report
279+
280+ ${passed ? '✅' : '❌'} **Coverage:** ${coverage}% vs ${baselineCoverage}% (target branch)
281+
282+ ### Test Results
283+ - **Status:** ${passed ? 'PASSED' : 'FAILED'}
284+ - **Current Coverage:** ${coverage}%
285+ - **Target Branch Coverage:** ${baselineCoverage}%
286+ - **Change:** ${improvement > 0 ? '+' : ''}${improvement.toFixed(1)}%
287+
288+ ${passed ?
289+ (improvement > 0 ?
290+ `🎉 Coverage improved by ${improvement.toFixed(1)}%! Great work on adding tests.` :
291+ '✅ Coverage maintained. No regression detected.') :
292+ '⚠️ Coverage regression detected. This PR would reduce test coverage. Please add more tests.'
293+ }
294+
295+ 📁 Detailed reports are available in the workflow artifacts.
296+ `;
297+
298+ github.rest.issues.createComment({
299+ issue_number: context.issue.number,
300+ owner: context.repo.owner,
301+ repo: context.repo.repo,
302+ body: body
303+ });
304+
305+ - name : Upload Test Results
306+ uses : actions/upload-artifact@v4
307+ if : always()
308+ with :
309+ name : test-results-${{ github.run_number }}
310+ path : |
311+ test-results.xml
312+ test-report.html
313+ htmlcov/
314+ coverage.xml
315+ coverage.json
316+ retention-days : 30
317+
318+ - name : Upload Coverage Reports
319+ uses : actions/upload-artifact@v4
320+ if : always()
321+ with :
322+ name : coverage-report-${{ github.run_number }}
323+ path : |
324+ htmlcov/
325+ coverage.xml
326+ coverage.json
327+ retention-days : 30
0 commit comments