Skip to content

Commit 7c01fa3

Browse files
committed
feat(django): add urls and ignore_template_errors markers (#35)
- Add positional argument extraction to Rust marker scanner - Implement @pytest.mark.urls('module') for ROOT_URLCONF override - Implement @pytest.mark.ignore_template_errors for template suppression - Integrate URL/template markers into run_test() with proper cleanup - Update roadmap: merge Django markers into 0.2.3, shift Landlock to 0.2.4 - Defer transaction=True, reset_sequences, multi-db to 0.3.0 (#49)
1 parent b4b33fa commit 7c01fa3

File tree

3 files changed

+208
-7
lines changed

3 files changed

+208
-7
lines changed

docs/research/roadmap.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,14 @@ flowchart TB
5757
P2_0["0.2.0 Hook Framework ✅"]
5858
P2_1["0.2.1 pytest-django ✅"]
5959
P2_2["0.2.2 pytest-asyncio ✅"]
60-
P2_3["0.2.3 pytest-mock/env/timeout ✅"]
60+
P2_3["0.2.3 pytest-mock/env/timeout + Django Markers ✅"]
6161
P2_4["0.2.4 Landlock V4-V6"]
6262
P2_5["0.2.5 Plugin Stabilization"]
6363
6464
P2_0 --> P2_1
6565
P2_0 --> P2_2
6666
P2_0 --> P2_3
67+
P2_1 --> P2_3
6768
P2_0 --> P2_4
6869
P2_1 --> P2_5
6970
P2_2 --> P2_5
@@ -274,7 +275,7 @@ flowchart TB
274275
**Current Status:**
275276

276277
- Phase 1 (0.1.x): Complete
277-
- Phase 2 (0.2.x): In Progress - 0.2.0 done, 0.2.1 done, 0.2.2 done, 0.2.3 done, 0.2.4 can start
278+
- Phase 2 (0.2.x): In Progress - 0.2.0-0.2.4 done, 0.2.5 (Landlock) can start
278279
- Phases 3-4: Blocked by Phase 2 plugin work
279280
- Phases 5-9: **Many items can start NOW** - see blue "Can Start" nodes in flowchart
280281
- Phase 10: Not started
@@ -629,13 +630,13 @@ See #39 for tracking:
629630
- [x] Support gather/wait patterns
630631
- [x] Handle TaskGroup cleanup
631632

632-
### 0.2.3 - Additional Plugin Support
633+
### 0.2.3 - Additional Plugin Support + Django Markers
633634

634-
**Target**: Support for commonly used pytest plugins.
635+
**Target**: Support for commonly used pytest plugins and additional Django markers.
635636

636637
**Status**: ✅ COMPLETE
637638

638-
> **Parallelization**: Can be developed in parallel with 0.2.1, 0.2.2, and 0.2.3.1. Only requires 0.2.0 (hook framework) to be complete. No dependencies on other 0.2.x versions.
639+
> **Parallelization**: Can be developed in parallel with 0.2.1 and 0.2.2. Only requires 0.2.0 (hook framework) to be complete.
639640
640641
#### pytest-mock
641642

@@ -672,6 +673,22 @@ See #39 for tracking:
672673
- [x] Per-phase timeouts (setup, call, teardown)
673674
> Note: Current implementation is aggregate timeout. Per-phase is future enhancement.
674675
676+
#### Django URL and Template Markers (Issue #35)
677+
678+
- [x] `@pytest.mark.urls('myapp.test_urls')` - Override ROOT_URLCONF per test
679+
- [x] `@pytest.mark.ignore_template_errors` - Suppress template errors
680+
- [x] Positional argument extraction in Rust scanner
681+
- [x] URL cache clearing on override/restore
682+
- [x] Template debug mode toggle
683+
684+
#### Deferred to 0.3.x (Database Integration)
685+
686+
The following django_db marker options require deeper database transaction support:
687+
688+
- `transaction=True` - Use real transactions (not savepoints)
689+
- `reset_sequences=True` - Reset auto-increment sequences
690+
- `databases=['default', 'secondary']` - Multi-database support
691+
675692
#### pytest-cov (Deferred)
676693

677694
- [ ] Detect pytest-cov and warn about Tach's native coverage
@@ -691,7 +708,7 @@ See #39 for tracking:
691708

692709
**Target**: Use Landlock for network isolation when available, reducing reliance on CLONE_NEWNET.
693710

694-
> **Parallelization**: Fully independent. Can be developed at any time after 0.2.0. This is a kernel feature enhancement with no dependencies on plugin shims (0.2.1-0.2.3).
711+
> **Parallelization**: Fully independent. Can be developed at any time after 0.2.0. This is a kernel feature enhancement with no dependencies on plugin shims (0.2.1-0.2.4).
695712
696713
#### Network Restriction Rules
697714

@@ -713,7 +730,7 @@ allow_bind_ports = [8000, 8080] # Empty = no binding allowed
713730

714731
> **External Ref:** [Landlock Kernel Docs - Network](https://docs.kernel.org/userspace-api/landlock.html)
715732
716-
### 0.2.5 - Plugin Testing and Stabilization
733+
### 0.2.6 - Plugin Testing and Stabilization
717734

718735
**Target**: Ensure plugin shims work correctly with real-world projects.
719736

src/discovery/scanner.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,9 +646,19 @@ fn expr_to_json_value(expr: &ast::Expr) -> Option<serde_json::Value> {
646646
/// Extract keyword arguments from a Call expression
647647
///
648648
/// Returns a HashMap of argument name -> JSON value
649+
/// Positional args are stored with numeric keys ("0", "1", etc.)
649650
fn extract_marker_arguments(call: &ast::ExprCall) -> HashMap<String, serde_json::Value> {
650651
let mut args = HashMap::new();
651652

653+
// Capture positional arguments with numeric keys
654+
// e.g., @pytest.mark.urls('myapp.test_urls') -> {"0": "myapp.test_urls"}
655+
for (i, arg) in call.args.iter().enumerate() {
656+
if let Some(value) = expr_to_json_value(arg) {
657+
args.insert(i.to_string(), value);
658+
}
659+
}
660+
661+
// Capture keyword arguments
652662
for keyword in &call.keywords {
653663
if let Some(ref arg_name) = keyword.arg
654664
&& let Some(value) = expr_to_json_value(&keyword.value)

src/tach_harness.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2298,6 +2298,170 @@ def _cleanup_django_db_isolation(savepoints: list[tuple[str, str]]) -> None:
22982298
print(f"[tach:harness] WARN: Failed to rollback savepoint for '{alias}': {e}", file=sys.stderr)
22992299

23002300

2301+
# =============================================================================
2302+
# Django URL and Template Markers (v0.2.4 - Issue #35)
2303+
# =============================================================================
2304+
2305+
2306+
def _parse_urls_marker(marker_info: list[dict[str, Any]] | None) -> str | None:
2307+
"""Extract URL module path from @pytest.mark.urls marker.
2308+
2309+
The urls marker accepts a single positional argument specifying the
2310+
ROOT_URLCONF module to use for the test.
2311+
2312+
Example:
2313+
@pytest.mark.urls('myapp.test_urls')
2314+
2315+
Args:
2316+
marker_info: List of marker dictionaries from discovery.
2317+
2318+
Returns:
2319+
The URL module path string, or None if marker not present.
2320+
"""
2321+
if not marker_info:
2322+
return None
2323+
2324+
for marker in marker_info:
2325+
if isinstance(marker, dict) and marker.get("name") == "urls":
2326+
args = marker.get("args", {})
2327+
# Positional arg is stored with key "0"
2328+
return args.get("0")
2329+
2330+
return None
2331+
2332+
2333+
def _apply_urls_override(urlconf: str | None) -> str | None:
2334+
"""Override ROOT_URLCONF for a test and clear URL resolver cache.
2335+
2336+
Args:
2337+
urlconf: The URL module path to use, or None to skip override.
2338+
2339+
Returns:
2340+
The original ROOT_URLCONF value for restoration, or None if not applied.
2341+
"""
2342+
if urlconf is None or not _is_django_available():
2343+
return None
2344+
2345+
try:
2346+
from django.conf import settings
2347+
from django.urls import clear_url_caches
2348+
2349+
original = getattr(settings, "ROOT_URLCONF", None)
2350+
settings.ROOT_URLCONF = urlconf
2351+
clear_url_caches()
2352+
return original
2353+
except Exception as e:
2354+
print(f"[tach:harness] WARN: Failed to apply urls override: {e}", file=sys.stderr)
2355+
return None
2356+
2357+
2358+
def _cleanup_urls_override(original_urlconf: str | None) -> None:
2359+
"""Restore original ROOT_URLCONF after test.
2360+
2361+
Args:
2362+
original_urlconf: The original ROOT_URLCONF value to restore,
2363+
or None if no override was applied.
2364+
"""
2365+
if original_urlconf is None:
2366+
return
2367+
2368+
try:
2369+
from django.conf import settings
2370+
from django.urls import clear_url_caches
2371+
2372+
settings.ROOT_URLCONF = original_urlconf
2373+
clear_url_caches()
2374+
except Exception as e:
2375+
print(f"[tach:harness] WARN: Failed to restore urls: {e}", file=sys.stderr)
2376+
2377+
2378+
def _parse_ignore_template_errors_marker(marker_info: list[dict[str, Any]] | None) -> bool:
2379+
"""Check if @pytest.mark.ignore_template_errors marker is present.
2380+
2381+
Args:
2382+
marker_info: List of marker dictionaries from discovery.
2383+
2384+
Returns:
2385+
True if the marker is present, False otherwise.
2386+
"""
2387+
if not marker_info:
2388+
return False
2389+
2390+
for marker in marker_info:
2391+
if isinstance(marker, dict) and marker.get("name") == "ignore_template_errors":
2392+
return True
2393+
2394+
return False
2395+
2396+
2397+
def _apply_ignore_template_errors(ignore: bool) -> dict[str, Any] | None:
2398+
"""Disable template debug mode to suppress template errors.
2399+
2400+
When ignore_template_errors is set, we disable DEBUG and template
2401+
debugging to prevent TemplateSyntaxError from being raised.
2402+
2403+
Args:
2404+
ignore: Whether to ignore template errors.
2405+
2406+
Returns:
2407+
Dictionary of original settings for restoration, or None if not applied.
2408+
"""
2409+
if not ignore or not _is_django_available():
2410+
return None
2411+
2412+
try:
2413+
from django.conf import settings
2414+
2415+
originals: dict[str, Any] = {}
2416+
2417+
# Save and modify DEBUG
2418+
originals["DEBUG"] = getattr(settings, "DEBUG", False)
2419+
2420+
# Save and modify TEMPLATES debug settings
2421+
if hasattr(settings, "TEMPLATES"):
2422+
originals["TEMPLATES"] = []
2423+
for i, template_config in enumerate(settings.TEMPLATES):
2424+
if isinstance(template_config, dict):
2425+
orig_debug = template_config.get("OPTIONS", {}).get("debug")
2426+
originals["TEMPLATES"].append(orig_debug)
2427+
# Set debug to False to suppress template errors
2428+
if "OPTIONS" not in template_config:
2429+
template_config["OPTIONS"] = {}
2430+
template_config["OPTIONS"]["debug"] = False
2431+
2432+
return originals
2433+
except Exception as e:
2434+
print(f"[tach:harness] WARN: Failed to apply ignore_template_errors: {e}", file=sys.stderr)
2435+
return None
2436+
2437+
2438+
def _cleanup_ignore_template_errors(originals: dict[str, Any] | None) -> None:
2439+
"""Restore original template settings after test.
2440+
2441+
Args:
2442+
originals: Dictionary of original settings from _apply_ignore_template_errors,
2443+
or None if no override was applied.
2444+
"""
2445+
if originals is None:
2446+
return
2447+
2448+
try:
2449+
from django.conf import settings
2450+
2451+
# Restore TEMPLATES debug settings
2452+
if "TEMPLATES" in originals and hasattr(settings, "TEMPLATES"):
2453+
for i, orig_debug in enumerate(originals["TEMPLATES"]):
2454+
if i < len(settings.TEMPLATES):
2455+
template_config = settings.TEMPLATES[i]
2456+
if isinstance(template_config, dict) and "OPTIONS" in template_config:
2457+
if orig_debug is None:
2458+
template_config["OPTIONS"].pop("debug", None)
2459+
else:
2460+
template_config["OPTIONS"]["debug"] = orig_debug
2461+
except Exception as e:
2462+
print(f"[tach:harness] WARN: Failed to restore template settings: {e}", file=sys.stderr)
2463+
2464+
23012465
def run_test(
23022466
file_path: str,
23032467
node_id: str,
@@ -2422,9 +2586,19 @@ def sync_wrapper(*args, **kwargs):
24222586
django_db_args = _parse_django_db_marker(marker_info)
24232587
django_savepoints = _apply_django_db_isolation(django_db_args)
24242588

2589+
# Django URL and Template Markers (v0.2.4 - Issue #35)
2590+
urlconf = _parse_urls_marker(marker_info)
2591+
original_urlconf = _apply_urls_override(urlconf)
2592+
2593+
ignore_templates = _parse_ignore_template_errors_marker(marker_info)
2594+
template_originals = _apply_ignore_template_errors(ignore_templates)
2595+
24252596
try:
24262597
reports = _pytest.runner.runtestprotocol(target_item, nextitem=None, log=False)
24272598
finally:
2599+
# Cleanup in reverse order of application
2600+
_cleanup_ignore_template_errors(template_originals)
2601+
_cleanup_urls_override(original_urlconf)
24282602
# Rollback savepoints to restore database state
24292603
_cleanup_django_db_isolation(django_savepoints)
24302604

0 commit comments

Comments
 (0)