Coverage for tests/test_detected_features.py: 100%
60 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-03 00:00 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-03 00:00 +0000
1import pathlib
2import types
3import typing
5import hypothesis
6import pytest
7from hypothesis import strategies as st
9from tests.diagram_parts import EDGE_ARROW
10from tests.rendered_diagram import SHOWCASE_SETTINGS, render_example_diagram, render_source_diagram
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_ASYNC_CLIENT_SOURCE: typing.Final = """import httpx
45payments_client = httpx.AsyncClient(base_url="https://payments.example.com")
46"""
47_SYNC_CLIENTS_SOURCE: typing.Final = """import niquests
48import requests
51def fetch_payment_status(payment_id: int) -> dict:
52 requests.get(f"https://payments.example.com/{payment_id}", timeout=10)
53 return niquests.get(f"https://payments.example.com/{payment_id}", timeout=10).json()
54"""
55_CELERY_SOURCE: typing.Final = '''import celery
57"""In the cluster the tasks are executed by `celery worker`."""
59celery_app = celery.Celery(broker="redis://localhost:6379/0")
62@celery_app.task
63def send_receipt(order_id: int) -> None: ...
64'''
65_EVERY_QUEUE_SOURCE: typing.Final = """import arq
66import celery
67import dramatiq
68import huey
69import rq
70import taskiq
71"""
72_RABBITMQ_BROKER_SOURCE: typing.Final = """import dramatiq
75dramatiq_broker = dramatiq.RabbitMQBroker(url="amqp://localhost:5672/")
76"""
77_POSTGRESQL_BROKER_SOURCE: typing.Final = """import huey
80huey_broker = PostgreSQLBroker("postgres://localhost:5432/tasks")
81"""
82_REDIS_CACHE_SOURCE: typing.Final = """import redis
83import redis.asyncio
86RETRY_POLICY = redis.Retry(ExponentialBackoff(), 3)
87cache_client = redis.asyncio.Redis(host="cache.internal", retry=RETRY_POLICY)
88"""
89_REDIS_CLUSTER_SOURCE: typing.Final = """from redis.cluster import RedisCluster
92cluster_client = RedisCluster(startup_nodes=[])
93"""
94_REDIS_SENTINEL_SOURCE: typing.Final = """from redis.sentinel import Sentinel
97sentinel_client = Sentinel([("sentinel-one", 26379)])
98"""
99_REDIS_IMPORT_ONLY_SOURCE: typing.Final = """import redis
102CACHE_HOST = "cache.internal"
103"""
104_ASYNC_DATABASE_SOURCE: typing.Final = """from sqlalchemy.ext.asyncio import create_async_engine
107async_engine = create_async_engine(
108 "postgresql+asyncpg://user:password@pg-primary:5432/orders?target_session_attrs=read-write",
109)
110"""
111_REPLICA_DATABASE_SOURCE: typing.Final = """from sqlalchemy import create_engine
114replica_engine = create_engine(
115 'postgresql+psycopg://user:password@pg-replica-one:5432,pg-replica-two:5432/orders',
116 pool_size=10,
117)
118"""
119_DATABASE_URL_BEHIND_A_CONSTANT_SOURCE: typing.Final = """from sqlalchemy import create_engine
121from src.config import DATABASE_URL
124engine = create_engine(DATABASE_URL)
125"""
126_CONSUMER_SOURCE: typing.Final = """from faststream import FastStream
127from faststream.rabbit import RabbitBroker
130rabbit_broker = RabbitBroker("amqp://user:password@localhost:5672/")
131faststream_app = FastStream(rabbit_broker)
134@rabbit_broker.subscriber("commands")
135async def handle_command(command: dict) -> None: ...
136"""
137_PUBLISHER_DECORATOR_SOURCE: typing.Final = """from faststream.rabbit import RabbitBroker
140rabbit_broker = RabbitBroker("amqp://localhost:5672/")
143@rabbit_broker.publisher("events")
144async def publish_event(event: dict) -> dict:
145 return event
146"""
147_PUBLISH_CALL_SOURCE: typing.Final = """from faststream.rabbit import RabbitBroker
150rabbit_broker = RabbitBroker("amqp://localhost:5672/")
153async def publish_event(event: dict) -> None:
154 await rabbit_broker.publish(event, queue="events")
155"""
156_TOPIC_BEHIND_A_CONSTANT_SOURCE: typing.Final = """from faststream.rabbit import RabbitBroker
159COMMANDS_QUEUE = "commands"
160rabbit_broker = RabbitBroker("amqp://localhost:5672/")
163@rabbit_broker.subscriber(COMMANDS_QUEUE)
164async def handle_command(command: dict) -> None: ...
165"""
166_BROKER_WITHOUT_A_FLOW_SOURCE: typing.Final = """from faststream.kafka import KafkaBroker
169kafka_broker = KafkaBroker("localhost:9092")
170"""
171_GRANIAN_SOURCE: typing.Final = """import granian
172from granian.constants import HTTPModes, Interfaces
175granian.Granian(
176 "src.main:app",
177 port=8000,
178 workers=4,
179 interface=Interfaces.ASGI,
180 http=HTTPModes.http2,
181 ssl_cert="/etc/tls/tls.crt",
182).serve()
183"""
184_GUNICORN_CONFIG_SOURCE: typing.Final = """bind = "0.0.0.0:8000"
185workers = 1
186worker_class = "uvicorn_worker.UvicornWorker"
187"""
188_CARRIER_LIBRARIES_SOURCE: typing.Final = """import gevent
189import tornado.httpclient
190from werkzeug.datastructures import Headers
192import fastapi
195app = fastapi.FastAPI()
196"""
197_ASYNC_DATABASE_EDGE: typing.Final = (
198 'app_svc --> |"async, postgresql+asyncpg://***@pg-primary:5432/orders'
199 '?target_session_attrs=read-write"| postgresql_asyncpgdb'
200)
201_REPLICA_DATABASE_EDGE: typing.Final = (
202 'app_svc --> |"postgresql+psycopg://***@pg-replica-one:5432,pg-replica-two:5432/orders"| postgresql_psycopgdb0'
203)
204_ALL_FEATURE_CASES: typing.Final = types.MappingProxyType(
205 {
206 "every served method": (
207 _EVERY_METHOD_SOURCE,
208 ('external_client --> |"REST (delete, get, head, options, patch, post, put, trace)"| app_svc',),
209 (),
210 ),
211 "litestar route": (
212 _LITESTAR_ROUTE_SOURCE,
213 ('external_client --> |"REST (post)"| app_svc',),
214 (),
215 ),
216 "route without a framework": (_ROUTE_WITHOUT_FRAMEWORK_SOURCE, (), ("REST",)),
217 "async http client": (
218 _ASYNC_CLIENT_SOURCE,
219 ('app_svc --> |"HTTP (async, httpx)"| External_API', 'External_API["External API"]'),
220 (),
221 ),
222 "sync http clients": (
223 _SYNC_CLIENTS_SOURCE,
224 ('app_svc --> |"HTTP (niquests, requests)"| External_API',),
225 ("async",),
226 ),
227 "task queue with a worker": (
228 _CELERY_SOURCE,
229 ('app_svc --> |"Tasks (celery, workers, redis)"| TaskQueue_Worker', 'TaskQueue_Worker["Task workers"]'),
230 (),
231 ),
232 "every task queue": (
233 _EVERY_QUEUE_SOURCE,
234 ('app_svc --> |"Tasks (arq, celery, dramatiq, huey, rq, taskiq)"| TaskQueue_Worker',),
235 (),
236 ),
237 "rabbitmq backed queue": (_RABBITMQ_BROKER_SOURCE, ('"Tasks (dramatiq, rabbitmq)"',), ()),
238 "postgresql backed queue": (_POSTGRESQL_BROKER_SOURCE, ('"Tasks (huey, postgresql)"',), ('db["',)),
239 "redis cache": (
240 _REDIS_CACHE_SOURCE,
241 ('app_svc --> |"async, retry"| redisdb', 'redisdb["redis"]'),
242 (),
243 ),
244 "redis cluster": (
245 _REDIS_CLUSTER_SOURCE,
246 ('redisdb_cluster0["redis cluster #0"]', 'redisdb_cluster2["redis cluster #2"]', "app_svc --> redisdb"),
247 ("redisdb[",),
248 ),
249 "redis sentinel": (
250 _REDIS_SENTINEL_SOURCE,
251 ('redisdb_sentinel0["redis sentinel #0"]', 'redisdb_sentinel2["redis sentinel #2"]'),
252 ("redisdb[",),
253 ),
254 "redis import alone": (_REDIS_IMPORT_ONLY_SOURCE, (), ("redisdb",)),
255 "async database": (
256 _ASYNC_DATABASE_SOURCE,
257 ('postgresql_asyncpgdb["postgresql+asyncpg"]', _ASYNC_DATABASE_EDGE),
258 ("user:password",),
259 ),
260 "replicated database": (
261 _REPLICA_DATABASE_SOURCE,
262 (
263 'postgresql_psycopgdb0["postgresql+psycopg #0"]',
264 'postgresql_psycopgdb2["postgresql+psycopg #2"]',
265 _REPLICA_DATABASE_EDGE,
266 ),
267 ("user:password",),
268 ),
269 "database url behind a constant": (_DATABASE_URL_BEHIND_A_CONSTANT_SOURCE, (), ('db["',)),
270 "consumed messages": (
271 _CONSUMER_SOURCE,
272 ('rabbit --> |"commands"| app_svc', 'rabbit["rabbit"]'),
273 ("kafka", "nats", "user:password"),
274 ),
275 "messages published by a decorator": (
276 _PUBLISHER_DECORATOR_SOURCE,
277 ('app_svc --> |"events"| rabbit',),
278 ("rabbit -->",),
279 ),
280 "messages published by a call": (
281 _PUBLISH_CALL_SOURCE,
282 ('app_svc --> |"events"| rabbit',),
283 ("rabbit -->",),
284 ),
285 "topic behind a constant": (_TOPIC_BEHIND_A_CONSTANT_SOURCE, ("rabbit --> app_svc",), ('|"',)),
286 "broker without a flow": (_BROKER_WITHOUT_A_FLOW_SOURCE, (), ("kafka",)),
287 "application server properties": (
288 _GRANIAN_SOURCE,
289 ('external_client --> |"Served by granian, 4 workers, port 8000, TLS, HTTP/2"| app_svc',),
290 (),
291 ),
292 "worker class naming both servers": (
293 _GUNICORN_CONFIG_SOURCE,
294 ('external_client --> |"Served by gunicorn, uvicorn, single worker, port 8000"| app_svc',),
295 (),
296 ),
297 "carrier library is not a server": (_CARRIER_LIBRARIES_SOURCE, (), ("Served by",)),
298 },
299)
300_ALL_SERVER_SOURCES: typing.Final = types.MappingProxyType(
301 {
302 "granian": "import granian\n\ngranian.Granian('src.main:app').serve()\n",
303 "uvicorn": "import uvicorn\n\nuvicorn.run('src.main:app')\n",
304 "gunicorn": "from gunicorn.app.base import BaseApplication\n",
305 "hypercorn": "from hypercorn.asyncio import serve\n",
306 "daphne": "from daphne.server import Server\n",
307 "waitress": "from waitress import serve\n",
308 "uwsgi": "import uwsgi\n",
309 "mod_wsgi": "from mod_wsgi import server\n",
310 "bjoern": "import bjoern\n",
311 "meinheld": "from meinheld import server\n",
312 "cheroot": "from cheroot.wsgi import Server\n",
313 "tornado": "import tornado.httpserver\n",
314 "gevent": "from gevent.pywsgi import WSGIServer\n",
315 "eventlet": "import eventlet.wsgi\n",
316 "werkzeug": "from werkzeug.serving import run_simple\n",
317 "wsgiref": "from wsgiref.simple_server import make_server\n",
318 },
319)
320_REQUIRED_SHOWCASE_MARKS: typing.Final = (
321 "REST",
322 "httpx",
323 "aiohttp",
324 "requests",
325 "niquests",
326 "celery",
327 "taskiq",
328 "arq",
329 "rq",
330 "dramatiq",
331 "huey",
332 "rabbit",
333 "kafka",
334 "nats",
335 "redis",
336 "retry",
337 "sentinel",
338 "cluster",
339 "postgresql",
340 "sqlite",
341 "replicas",
342 "HPA",
343 "cpu",
344 "RAM",
345 "GPU",
346 "ConfigMap_app_config",
347 "Secret_app_secrets",
348 "PersistentVolume",
349 "granian",
350 "gunicorn",
351 "uvicorn",
352)
353_ALL_FEATURE_LITERALS: typing.Final = (
354 *_ALL_SERVER_SOURCES,
355 "fastapi",
356 "litestar",
357 "faststream",
358 "redis",
359 "sqlalchemy",
360 "create_engine",
361 "target_session_attrs",
362 "postgresql",
363 "mysql",
364 "sqlite",
365 "oracle",
366 "mssql",
367 "mariadb",
368 "cockroachdb",
369 "httpx",
370 "aiohttp",
371 "requests",
372 "niquests",
373 "celery",
374 "taskiq",
375 "arq",
376 "rq",
377 "dramatiq",
378 "huey",
379 "run_simple",
380 "worker_class",
381 "--worker-class",
382)
383_HYPOTHESIS_EXAMPLES: typing.Final = 30
384_FIRST_PRINTABLE_CODE: typing.Final = 32
385_LAST_PRINTABLE_CODE: typing.Final = 126
386_LONGEST_RANDOM_SOURCE: typing.Final = 200
389def _has_no_feature_literal(source_code: str, /) -> bool:
390 return not any(one_literal in source_code.lower() for one_literal in _ALL_FEATURE_LITERALS)
393_UNRELATED_SOURCE_STRATEGY: typing.Final = st.text(
394 alphabet=st.characters(min_codepoint=_FIRST_PRINTABLE_CODE, max_codepoint=_LAST_PRINTABLE_CODE),
395 max_size=_LONGEST_RANDOM_SOURCE,
396).filter(_has_no_feature_literal)
399@pytest.mark.parametrize(
400 ("source_code", "expected_parts", "forbidden_parts"),
401 _ALL_FEATURE_CASES.values(),
402 ids=_ALL_FEATURE_CASES,
403)
404def test_source_reaches_the_diagram(
405 tmp_path: pathlib.Path,
406 source_code: str,
407 expected_parts: tuple[str, ...],
408 forbidden_parts: tuple[str, ...],
409) -> None:
410 rendered_diagram: typing.Final = render_source_diagram(tmp_path, source_code)
412 for one_expected_part in expected_parts:
413 assert one_expected_part in rendered_diagram, one_expected_part
414 for one_forbidden_part in forbidden_parts:
415 assert one_forbidden_part not in rendered_diagram, one_forbidden_part
418@pytest.mark.parametrize("server_name", _ALL_SERVER_SOURCES)
419def test_every_server_reaches_the_diagram(tmp_path: pathlib.Path, server_name: str) -> None:
420 rendered_diagram: typing.Final = render_source_diagram(tmp_path, _ALL_SERVER_SOURCES[server_name])
422 assert f'external_client --> |"Served by {server_name}"| app_svc' in rendered_diagram
425@pytest.mark.parametrize("feature_mark", _REQUIRED_SHOWCASE_MARKS)
426def test_showcase_shows_every_supported_feature(feature_mark: str) -> None:
427 assert feature_mark in render_example_diagram(SHOWCASE_SETTINGS)
430@hypothesis.settings(
431 deadline=None,
432 max_examples=_HYPOTHESIS_EXAMPLES,
433 suppress_health_check=[hypothesis.HealthCheck.function_scoped_fixture],
434)
435@hypothesis.given(source_code=_UNRELATED_SOURCE_STRATEGY)
436def test_unrelated_source_draws_the_service_alone(tmp_path: pathlib.Path, source_code: str) -> None:
437 assert EDGE_ARROW not in render_source_diagram(tmp_path, source_code)