Coverage for tests/test_detected_features.py: 100%
77 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-09 14:41 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-09 14:41 +0000
1import pathlib
2import types
3import typing
5import hypothesis
6import pytest
7from hypothesis import strategies as st
9from tests import diagram_rendering
10from tests.diagram_parts import EDGE_ARROW
13# What a parser found is only interesting once it reaches an arrow, so every case here is one
14# source file, the diagram parts it has to draw and the parts it may not.
15_EVERY_METHOD_SOURCE: typing.Final = '''import fastapi
17router = fastapi.APIRouter()
20@router.get("/x")
21@router.post("/x")
22@router.put("/x")
23@router.patch("/x")
24@router.delete("/x")
25@router.head("/x")
26@router.options("/x")
27@router.trace("/x")
28async def handle_everything() -> None:
29 """A route is traffic the service receives whatever verb it answers."""
30'''
31_LITESTAR_ROUTE_SOURCE: typing.Final = """from litestar import post
34@post("/items")
35async def create_item(data: dict) -> dict:
36 return data
37"""
38_ROUTE_WITHOUT_FRAMEWORK_SOURCE: typing.Final = """@router.get("/x")
39async def read_items() -> list[dict]:
40 return []
41"""
42_PREFIXED_ROUTE_SOURCE: typing.Final = """import fastapi
44API_PREFIX = "/api/v1"
45router = fastapi.APIRouter()
48@router.get(f"{API_PREFIX}/items")
49async def read_items() -> list[dict]:
50 return []
51"""
52_MOCKED_TEST_SOURCE: typing.Final = """from unittest import mock
54from fastapi.testclient import TestClient
57@mock.patch("src.payments.fetch_payment_status")
58def test_fetching(fetch_mock: mock.MagicMock) -> None: ...
59"""
60_ASYNC_CLIENT_SOURCE: typing.Final = """import httpx
63payments_client = httpx.AsyncClient(base_url="https://payments.example.com")
64"""
65_SYNC_CLIENTS_SOURCE: typing.Final = """import niquests
66import requests
69def fetch_payment_status(payment_id: int) -> dict:
70 requests.get(f"https://payments.example.com/{payment_id}", timeout=10)
71 return niquests.get(f"https://payments.example.com/{payment_id}", timeout=10).json()
72"""
73_CELERY_SOURCE: typing.Final = '''import celery
75"""In the cluster the tasks are executed by `celery worker`."""
77celery_app = celery.Celery(broker="redis://localhost:6379/0")
80@celery_app.task
81def send_receipt(order_id: int) -> None: ...
82'''
83_EVERY_QUEUE_SOURCE: typing.Final = """import arq
84import celery
85import dramatiq
86import huey
87import rq
88import taskiq
89"""
90_TWO_BROKER_QUEUE_SOURCE: typing.Final = """import celery
93celery_app = celery.Celery(broker="amqp://localhost:5672/", backend="redis://localhost:6379/1")
94"""
95_RABBITMQ_BROKER_SOURCE: typing.Final = """import dramatiq
98dramatiq_broker = dramatiq.RabbitMQBroker(url="amqp://localhost:5672/")
99"""
100_POSTGRESQL_BROKER_SOURCE: typing.Final = """import huey
103huey_broker = PostgreSQLBroker("postgres://localhost:5432/tasks")
104"""
105_REDIS_CACHE_SOURCE: typing.Final = """import redis
106import redis.asyncio
109RETRY_POLICY = redis.Retry(ExponentialBackoff(), 3)
110cache_client = redis.asyncio.Redis(host="cache.internal", retry=RETRY_POLICY)
111"""
112_REDIS_CLUSTER_SOURCE: typing.Final = """from redis.cluster import RedisCluster
115cluster_client = RedisCluster(startup_nodes=[])
116"""
117_REDIS_SENTINEL_SOURCE: typing.Final = """from redis.sentinel import Sentinel
120sentinel_client = Sentinel([("sentinel-one", 26379)])
121"""
122_ASYNC_REDIS_CLUSTER_SOURCE: typing.Final = """from redis.asyncio import RedisCluster
125cluster_client = RedisCluster(startup_nodes=[])
126"""
127_ASYNC_REDIS_SENTINEL_SOURCE: typing.Final = """from redis.asyncio.sentinel import Sentinel
130sentinel_client = Sentinel([("sentinel-one", 26379)])
131"""
132_REDIS_IMPORT_ONLY_SOURCE: typing.Final = """import redis
135CACHE_HOST = "cache.internal"
136"""
137_ASYNC_DATABASE_SOURCE: typing.Final = """from sqlalchemy.ext.asyncio import create_async_engine
140async_engine = create_async_engine(
141 "postgresql+asyncpg://user:password@pg-primary:5432/orders?target_session_attrs=read-write",
142)
143"""
144_REPLICA_DATABASE_SOURCE: typing.Final = """from sqlalchemy import create_engine
147replica_engine = create_engine(
148 'postgresql+psycopg://user:password@pg-replica-one:5432,pg-replica-two:5432/orders',
149 pool_size=10,
150)
151"""
152_DOUBLE_QUOTED_REPLICA_SOURCE: typing.Final = """from sqlalchemy import create_engine
155replica_engine = create_engine(
156 "postgresql+psycopg://user:password@pg-replica-one:5432,pg-replica-two:5432/orders",
157 pool_size=10,
158)
159"""
160_LINES_BETWEEN_ENGINE_AND_POOL: typing.Final = 40
161_POOL_FAR_FROM_THE_ENGINE_SOURCE: typing.Final = (
162 "from sqlalchemy import create_engine\n\n\n"
163 "replica_engine = create_engine('postgresql+psycopg://pg-replica-one:5432,pg-replica-two:5432/orders')\n"
164 + "# padding that keeps the unrelated pool far away from the engine call\n" * _LINES_BETWEEN_ENGINE_AND_POOL
165 + "adapter_settings = {'pool_maxsize': 10}\n"
166)
167_SESSION_ATTRS_BESIDE_THE_DSN_SOURCE: typing.Final = """from sqlalchemy import create_engine
170primary_engine = create_engine(
171 "postgresql+psycopg://user:password@pg-primary:5432/orders",
172 connect_args={"target_session_attrs": "read-write"},
173)
174"""
175_SESSION_ATTRS_IN_A_SETTING_SOURCE: typing.Final = """from sqlalchemy import create_engine
178TARGET_SESSION_ATTRS = "standby"
180replica_engine = create_engine("postgresql+psycopg://pg-replica:5432/orders")
181"""
182_SESSION_ATTRS_IN_LIBPQ_OPTIONS_SOURCE: typing.Final = """from sqlalchemy import create_engine
185LIBPQ_OPTIONS = "host=pg-one,pg-two target_session_attrs=read-write"
187primary_engine = create_engine(
188 "postgresql+psycopg://pg-one:5432/orders",
189 connect_args={"options": LIBPQ_OPTIONS},
190)
191"""
192_DATABASE_URL_BEHIND_A_CONSTANT_SOURCE: typing.Final = """from sqlalchemy import create_engine
194from src.config import DATABASE_URL
197engine = create_engine(DATABASE_URL)
198"""
199_POOLED_ASYNC_ENGINE_SOURCE: typing.Final = """from sqlalchemy.ext.asyncio import create_async_engine
202async_engine = create_async_engine(
203 "postgresql+asyncpg://pg-one:5432,pg-two:5432/orders",
204 pool_size=10,
205 target_session_attrs="prefer-standby",
206)
207"""
208_ENGINE_FROM_A_FACTORY_TABLE_SOURCE: typing.Final = """from sqlalchemy import create_engine
211ENGINE_FACTORIES = {"primary": create_engine}
212primary_engine = ENGINE_FACTORIES["primary"]("postgresql+psycopg://pg-one:5432,pg-two:5432/orders", pool_size=10)
213"""
214_UNPARSEABLE_DATABASE_SOURCE: typing.Final = """from sqlalchemy import create_engine
217primary_engine = create_engine("postgresql+psycopg://pg-primary:5432/orders"
218"""
219_CONSUMER_SOURCE: typing.Final = """from faststream import FastStream
220from faststream.rabbit import RabbitBroker
223rabbit_broker = RabbitBroker("amqp://user:password@localhost:5672/")
224faststream_app = FastStream(rabbit_broker)
227@rabbit_broker.subscriber("commands")
228async def handle_command(command: dict) -> None: ...
229"""
230_PUBLISHER_DECORATOR_SOURCE: typing.Final = """from faststream.rabbit import RabbitBroker
233rabbit_broker = RabbitBroker("amqp://localhost:5672/")
236@rabbit_broker.publisher("events")
237async def publish_event(event: dict) -> dict:
238 return event
239"""
240_PUBLISH_CALL_SOURCE: typing.Final = """from faststream.rabbit import RabbitBroker
243rabbit_broker = RabbitBroker("amqp://localhost:5672/")
246async def publish_event(event: dict) -> None:
247 await rabbit_broker.publish(event, queue="events")
248"""
249_TOPIC_BEHIND_A_CONSTANT_SOURCE: typing.Final = """from faststream.rabbit import RabbitBroker
252COMMANDS_QUEUE = "commands"
253rabbit_broker = RabbitBroker("amqp://localhost:5672/")
256@rabbit_broker.subscriber(COMMANDS_QUEUE)
257async def handle_command(command: dict) -> None: ...
258"""
259_TWO_TOPIC_FLOW_SOURCE: typing.Final = """from faststream.kafka import KafkaBroker
262kafka_broker = KafkaBroker("localhost:9092")
265@kafka_broker.subscriber("orders")
266async def handle_order(order: dict) -> None: ...
269@kafka_broker.subscriber("payments")
270async def handle_payment(payment: dict) -> None: ...
273@kafka_broker.publisher("orders-done")
274async def publish_order_done(order: dict) -> dict:
275 return order
278@kafka_broker.publisher("payments-done")
279async def publish_payment_done(payment: dict) -> dict:
280 return payment
281"""
282_PIPED_TOPIC_SOURCE: typing.Final = """from faststream.nats import NatsBroker
285nats_broker = NatsBroker("nats://localhost:4222")
288@nats_broker.subscriber("orders|paid")
289async def handle_paid_order(order: dict) -> None: ...
290"""
291_BROKER_WITHOUT_A_FLOW_SOURCE: typing.Final = """from faststream.kafka import KafkaBroker
294kafka_broker = KafkaBroker("localhost:9092")
295"""
296_GRANIAN_SOURCE: typing.Final = """import granian
297from granian.constants import HTTPModes, Interfaces
300granian.Granian(
301 "src.main:app",
302 port=8000,
303 workers=4,
304 interface=Interfaces.ASGI,
305 http=HTTPModes.http2,
306 ssl_cert="/etc/tls/tls.crt",
307).serve()
308"""
309_GUNICORN_CONFIG_SOURCE: typing.Final = """bind = "0.0.0.0:8000"
310workers = 1
311worker_class = "uvicorn_worker.UvicornWorker"
312"""
313_CARRIER_LIBRARIES_SOURCE: typing.Final = """import gevent
314import tornado.httpclient
315from werkzeug.datastructures import Headers
317import fastapi
320app = fastapi.FastAPI()
321"""
322_MENTIONS_WITHOUT_IMPORTS_SOURCE: typing.Final = '''"""Runbook of the service.
324Work is handed over to celery, taskiq, arq, rq, dramatiq and huey workers, and the payments
325API is called over httpx, aiohttp, requests and niquests.
326"""
328RUNBOOK_URL = "https://wiki.example.com/runbook"
329'''
330_THREAD_POOL_SOURCE: typing.Final = """from concurrent.futures import ThreadPoolExecutor
332import uvicorn
335executor = ThreadPoolExecutor(max_workers=10)
336transport = 8080
337uvicorn.run("src.main:app")
338"""
339_ASYNC_DATABASE_EDGE: typing.Final = (
340 'app_svc --> |"async, postgresql+asyncpg://***@pg-primary:5432/orders'
341 '?target_session_attrs=read-write"| postgresql_asyncpgdb'
342)
343_REPLICA_DATABASE_EDGE: typing.Final = (
344 'app_svc --> |"postgresql+psycopg://***@pg-replica-one:5432,pg-replica-two:5432/orders"| postgresql_psycopgdb0'
345)
346_PLAIN_REDIS_NODE_MARK: typing.Final = "redisdb["
347_CREDENTIALS_MARK: typing.Final = "user:password"
348_ALL_FEATURE_CASES: typing.Final = types.MappingProxyType(
349 {
350 "every served method": (
351 _EVERY_METHOD_SOURCE,
352 ('external_client --> |"REST (delete, get, head, options, patch, post, put, trace)"| app_svc',),
353 (),
354 ),
355 "litestar route": (
356 _LITESTAR_ROUTE_SOURCE,
357 ('external_client --> |"REST (post)"| app_svc',),
358 (),
359 ),
360 "route without a framework": (_ROUTE_WITHOUT_FRAMEWORK_SOURCE, (), ("REST",)),
361 "route behind an f-string prefix": (
362 _PREFIXED_ROUTE_SOURCE,
363 ('external_client --> |"REST (get)"| app_svc',),
364 (),
365 ),
366 "mocked test file is not an api": (_MOCKED_TEST_SOURCE, (), ("REST",)),
367 "async http client": (
368 _ASYNC_CLIENT_SOURCE,
369 ('app_svc --> |"HTTP (async, httpx)"| External_API', 'External_API["External API"]'),
370 (),
371 ),
372 "sync http clients": (
373 _SYNC_CLIENTS_SOURCE,
374 ('app_svc --> |"HTTP (niquests, requests)"| External_API',),
375 ("async",),
376 ),
377 "task queue with a worker": (
378 _CELERY_SOURCE,
379 ('app_svc --> |"Tasks (celery, workers, redis)"| TaskQueue_Worker', 'TaskQueue_Worker["Task workers"]'),
380 (),
381 ),
382 "every task queue": (
383 _EVERY_QUEUE_SOURCE,
384 ('app_svc --> |"Tasks (arq, celery, dramatiq, huey, rq, taskiq)"| TaskQueue_Worker',),
385 (),
386 ),
387 "queue behind two brokers": (_TWO_BROKER_QUEUE_SOURCE, ('"Tasks (celery, rabbitmq, redis)"',), ()),
388 "rabbitmq backed queue": (_RABBITMQ_BROKER_SOURCE, ('"Tasks (dramatiq, rabbitmq)"',), ()),
389 "postgresql backed queue": (_POSTGRESQL_BROKER_SOURCE, ('"Tasks (huey, postgresql)"',), ('db["',)),
390 "redis cache": (
391 _REDIS_CACHE_SOURCE,
392 ('app_svc --> |"async, retry"| redisdb', 'redisdb["redis"]'),
393 (),
394 ),
395 "redis cluster": (
396 _REDIS_CLUSTER_SOURCE,
397 ('redisdb_cluster0["redis cluster #0"]', 'redisdb_cluster2["redis cluster #2"]', "app_svc --> redisdb"),
398 (_PLAIN_REDIS_NODE_MARK,),
399 ),
400 "redis sentinel": (
401 _REDIS_SENTINEL_SOURCE,
402 ('redisdb_sentinel0["redis sentinel #0"]', 'redisdb_sentinel2["redis sentinel #2"]'),
403 (_PLAIN_REDIS_NODE_MARK,),
404 ),
405 "redis cluster over asyncio": (
406 _ASYNC_REDIS_CLUSTER_SOURCE,
407 ('redisdb_cluster0["redis cluster #0"]', '|"async"|'),
408 (_PLAIN_REDIS_NODE_MARK,),
409 ),
410 "redis sentinel over asyncio": (
411 _ASYNC_REDIS_SENTINEL_SOURCE,
412 ('redisdb_sentinel0["redis sentinel #0"]', '|"async"|'),
413 (_PLAIN_REDIS_NODE_MARK,),
414 ),
415 "redis import alone": (_REDIS_IMPORT_ONLY_SOURCE, (), ("redisdb",)),
416 # The dsn is drawn whole, so the attribute inside it is already on the arrow: repeating it
417 # beside the dsn is what the forbidden part guards against.
418 "async database": (
419 _ASYNC_DATABASE_SOURCE,
420 ('postgresql_asyncpgdb["postgresql+asyncpg"]', _ASYNC_DATABASE_EDGE),
421 (_CREDENTIALS_MARK, "read-write, read-write"),
422 ),
423 "session attrs beside the dsn": (
424 _SESSION_ATTRS_BESIDE_THE_DSN_SOURCE,
425 ('app_svc --> |"postgresql+psycopg://***@pg-primary:5432/orders, read-write"| postgresql_psycopgdb',),
426 (_CREDENTIALS_MARK,),
427 ),
428 # The libpq keyword/value spelling quotes the whole string, not the value inside it.
429 "session attrs in libpq options": (
430 _SESSION_ATTRS_IN_LIBPQ_OPTIONS_SOURCE,
431 ('app_svc --> |"postgresql+psycopg://pg-one:5432/orders, read-write"| postgresql_psycopgdb',),
432 (),
433 ),
434 "session attrs in a setting": (
435 _SESSION_ATTRS_IN_A_SETTING_SOURCE,
436 ('app_svc --> |"postgresql+psycopg://pg-replica:5432/orders, standby"| postgresql_psycopgdb',),
437 (),
438 ),
439 "replicated database": (
440 _REPLICA_DATABASE_SOURCE,
441 (
442 'postgresql_psycopgdb0["postgresql+psycopg #0"]',
443 'postgresql_psycopgdb2["postgresql+psycopg #2"]',
444 _REPLICA_DATABASE_EDGE,
445 ),
446 (_CREDENTIALS_MARK,),
447 ),
448 "replicated database in double quotes": (
449 _DOUBLE_QUOTED_REPLICA_SOURCE,
450 ('postgresql_psycopgdb0["postgresql+psycopg #0"]', 'postgresql_psycopgdb2["postgresql+psycopg #2"]'),
451 (_CREDENTIALS_MARK,),
452 ),
453 "pool far from the engine call": (
454 _POOL_FAR_FROM_THE_ENGINE_SOURCE,
455 ('postgresql_psycopgdb0["postgresql+psycopg #0"]',),
456 ("postgresql_psycopgdb1",),
457 ),
458 "database url behind a constant": (_DATABASE_URL_BEHIND_A_CONSTANT_SOURCE, (), ('db["',)),
459 "pooled async engine": (
460 _POOLED_ASYNC_ENGINE_SOURCE,
461 (
462 (
463 'app_svc --> |"async, postgresql+asyncpg://pg-one:5432,pg-two:5432/orders, prefer-standby"| '
464 "postgresql_asyncpgdb2"
465 ),
466 ),
467 (),
468 ),
469 # An engine called through anything but its name is not recognised as one: the dsn is still
470 # drawn, the pool and the replicas behind it are not.
471 "engine from a factory table": (
472 _ENGINE_FROM_A_FACTORY_TABLE_SOURCE,
473 ('postgresql_psycopgdb["postgresql+psycopg"]',),
474 ("postgresql_psycopgdb0",),
475 ),
476 "unparseable database source": (_UNPARSEABLE_DATABASE_SOURCE, (), ('db["',)),
477 "consumed messages": (
478 _CONSUMER_SOURCE,
479 ('rabbit --> |"commands"| app_svc', 'rabbit["rabbit"]'),
480 ("kafka", "nats", "user:password"),
481 ),
482 "messages published by a decorator": (
483 _PUBLISHER_DECORATOR_SOURCE,
484 ('app_svc --> |"events"| rabbit',),
485 ("rabbit -->",),
486 ),
487 "messages published by a call": (
488 _PUBLISH_CALL_SOURCE,
489 ('app_svc --> |"events"| rabbit',),
490 ("rabbit -->",),
491 ),
492 "every topic of one broker": (
493 _TWO_TOPIC_FLOW_SOURCE,
494 ('kafka --> |"orders, payments"| app_svc', 'app_svc --> |"orders-done, payments-done"| kafka'),
495 (),
496 ),
497 # The pipe is the edge label's own delimiter: a topic carrying one may not reach the page.
498 "topic spelled with a pipe": (_PIPED_TOPIC_SOURCE, ('nats --> |"orderspaid"| app_svc',), ("orders|paid",)),
499 "topic behind a constant": (_TOPIC_BEHIND_A_CONSTANT_SOURCE, ("rabbit --> app_svc",), ('|"',)),
500 "broker without a flow": (_BROKER_WITHOUT_A_FLOW_SOURCE, (), ("kafka",)),
501 "application server properties": (
502 _GRANIAN_SOURCE,
503 ('external_client --> |"Served by granian, 4 workers, port 8000, TLS, HTTP/2"| app_svc',),
504 (),
505 ),
506 "worker class naming both servers": (
507 _GUNICORN_CONFIG_SOURCE,
508 ('external_client --> |"Served by gunicorn, uvicorn, single worker, port 8000"| app_svc',),
509 (),
510 ),
511 "carrier library is not a server": (_CARRIER_LIBRARIES_SOURCE, (), ("Served by",)),
512 # A name in prose is what the prefilter matches on, and only the import decides.
513 "technology named but never imported": (_MENTIONS_WITHOUT_IMPORTS_SOURCE, (), (EDGE_ARROW,)),
514 "thread pool is not a server worker": (
515 _THREAD_POOL_SOURCE,
516 ('external_client --> |"Served by uvicorn"| app_svc',),
517 ("workers", "port 8080"),
518 ),
519 },
520)
521_ALL_SERVER_SOURCES: typing.Final = types.MappingProxyType(
522 {
523 "granian": "import granian\n\ngranian.Granian('src.main:app').serve()\n",
524 "uvicorn": "import uvicorn\n\nuvicorn.run('src.main:app')\n",
525 "gunicorn": "from gunicorn.app.base import BaseApplication\n",
526 "hypercorn": "from hypercorn.asyncio import serve\n",
527 "daphne": "from daphne.server import Server\n",
528 "waitress": "from waitress import serve\n",
529 "uwsgi": "import uwsgi\n",
530 "mod_wsgi": "from mod_wsgi import server\n",
531 "bjoern": "import bjoern\n",
532 "meinheld": "from meinheld import server\n",
533 "cheroot": "from cheroot.wsgi import Server\n",
534 "tornado": "import tornado.httpserver\n",
535 "gevent": "from gevent.pywsgi import WSGIServer\n",
536 "eventlet": "import eventlet.wsgi\n",
537 "werkzeug": "from werkzeug.serving import run_simple\n",
538 "wsgiref": "from wsgiref.simple_server import make_server\n",
539 },
540)
541_REQUIRED_SHOWCASE_MARKS: typing.Final = (
542 "REST",
543 "httpx",
544 "aiohttp",
545 "requests",
546 "niquests",
547 "celery",
548 "taskiq",
549 "arq",
550 "rq",
551 "dramatiq",
552 "huey",
553 "rabbit",
554 "kafka",
555 "nats",
556 "redis",
557 "retry",
558 "sentinel",
559 "cluster",
560 "postgresql",
561 "sqlite",
562 "replicas",
563 "HPA",
564 "cpu",
565 "RAM",
566 "GPU",
567 "ConfigMap_app_config",
568 "Secret_app_secrets",
569 "PersistentVolume",
570 "granian",
571 "gunicorn",
572 "uvicorn",
573)
574_ALL_FEATURE_LITERALS: typing.Final = (
575 *_ALL_SERVER_SOURCES,
576 "fastapi",
577 "litestar",
578 "faststream",
579 "redis",
580 "sqlalchemy",
581 "create_engine",
582 "target_session_attrs",
583 "postgresql",
584 "mysql",
585 "sqlite",
586 "oracle",
587 "mssql",
588 "mariadb",
589 "cockroachdb",
590 "httpx",
591 "aiohttp",
592 "requests",
593 "niquests",
594 "celery",
595 "taskiq",
596 "arq",
597 "rq",
598 "dramatiq",
599 "huey",
600 "run_simple",
601 "worker_class",
602 "--worker-class",
603)
604_HYPOTHESIS_EXAMPLES: typing.Final = 30
605_FIRST_PRINTABLE_CODE: typing.Final = 32
606_LAST_PRINTABLE_CODE: typing.Final = 126
607_LONGEST_RANDOM_SOURCE: typing.Final = 200
610def _has_no_feature_literal(source_code: str, /) -> bool:
611 return not any(one_literal in source_code.lower() for one_literal in _ALL_FEATURE_LITERALS)
614_UNRELATED_SOURCE_STRATEGY: typing.Final = st.text(
615 alphabet=st.characters(min_codepoint=_FIRST_PRINTABLE_CODE, max_codepoint=_LAST_PRINTABLE_CODE),
616 max_size=_LONGEST_RANDOM_SOURCE,
617).filter(_has_no_feature_literal)
620@pytest.mark.parametrize(
621 ("source_code", "expected_parts", "forbidden_parts"),
622 _ALL_FEATURE_CASES.values(),
623 ids=_ALL_FEATURE_CASES,
624)
625def test_source_reaches_the_diagram(
626 tmp_path: pathlib.Path,
627 source_code: str,
628 expected_parts: tuple[str, ...],
629 forbidden_parts: tuple[str, ...],
630) -> None:
631 rendered_diagram: typing.Final = diagram_rendering.render_source_diagram(tmp_path, source_code)
633 for one_expected_part in expected_parts:
634 assert one_expected_part in rendered_diagram, one_expected_part
635 for one_forbidden_part in forbidden_parts:
636 assert one_forbidden_part not in rendered_diagram, one_forbidden_part
639@pytest.mark.parametrize("server_name", _ALL_SERVER_SOURCES)
640def test_every_server_reaches_the_diagram(tmp_path: pathlib.Path, server_name: str) -> None:
641 rendered_diagram: typing.Final = diagram_rendering.render_source_diagram(tmp_path, _ALL_SERVER_SOURCES[server_name])
643 assert f'external_client --> |"Served by {server_name}"| app_svc' in rendered_diagram
646@pytest.mark.parametrize("feature_mark", _REQUIRED_SHOWCASE_MARKS)
647def test_showcase_shows_every_supported_feature(feature_mark: str) -> None:
648 assert feature_mark in diagram_rendering.render_example_diagram(diagram_rendering.SHOWCASE_SETTINGS)
651@hypothesis.settings(
652 deadline=None,
653 max_examples=_HYPOTHESIS_EXAMPLES,
654 suppress_health_check=[hypothesis.HealthCheck.function_scoped_fixture],
655)
656@hypothesis.given(source_code=_UNRELATED_SOURCE_STRATEGY)
657def test_unrelated_source_draws_the_service_alone(tmp_path: pathlib.Path, source_code: str) -> None:
658 assert EDGE_ARROW not in diagram_rendering.render_source_diagram(tmp_path, source_code)