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

1import pathlib 

2import types 

3import typing 

4 

5import hypothesis 

6import pytest 

7from hypothesis import strategies as st 

8 

9from tests.diagram_parts import EDGE_ARROW 

10from tests.rendered_diagram import SHOWCASE_SETTINGS, render_example_diagram, render_source_diagram 

11 

12 

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 

16 

17router = fastapi.APIRouter() 

18 

19 

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 

32 

33 

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 

43 

44 

45payments_client = httpx.AsyncClient(base_url="https://payments.example.com") 

46""" 

47_SYNC_CLIENTS_SOURCE: typing.Final = """import niquests 

48import requests 

49 

50 

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 

56 

57"""In the cluster the tasks are executed by `celery worker`.""" 

58 

59celery_app = celery.Celery(broker="redis://localhost:6379/0") 

60 

61 

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 

73 

74 

75dramatiq_broker = dramatiq.RabbitMQBroker(url="amqp://localhost:5672/") 

76""" 

77_POSTGRESQL_BROKER_SOURCE: typing.Final = """import huey 

78 

79 

80huey_broker = PostgreSQLBroker("postgres://localhost:5432/tasks") 

81""" 

82_REDIS_CACHE_SOURCE: typing.Final = """import redis 

83import redis.asyncio 

84 

85 

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 

90 

91 

92cluster_client = RedisCluster(startup_nodes=[]) 

93""" 

94_REDIS_SENTINEL_SOURCE: typing.Final = """from redis.sentinel import Sentinel 

95 

96 

97sentinel_client = Sentinel([("sentinel-one", 26379)]) 

98""" 

99_REDIS_IMPORT_ONLY_SOURCE: typing.Final = """import redis 

100 

101 

102CACHE_HOST = "cache.internal" 

103""" 

104_ASYNC_DATABASE_SOURCE: typing.Final = """from sqlalchemy.ext.asyncio import create_async_engine 

105 

106 

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 

112 

113 

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 

120 

121from src.config import DATABASE_URL 

122 

123 

124engine = create_engine(DATABASE_URL) 

125""" 

126_CONSUMER_SOURCE: typing.Final = """from faststream import FastStream 

127from faststream.rabbit import RabbitBroker 

128 

129 

130rabbit_broker = RabbitBroker("amqp://user:password@localhost:5672/") 

131faststream_app = FastStream(rabbit_broker) 

132 

133 

134@rabbit_broker.subscriber("commands") 

135async def handle_command(command: dict) -> None: ... 

136""" 

137_PUBLISHER_DECORATOR_SOURCE: typing.Final = """from faststream.rabbit import RabbitBroker 

138 

139 

140rabbit_broker = RabbitBroker("amqp://localhost:5672/") 

141 

142 

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 

148 

149 

150rabbit_broker = RabbitBroker("amqp://localhost:5672/") 

151 

152 

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 

157 

158 

159COMMANDS_QUEUE = "commands" 

160rabbit_broker = RabbitBroker("amqp://localhost:5672/") 

161 

162 

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 

167 

168 

169kafka_broker = KafkaBroker("localhost:9092") 

170""" 

171_GRANIAN_SOURCE: typing.Final = """import granian 

172from granian.constants import HTTPModes, Interfaces 

173 

174 

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 

191 

192import fastapi 

193 

194 

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 

387 

388 

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) 

391 

392 

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) 

397 

398 

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) 

411 

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 

416 

417 

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]) 

421 

422 assert f'external_client --> |"Served by {server_name}"| app_svc' in rendered_diagram 

423 

424 

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) 

428 

429 

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)