Coverage for tests/test_detected_features.py: 100%
74 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 22:03 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 22:03 +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_CONSUMER_SOURCE: typing.Final = """from faststream import FastStream
200from faststream.rabbit import RabbitBroker
203rabbit_broker = RabbitBroker("amqp://user:password@localhost:5672/")
204faststream_app = FastStream(rabbit_broker)
207@rabbit_broker.subscriber("commands")
208async def handle_command(command: dict) -> None: ...
209"""
210_PUBLISHER_DECORATOR_SOURCE: typing.Final = """from faststream.rabbit import RabbitBroker
213rabbit_broker = RabbitBroker("amqp://localhost:5672/")
216@rabbit_broker.publisher("events")
217async def publish_event(event: dict) -> dict:
218 return event
219"""
220_PUBLISH_CALL_SOURCE: typing.Final = """from faststream.rabbit import RabbitBroker
223rabbit_broker = RabbitBroker("amqp://localhost:5672/")
226async def publish_event(event: dict) -> None:
227 await rabbit_broker.publish(event, queue="events")
228"""
229_TOPIC_BEHIND_A_CONSTANT_SOURCE: typing.Final = """from faststream.rabbit import RabbitBroker
232COMMANDS_QUEUE = "commands"
233rabbit_broker = RabbitBroker("amqp://localhost:5672/")
236@rabbit_broker.subscriber(COMMANDS_QUEUE)
237async def handle_command(command: dict) -> None: ...
238"""
239_TWO_TOPIC_FLOW_SOURCE: typing.Final = """from faststream.kafka import KafkaBroker
242kafka_broker = KafkaBroker("localhost:9092")
245@kafka_broker.subscriber("orders")
246async def handle_order(order: dict) -> None: ...
249@kafka_broker.subscriber("payments")
250async def handle_payment(payment: dict) -> None: ...
253@kafka_broker.publisher("orders-done")
254async def publish_order_done(order: dict) -> dict:
255 return order
258@kafka_broker.publisher("payments-done")
259async def publish_payment_done(payment: dict) -> dict:
260 return payment
261"""
262_PIPED_TOPIC_SOURCE: typing.Final = """from faststream.nats import NatsBroker
265nats_broker = NatsBroker("nats://localhost:4222")
268@nats_broker.subscriber("orders|paid")
269async def handle_paid_order(order: dict) -> None: ...
270"""
271_BROKER_WITHOUT_A_FLOW_SOURCE: typing.Final = """from faststream.kafka import KafkaBroker
274kafka_broker = KafkaBroker("localhost:9092")
275"""
276_GRANIAN_SOURCE: typing.Final = """import granian
277from granian.constants import HTTPModes, Interfaces
280granian.Granian(
281 "src.main:app",
282 port=8000,
283 workers=4,
284 interface=Interfaces.ASGI,
285 http=HTTPModes.http2,
286 ssl_cert="/etc/tls/tls.crt",
287).serve()
288"""
289_GUNICORN_CONFIG_SOURCE: typing.Final = """bind = "0.0.0.0:8000"
290workers = 1
291worker_class = "uvicorn_worker.UvicornWorker"
292"""
293_CARRIER_LIBRARIES_SOURCE: typing.Final = """import gevent
294import tornado.httpclient
295from werkzeug.datastructures import Headers
297import fastapi
300app = fastapi.FastAPI()
301"""
302_MENTIONS_WITHOUT_IMPORTS_SOURCE: typing.Final = '''"""Runbook of the service.
304Work is handed over to celery, taskiq, arq, rq, dramatiq and huey workers, and the payments
305API is called over httpx, aiohttp, requests and niquests.
306"""
308RUNBOOK_URL = "https://wiki.example.com/runbook"
309'''
310_THREAD_POOL_SOURCE: typing.Final = """from concurrent.futures import ThreadPoolExecutor
312import uvicorn
315executor = ThreadPoolExecutor(max_workers=10)
316transport = 8080
317uvicorn.run("src.main:app")
318"""
319_ASYNC_DATABASE_EDGE: typing.Final = (
320 'app_svc --> |"async, postgresql+asyncpg://***@pg-primary:5432/orders'
321 '?target_session_attrs=read-write"| postgresql_asyncpgdb'
322)
323_REPLICA_DATABASE_EDGE: typing.Final = (
324 'app_svc --> |"postgresql+psycopg://***@pg-replica-one:5432,pg-replica-two:5432/orders"| postgresql_psycopgdb0'
325)
326_PLAIN_REDIS_NODE_MARK: typing.Final = "redisdb["
327_CREDENTIALS_MARK: typing.Final = "user:password"
328_ALL_FEATURE_CASES: typing.Final = types.MappingProxyType(
329 {
330 "every served method": (
331 _EVERY_METHOD_SOURCE,
332 ('external_client --> |"REST (delete, get, head, options, patch, post, put, trace)"| app_svc',),
333 (),
334 ),
335 "litestar route": (
336 _LITESTAR_ROUTE_SOURCE,
337 ('external_client --> |"REST (post)"| app_svc',),
338 (),
339 ),
340 "route without a framework": (_ROUTE_WITHOUT_FRAMEWORK_SOURCE, (), ("REST",)),
341 "route behind an f-string prefix": (
342 _PREFIXED_ROUTE_SOURCE,
343 ('external_client --> |"REST (get)"| app_svc',),
344 (),
345 ),
346 "mocked test file is not an api": (_MOCKED_TEST_SOURCE, (), ("REST",)),
347 "async http client": (
348 _ASYNC_CLIENT_SOURCE,
349 ('app_svc --> |"HTTP (async, httpx)"| External_API', 'External_API["External API"]'),
350 (),
351 ),
352 "sync http clients": (
353 _SYNC_CLIENTS_SOURCE,
354 ('app_svc --> |"HTTP (niquests, requests)"| External_API',),
355 ("async",),
356 ),
357 "task queue with a worker": (
358 _CELERY_SOURCE,
359 ('app_svc --> |"Tasks (celery, workers, redis)"| TaskQueue_Worker', 'TaskQueue_Worker["Task workers"]'),
360 (),
361 ),
362 "every task queue": (
363 _EVERY_QUEUE_SOURCE,
364 ('app_svc --> |"Tasks (arq, celery, dramatiq, huey, rq, taskiq)"| TaskQueue_Worker',),
365 (),
366 ),
367 "queue behind two brokers": (_TWO_BROKER_QUEUE_SOURCE, ('"Tasks (celery, rabbitmq, redis)"',), ()),
368 "rabbitmq backed queue": (_RABBITMQ_BROKER_SOURCE, ('"Tasks (dramatiq, rabbitmq)"',), ()),
369 "postgresql backed queue": (_POSTGRESQL_BROKER_SOURCE, ('"Tasks (huey, postgresql)"',), ('db["',)),
370 "redis cache": (
371 _REDIS_CACHE_SOURCE,
372 ('app_svc --> |"async, retry"| redisdb', 'redisdb["redis"]'),
373 (),
374 ),
375 "redis cluster": (
376 _REDIS_CLUSTER_SOURCE,
377 ('redisdb_cluster0["redis cluster #0"]', 'redisdb_cluster2["redis cluster #2"]', "app_svc --> redisdb"),
378 (_PLAIN_REDIS_NODE_MARK,),
379 ),
380 "redis sentinel": (
381 _REDIS_SENTINEL_SOURCE,
382 ('redisdb_sentinel0["redis sentinel #0"]', 'redisdb_sentinel2["redis sentinel #2"]'),
383 (_PLAIN_REDIS_NODE_MARK,),
384 ),
385 "redis cluster over asyncio": (
386 _ASYNC_REDIS_CLUSTER_SOURCE,
387 ('redisdb_cluster0["redis cluster #0"]', '|"async"|'),
388 (_PLAIN_REDIS_NODE_MARK,),
389 ),
390 "redis sentinel over asyncio": (
391 _ASYNC_REDIS_SENTINEL_SOURCE,
392 ('redisdb_sentinel0["redis sentinel #0"]', '|"async"|'),
393 (_PLAIN_REDIS_NODE_MARK,),
394 ),
395 "redis import alone": (_REDIS_IMPORT_ONLY_SOURCE, (), ("redisdb",)),
396 # The dsn is drawn whole, so the attribute inside it is already on the arrow: repeating it
397 # beside the dsn is what the forbidden part guards against.
398 "async database": (
399 _ASYNC_DATABASE_SOURCE,
400 ('postgresql_asyncpgdb["postgresql+asyncpg"]', _ASYNC_DATABASE_EDGE),
401 (_CREDENTIALS_MARK, "read-write, read-write"),
402 ),
403 "session attrs beside the dsn": (
404 _SESSION_ATTRS_BESIDE_THE_DSN_SOURCE,
405 ('app_svc --> |"postgresql+psycopg://***@pg-primary:5432/orders, read-write"| postgresql_psycopgdb',),
406 (_CREDENTIALS_MARK,),
407 ),
408 # The libpq keyword/value spelling quotes the whole string, not the value inside it.
409 "session attrs in libpq options": (
410 _SESSION_ATTRS_IN_LIBPQ_OPTIONS_SOURCE,
411 ('app_svc --> |"postgresql+psycopg://pg-one:5432/orders, read-write"| postgresql_psycopgdb',),
412 (),
413 ),
414 "session attrs in a setting": (
415 _SESSION_ATTRS_IN_A_SETTING_SOURCE,
416 ('app_svc --> |"postgresql+psycopg://pg-replica:5432/orders, standby"| postgresql_psycopgdb',),
417 (),
418 ),
419 "replicated database": (
420 _REPLICA_DATABASE_SOURCE,
421 (
422 'postgresql_psycopgdb0["postgresql+psycopg #0"]',
423 'postgresql_psycopgdb2["postgresql+psycopg #2"]',
424 _REPLICA_DATABASE_EDGE,
425 ),
426 (_CREDENTIALS_MARK,),
427 ),
428 "replicated database in double quotes": (
429 _DOUBLE_QUOTED_REPLICA_SOURCE,
430 ('postgresql_psycopgdb0["postgresql+psycopg #0"]', 'postgresql_psycopgdb2["postgresql+psycopg #2"]'),
431 (_CREDENTIALS_MARK,),
432 ),
433 "pool far from the engine call": (
434 _POOL_FAR_FROM_THE_ENGINE_SOURCE,
435 ('postgresql_psycopgdb0["postgresql+psycopg #0"]',),
436 ("postgresql_psycopgdb1",),
437 ),
438 "database url behind a constant": (_DATABASE_URL_BEHIND_A_CONSTANT_SOURCE, (), ('db["',)),
439 "consumed messages": (
440 _CONSUMER_SOURCE,
441 ('rabbit --> |"commands"| app_svc', 'rabbit["rabbit"]'),
442 ("kafka", "nats", "user:password"),
443 ),
444 "messages published by a decorator": (
445 _PUBLISHER_DECORATOR_SOURCE,
446 ('app_svc --> |"events"| rabbit',),
447 ("rabbit -->",),
448 ),
449 "messages published by a call": (
450 _PUBLISH_CALL_SOURCE,
451 ('app_svc --> |"events"| rabbit',),
452 ("rabbit -->",),
453 ),
454 "every topic of one broker": (
455 _TWO_TOPIC_FLOW_SOURCE,
456 ('kafka --> |"orders, payments"| app_svc', 'app_svc --> |"orders-done, payments-done"| kafka'),
457 (),
458 ),
459 # The pipe is the edge label's own delimiter: a topic carrying one may not reach the page.
460 "topic spelled with a pipe": (_PIPED_TOPIC_SOURCE, ('nats --> |"orderspaid"| app_svc',), ("orders|paid",)),
461 "topic behind a constant": (_TOPIC_BEHIND_A_CONSTANT_SOURCE, ("rabbit --> app_svc",), ('|"',)),
462 "broker without a flow": (_BROKER_WITHOUT_A_FLOW_SOURCE, (), ("kafka",)),
463 "application server properties": (
464 _GRANIAN_SOURCE,
465 ('external_client --> |"Served by granian, 4 workers, port 8000, TLS, HTTP/2"| app_svc',),
466 (),
467 ),
468 "worker class naming both servers": (
469 _GUNICORN_CONFIG_SOURCE,
470 ('external_client --> |"Served by gunicorn, uvicorn, single worker, port 8000"| app_svc',),
471 (),
472 ),
473 "carrier library is not a server": (_CARRIER_LIBRARIES_SOURCE, (), ("Served by",)),
474 # A name in prose is what the prefilter matches on, and only the import decides.
475 "technology named but never imported": (_MENTIONS_WITHOUT_IMPORTS_SOURCE, (), (EDGE_ARROW,)),
476 "thread pool is not a server worker": (
477 _THREAD_POOL_SOURCE,
478 ('external_client --> |"Served by uvicorn"| app_svc',),
479 ("workers", "port 8080"),
480 ),
481 },
482)
483_ALL_SERVER_SOURCES: typing.Final = types.MappingProxyType(
484 {
485 "granian": "import granian\n\ngranian.Granian('src.main:app').serve()\n",
486 "uvicorn": "import uvicorn\n\nuvicorn.run('src.main:app')\n",
487 "gunicorn": "from gunicorn.app.base import BaseApplication\n",
488 "hypercorn": "from hypercorn.asyncio import serve\n",
489 "daphne": "from daphne.server import Server\n",
490 "waitress": "from waitress import serve\n",
491 "uwsgi": "import uwsgi\n",
492 "mod_wsgi": "from mod_wsgi import server\n",
493 "bjoern": "import bjoern\n",
494 "meinheld": "from meinheld import server\n",
495 "cheroot": "from cheroot.wsgi import Server\n",
496 "tornado": "import tornado.httpserver\n",
497 "gevent": "from gevent.pywsgi import WSGIServer\n",
498 "eventlet": "import eventlet.wsgi\n",
499 "werkzeug": "from werkzeug.serving import run_simple\n",
500 "wsgiref": "from wsgiref.simple_server import make_server\n",
501 },
502)
503_REQUIRED_SHOWCASE_MARKS: typing.Final = (
504 "REST",
505 "httpx",
506 "aiohttp",
507 "requests",
508 "niquests",
509 "celery",
510 "taskiq",
511 "arq",
512 "rq",
513 "dramatiq",
514 "huey",
515 "rabbit",
516 "kafka",
517 "nats",
518 "redis",
519 "retry",
520 "sentinel",
521 "cluster",
522 "postgresql",
523 "sqlite",
524 "replicas",
525 "HPA",
526 "cpu",
527 "RAM",
528 "GPU",
529 "ConfigMap_app_config",
530 "Secret_app_secrets",
531 "PersistentVolume",
532 "granian",
533 "gunicorn",
534 "uvicorn",
535)
536_ALL_FEATURE_LITERALS: typing.Final = (
537 *_ALL_SERVER_SOURCES,
538 "fastapi",
539 "litestar",
540 "faststream",
541 "redis",
542 "sqlalchemy",
543 "create_engine",
544 "target_session_attrs",
545 "postgresql",
546 "mysql",
547 "sqlite",
548 "oracle",
549 "mssql",
550 "mariadb",
551 "cockroachdb",
552 "httpx",
553 "aiohttp",
554 "requests",
555 "niquests",
556 "celery",
557 "taskiq",
558 "arq",
559 "rq",
560 "dramatiq",
561 "huey",
562 "run_simple",
563 "worker_class",
564 "--worker-class",
565)
566_HYPOTHESIS_EXAMPLES: typing.Final = 30
567_FIRST_PRINTABLE_CODE: typing.Final = 32
568_LAST_PRINTABLE_CODE: typing.Final = 126
569_LONGEST_RANDOM_SOURCE: typing.Final = 200
572def _has_no_feature_literal(source_code: str, /) -> bool:
573 return not any(one_literal in source_code.lower() for one_literal in _ALL_FEATURE_LITERALS)
576_UNRELATED_SOURCE_STRATEGY: typing.Final = st.text(
577 alphabet=st.characters(min_codepoint=_FIRST_PRINTABLE_CODE, max_codepoint=_LAST_PRINTABLE_CODE),
578 max_size=_LONGEST_RANDOM_SOURCE,
579).filter(_has_no_feature_literal)
582@pytest.mark.parametrize(
583 ("source_code", "expected_parts", "forbidden_parts"),
584 _ALL_FEATURE_CASES.values(),
585 ids=_ALL_FEATURE_CASES,
586)
587def test_source_reaches_the_diagram(
588 tmp_path: pathlib.Path,
589 source_code: str,
590 expected_parts: tuple[str, ...],
591 forbidden_parts: tuple[str, ...],
592) -> None:
593 rendered_diagram: typing.Final = diagram_rendering.render_source_diagram(tmp_path, source_code)
595 for one_expected_part in expected_parts:
596 assert one_expected_part in rendered_diagram, one_expected_part
597 for one_forbidden_part in forbidden_parts:
598 assert one_forbidden_part not in rendered_diagram, one_forbidden_part
601@pytest.mark.parametrize("server_name", _ALL_SERVER_SOURCES)
602def test_every_server_reaches_the_diagram(tmp_path: pathlib.Path, server_name: str) -> None:
603 rendered_diagram: typing.Final = diagram_rendering.render_source_diagram(tmp_path, _ALL_SERVER_SOURCES[server_name])
605 assert f'external_client --> |"Served by {server_name}"| app_svc' in rendered_diagram
608@pytest.mark.parametrize("feature_mark", _REQUIRED_SHOWCASE_MARKS)
609def test_showcase_shows_every_supported_feature(feature_mark: str) -> None:
610 assert feature_mark in diagram_rendering.render_example_diagram(diagram_rendering.SHOWCASE_SETTINGS)
613@hypothesis.settings(
614 deadline=None,
615 max_examples=_HYPOTHESIS_EXAMPLES,
616 suppress_health_check=[hypothesis.HealthCheck.function_scoped_fixture],
617)
618@hypothesis.given(source_code=_UNRELATED_SOURCE_STRATEGY)
619def test_unrelated_source_draws_the_service_alone(tmp_path: pathlib.Path, source_code: str) -> None:
620 assert EDGE_ARROW not in diagram_rendering.render_source_diagram(tmp_path, source_code)