Skip to content

API Reference

The public entry point for programmatic usage is azure_functions_doctor.api.run_diagnostics(path, profile, rules_path, target_python). It uses the same diagnostics engine as azure-functions-doctor doctor, then returns structured results that can be consumed in scripts, CI pipelines, or custom tooling.

Public API at a Glance

API Purpose Typical usage
run_diagnostics(path, profile, rules_path, target_python) Run all checks and return section-level results. CI validation, custom wrappers, pre-commit hooks.
Doctor(path, profile, rules_path, target_python) Lower-level runner with explicit lifecycle methods. Advanced control over rule loading and execution.
CheckResult Result object for one check item. Output processing and custom reporting.
SectionResult Result object for a group of checks. Rendering grouped summaries by category.
HandlerRegistry Maps rule type to execution handlers. Handler extension and internal diagnostics flow.

Programmatic Usage with run_diagnostics

Use run_diagnostics when you want behavior that matches the CLI while staying inside Python code.

from pathlib import Path

from azure_functions_doctor.api import run_diagnostics


def summarize_failures(project_path: str) -> int:
    results = run_diagnostics(path=project_path, profile="full", rules_path=None)
    failed_required = 0

    for section in results:
        for item in section["items"]:
            if item["status"] == "fail":
                failed_required += 1

    return failed_required


if __name__ == "__main__":
    target = str(Path(".").resolve())
    failed = summarize_failures(target)
    raise SystemExit(1 if failed else 0)

Parameters

Parameter Type Required Description
path str Yes File system path to the Azure Functions app root.
profile str \| None No Rule profile: "minimal" (required checks only), "deploy" (runtime/hosting/deployment correctness), "development" (dev-environment checks), or "full" (default behavior: all rules).
rules_path pathlib.Path | None No Optional path to a custom rules file matching the rules schema.
target_python str | None No Override target Python runtime version (e.g. "3.12"). Defaults to None (use tool runtime).

Return Value

run_diagnostics returns list[SectionResult], where each section includes:

  • title: human-readable section title
  • category: machine-friendly section key
  • status: pass or fail at section level
  • items: list of CheckResult entries

Working with CheckResult and SectionResult

The following snippet shows safe access to optional fields (hint, hint_url) while creating a report.

from azure_functions_doctor.api import run_diagnostics


def flatten_results(path: str) -> list[dict[str, str]]:
    rows: list[dict[str, str]] = []
    for section in run_diagnostics(path=path, profile=None, rules_path=None):
        for item in section["items"]:
            rows.append(
                {
                    "section": section["category"],
                    "label": item["label"],
                    "status": item["status"],
                    "value": item["value"],
                    "hint": item.get("hint", ""),
                    "hint_url": item.get("hint_url", ""),
                }
            )
    return rows

Using Doctor Directly

Use Doctor if you need to separate rule loading, validation, and execution.

from pathlib import Path

from azure_functions_doctor.doctor import Doctor


def run_with_custom_rules(project_dir: str, custom_rules_file: str) -> list[dict]:
    doctor = Doctor(
        path=project_dir,
        profile="minimal",
        rules_path=Path(custom_rules_file),
    )
    rules = doctor.load_rules()
    return doctor.run_all_checks(rules=rules)

Handler Registry Integration

HandlerRegistry stores the mapping from rule type to concrete handler methods. Most users do not need to call it directly, but it is useful in internal extensions.

from pathlib import Path

from azure_functions_doctor.handlers import HandlerRegistry


def run_single_rule(rule: dict, project_path: str) -> dict[str, str]:
    registry = HandlerRegistry()
    result = registry.handle(rule=rule, path=Path(project_path))
    return result

CLI

azure_functions_alias()

Deprecated alias entry point for the azure-functions console script.

Source code in src/azure_functions_doctor/cli.py
def azure_functions_alias() -> None:
    """Deprecated alias entry point for the `azure-functions` console script."""
    _warn_deprecated_alias("azure-functions")
    cli()

doctor(path='.', verbose=False, debug=False, format='table', output=None, profile=None, rules=None, summary_json=None, target_python=None, deployment_mode='remote-build', hosting_plan=None)

Run diagnostics on an Azure Functions application.

Parameters:

Name Type Description Default
path str

Path to the Azure Functions app. Defaults to current directory.

'.'
verbose Annotated[bool, Option(-v, --verbose, help='Show detailed hints for failed checks')]

Show detailed hints for failed checks.

False
debug Annotated[bool, Option(help='Enable debug logging')]

Enable debug logging to stderr.

False
format Annotated[str, Option(help="Output format: 'table', 'json', 'sarif', or 'junit'")]

Output format: 'table', 'json', 'sarif', or 'junit'.

'table'
output Annotated[Optional[Path], Option(help='Optional path to save output result')]

Optional file path to save output result.

None
profile Annotated[Optional[str], Option(help="Rule profile: 'minimal' (required gating checks), 'deploy' (Azure runtime/hosting/deployment correctness), 'development' (local dev-environment checks), or 'full' (all rules).")]

Optional rule profile ('minimal', 'deploy', 'development', or 'full').

None
rules Annotated[Optional[Path], Option(help='Optional path to a custom rules file')]

Optional path to a custom rules file.

None
summary_json Annotated[Optional[Path], Option(--summary - json, help='Write a JSON summary of counts (passed/warned/failed) to this path')]

Path to write a JSON summary with passed/warned/failed counts.

None
target_python Annotated[Optional[str], Option(--target - python, help='Override target Python runtime')]

Optional target Python runtime override.

None
Source code in src/azure_functions_doctor/cli.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
@cli.command(name="doctor")
def doctor(
    path: str = ".",
    verbose: Annotated[
        bool, typer.Option("-v", "--verbose", help="Show detailed hints for failed checks")
    ] = False,
    debug: Annotated[bool, typer.Option(help="Enable debug logging")] = False,
    format: Annotated[
        str, typer.Option(help="Output format: 'table', 'json', 'sarif', or 'junit'")
    ] = "table",
    output: Annotated[
        Optional[Path], typer.Option(help="Optional path to save output result")
    ] = None,
    profile: Annotated[
        Optional[str],
        typer.Option(
            help=(
                "Rule profile: 'minimal' (required gating checks), 'deploy' "
                "(Azure runtime/hosting/deployment correctness), 'development' "
                "(local dev-environment checks), or 'full' (all rules)."
            ),
        ),
    ] = None,
    rules: Annotated[
        Optional[Path], typer.Option(help="Optional path to a custom rules file")
    ] = None,
    summary_json: Annotated[
        Optional[Path],
        typer.Option(
            "--summary-json",
            help="Write a JSON summary of counts (passed/warned/failed) to this path",
        ),
    ] = None,
    target_python: Annotated[
        Optional[str], typer.Option("--target-python", help="Override target Python runtime")
    ] = None,
    deployment_mode: Annotated[
        str,
        typer.Option(
            "--deployment-mode",
            help=(
                "Deployment mode: 'remote-build' (Azure builds from requirements.txt), "
                "'local' or 'local-prebuilt' (dependencies prebuilt/vendored locally), "
                "or 'container' (dependencies baked into a custom container image)."
            ),
        ),
    ] = "remote-build",
    hosting_plan: Annotated[
        Optional[str],
        typer.Option(
            "--hosting-plan",
            help=(
                "Target Azure hosting plan for Python-version validation: "
                "'linux-consumption' (caps at Python 3.12), 'flex-consumption', "
                "'premium', or 'dedicated'."
            ),
        ),
    ] = None,
) -> None:
    """
    Run diagnostics on an Azure Functions application.

    Args:
        path: Path to the Azure Functions app. Defaults to current directory.
        verbose: Show detailed hints for failed checks.
        debug: Enable debug logging to stderr.
        format: Output format: 'table', 'json', 'sarif', or 'junit'.
        output: Optional file path to save output result.
        profile: Optional rule profile ('minimal', 'deploy', 'development', or 'full').
        rules: Optional path to a custom rules file.
        summary_json: Path to write a JSON summary with passed/warned/failed counts.
        target_python: Optional target Python runtime override.
    """
    # Validate inputs before proceeding
    _validate_inputs(path, format, output, target_python, deployment_mode, hosting_plan)

    if rules is not None and not rules.exists():
        raise typer.BadParameter(f"Rules path does not exist: {rules}")

    # Configure logging based on CLI flags
    if debug:
        setup_logging(level="DEBUG", format_style="structured")
    else:
        # Use environment variable or default to WARNING
        setup_logging(level=None, format_style="simple")

    start_time = time.time()
    doctor = Doctor(
        path,
        profile=profile,
        rules_path=rules,
        target_python=target_python,
        deployment_mode=deployment_mode,
        hosting_plan=hosting_plan,
    )
    resolved_path = Path(path).resolve()
    report_properties = doctor.get_report_properties()

    # Log diagnostic start
    loaded_rules = doctor.load_rules()
    log_diagnostic_start(str(resolved_path), len(loaded_rules))
    results = doctor.run_all_checks(rules=loaded_rules)

    # Calculate execution metrics
    end_time = time.time()
    duration_ms = (end_time - start_time) * 1000

    # Count results for logging
    total_checks = sum(len(section["items"]) for section in results)
    passed_items = sum(
        1 for section in results for item in section["items"] if item.get("status") == "pass"
    )
    failed_items = sum(
        1 for section in results for item in section["items"] if item.get("status") == "fail"
    )
    # Note: handlers currently only return "pass"/"fail", not "error"
    errors = 0

    # Log diagnostic completion
    log_diagnostic_complete(total_checks, passed_items, failed_items, errors, duration_ms)

    # Pre-compute aggregated counts from normalized item['status'] values
    passed_count = 0
    warning_count = 0  # explicit 'warn' statuses
    fail_count = 0  # explicit 'fail' statuses
    skipped_count = 0  # explicit 'skip' statuses (check not applicable / prerequisite absent)
    for section in results:
        for item in section["items"]:
            s = item.get("status")
            if s == "pass":
                passed_count += 1
            elif s == "warn":
                warning_count += 1
            elif s == "fail":
                fail_count += 1
            elif s == "skip":
                skipped_count += 1
            else:
                warning_count += 1  # unknown treated as warning

    # Write summary JSON sidecar when --summary-json is specified (format-independent)
    if summary_json is not None:
        summary_data = {
            "passed": passed_count,
            "warned": warning_count,
            "failed": fail_count,
            "skipped": skipped_count,
        }
        try:
            summary_json.parent.mkdir(parents=True, exist_ok=True)
            summary_json.write_text(json.dumps(summary_data), encoding="utf-8")
        except (OSError, PermissionError) as exc:
            logger.warning(f"Failed to write summary JSON to {summary_json}: {exc}")

    if format == "json":
        generated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
        metadata = {
            "tool_version": __version__,
            "generated_at": generated_at,
            "target_path": str(Path(path).resolve()),
            **report_properties,
        }
        json_output = {
            "schema_version": FINDING_SCHEMA_VERSION,
            "metadata": metadata,
            "results": results,
        }
        _write_output(json.dumps(json_output, indent=2), output, "JSON")
        raise typer.Exit(1 if fail_count > 0 else 0)

    if format == "sarif":
        # rule_id is emitted on every result, so SARIF uses it directly

        # Build driver.rules from the full loaded ruleset
        driver_rules = []
        for rule in loaded_rules:
            driver_rule: dict[str, object] = {
                "id": rule.get("id", "unknown_rule"),
                "name": rule.get("label", "unknown_rule"),
                "shortDescription": {
                    "text": rule.get("description", rule.get("label", "unknown_rule"))
                },
                "properties": {
                    "category": rule.get("category", ""),
                    "required": rule.get("required", False),
                    "tier": _resolve_tier(rule),
                    "severity": _resolve_severity(rule),
                },
            }
            hint_url = rule.get("hint_url", "")
            if hint_url:
                driver_rule["helpUri"] = hint_url
            driver_rules.append(driver_rule)

        sarif_results = []
        # SARIF artifactLocation URIs paired with %SRCROOT% must be relative
        # references (SARIF 2.1.0 §3.4.4), so the scan root is normalized once
        # (#392): a relative --path (e.g. "services/api" in a monorepo) becomes
        # the repo-root prefix for every URI; an absolute --path cannot be
        # related to the runner's checkout root, so URIs stay scan-root-relative
        # and never leak filesystem paths into the report.
        scan_root_norm = path.replace("\\", "/").rstrip("/")
        scan_root_is_absolute = scan_root_norm.startswith("/") or (
            len(scan_root_norm) > 1 and scan_root_norm[1] == ":"
        )
        scan_prefix = ""
        if not scan_root_is_absolute and scan_root_norm not in ("", "."):
            scan_prefix = scan_root_norm + "/"

        def _physical_location_for(
            entry: Mapping[str, object],
        ) -> tuple[dict[str, object], str, object]:
            """Build one physicalLocation from an item or per-finding entry.

            Returns ``(physical_location, artifact_uri, loc_line)``. URIs are
            repo-root-relative per issue #392; entries may carry an absolute
            path defensively, which is rebased onto the scan root.
            """
            loc_file = entry.get("file")
            artifact_uri = ""
            loc_line: object = None
            if loc_file:
                artifact_uri = str(loc_file).replace("\\", "/")
                if Path(str(loc_file)).is_absolute():
                    try:
                        artifact_uri = str(Path(str(loc_file)).relative_to(Path(path))).replace(
                            "\\", "/"
                        )
                    except ValueError:
                        artifact_uri = Path(str(loc_file)).name
                if artifact_uri.startswith("./"):
                    artifact_uri = artifact_uri[2:]
                artifact_uri = scan_prefix + artifact_uri
                physical: dict[str, object] = {
                    "artifactLocation": {
                        "uri": artifact_uri,
                        "uriBaseId": "%SRCROOT%",
                    }
                }
                raw_line = entry.get("line")
                if isinstance(raw_line, int) and raw_line > 0:
                    loc_line = raw_line
                    region: dict[str, object] = {"startLine": raw_line}
                    raw_end = entry.get("end_line")
                    if isinstance(raw_end, int) and raw_end > 0:
                        region["endLine"] = raw_end
                    raw_col = entry.get("column")
                    if isinstance(raw_col, int) and raw_col > 0:
                        region["startColumn"] = raw_col
                    physical["region"] = region
                return physical, artifact_uri, loc_line
            # Rules without a file location point at the scan root in its
            # repo-root-relative form; absolute roots collapse to "." (#392).
            return (
                {
                    "artifactLocation": {
                        "uri": scan_prefix if scan_prefix else ".",
                        "uriBaseId": "%SRCROOT%",
                    }
                },
                scan_prefix if scan_prefix else ".",
                None,
            )

        for section in results:
            for item in section["items"]:
                status = item.get("status")
                # Skipped checks are not findings; exclude them from SARIF output.
                if status in ("pass", "skip"):
                    continue
                rule_id = item.get("rule_id") or item.get("label", "")
                level = "error" if status == "fail" else "warning"

                # One SARIF result per finding (issue #395): when a handler
                # supplies structured ``locations``, each entry becomes its
                # own result with its own region and message.
                item_map = cast(Mapping[str, object], item)
                raw_locations = item_map.get("locations")
                entries: list[tuple[Mapping[str, object], str]] = []
                if isinstance(raw_locations, list) and raw_locations:
                    for raw_entry in raw_locations:
                        if isinstance(raw_entry, dict):
                            entry_map = cast(Mapping[str, object], raw_entry)
                            per_message = str(entry_map.get("message") or item.get("value", ""))
                            entries.append((entry_map, per_message))
                if not entries:
                    entries = [(item_map, str(item.get("value", "")))]

                props: dict[str, object] = {}
                if item.get("hint"):
                    props["hint"] = item.get("hint", "")
                for key in FINDING_EVIDENCE_KEYS:
                    val = item_map.get(key)
                    if isinstance(val, str) and val:
                        props[key] = val
                analysis = item.get("analysis")
                if analysis:
                    props["analysis"] = analysis

                for entry, message_text in entries:
                    physical_location, artifact_uri, loc_line = _physical_location_for(entry)
                    # Stable fingerprint so Code Scanning can match alerts across
                    # runs even when lines shift (#392).
                    line_for_seed = loc_line if isinstance(loc_line, int) else 0
                    fingerprint_seed = f"{rule_id}:{artifact_uri}:{line_for_seed}"
                    sarif_result: dict[str, object] = {
                        "ruleId": rule_id,
                        "message": {"text": message_text},
                        "level": level,
                        "locations": [{"physicalLocation": physical_location}],
                        "partialFingerprints": {
                            "primaryLocationLineHash": hashlib.sha256(
                                fingerprint_seed.encode("utf-8")
                            ).hexdigest()
                        },
                    }
                    if props:
                        sarif_result["properties"] = props
                    sarif_results.append(sarif_result)

        sarif_output = {
            "version": "2.1.0",
            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
            "runs": [
                {
                    "properties": report_properties,
                    "tool": {
                        "driver": {
                            "name": "azure-functions-doctor",
                            "version": __version__,
                            "informationUri": "https://github.com/yeongseon/azure-functions-doctor-python",
                            "rules": driver_rules,
                        }
                    },
                    "results": sarif_results,
                }
            ],
        }
        _write_output(json.dumps(sarif_output, indent=2), output, "SARIF")
        raise typer.Exit(1 if fail_count > 0 else 0)

    if format == "junit":
        import xml.etree.ElementTree as ET  # nosec B405

        tests = 0
        failures = 0
        skipped = 0
        suite = ET.Element(
            "testsuite",
            name="azure-functions-doctor",
            tests="0",
            failures="0",
            skipped="0",
            time=f"{duration_ms / 1000:.3f}",
        )

        for section in results:
            for item in section["items"]:
                tests += 1
                case = ET.SubElement(
                    suite, "testcase", classname=section["title"], name=item.get("label", "")
                )
                status = item.get("status")
                if status == "fail":
                    failures += 1
                    failure = ET.SubElement(case, "failure", message=item.get("value", ""))
                    failure.text = item.get("hint", "")
                elif status in ("warn", "skip"):
                    skipped += 1
                    skipped_el = ET.SubElement(case, "skipped", message=item.get("value", ""))
                    skipped_el.text = item.get("hint", "")

        suite.set("tests", str(tests))
        suite.set("failures", str(failures))
        suite.set("skipped", str(skipped))
        junit_output = ET.tostring(suite, encoding="utf-8", xml_declaration=True).decode("utf-8")
        _write_output(junit_output, output, "JUnit")
        raise typer.Exit(1 if fail_count > 0 else 0)

    # Note: Top header removed per UI change; programming model header intentionally omitted

    if debug:
        console.print("[dim]Debug logging enabled - check stderr for detailed logs[/dim]\n")

    # Table-format user-facing output (requested design)
    console.print("Azure Functions Doctor   ")
    console.print(f"Path: {resolved_path}")
    if target_python is not None:
        console.print(f"Target Python: {target_python} (override)")
    else:
        resolved_target, target_source = resolve_python_target(resolved_path)
        if target_source != "tool-runtime":
            console.print(f"Target Python: {resolved_target} ({target_source})")

    # Print each section with simple title and items
    for section in results:
        console.print()
        console.print(section["title"])

        for item in section["items"]:
            label = item.get("label", "")
            value = item.get("value", "")
            status = item.get("status", "pass")
            icon = format_status_icon(status)

            # Compose main line: [ICON] Label: value (status)
            line = Text.assemble((f"[{icon}] ", "bold"), (label, "dim"))
            if value:
                line.append(": ")
                line.append(format_detail(status, value))

            # append status in parentheses for clarity on UI when non-pass
            if status != "pass":
                line.append(f" ({status})", "italic dim")

            console.print(line)

            # Finding Contract v2 (issue #348): surface source-verified freshness
            # for date / compatibility findings that carry it.
            freshness = format_freshness_line(
                item.get("last_verified", ""), item.get("source_url", "")
            )
            if freshness:
                console.print(f"    [dim]{freshness}[/dim]")

            # show hint as 'fix:' only when verbose is enabled
            if status != "pass" and verbose:
                hint = item.get("hint", "")
                if hint:
                    prefix = "↪ "
                    console.print(f"    {prefix}fix: {hint}")

    # Use the precomputed counts from earlier for final output
    console.print()
    # Print Doctor summary at the bottom like the requested sample
    console.print("Doctor summary (to see all details, run azure-functions-doctor doctor -v):")
    # Use singular/plural simple form as in sample (error vs errors)
    # Summary now reflects canonical statuses: fails, warnings, passed
    w_label = "warning" if warning_count == 1 else "warnings"
    f_label = "fail" if fail_count == 1 else "fails"
    # 'passed' label remains same for singular/plural in current design
    console.print(f"  {fail_count} {f_label}, {warning_count} {w_label}, {passed_count} passed")
    if skipped_count:
        console.print(f"  {skipped_count} skipped")
    exit_code = 1 if fail_count > 0 else 0
    console.print(f"Exit code: {exit_code}")
    if exit_code != 0:
        raise typer.Exit(exit_code)

fdoctor_alias()

Deprecated alias entry point for the fdoctor console script.

Source code in src/azure_functions_doctor/cli.py
def fdoctor_alias() -> None:
    """Deprecated alias entry point for the `fdoctor` console script."""
    _warn_deprecated_alias("fdoctor")
    cli()

main()

Console-script entry point that tolerates the omitted subcommand.

Source code in src/azure_functions_doctor/cli.py
def main() -> None:
    """Console-script entry point that tolerates the omitted subcommand."""
    import sys

    normalized = normalize_argv(sys.argv[1:])
    if normalized != sys.argv[1:]:
        sys.argv = [sys.argv[0], *normalized]
    cli()

normalize_argv(argv)

Insert the implicit doctor subcommand when it is omitted (#399).

doctor is the only command, so the top level accepts the same options: azure-functions-doctor --path . --format json behaves identically to azure-functions-doctor doctor --path . --format json. Pure function so the dispatch is unit-testable without spawning a process.

Source code in src/azure_functions_doctor/cli.py
def normalize_argv(argv: list[str]) -> list[str]:
    """Insert the implicit ``doctor`` subcommand when it is omitted (#399).

    ``doctor`` is the only command, so the top level accepts the same options:
    ``azure-functions-doctor --path . --format json`` behaves identically to
    ``azure-functions-doctor doctor --path . --format json``. Pure function so
    the dispatch is unit-testable without spawning a process.
    """
    if not argv:
        return ["doctor"]
    first = argv[0]
    if first == "doctor":
        return list(argv)
    if first in _APP_LEVEL_FLAGS:
        return list(argv)
    if first.startswith("-"):
        return ["doctor", *argv]
    # Unknown bare word: let Typer surface its own "no such command" error.
    return list(argv)

Doctor

Doctor(path='.', profile=None, rules_path=None, target_python=None, deployment_mode='remote-build', hosting_plan=None)

Diagnostic runner for Azure Functions apps.

Loads checks from the built-in Azure Functions Python v2 rule asset located at azure_functions_doctor.assets.rules.v2.json.

Source code in src/azure_functions_doctor/doctor.py
def __init__(
    self,
    path: str = ".",
    profile: Optional[str] = None,
    rules_path: Optional[Path] = None,
    target_python: Optional[str] = None,
    deployment_mode: str = "remote-build",
    hosting_plan: Optional[str] = None,
) -> None:
    self.project_path: Path = Path(path).resolve()
    self.profile = profile
    self.target_python: Optional[str] = target_python
    self.deployment_mode: str = deployment_mode
    self.hosting_plan: Optional[str] = hosting_plan
    self.rules_path: Optional[Path] = None
    if rules_path is not None:
        resolved = rules_path.resolve()
        if not resolved.is_file():
            raise ValueError(f"rules_path must be an existing file: {resolved}")
        self.rules_path = resolved
    # Config-based suppression / exclusion (issue #290). CLI selections
    # (profile, rules_path) take precedence for ruleset selection; the
    # config ``ignore``/``exclude`` layer on top of the resolved run.
    doctor_config = load_doctor_config(self.project_path)
    self.ignore_rules: set[str] = set(doctor_config["ignore"])
    self.exclude_globs: list[str] = list(doctor_config["exclude"])
    self.programming_model: ProgrammingModel = self._detect_programming_model()

get_report_properties()

Return top-level report properties shared across output formats.

Source code in src/azure_functions_doctor/doctor.py
def get_report_properties(self) -> dict[str, Optional[str]]:
    """Return top-level report properties shared across output formats."""
    return {
        "programming_model": self.programming_model,
        "target_python": self.target_python,
        "deployment_mode": self.deployment_mode,
        "hosting_plan": self.hosting_plan,
    }

load_rules()

Load and validate rules from a custom path or the built-in v2 ruleset.

Source code in src/azure_functions_doctor/doctor.py
def load_rules(self) -> list[Rule]:
    """Load and validate rules from a custom path or the built-in v2 ruleset."""
    if self.rules_path is not None:
        with self.rules_path.open(encoding="utf-8") as f:
            rules: list[Rule] = json.load(f)
    else:
        rules = self._load_v2_rules()

    self._validate_rules(rules)
    return sorted(rules, key=lambda r: r.get("check_order", 999))

profiles_for_rule(rule)

Return the profile names rule participates in, widest-last.

Source code in src/azure_functions_doctor/profiles.py
def profiles_for_rule(rule: Mapping[str, object]) -> list[str]:
    """Return the profile names ``rule`` participates in, widest-last."""
    return [name for name in PROFILE_NAMES if rule_matches_profile(rule, name)]

rule_matches_profile(rule, profile)

Return whether rule runs under the given profile.

  • full: every rule.
  • minimal: required (gating) rules only.
  • deploy: core-group rules covering Azure runtime/hosting/deployment correctness; developer-environment and integration rules are excluded.
  • development: developer-environment checks (virtual environment, Python executable, Core Tools, local.settings existence).
Source code in src/azure_functions_doctor/profiles.py
def rule_matches_profile(rule: Mapping[str, object], profile: str) -> bool:
    """Return whether ``rule`` runs under the given ``profile``.

    - ``full``: every rule.
    - ``minimal``: required (gating) rules only.
    - ``deploy``: core-group rules covering Azure runtime/hosting/deployment
      correctness; developer-environment and integration rules are excluded.
    - ``development``: developer-environment checks (virtual environment, Python
      executable, Core Tools, local.settings existence).
    """
    if profile == "full":
        return True
    if profile == "minimal":
        return bool(rule.get("required", True))
    if profile == "development":
        return rule.get("id") in DEV_ENVIRONMENT_RULES
    if profile == "deploy":
        return rule.get("group", "core") == "core" and rule.get("id") not in DEV_ENVIRONMENT_RULES
    raise ValueError("Profile must be one of: " + ", ".join(PROFILE_NAMES))

Handlers

Diagnostic check handlers for Azure Functions Doctor.

This package was split from a single handlers.py module. The public API is preserved: import from azure_functions_doctor.handlers exactly as before. Implementation lives in :mod:._helpers (pure helpers and types) and :mod:.registry (the HandlerRegistry dispatch class).

DoctorConfig

Bases: TypedDict

Resolved [tool.azure-functions-doctor] project configuration.

HandlerRegistry()

Bases: GenericHandlers, DependencyHandlers, RuntimeHandlers, MonitoringHandlers, DeploymentHandlers, BindingHandlers, ProjectHandlers, DurableHandlers, IntegrationHandlers

Registry for diagnostic check handlers with individual handler methods.

Source code in src/azure_functions_doctor/handlers/registry.py
def __init__(self) -> None:
    self._handlers: Dict[str, Callable[[Rule, Path, Optional[RuleContext]], HandlerResult]] = {
        check_type: getattr(self, method_name)
        for check_type, method_name in _RULE_DISPATCH.items()
    }

handle(rule, path, context=None)

Route rule execution to appropriate handler.

Source code in src/azure_functions_doctor/handlers/registry.py
def handle(
    self, rule: Rule, path: Path, context: Optional[RuleContext] = None
) -> HandlerResult:
    """Route rule execution to appropriate handler."""
    check_type = rule.get("type")
    if check_type is None:
        return _create_result("fail", "Missing check type in rule")
    handler = self._handlers.get(check_type)

    if not handler:
        return _create_result("fail", f"Unknown check type: {check_type}")

    try:
        return handler(rule, path, context)
    except Exception as exc:
        return _handle_specific_exceptions(f"executing {check_type} check", exc)

ResolvedField(value, source) dataclass

A resolved configuration value together with its provenance.

is_known property

Return True when a concrete value was resolved.

TargetConfig(hosting_plan, runtime_name, runtime_version, extension_version, deployment_storage, app_settings=dict(), app_settings_files=dict()) dataclass

The resolved target Azure configuration, one source of truth for handlers.

unknown() classmethod

Return a fully-unknown config (used when nothing can be resolved).

Source code in src/azure_functions_doctor/deploy_config.py
@classmethod
def unknown(cls) -> "TargetConfig":
    """Return a fully-unknown config (used when nothing can be resolved)."""
    blank = ResolvedField(None, SOURCE_UNKNOWN)
    return cls(
        hosting_plan=blank,
        runtime_name=blank,
        runtime_version=blank,
        extension_version=blank,
        deployment_storage=blank,
        app_settings={},
        app_settings_files={},
    )

generic_handler(rule, path, context=None)

Execute a diagnostic rule based on its type and condition.

This function maintains backward compatibility while delegating to the registry.

Parameters:

Name Type Description Default
rule Rule

The diagnostic rule to execute.

required
path Path

Path to the Azure Functions project.

required

Returns:

Type Description
HandlerResult

A dictionary with the status and detail of the check.

Source code in src/azure_functions_doctor/handlers/registry.py
def generic_handler(rule: Rule, path: Path, context: Optional[RuleContext] = None) -> HandlerResult:
    """
    Execute a diagnostic rule based on its type and condition.

    This function maintains backward compatibility while delegating to the registry.

    Args:
        rule: The diagnostic rule to execute.
        path: Path to the Azure Functions project.

    Returns:
        A dictionary with the status and detail of the check.
    """
    return _registry.handle(rule, path, context)

iter_project_files(project_path, patterns)

Single entry point for project file traversal (issue #393).

Yields files under project_path matching patterns (rglob syntax), honoring EXCLUDED_PROJECT_DIRS and the user's [tool.azure-functions-doctor].exclude globs. Handlers must traverse through this helper instead of calling Path.rglob directly so virtualenvs, node_modules, caches, and user excludes are never scanned - a regression test forbids raw rglob outside this module.

Source code in src/azure_functions_doctor/handlers/_helpers.py
def iter_project_files(
    project_path: Path, patterns: Union[str, tuple[str, ...], list[str]]
) -> Iterator[Path]:
    """Single entry point for project file traversal (issue #393).

    Yields files under ``project_path`` matching ``patterns`` (rglob syntax),
    honoring ``EXCLUDED_PROJECT_DIRS`` and the user's
    ``[tool.azure-functions-doctor].exclude`` globs. Handlers must traverse
    through this helper instead of calling ``Path.rglob`` directly so
    virtualenvs, node_modules, caches, and user excludes are never scanned -
    a regression test forbids raw rglob outside this module.
    """
    pattern_list: tuple[str, ...]
    if isinstance(patterns, str):
        pattern_list = (patterns,)
    else:
        pattern_list = tuple(patterns)
    for pattern in pattern_list:
        for candidate in sorted(project_path.rglob(pattern)):
            if not _is_excluded_path(candidate):
                yield candidate

load_doctor_config(path)

Load [tool.azure-functions-doctor] settings from pyproject.toml.

Returns ignore (rule ids to suppress and report as skip) and exclude (extra path globs layered on top of EXCLUDED_PROJECT_DIRS). Missing files, tables, or keys yield empty lists. Only string list entries are honored; malformed values are ignored rather than raising.

Source code in src/azure_functions_doctor/handlers/_helpers.py
def load_doctor_config(path: Path) -> DoctorConfig:
    """Load ``[tool.azure-functions-doctor]`` settings from ``pyproject.toml``.

    Returns ``ignore`` (rule ids to suppress and report as ``skip``) and
    ``exclude`` (extra path globs layered on top of ``EXCLUDED_PROJECT_DIRS``).
    Missing files, tables, or keys yield empty lists. Only string list entries
    are honored; malformed values are ignored rather than raising.
    """
    config: DoctorConfig = {"ignore": [], "exclude": []}
    data = _load_pyproject(path)
    if not data:
        return config
    tool = data.get("tool")
    if not isinstance(tool, dict):
        return config
    table = tool.get("azure-functions-doctor")
    if not isinstance(table, dict):
        return config
    for key in ("ignore", "exclude"):
        raw = table.get(key)
        if isinstance(raw, list):
            config[key] = [item for item in raw if isinstance(item, str)]
    return config

reset_extra_excludes(token)

Restore the previous extra-exclude state.

Source code in src/azure_functions_doctor/handlers/_helpers.py
def reset_extra_excludes(
    token: contextvars.Token[Tuple[Path, Tuple[str, ...]]],
) -> None:
    """Restore the previous extra-exclude state."""
    _extra_excludes.reset(token)

resolve_target_config(project_path, overrides=None)

Resolve the target Azure configuration for project_path.

Parameters:

Name Type Description Default
project_path Path

Root of the project under diagnosis.

required
overrides Optional[Mapping[str, Optional[str]]]

Optional CLI overrides. Recognized keys are "hosting_plan" and "runtime_version"; a non-None value takes precedence over any IaC or local signal for that field.

None

Returns:

Name Type Description
A TargetConfig

class:TargetConfig where every field records its provenance. When no

TargetConfig

infrastructure or signal is found, all fields are unknown.

Source code in src/azure_functions_doctor/deploy_config.py
def resolve_target_config(
    project_path: Path,
    overrides: Optional[Mapping[str, Optional[str]]] = None,
) -> TargetConfig:
    """Resolve the target Azure configuration for ``project_path``.

    Args:
        project_path: Root of the project under diagnosis.
        overrides: Optional CLI overrides. Recognized keys are ``"hosting_plan"``
            and ``"runtime_version"``; a non-``None`` value takes precedence over
            any IaC or local signal for that field.

    Returns:
        A :class:`TargetConfig` where every field records its provenance. When no
        infrastructure or signal is found, all fields are ``unknown``.
    """
    overrides = overrides or {}
    iac = _scan_iac(project_path)
    local = _local_signals(project_path)

    app_settings = dict(local.app_settings)
    app_settings.update(iac.app_settings)

    return TargetConfig(
        hosting_plan=_resolve_field(
            overrides.get("hosting_plan"), iac.hosting_plan, local.hosting_plan
        ),
        runtime_name=_resolve_field(None, iac.runtime_name, local.runtime_name),
        runtime_version=_resolve_field(
            overrides.get("runtime_version"), iac.runtime_version, local.runtime_version
        ),
        extension_version=_resolve_field(None, iac.extension_version, local.extension_version),
        deployment_storage=_resolve_field(None, iac.deployment_storage, local.deployment_storage),
        app_settings=app_settings,
        app_settings_files=dict(iac.app_settings_files),
    )

resolve_target_value(target, override=None)

Resolve the current value of a target used in version comparison or diagnostics.

Parameters:

Name Type Description Default
target str

The name of the target to resolve. Examples include "python" or "func_core_tools".

required

Returns:

Type Description
str

A string representing the resolved version or value.

Raises:

Type Description
ValueError

If the target is not recognized.

Source code in src/azure_functions_doctor/target_resolver.py
def resolve_target_value(target: str, override: Optional[str] = None) -> str:
    """
    Resolve the current value of a target used in version comparison or diagnostics.

    Args:
        target: The name of the target to resolve. Examples include "python" or "func_core_tools".

    Returns:
        A string representing the resolved version or value.

    Raises:
        ValueError: If the target is not recognized.
    """
    resolver = _TARGET_RESOLVERS.get(target)
    if resolver is None:
        raise ValueError(f"Unknown target: {target}")
    return resolver(override)

set_extra_excludes(root, globs)

Set the active extra-exclude globs and return a reset token.

Source code in src/azure_functions_doctor/handlers/_helpers.py
def set_extra_excludes(
    root: Path, globs: Iterable[str]
) -> contextvars.Token[Tuple[Path, Tuple[str, ...]]]:
    """Set the active extra-exclude globs and return a reset token."""
    normalized = tuple(g for g in globs if g)
    return _extra_excludes.set((root, normalized))

Target Resolver

is_supported_python_for_plan(version, plan)

Return True when version is supported on the given hosting plan.

Only the major.minor components are considered. Unknown plans fall back to the plan-agnostic :func:is_supported_python_target check so callers never reject a version merely because the plan name is unrecognized.

Source code in src/azure_functions_doctor/target_resolver.py
def is_supported_python_for_plan(version: str, plan: str) -> bool:
    """Return ``True`` when ``version`` is supported on the given hosting ``plan``.

    Only the major.minor components are considered. Unknown plans fall back to
    the plan-agnostic :func:`is_supported_python_target` check so callers never
    reject a version merely because the plan name is unrecognized.
    """
    allowed = PYTHON_HOSTING_PLAN_MATRIX.get(plan)
    if allowed is None:
        return is_supported_python_target(version)
    parsed = _major_minor(version)
    if parsed is None:
        return False
    supported = {_major_minor(v) for v in allowed}
    return parsed in supported

is_supported_python_target(version)

Return True when version's major.minor is a supported Azure target.

Only the major and minor components are considered, so patch releases such as "3.14.2" are supported while "3.15.0" and "3.9.1" are not.

Source code in src/azure_functions_doctor/target_resolver.py
def is_supported_python_target(version: str) -> bool:
    """Return ``True`` when ``version``'s major.minor is a supported Azure target.

    Only the major and minor components are considered, so patch releases such
    as ``"3.14.2"`` are supported while ``"3.15.0"`` and ``"3.9.1"`` are not.
    """
    parsed = _major_minor(version)
    if parsed is None:
        return False
    supported = {_major_minor(v) for v in SUPPORTED_PYTHON_VERSIONS}
    return parsed in supported

resolve_python_target(project_path=None, override=None)

Resolve the Python version to diagnose against, with provenance.

Precedence (first match wins):

  1. Explicit override (e.g. --target-python) -> source "override".
  2. .python-version file -> source ".python-version".
  3. The running interpreter -> source "tool-runtime" (always resolves).

The [project] requires-python value in pyproject.toml is deliberately NOT used as the target: it declares a compatibility floor, not the interpreter the app will actually be deployed against, so treating it as the target masks unsupported runtimes.

Parameters:

Name Type Description Default
project_path Optional[Path]

Root of the project under diagnosis. When None or when no project signal is found, the running interpreter is used.

None
override Optional[str]

Explicit target that short-circuits project detection.

None

Returns:

Type Description
Tuple[str, str]

A (version, source) tuple where source records provenance.

Source code in src/azure_functions_doctor/target_resolver.py
def resolve_python_target(
    project_path: Optional[Path] = None, override: Optional[str] = None
) -> Tuple[str, str]:
    """Resolve the Python version to diagnose against, with provenance.

    Precedence (first match wins):

    1. Explicit ``override`` (e.g. ``--target-python``) -> source ``"override"``.
    2. ``.python-version`` file -> source ``".python-version"``.
    3. The running interpreter -> source ``"tool-runtime"`` (always resolves).

    The ``[project] requires-python`` value in ``pyproject.toml`` is deliberately
    NOT used as the target: it declares a compatibility *floor*, not the
    interpreter the app will actually be deployed against, so treating it as the
    target masks unsupported runtimes.

    Args:
        project_path: Root of the project under diagnosis. When ``None`` or when
            no project signal is found, the running interpreter is used.
        override: Explicit target that short-circuits project detection.

    Returns:
        A ``(version, source)`` tuple where ``source`` records provenance.
    """
    if override is not None:
        return override, "override"

    if project_path is not None:
        python_version_file = project_path / ".python-version"
        if python_version_file.is_file():
            try:
                content = python_version_file.read_text(encoding="utf-8").strip()
            except OSError:
                content = ""
            match = _PYTHON_VERSION_RE.search(content)
            if match:
                return match.group(1), ".python-version"

    return sys.version.split()[0], "tool-runtime"

resolve_target_value(target, override=None)

Resolve the current value of a target used in version comparison or diagnostics.

Parameters:

Name Type Description Default
target str

The name of the target to resolve. Examples include "python" or "func_core_tools".

required

Returns:

Type Description
str

A string representing the resolved version or value.

Raises:

Type Description
ValueError

If the target is not recognized.

Source code in src/azure_functions_doctor/target_resolver.py
def resolve_target_value(target: str, override: Optional[str] = None) -> str:
    """
    Resolve the current value of a target used in version comparison or diagnostics.

    Args:
        target: The name of the target to resolve. Examples include "python" or "func_core_tools".

    Returns:
        A string representing the resolved version or value.

    Raises:
        ValueError: If the target is not recognized.
    """
    resolver = _TARGET_RESOLVERS.get(target)
    if resolver is None:
        raise ValueError(f"Unknown target: {target}")
    return resolver(override)

Utility

format_detail(status, value)

Return a colored Text element based on status and value.

Parameters:

Name Type Description Default
status str

Diagnostic status ("pass", "fail", "warn", "skip").

required
value str

Text to display, typically a description.

required

Returns:

Type Description
Text

A Rich Text object styled with status color.

Source code in src/azure_functions_doctor/utils.py
def format_detail(status: str, value: str) -> Text:
    """
    Return a colored Text element based on status and value.

    Args:
        status: Diagnostic status ("pass", "fail", "warn", "skip").
        value: Text to display, typically a description.

    Returns:
        A Rich Text object styled with status color.
    """
    color = DETAIL_COLOR_MAP.get(status, "white")
    return Text(value, style=color)

format_freshness_line(last_verified, source_url='')

Render the Finding Contract v2 freshness line for date/compat findings.

Parameters:

Name Type Description Default
last_verified str

The YYYY-MM-DD date the underlying fact was verified.

required
source_url str

Optional authoritative source URL for the fact.

''

Returns:

Type Description
str

A human-readable verified as of YYYY-MM-DD string, with the source

str

URL appended when available. Returns an empty string when

str

last_verified is missing so callers can skip rendering.

Source code in src/azure_functions_doctor/utils.py
def format_freshness_line(last_verified: str, source_url: str = "") -> str:
    """Render the Finding Contract v2 freshness line for date/compat findings.

    Args:
        last_verified: The ``YYYY-MM-DD`` date the underlying fact was verified.
        source_url: Optional authoritative source URL for the fact.

    Returns:
        A human-readable ``verified as of YYYY-MM-DD`` string, with the source
        URL appended when available. Returns an empty string when
        ``last_verified`` is missing so callers can skip rendering.
    """
    if not last_verified:
        return ""
    line = f"verified as of {last_verified}"
    if source_url:
        line += f" — {source_url}"
    return line

format_result(status)

Return a styled icon Text element based on status.

Parameters:

Name Type Description Default
status str

Diagnostic status ("pass", "fail", "warn", "skip").

required

Returns:

Type Description
Text

A Rich Text object with icon and style for headers.

Source code in src/azure_functions_doctor/utils.py
def format_result(status: str) -> Text:
    """
    Return a styled icon Text element based on status.

    Args:
        status: Diagnostic status ("pass", "fail", "warn", "skip").

    Returns:
        A Rich Text object with icon and style for headers.
    """
    style = STATUS_STYLES.get(status, Style(color="white"))
    icon = format_status_icon(status)
    return Text(icon, style=style)

format_status_icon(status)

Return a simple icon character based on status.

Parameters:

Name Type Description Default
status str

Diagnostic status ("pass", "fail", "warn", "skip").

required

Returns: A string icon such as ✓, !, or ✗.

Source code in src/azure_functions_doctor/utils.py
def format_status_icon(status: str) -> str:
    """
    Return a simple icon character based on status.

    Args:
        status: Diagnostic status ("pass", "fail", "warn", "skip").

    Returns:
    A string icon such as ✓, !, or ✗.
    """
    return STATUS_ICONS.get(status, "?")