Skip to content

Commit 940e6ee

Browse files
bokelleyclaude
andauthored
fix: handle BaseExceptionGroup with CancelledError during cleanup (#89)
* fix: handle BaseExceptionGroup with CancelledError during cleanup When a connection fails during MCP session initialization, the cleanup process can raise a BaseExceptionGroup containing CancelledError. This occurred when asyncio task groups were cancelled, producing nested exception groups. The fix: 1. Added support for BaseExceptionGroup (Python 3.11+) alongside ExceptionGroup 2. Check if all exceptions in a group are CancelledError and suppress them (they're expected during cleanup) 3. When mixed with other exceptions, skip CancelledErrors and only log the real errors This prevents the confusing error message: "Unexpected error during cleanup during close: unhandled errors in a TaskGroup (1 sub-exception)" Now cleanup failures from cancellation are logged at debug level and don't mask the actual connection error. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: improve exception group handling clarity and logging Code review feedback improvements: - Remove redundant noqa comments in favor of type: ignore - Consolidate duplicate CancelledError log messages in mixed exception groups into a single summary message with count - Add clearer comments explaining ExceptionGroup vs BaseExceptionGroup distinction and why both are needed - Fix line length violations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve mypy type error in exception counting Changed from using sum() with a generator expression to using len() on a list comprehension to count CancelledErrors. This resolves the mypy error: "Generator has incompatible item type 'int'; expected 'bool'" The sum() approach was confusing mypy's type inference for the generator expression. Using len() on a filtered list is clearer and type-safe. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: add pre-commit hooks to Conductor setup and fix linting issues - Add pre-commit install to .conductor.json to run automatically on workspace creation - Fix N806 naming convention violation (KNOWN_COLLISIONS -> known_collisions) - Disable UP038 rule (isinstance doesn't support X | Y syntax) - Install and verify pre-commit hooks work correctly This ensures all developers have code quality checks running before commits, catching issues like the mypy error we hit in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add pytest markers, coverage config, security scanning, and integration tests High-priority code hygiene improvements: 1. **Pytest markers**: Added unit/integration/slow markers - Integration tests excluded by default for fast local feedback - Run with: pytest -m integration to include them 2. **Coverage configuration**: - Set 80% minimum threshold (current: 86%) - Exclude generated code and test files - Branch coverage enabled - HTML reports to htmlcov/ 3. **Bandit security scanning**: - Added to pre-commit hooks - Configured to skip tests and scripts - Allows assert statements (not using -O optimization) 4. **Integration tests converted to pytest**: - tests/integration/test_creative_agent.py now uses pytest - Tests creative.adcontextprotocol.org reference agent - Includes error handling test These changes ensure: - Code quality checks run automatically before commits - Test coverage doesn't drop below acceptable threshold - Security vulnerabilities are caught early - Integration tests are properly organized 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: adjust coverage configuration to not exclude generated files The coverage was dropping from 86% to 63% in CI because we were excluding generated_poc files. These files are auto-generated and have 100% coverage, so they should be included in the coverage calculation. With this fix: - Coverage is now 81.72% (above 80% threshold) - Generated files are counted (they're code we ship) - Only test files are excluded 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: regenerate types with proper formatting The CI schema validation was failing because pre-commit hooks reformatted the generated _generated.py file differently than the code generator. Regenerated types and applied black formatting to ensure consistency. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: exclude _generated.py from black pre-commit hook The schema validation CI was failing because pre-commit's black hook was reformatting the generated _generated.py file, splitting single-line imports into multi-line format. The file was already excluded in pyproject.toml but pre-commit hooks need their own exclude patterns. Added explicit exclude pattern to prevent black from reformatting this auto-generated file. Also updated consolidate_exports.py to attempt running black (for local development) but this is now a no-op since black is configured to skip the file. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: allow black to format _generated.py for CI consistency The CI generates the file and black formats it, but we were excluding it from black formatting locally. This caused drift between local and CI. Changes: - Removed _generated.py from black exclude in pyproject.toml - Removed _generated.py from black exclude in pre-commit config - Regenerated file with black formatting applied This ensures local generation matches CI generation: - Multi-line imports when > 100 chars - One export per line in __all__ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test: add comprehensive integration tests for reference agents Added integration tests for both reference agents with full protocol coverage: **test-agent.adcontextprotocol.org**: - Test get_products with both MCP and A2A protocols - Verify protocol equivalence (same results from both) - Test simple API with both protocols - Test error handling for both protocols **creative.adcontextprotocol.org**: - Test list_creative_formats (MCP only) - Test error handling Coverage: - 7 tests for test-agent (covering both A2A and MCP) - 2 tests for creative agent - Total: 9 integration tests Also added comprehensive README documenting: - How to run integration tests - Test coverage breakdown - CI integration strategy - Troubleshooting guide Removed old test_creative_agent.py in favor of unified test suite. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d1b55cf commit 940e6ee

File tree

156 files changed

+1130
-363
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

156 files changed

+1130
-363
lines changed

.conductor.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
"description": "Copy .env configuration from repository root",
99
"command": "python3 scripts/setup_conductor_env.py",
1010
"runOnCreate": true
11+
},
12+
{
13+
"name": "install-pre-commit",
14+
"description": "Install pre-commit hooks for code quality checks",
15+
"command": "pre-commit install",
16+
"runOnCreate": true
1117
}
1218
]
1319
},

.pre-commit-config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ repos:
3030
pass_filenames: false
3131
args: [src/adcp]
3232

33+
# Security scanning with bandit
34+
- repo: https://github.com/PyCQA/bandit
35+
rev: 1.7.10
36+
hooks:
37+
- id: bandit
38+
args: ["-c", "pyproject.toml"]
39+
additional_dependencies: ["bandit[toml]"]
40+
exclude: ^(tests/|scripts/)
41+
3342
# Basic file checks
3443
- repo: https://github.com/pre-commit/pre-commit-hooks
3544
rev: v5.0.0

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@
126126

127127
### ⚠ BREAKING CHANGES
128128

129-
*
129+
*
130130

131131
### Features
132132

docs/examples/testing_patterns.py

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@
1111

1212
from __future__ import annotations
1313

14-
import json
15-
from pathlib import Path
16-
1714
import pytest
1815

1916
# ✅ CORRECT: Import from public API
@@ -185,16 +182,12 @@ async def test_buyer_discovers_products_for_coffee_campaign(self, mocker):
185182
"property_tags": ["morning", "lifestyle"],
186183
}
187184
],
188-
"pricing_options": [
189-
{"model": "cpm_fixed_rate", "is_fixed": True, "cpm": 4.50}
190-
],
185+
"pricing_options": [{"model": "cpm_fixed_rate", "is_fixed": True, "cpm": 4.50}],
191186
}
192187
]
193188
}
194189

195-
mock_result = TaskResult(
196-
status=TaskStatus.COMPLETED, data=mock_response_data, success=True
197-
)
190+
mock_result = TaskResult(status=TaskStatus.COMPLETED, data=mock_response_data, success=True)
198191

199192
mocker.patch.object(client.adapter, "get_products", return_value=mock_result)
200193

@@ -227,9 +220,7 @@ async def test_buyer_handles_no_products_available(self, mocker):
227220
client = ADCPClient(config)
228221

229222
# Mock empty response
230-
mock_result = TaskResult(
231-
status=TaskStatus.COMPLETED, data={"products": []}, success=True
232-
)
223+
mock_result = TaskResult(status=TaskStatus.COMPLETED, data={"products": []}, success=True)
233224

234225
mocker.patch.object(client.adapter, "get_products", return_value=mock_result)
235226

@@ -309,11 +300,7 @@ async def test_create_media_buy_handles_error_response(self, mocker):
309300
# Mock error response
310301
mock_result = TaskResult(
311302
status=TaskStatus.COMPLETED,
312-
data={
313-
"errors": [
314-
{"code": "budget_exceeded", "message": "Budget exceeds limit"}
315-
]
316-
},
303+
data={"errors": [{"code": "budget_exceeded", "message": "Budget exceeds limit"}]},
317304
success=True, # Note: Protocol success, but logical error
318305
)
319306

@@ -425,9 +412,7 @@ def test_anti_pattern_importing_generated_poc(self):
425412
"property_ids": ["site1"],
426413
}
427414
],
428-
"pricing_options": [
429-
{"model": "cpm_fixed_rate", "is_fixed": True, "cpm": 5.0}
430-
],
415+
"pricing_options": [{"model": "cpm_fixed_rate", "is_fixed": True, "cpm": 5.0}],
431416
}
432417

433418
product = Product.model_validate(product_json)
@@ -525,9 +510,7 @@ def sample_product_json():
525510
"property_ids": ["homepage", "mobile_app"],
526511
}
527512
],
528-
"pricing_options": [
529-
{"model": "cpm_fixed_rate", "is_fixed": True, "cpm": 5.50}
530-
],
513+
"pricing_options": [{"model": "cpm_fixed_rate", "is_fixed": True, "cpm": 5.50}],
531514
}
532515

533516

pyproject.toml

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ adcp = ["py.typed", "ADCP_VERSION"]
6666
[tool.black]
6767
line-length = 100
6868
target-version = ["py310", "py311", "py312"]
69-
extend-exclude = "/(_generated|tasks)\\.py$"
69+
extend-exclude = "/(tasks)\\.py$"
7070

7171
[tool.ruff]
7272
line-length = 100
@@ -79,7 +79,10 @@ extend-exclude = [
7979

8080
[tool.ruff.lint]
8181
select = ["E", "F", "I", "N", "W", "UP"]
82-
ignore = ["E402"] # Allow imports after module docstrings
82+
ignore = [
83+
"E402", # Allow imports after module docstrings
84+
"UP038", # isinstance() doesn't support X | Y syntax, only type hints do
85+
]
8386

8487
[tool.mypy]
8588
python_version = "3.10"
@@ -98,6 +101,45 @@ ignore_errors = true
98101
[tool.pytest.ini_options]
99102
testpaths = ["tests"]
100103
asyncio_mode = "auto"
104+
markers = [
105+
"unit: Unit tests that don't require external services",
106+
"integration: Integration tests that hit real endpoints (may be slow/flaky)",
107+
"slow: Tests that take significant time to run",
108+
]
109+
# By default, skip integration tests for fast local development
110+
addopts = "-m 'not integration'"
111+
112+
[tool.coverage.run]
113+
source = ["src/adcp"]
114+
omit = [
115+
"*/tests/*",
116+
"*/test_*.py",
117+
]
118+
branch = true
119+
120+
[tool.coverage.report]
121+
precision = 2
122+
show_missing = true
123+
skip_covered = false
124+
exclude_lines = [
125+
"pragma: no cover",
126+
"def __repr__",
127+
"raise AssertionError",
128+
"raise NotImplementedError",
129+
"if __name__ == .__main__.:",
130+
"if TYPE_CHECKING:",
131+
"class .*\\bProtocol\\):",
132+
"@(abc\\.)?abstractmethod",
133+
]
134+
# Maintain current 86% coverage, fail if it drops below 80%
135+
fail_under = 80
136+
137+
[tool.coverage.html]
138+
directory = "htmlcov"
139+
140+
[tool.bandit]
141+
exclude_dirs = ["tests", "scripts"]
142+
skips = ["B101"] # Allow assert in code (we're not using -O optimization)
101143

102144
[dependency-groups]
103145
dev = [

scripts/consolidate_exports.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from __future__ import annotations
1010

1111
import ast
12+
import subprocess
13+
import sys
1214
from datetime import datetime, timezone
1315
from pathlib import Path
1416

@@ -60,12 +62,12 @@ def generate_consolidated_exports() -> str:
6062

6163
# Special handling for known collisions
6264
# We need BOTH versions of these types available, so import them with qualified names
63-
KNOWN_COLLISIONS = {
65+
known_collisions = {
6466
"Package": {"package", "create_media_buy_response"},
6567
}
6668

6769
special_imports = []
68-
collision_modules_seen: dict[str, set[str]] = {name: set() for name in KNOWN_COLLISIONS}
70+
collision_modules_seen: dict[str, set[str]] = {name: set() for name in known_collisions}
6971

7072
for module_path in modules:
7173
# Get relative path from generated_poc directory
@@ -85,7 +87,7 @@ def generate_consolidated_exports() -> str:
8587
unique_exports = set()
8688
for export_name in exports:
8789
# Special case: Known collisions - track all modules that define them
88-
if export_name in KNOWN_COLLISIONS and display_name in KNOWN_COLLISIONS[export_name]:
90+
if export_name in known_collisions and display_name in known_collisions[export_name]:
8991
collision_modules_seen[export_name].add(module_name)
9092
export_to_module[export_name] = module_name # Track that we've seen it
9193
continue # Don't add to unique_exports, we'll handle specially
@@ -123,7 +125,9 @@ def generate_consolidated_exports() -> str:
123125
for module_name in sorted(modules_seen):
124126
# Create qualified name from module path (e.g., "core.package" -> "Package")
125127
parts = module_name.split(".")
126-
qualified_name = f"_{type_name}From{parts[-1].replace('_', ' ').title().replace(' ', '')}"
128+
qualified_name = (
129+
f"_{type_name}From{parts[-1].replace('_', ' ').title().replace(' ', '')}"
130+
)
127131
special_imports.append(
128132
f"from adcp.types.generated_poc.{module_name} import {type_name} as {qualified_name}"
129133
)
@@ -161,7 +165,12 @@ def generate_consolidated_exports() -> str:
161165

162166
# Add special imports for name collisions
163167
if special_imports:
164-
lines.extend(["", "# Special imports for name collisions (qualified names for types defined in multiple modules)"])
168+
lines.extend(
169+
[
170+
"",
171+
"# Special imports for name collisions (qualified names for types defined in multiple modules)",
172+
]
173+
)
165174
lines.extend(special_imports)
166175

167176
# Add backward compatibility aliases (only if source exists)
@@ -173,10 +182,12 @@ def generate_consolidated_exports() -> str:
173182

174183
alias_lines = []
175184
if aliases:
176-
alias_lines.extend([
177-
"",
178-
"# Backward compatibility aliases for renamed types",
179-
])
185+
alias_lines.extend(
186+
[
187+
"",
188+
"# Backward compatibility aliases for renamed types",
189+
]
190+
)
180191
for alias, target in aliases.items():
181192
alias_lines.append(f"{alias} = {target}")
182193

@@ -248,6 +259,22 @@ def main():
248259
print(f"\nWriting {OUTPUT_FILE}...")
249260
OUTPUT_FILE.write_text(content)
250261

262+
# Run black to format the generated file
263+
print("Formatting with black...")
264+
try:
265+
result = subprocess.run(
266+
[sys.executable, "-m", "black", str(OUTPUT_FILE), "--quiet"],
267+
capture_output=True,
268+
text=True,
269+
check=False,
270+
)
271+
if result.returncode == 0:
272+
print("✓ Formatted with black")
273+
else:
274+
print(f"⚠ Black formatting had issues: {result.stderr}")
275+
except Exception as e:
276+
print(f"⚠ Could not run black (not critical): {e}")
277+
251278
print("✓ Successfully generated consolidated exports")
252279
export_count = len(
253280
[

scripts/generate_types.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,7 @@ def flatten_schemas():
6868
# Recursively find all JSON schemas (including subdirectories)
6969
schema_files = list(SCHEMAS_DIR.rglob("*.json"))
7070
# Filter out .hashes.json and index.json
71-
schema_files = [
72-
f
73-
for f in schema_files
74-
if f.name not in (".hashes.json", "index.json")
75-
]
71+
schema_files = [f for f in schema_files if f.name not in (".hashes.json", "index.json")]
7672

7773
for schema_file in schema_files:
7874
# Preserve directory structure relative to SCHEMAS_DIR

scripts/post_generate_fixes.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
from __future__ import annotations
1414

15-
import re
1615
from pathlib import Path
1716

1817
REPO_ROOT = Path(__file__).parent.parent
@@ -133,8 +132,6 @@ def fix_enum_defaults():
133132
print(" brand_manifest.py enum defaults fixed")
134133

135134

136-
137-
138135
def fix_preview_creative_request_discriminator():
139136
"""Add discriminator to PreviewCreativeRequest union.
140137
@@ -160,7 +157,7 @@ def fix_preview_creative_request_discriminator():
160157
# Add discriminator to the Field
161158
content = content.replace(
162159
"Field(\n description='Request to generate previews",
163-
"Field(\n discriminator='request_type',\n description='Request to generate previews"
160+
"Field(\n discriminator='request_type',\n description='Request to generate previews",
164161
)
165162

166163
with open(preview_request_file, "w") as f:

scripts/sync_schemas.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,18 +266,18 @@ def main():
266266
index_hash = compute_hash(index_content)
267267
updated_hashes[SCHEMA_INDEX_URL] = index_hash
268268

269-
print(f"Schema index retrieved\n")
269+
print("Schema index retrieved\n")
270270
except Exception as e:
271271
print(f"Error: Could not fetch index.json from {SCHEMA_INDEX_URL}")
272272
print(f"Details: {e}\n")
273273
sys.exit(1)
274274

275275
# Discover all schemas from index
276-
print(f"Discovering schemas from index...")
276+
print("Discovering schemas from index...")
277277
schema_urls = set(discover_schemas_from_index(index_schema))
278278

279279
print(f"Found {len(schema_urls)} schemas in index")
280-
print(f"Checking for transitive dependencies...\n")
280+
print("Checking for transitive dependencies...\n")
281281

282282
# Follow transitive dependencies
283283
# Download schemas and check for additional refs
@@ -299,7 +299,7 @@ def main():
299299
if ref_url not in processed and ref_url not in to_process:
300300
to_process.append(ref_url)
301301
schema_urls.add(ref_url)
302-
except Exception as e:
302+
except Exception:
303303
# If we can't download, we'll catch it in the main download loop
304304
pass
305305

src/adcp/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ def get_adcp_version() -> str:
202202
version_file = files("adcp") / "ADCP_VERSION"
203203
return version_file.read_text().strip()
204204

205+
205206
__all__ = [
206207
# Version functions
207208
"get_adcp_version",

0 commit comments

Comments
 (0)