Skip to content

Commit 8619103

Browse files
Feature/dev cli improvements (#171)
1 parent 61df8a5 commit 8619103

5 files changed

Lines changed: 174 additions & 31 deletions

File tree

setup/verify_local_setup.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@
1717
Run after setup (local or devcontainer) to ensure everything is working.
1818
"""
1919

20-
import sys
21-
import subprocess
22-
import os
2320
import io
2421
import json
22+
import os
2523
import shutil
24+
import subprocess
25+
import sys
2626
from pathlib import Path
2727

2828
# Configure UTF-8 encoding for console output
@@ -305,6 +305,47 @@ def check_azure_providers():
305305
return False, 'Log in then retry: az login --tenant <tenant> && az account set --subscription <subscription>'
306306

307307

308+
def check_git_notebook_filter():
309+
"""Check if the notebook-metadata git clean filter is configured.
310+
311+
Option 1 (Complete environment setup) configures ``filter.notebook-metadata.clean``
312+
so that notebook kernel metadata is normalized on ``git add``. Without it, a
313+
contributor's local kernel name and Python version will leak into committed
314+
notebooks. ``.gitattributes`` references this filter for ``*.ipynb``.
315+
"""
316+
git_path = shutil.which('git')
317+
if not git_path:
318+
return False, 'Install Git: https://git-scm.com/downloads'
319+
320+
expected_clean = 'python setup/normalize_notebook_metadata.py'
321+
expected_smudge = 'cat'
322+
fix = "Run 'Complete environment setup' in the Developer CLI (or: python setup/local_setup.py --complete-setup)"
323+
324+
try:
325+
clean = subprocess.run(
326+
[git_path, 'config', '--get', 'filter.notebook-metadata.clean'],
327+
capture_output=True,
328+
text=True,
329+
check=True,
330+
).stdout.strip()
331+
smudge = subprocess.run(
332+
[git_path, 'config', '--get', 'filter.notebook-metadata.smudge'],
333+
capture_output=True,
334+
text=True,
335+
check=True,
336+
).stdout.strip()
337+
except subprocess.CalledProcessError:
338+
# git config --get exits non-zero when the key is unset.
339+
return False, fix
340+
except FileNotFoundError:
341+
return False, 'Install Git: https://git-scm.com/downloads'
342+
343+
if clean != expected_clean or smudge != expected_smudge:
344+
return False, f'{fix} (found clean="{clean}", smudge="{smudge}")'
345+
346+
return True, ''
347+
348+
308349
def main():
309350
"""Run all verification checks."""
310351
print('🔍 APIM Samples Local Environment Verification')
@@ -322,6 +363,7 @@ def main():
322363
('Azure Providers', check_azure_providers),
323364
('Jupyter Kernel', check_jupyter_kernel),
324365
('VS Code Settings', check_vscode_settings),
366+
('Git Notebook Filter', check_git_notebook_filter),
325367
]
326368

327369
results = []

tests/python/check_python.ps1

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,20 @@ $TestExitCode = $LASTEXITCODE
8787
$TotalTests = 0
8888
$PassedTests = 0
8989
$FailedTests = 0
90+
$WarningCount = 0
9091

9192
foreach ($Line in $TestOutput) {
9293
$LineStr = $Line.ToString()
93-
# Look for pytest summary line like "908 passed, 9 failed in 26.76s"
94+
# Look for pytest summary line like "908 passed, 9 failed, 4 warnings in 26.76s"
9495
if ($LineStr -match '(\d+)\s+passed') {
9596
$PassedTests = [int]::Parse($matches[1])
9697
}
9798
if ($LineStr -match '(\d+)\s+failed') {
9899
$FailedTests = [int]::Parse($matches[1])
99100
}
101+
if ($LineStr -match '(\d+)\s+warning') {
102+
$WarningCount = [int]::Parse($matches[1])
103+
}
100104
}
101105

102106
$TotalTests = $PassedTests + $FailedTests
@@ -243,13 +247,29 @@ if ($TotalTests -gt 0) {
243247
$PassedPercentStr = "{0,6:F2}" -f $PassedPercent
244248
$FailedPercentStr = "{0,6:F2}" -f $FailedPercent
245249

246-
Write-Host " • Total : $TotalPadded" -ForegroundColor Gray
247-
Write-Host " • Passed : $PassedPadded (" -ForegroundColor Gray -NoNewline
250+
Write-Host " • Total : $TotalPadded" -ForegroundColor Gray
251+
Write-Host " • Passed : $PassedPadded (" -ForegroundColor Gray -NoNewline
248252
Write-Host $PassedPercentStr -ForegroundColor Gray -NoNewline
249253
Write-Host "%)" -ForegroundColor Gray
250-
Write-Host " • Failed : $FailedPadded (" -ForegroundColor Gray -NoNewline
254+
Write-Host " • Failed : $FailedPadded (" -ForegroundColor Gray -NoNewline
251255
Write-Host $FailedPercentStr -ForegroundColor Gray -NoNewline
252256
Write-Host "%)" -ForegroundColor Gray
257+
if ($WarningCount -gt 0) {
258+
$WarningPadded = "{0,5}" -f $WarningCount
259+
$WarningPercent = ($WarningCount / $TotalTests * 100)
260+
$WarningPercentStr = "{0,6:F2}" -f $WarningPercent
261+
Write-Host " • Warnings : $WarningPadded (" -ForegroundColor Yellow -NoNewline
262+
Write-Host $WarningPercentStr -ForegroundColor Yellow -NoNewline
263+
Write-Host "%)" -ForegroundColor Yellow
264+
}
265+
}
266+
267+
# Display code coverage
268+
if ($CoveragePercent -ne $null) {
269+
Write-Host "Coverage : " -NoNewline
270+
Write-Host "📊 " -NoNewline
271+
Write-Host ("{0:F2}" -f $CoveragePercent) -ForegroundColor Cyan -NoNewline
272+
Write-Host "%" -ForegroundColor Cyan
253273
}
254274

255275
# Display Bicep status with file count
@@ -264,14 +284,6 @@ if ($BicepFileCount -ne $null) {
264284
Write-Host ""
265285
}
266286

267-
# Display code coverage
268-
if ($CoveragePercent -ne $null) {
269-
Write-Host "Coverage : " -NoNewline
270-
Write-Host "📊 " -NoNewline
271-
Write-Host ("{0:F2}" -f $CoveragePercent) -ForegroundColor Cyan -NoNewline
272-
Write-Host "%" -ForegroundColor Cyan
273-
}
274-
275287
# Display slow tests warning if detected
276288
if ($SlowTestsFound) {
277289
Write-Host ""

tests/python/check_python.sh

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ echo "$TEST_OUTPUT"
7777
# Parse test results from output
7878
PASSED_TESTS=$(echo "$TEST_OUTPUT" | grep -oE '[0-9]+ passed' | head -1 | grep -oE '[0-9]+' || echo "0")
7979
FAILED_TESTS=$(echo "$TEST_OUTPUT" | grep -oE '[0-9]+ failed' | head -1 | grep -oE '[0-9]+' || echo "0")
80+
WARNING_COUNT=$(echo "$TEST_OUTPUT" | grep -oE '[0-9]+ warning' | head -1 | grep -oE '[0-9]+' || echo "0")
8081
TOTAL_TESTS=$((PASSED_TESTS + FAILED_TESTS))
8182

8283
# Parse coverage from pytest output (e.g., "TOTAL ... 95%")
@@ -174,9 +175,18 @@ if [ $TOTAL_TESTS -gt 0 ]; then
174175
FAILED_PERCENT=$(echo "$FAILED_TESTS $TOTAL_TESTS" | awk '{printf "%.2f", ($1 / $2 * 100)}')
175176

176177
# Right-align numbers with padding
177-
printf " • Total : %5d\n" "$TOTAL_TESTS"
178-
printf " • Passed : %5d (%6.2f%%)\n" "$PASSED_TESTS" "$PASSED_PERCENT"
179-
printf " • Failed : %5d (%6.2f%%)\n" "$FAILED_TESTS" "$FAILED_PERCENT"
178+
printf " • Total : %5d\n" "$TOTAL_TESTS"
179+
printf " • Passed : %5d (%6.2f%%)\n" "$PASSED_TESTS" "$PASSED_PERCENT"
180+
printf " • Failed : %5d (%6.2f%%)\n" "$FAILED_TESTS" "$FAILED_PERCENT"
181+
if [ "$WARNING_COUNT" -gt 0 ]; then
182+
WARNING_PERCENT=$(echo "$WARNING_COUNT $TOTAL_TESTS" | awk '{printf "%.2f", ($1 / $2 * 100)}')
183+
printf " \e[33m• Warnings : %5d (%6.2f%%)\e[0m\n" "$WARNING_COUNT" "$WARNING_PERCENT"
184+
fi
185+
fi
186+
187+
# Display code coverage
188+
if [ -n "$COVERAGE_PERCENT" ]; then
189+
echo "Coverage : 📊 ${COVERAGE_PERCENT}"
180190
fi
181191

182192
# Display Bicep status with file count
@@ -189,11 +199,6 @@ if [ -n "$BICEP_FILE_COUNT" ]; then
189199
echo " ($BICEP_FILE_COUNT files)"
190200
fi
191201

192-
# Display code coverage
193-
if [ -n "$COVERAGE_PERCENT" ]; then
194-
echo "Coverage : 📊 ${COVERAGE_PERCENT}"
195-
fi
196-
197202
# Display slow tests warning if detected
198203
if [ $SLOW_TESTS_FOUND -eq 1 ]; then
199204
echo ""

tests/python/test_authfactory.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@
33
"""
44

55
import time
6+
67
import pytest
78

89
# APIM Samples imports
9-
from authfactory import JwtPayload, SymmetricJwtToken, AuthFactory
10+
from authfactory import AuthFactory, JwtPayload, SymmetricJwtToken
1011
from users import User
1112

1213
# ------------------------------
1314
# CONSTANTS
1415
# ------------------------------
1516

16-
TEST_KEY = 'test-secret-key'
17+
# HS256 keys must be >= 32 bytes (RFC 7518 section 3.2) or PyJWT emits InsecureKeyLengthWarning.
18+
TEST_KEY = 'test-secret-key-32-bytes-minimum'
19+
TEST_KEY_ALT = 'alt-test-secret-key-32-bytes-min'
1720
TEST_USER = User(user_id='u1', name='Test User', roles=['role1', 'role2'])
1821

1922
# ------------------------------
@@ -101,8 +104,8 @@ def test_symmetric_jwt_token_edge_cases():
101104
payload = JwtPayload('test', 'Test', roles=['role1'])
102105

103106
# Test that different keys produce different tokens
104-
token1 = SymmetricJwtToken('key1', payload)
105-
token2 = SymmetricJwtToken('key2', payload)
107+
token1 = SymmetricJwtToken(TEST_KEY, payload)
108+
token2 = SymmetricJwtToken(TEST_KEY_ALT, payload)
106109

107110
encoded1 = token1.encode()
108111
encoded2 = token2.encode()
@@ -112,7 +115,7 @@ def test_symmetric_jwt_token_edge_cases():
112115
assert isinstance(encoded2, str)
113116

114117
# Test with same key should produce same token
115-
token3 = SymmetricJwtToken('key1', payload)
118+
token3 = SymmetricJwtToken(TEST_KEY, payload)
116119
encoded3 = token3.encode()
117120
assert encoded1 == encoded3
118121

@@ -127,7 +130,7 @@ def test_auth_factory_edge_cases():
127130

128131
# Test with None user
129132
with pytest.raises(ValueError):
130-
AuthFactory.create_symmetric_jwt_token_for_user(None, 'test-key')
133+
AuthFactory.create_symmetric_jwt_token_for_user(None, TEST_KEY)
131134

132135

133136
def test_create_jwt_payload_for_user():
@@ -147,7 +150,7 @@ def test_create_jwt_payload_for_user():
147150
def test_jwt_token_structure():
148151
"""Test that generated JWT tokens have correct structure."""
149152
user = User('test', 'Test User', ['role1'])
150-
token = AuthFactory.create_symmetric_jwt_token_for_user(user, 'test-secret-key')
153+
token = AuthFactory.create_symmetric_jwt_token_for_user(user, TEST_KEY)
151154

152155
# JWT should have 3 parts separated by dots
153156
parts = token.split('.')

tests/python/test_verify_local_setup.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import sys
1010
from pathlib import Path
1111
from types import ModuleType, SimpleNamespace
12-
from typing import Any, TYPE_CHECKING, cast
12+
from typing import TYPE_CHECKING, Any, cast
1313
from unittest.mock import Mock, patch
1414

1515
import pytest
@@ -667,6 +667,86 @@ def test_check_azure_providers_json_error() -> None:
667667
assert 'Log in' in fix or 'login' in fix.lower()
668668

669669

670+
# ============================================================
671+
# Tests for check_git_notebook_filter
672+
# ============================================================
673+
674+
675+
def _git_config_side_effect(clean: str | Exception, smudge: str | Exception):
676+
"""Build a subprocess.run side_effect that answers git config --get calls."""
677+
678+
def _run(cmd, **kwargs):
679+
key = cmd[-1]
680+
value = clean if key == 'filter.notebook-metadata.clean' else smudge
681+
if isinstance(value, Exception):
682+
raise value
683+
return Mock(stdout=f'{value}\n', returncode=0)
684+
685+
return _run
686+
687+
688+
def test_check_git_notebook_filter_git_missing() -> None:
689+
"""Git notebook filter check should fail when git is not on PATH."""
690+
with patch('shutil.which', return_value=None):
691+
ok, fix = vls.check_git_notebook_filter()
692+
assert ok is False
693+
assert 'Install Git' in fix
694+
695+
696+
def test_check_git_notebook_filter_configured() -> None:
697+
"""Git notebook filter check should pass when clean and smudge match expected values."""
698+
side_effect = _git_config_side_effect(
699+
clean='python setup/normalize_notebook_metadata.py',
700+
smudge='cat',
701+
)
702+
with patch('shutil.which', return_value='/usr/bin/git'):
703+
with patch('subprocess.run', side_effect=side_effect):
704+
ok, fix = vls.check_git_notebook_filter()
705+
assert ok is True
706+
assert not fix
707+
708+
709+
def test_check_git_notebook_filter_not_configured() -> None:
710+
"""Git notebook filter check should fail when git config key is unset (exit 1)."""
711+
with patch('shutil.which', return_value='/usr/bin/git'):
712+
with patch('subprocess.run', side_effect=subprocess.CalledProcessError(1, 'git')):
713+
ok, fix = vls.check_git_notebook_filter()
714+
assert ok is False
715+
assert 'Complete environment setup' in fix
716+
717+
718+
def test_check_git_notebook_filter_wrong_clean_value() -> None:
719+
"""Git notebook filter check should fail when clean filter points to something unexpected."""
720+
side_effect = _git_config_side_effect(clean='nbstripout', smudge='cat')
721+
with patch('shutil.which', return_value='/usr/bin/git'):
722+
with patch('subprocess.run', side_effect=side_effect):
723+
ok, fix = vls.check_git_notebook_filter()
724+
assert ok is False
725+
assert 'nbstripout' in fix
726+
727+
728+
def test_check_git_notebook_filter_wrong_smudge_value() -> None:
729+
"""Git notebook filter check should fail when smudge filter is not 'cat'."""
730+
side_effect = _git_config_side_effect(
731+
clean='python setup/normalize_notebook_metadata.py',
732+
smudge='python something_else.py',
733+
)
734+
with patch('shutil.which', return_value='/usr/bin/git'):
735+
with patch('subprocess.run', side_effect=side_effect):
736+
ok, fix = vls.check_git_notebook_filter()
737+
assert ok is False
738+
assert 'something_else' in fix
739+
740+
741+
def test_check_git_notebook_filter_git_exec_missing() -> None:
742+
"""Git notebook filter check should fail gracefully when git disappears between which() and run()."""
743+
with patch('shutil.which', return_value='/usr/bin/git'):
744+
with patch('subprocess.run', side_effect=FileNotFoundError()):
745+
ok, fix = vls.check_git_notebook_filter()
746+
assert ok is False
747+
assert 'Install Git' in fix
748+
749+
670750
# ============================================================
671751
# Tests for main function
672752
# ============================================================
@@ -686,6 +766,7 @@ def _mock_all_checks(monkeypatch: pytest.MonkeyPatch, **overrides: tuple[bool, s
686766
'check_azure_providers': (True, ''),
687767
'check_jupyter_kernel': (True, ''),
688768
'check_vscode_settings': (True, ''),
769+
'check_git_notebook_filter': (True, ''),
689770
}
690771
defaults.update(overrides)
691772
for check_name, result in defaults.items():

0 commit comments

Comments
 (0)