diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c1d3d..d5d354b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - coverage.py dependency ### Added +- **Versioned exact effect presets**: expanded resolver-attested PyMongo/Motor, + filesystem, typed requests/httpx/aiohttp, and typed S3 contracts. Added a + message-bus preset containing only typed confluent-kafka `Producer.produce`, + with staged queue timing. Untyped message clients and Redis's timing-ambiguous + shared sync/async owners are omitted; receiver HTTP clients and S3 object + operations abstain from incomplete URL or Key-only resource identities. + This preset tranche does not include real-world evaluation, and Issue #97 + remains open for unsupported families and evaluation. - **Mypy integration**: Added mypy's build API for type-aware dependency analysis - New `_get_module_dependencies_via_mypy()` method for full dependency graph extraction - New `_module_to_file_path()` helper for module resolution diff --git a/docs/effect-contracts.md b/docs/effect-contracts.md index 7f41b8e..6501770 100644 --- a/docs/effect-contracts.md +++ b/docs/effect-contracts.md @@ -127,47 +127,62 @@ differ from the analyzed snapshot. Six conservative, independently versioned exact-symbol presets are bundled: -- `redis-v1` - `mongodb-v1` - `filesystem-v1` - `http-clients-v1` +- `message-bus-v1` - `object-storage-v1` - `sqlalchemy-v1` Validate one without copying package data: ```bash -fastapi-endpoint-detector validate-effect-contracts --preset redis-v1 --format json +fastapi-endpoint-detector validate-effect-contracts --preset filesystem-v1 --format json ``` Select exactly one preset in configuration: ```yaml analysis: - effect_preset: redis-v1 + effect_preset: filesystem-v1 ``` `effect_preset` and `effect_contracts` are mutually exclusive. Presets preserve the same exact `(canonical symbol, invocation)` matcher and evidence-only behavior as user documents. Version ranges are audited support metadata, not runtime package checks. Direct positional/keyword finite strings produce hashed resource -identities. `filesystem-v1` 2.0 additionally traces an exact `pathlib.Path(...)` +identities. `filesystem-v1` additionally traces an exact `pathlib.Path(...)` or `builtins.open(...)` constructor through one unconditional local assignment or active `with` binding into exact `_io`/`Path` instance methods. Reassignment, escaped handles, control flow, aliases, captured handles, composition, dynamic arguments, and unsupported factories fail closed. Dynamic boto3 clients without `mypy-boto3-s3`, generic HTTP `request`/`send`, -mode-specific append classification, deferred cursors, Redis pipelines, and bare -method names are intentionally absent. - -Each family has an independent identity and semantic hash. Filesystem receiver -origins, exact HTTP verb tables, and composite typed-S3 `(Bucket, Key)` identities -are version `2.0.0`; MongoDB and Redis remain `1.0.0`. HTTP contracts preserve -`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, or `OPTIONS` as structured -contract semantics while finite URLs remain hashed resource evidence. Typed S3 -contracts fail closed unless both bucket and key are finite. The v1 changelog and -known exclusions are frozen in `benchmarks/results/effect-presets-v1/README.md`. +mode-specific `open()` classification, deferred cursor consumption, Redis, and +bare method names are intentionally absent. Redis sync and async clients share +mypy declaration owners, but their immediate-versus-await timing cannot be +expressed by the current exact contract schema. Direct typed-S3 get, put, and +delete operations use the ordered schema-v4 `(Bucket, Key)` composite identity; +copy-source and other compound identities continue to abstain when every +component cannot be represented exactly. Untyped aiokafka, kafka-python, pika, +and kombu rows are also omitted. The message-bus preset contains only typed +confluent-kafka `Producer.produce`, conservatively declared as a staged queue +operation. + +Each family has an independent identity and semantic hash. Filesystem and HTTP +contracts are version `3.0.0`; MongoDB/Motor and typed S3 contracts are version +`2.0.0`; message bus starts at `1.0.0`. Requests support starts at its resolver- +typed `2.34` release. HTTP receiver-client contracts abstain from URL resource +identity because constructor `base_url` can make the call argument incomplete; +top-level requests/httpx convenience calls retain finite URL evidence. aiohttp +request timing is conservatively `await`. HTTP contracts preserve `GET`, `POST`, +`PUT`, `PATCH`, `DELETE`, `HEAD`, or `OPTIONS` as structured semantics. The v1 +changelog and known exclusions are frozen in +`benchmarks/results/effect-presets-v1/README.md`. That historical artifact is not +real-world evaluation and is not modified by this tranche. Issue #97 remains open +for unsupported package families, additional compound/base-URL identities, +applicability enforcement, version-matrix expansion, and controlled or real-world +evaluation. Multiple presets are not silently merged because the current provenance model has one authoritative contract source per analysis. diff --git a/docs/resource-coupling.md b/docs/resource-coupling.md index f75fb5d..bdf34ce 100644 --- a/docs/resource-coupling.md +++ b/docs/resource-coupling.md @@ -11,7 +11,7 @@ Configure an effect source and a separate namespace-qualified coupling document: ```yaml analysis: - effect_preset: redis-v1 + effect_preset: filesystem-v1 resource_coupling: .resource-coupling.yaml ``` @@ -19,10 +19,10 @@ analysis: schema_version: 1 mode: report_only groups: - - id: orders-cache - resource_space: production-orders-redis-db0 - producer_contract_ids: [redis-delete, redis-set] - consumer_contract_ids: [redis-get] + - id: generated-files + resource_space: application-data-directory + producer_contract_ids: [pathlib-write-text] + consumer_contract_ids: [pathlib-read-text] limits: max_endpoint_links_per_resource: 32 max_edges: 1000 diff --git a/src/fastapi_endpoint_detector/config.py b/src/fastapi_endpoint_detector/config.py index e800014..9bafa2a 100644 --- a/src/fastapi_endpoint_detector/config.py +++ b/src/fastapi_endpoint_detector/config.py @@ -82,9 +82,9 @@ class AnalysisConfig(BaseModel): Literal[ "filesystem-v1", "http-clients-v1", + "message-bus-v1", "mongodb-v1", "object-storage-v1", - "redis-v1", "sqlalchemy-v1", ] | None diff --git a/src/fastapi_endpoint_detector/models/effect_contract.py b/src/fastapi_endpoint_detector/models/effect_contract.py index 50edfb3..4592f09 100644 --- a/src/fastapi_endpoint_detector/models/effect_contract.py +++ b/src/fastapi_endpoint_detector/models/effect_contract.py @@ -28,11 +28,11 @@ BUNDLED_EFFECT_PRESETS = { "filesystem-v1": Path(__file__).parent.parent / "presets" / "effects_filesystem_v1.yaml", "http-clients-v1": Path(__file__).parent.parent / "presets" / "effects_http_clients_v1.yaml", + "message-bus-v1": Path(__file__).parent.parent / "presets" / "effects_message_bus_v1.yaml", "mongodb-v1": Path(__file__).parent.parent / "presets" / "effects_mongodb_v1.yaml", "object-storage-v1": ( Path(__file__).parent.parent / "presets" / "effects_object_storage_v1.yaml" ), - "redis-v1": Path(__file__).parent.parent / "presets" / "effects_redis_v1.yaml", "sqlalchemy-v1": Path(__file__).parent.parent / "presets" / "effects_sqlalchemy_v1.yaml", } diff --git a/src/fastapi_endpoint_detector/presets/effects_filesystem_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_filesystem_v1.yaml index 20b314a..95a19b7 100644 --- a/src/fastapi_endpoint_detector/presets/effects_filesystem_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_filesystem_v1.yaml @@ -1,11 +1,11 @@ schema_version: 1 preset: id: stdlib-filesystem-effects - version: 2.0.0 + version: 3.0.0 provenance: kind: preset source: fastapi-endpoint-detector/effects_filesystem_v1.yaml - revision: "2" + revision: "3" contracts: - id: pathlib-read-text symbol: pathlib.Path.read_text @@ -13,21 +13,82 @@ contracts: operation: read channel: filesystem resource: {kind: receiver} - package: {python: ">=3.10,<3.14"} + package: &python {python: ">=3.10,<3.14"} - id: pathlib-read-bytes symbol: pathlib.Path.read_bytes invocation: instance_method operation: read channel: filesystem resource: {kind: receiver} - package: {python: ">=3.10,<3.14"} + package: *python + - id: pathlib-write-text + symbol: pathlib.Path.write_text + invocation: instance_method + operation: write + channel: filesystem + resource: {kind: receiver} + value: {kind: argument, index: 0} + package: *python + - id: pathlib-write-bytes + symbol: pathlib.Path.write_bytes + invocation: instance_method + operation: write + channel: filesystem + resource: {kind: receiver} + value: {kind: argument, index: 0} + package: *python + - id: pathlib-touch + symbol: pathlib.Path.touch + invocation: instance_method + operation: write + channel: filesystem + resource: {kind: receiver} + package: *python + - id: pathlib-mkdir + symbol: pathlib.Path.mkdir + invocation: instance_method + operation: write + channel: filesystem + resource: {kind: receiver} + package: *python + - id: pathlib-unlink + symbol: pathlib.Path.unlink + invocation: instance_method + operation: delete + channel: filesystem + resource: {kind: receiver} + package: *python + - id: pathlib-rmdir + symbol: pathlib.Path.rmdir + invocation: instance_method + operation: delete + channel: filesystem + resource: {kind: receiver} + package: *python + - id: pathlib-rename + symbol: pathlib.Path.rename + invocation: instance_method + operation: update + channel: filesystem + resource: {kind: receiver} + value: {kind: argument, index: 0} + package: *python + - id: pathlib-replace + symbol: pathlib.Path.replace + invocation: instance_method + operation: update + channel: filesystem + resource: {kind: receiver} + value: {kind: argument, index: 0} + package: *python + - id: io-text-read symbol: _io._TextIOBase.read invocation: instance_method operation: read channel: filesystem resource: {kind: receiver} - package: {python: ">=3.10,<3.14"} + package: *python - id: io-text-write symbol: _io._TextIOBase.write invocation: instance_method @@ -35,14 +96,14 @@ contracts: channel: filesystem resource: {kind: receiver} value: {kind: argument, index: 0} - package: {python: ">=3.10,<3.14"} + package: *python - id: io-buffered-read symbol: _io._BufferedIOBase.read invocation: instance_method operation: read channel: filesystem resource: {kind: receiver} - package: {python: ">=3.10,<3.14"} + package: *python - id: io-buffered-write symbol: _io.BufferedWriter.write invocation: instance_method @@ -50,20 +111,96 @@ contracts: channel: filesystem resource: {kind: receiver} value: {kind: argument, index: 0} - package: {python: ">=3.10,<3.14"} - - id: pathlib-write-text - symbol: pathlib.Path.write_text - invocation: instance_method + package: *python + + - id: os-remove + symbol: os.remove + invocation: function + operation: delete + channel: filesystem + resource: {kind: argument, index: 0} + package: *python + - id: os-unlink + symbol: os.unlink + invocation: function + operation: delete + channel: filesystem + resource: {kind: argument, index: 0} + package: *python + - id: os-rmdir + symbol: os.rmdir + invocation: function + operation: delete + channel: filesystem + resource: {kind: argument, index: 0} + package: *python + - id: os-mkdir + symbol: os.mkdir + invocation: function operation: write channel: filesystem - resource: {kind: receiver} + resource: {kind: argument, index: 0} + package: *python + - id: os-makedirs + symbol: os.makedirs + invocation: function + operation: write + channel: filesystem + resource: {kind: argument, index: 0} + package: *python + - id: os-rename + symbol: os.rename + invocation: function + operation: update + channel: filesystem + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: *python + - id: os-replace + symbol: os.replace + invocation: function + operation: update + channel: filesystem + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: *python + + - id: shutil-copyfile + symbol: shutil.copyfile + invocation: function + operation: write + channel: filesystem + resource: {kind: argument, index: 1} value: {kind: argument, index: 0} - package: {python: ">=3.10,<3.14"} - - id: pathlib-write-bytes - symbol: pathlib.Path.write_bytes - invocation: instance_method + package: *python + - id: shutil-copy + symbol: shutil.copy + invocation: function operation: write channel: filesystem - resource: {kind: receiver} + resource: {kind: argument, index: 1} value: {kind: argument, index: 0} - package: {python: ">=3.10,<3.14"} + package: *python + - id: shutil-copy2 + symbol: shutil.copy2 + invocation: function + operation: write + channel: filesystem + resource: {kind: argument, index: 1} + value: {kind: argument, index: 0} + package: *python + - id: shutil-move + symbol: shutil.move + invocation: function + operation: update + channel: filesystem + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: *python + - id: shutil-rmtree + symbol: shutil.rmtree + invocation: function + operation: delete + channel: filesystem + resource: {kind: argument, index: 0} + package: *python diff --git a/src/fastapi_endpoint_detector/presets/effects_http_clients_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_http_clients_v1.yaml index 7061b4c..c44a1ac 100644 --- a/src/fastapi_endpoint_detector/presets/effects_http_clients_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_http_clients_v1.yaml @@ -1,11 +1,11 @@ schema_version: 3 preset: id: python-http-client-effects - version: 2.0.0 + version: 3.0.0 provenance: kind: preset source: fastapi-endpoint-detector/effects_http_clients_v1.yaml - revision: "2" + revision: "3" contracts: - id: requests-session-get symbol: requests.sessions.Session.get @@ -14,7 +14,7 @@ contracts: channel: outbound_http resource: {kind: argument, index: 0} http_method: GET - package: &requests {distribution: requests, version: ">=2.31,<3"} + package: &requests {distribution: requests, version: ">=2.34,<3"} - id: requests-session-post symbol: requests.sessions.Session.post invocation: instance_method @@ -69,7 +69,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: GET package: &httpx {distribution: httpx, version: ">=0.27,<1"} - id: httpx-client-post @@ -77,7 +76,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: POST package: *httpx - id: httpx-client-put @@ -85,7 +83,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: PUT package: *httpx - id: httpx-client-patch @@ -93,7 +90,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: PATCH package: *httpx - id: httpx-client-delete @@ -101,7 +97,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: DELETE package: *httpx - id: httpx-client-head @@ -109,7 +104,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: HEAD package: *httpx - id: httpx-client-options @@ -117,7 +111,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: OPTIONS package: *httpx @@ -126,7 +119,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: GET behavior: &httpx_async {async_mode: async, timing: await} package: *httpx @@ -135,7 +127,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: POST behavior: *httpx_async package: *httpx @@ -144,7 +135,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: PUT behavior: *httpx_async package: *httpx @@ -153,7 +143,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: PATCH behavior: *httpx_async package: *httpx @@ -162,7 +151,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: DELETE behavior: *httpx_async package: *httpx @@ -171,7 +159,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: HEAD behavior: *httpx_async package: *httpx @@ -180,7 +167,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: OPTIONS behavior: *httpx_async package: *httpx @@ -190,16 +176,14 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: GET - behavior: &aiohttp_async {async_mode: async, timing: context_enter} + behavior: &aiohttp_async {async_mode: async, timing: await} package: &aiohttp {distribution: aiohttp, version: ">=3.9,<4"} - id: aiohttp-session-post symbol: aiohttp.client.ClientSession.post invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: POST behavior: *aiohttp_async package: *aiohttp @@ -208,7 +192,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: PUT behavior: *aiohttp_async package: *aiohttp @@ -217,7 +200,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: PATCH behavior: *aiohttp_async package: *aiohttp @@ -226,7 +208,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: DELETE behavior: *aiohttp_async package: *aiohttp @@ -235,7 +216,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: HEAD behavior: *aiohttp_async package: *aiohttp @@ -244,7 +224,123 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: OPTIONS behavior: *aiohttp_async package: *aiohttp + + # Public convenience functions are separate exact symbols from Session methods. + # Generic request(method, url) APIs are deliberately absent because v3 has no + # selector for a bounded method argument. + - id: requests-api-get + symbol: requests.api.get + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: GET + package: *requests + - id: requests-api-post + symbol: requests.api.post + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: POST + package: *requests + - id: requests-api-put + symbol: requests.api.put + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: PUT + package: *requests + - id: requests-api-patch + symbol: requests.api.patch + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: PATCH + package: *requests + - id: requests-api-delete + symbol: requests.api.delete + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: DELETE + package: *requests + - id: requests-api-head + symbol: requests.api.head + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: HEAD + package: *requests + - id: requests-api-options + symbol: requests.api.options + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: OPTIONS + package: *requests + + - id: httpx-get + symbol: httpx._api.get + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: GET + package: *httpx + - id: httpx-post + symbol: httpx._api.post + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: POST + package: *httpx + - id: httpx-put + symbol: httpx._api.put + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: PUT + package: *httpx + - id: httpx-patch + symbol: httpx._api.patch + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: PATCH + package: *httpx + - id: httpx-delete + symbol: httpx._api.delete + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: DELETE + package: *httpx + - id: httpx-head + symbol: httpx._api.head + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: HEAD + package: *httpx + - id: httpx-options + symbol: httpx._api.options + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: OPTIONS + package: *httpx diff --git a/src/fastapi_endpoint_detector/presets/effects_message_bus_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_message_bus_v1.yaml new file mode 100644 index 0000000..6a4ee1e --- /dev/null +++ b/src/fastapi_endpoint_detector/presets/effects_message_bus_v1.yaml @@ -0,0 +1,20 @@ +schema_version: 1 +preset: + id: python-message-bus-effects + version: 1.0.0 + provenance: + kind: preset + source: fastapi-endpoint-detector/effects_message_bus_v1.yaml + revision: "1" +contracts: + # produce() synchronously queues a record for later delivery. Staged timing + # records that boundary without claiming immediate broker publication. + - id: confluent-kafka-produce + symbol: confluent_kafka.cimpl.Producer.produce + invocation: instance_method + operation: publish + channel: message_bus + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + behavior: {async_mode: sync, timing: staged} + package: {distribution: confluent-kafka, version: ">=2.13,<3"} diff --git a/src/fastapi_endpoint_detector/presets/effects_mongodb_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_mongodb_v1.yaml index 70057dc..a0d0a61 100644 --- a/src/fastapi_endpoint_detector/presets/effects_mongodb_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_mongodb_v1.yaml @@ -1,11 +1,11 @@ schema_version: 1 preset: id: pymongo-effects - version: 1.0.0 + version: 2.0.0 provenance: kind: preset source: fastapi-endpoint-detector/effects_mongodb_v1.yaml - revision: "1" + revision: "2" contracts: - id: pymongo-find-one symbol: pymongo.synchronous.collection.Collection.find_one @@ -14,7 +14,7 @@ contracts: channel: mongodb resource: {kind: receiver} value: {kind: argument, index: 0} - package: {distribution: pymongo, version: ">=4.9,<5"} + package: &pymongo {distribution: pymongo, version: ">=4.9,<5"} - id: pymongo-insert-one symbol: pymongo.synchronous.collection.Collection.insert_one invocation: instance_method @@ -22,7 +22,23 @@ contracts: channel: mongodb resource: {kind: receiver} value: {kind: argument, index: 0} - package: {distribution: pymongo, version: ">=4.9,<5"} + package: *pymongo + - id: pymongo-insert-many + symbol: pymongo.synchronous.collection.Collection.insert_many + invocation: instance_method + operation: write + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + package: *pymongo + - id: pymongo-replace-one + symbol: pymongo.synchronous.collection.Collection.replace_one + invocation: instance_method + operation: update + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 1} + package: *pymongo - id: pymongo-update-one symbol: pymongo.synchronous.collection.Collection.update_one invocation: instance_method @@ -30,7 +46,15 @@ contracts: channel: mongodb resource: {kind: receiver} value: {kind: argument, index: 1} - package: {distribution: pymongo, version: ">=4.9,<5"} + package: *pymongo + - id: pymongo-update-many + symbol: pymongo.synchronous.collection.Collection.update_many + invocation: instance_method + operation: update + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 1} + package: *pymongo - id: pymongo-delete-one symbol: pymongo.synchronous.collection.Collection.delete_one invocation: instance_method @@ -38,4 +62,85 @@ contracts: channel: mongodb resource: {kind: receiver} value: {kind: argument, index: 0} - package: {distribution: pymongo, version: ">=4.9,<5"} + package: *pymongo + - id: pymongo-delete-many + symbol: pymongo.synchronous.collection.Collection.delete_many + invocation: instance_method + operation: delete + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + package: *pymongo + + - id: motor-find-one + symbol: motor.core.AgnosticCollection.find_one + invocation: instance_method + operation: read + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + behavior: &motor_await {async_mode: async, timing: await} + package: &motor {distribution: motor, version: ">=3.6,<4"} + - id: motor-insert-one + symbol: motor.core.AgnosticCollection.insert_one + invocation: instance_method + operation: write + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + behavior: *motor_await + package: *motor + - id: motor-insert-many + symbol: motor.core.AgnosticCollection.insert_many + invocation: instance_method + operation: write + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + behavior: *motor_await + package: *motor + - id: motor-replace-one + symbol: motor.core.AgnosticCollection.replace_one + invocation: instance_method + operation: update + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 1} + behavior: *motor_await + package: *motor + - id: motor-update-one + symbol: motor.core.AgnosticCollection.update_one + invocation: instance_method + operation: update + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 1} + behavior: *motor_await + package: *motor + - id: motor-update-many + symbol: motor.core.AgnosticCollection.update_many + invocation: instance_method + operation: update + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 1} + behavior: *motor_await + package: *motor + - id: motor-delete-one + symbol: motor.core.AgnosticCollection.delete_one + invocation: instance_method + operation: delete + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + behavior: *motor_await + package: *motor + - id: motor-delete-many + symbol: motor.core.AgnosticCollection.delete_many + invocation: instance_method + operation: delete + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + behavior: *motor_await + package: *motor diff --git a/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml index 73727a1..b3377a1 100644 --- a/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml @@ -7,6 +7,9 @@ preset: source: fastapi-endpoint-detector/effects_object_storage_v1.yaml revision: "2" contracts: + # Direct object operations use the ordered (Bucket, Key) composite identity. + # CopySource and other compound identities remain unavailable until each + # component can be represented without guessing. - id: typed-s3-get-object symbol: mypy_boto3_s3.client.S3Client.get_object invocation: instance_method @@ -17,7 +20,13 @@ contracts: components: - {kind: keyword, name: Bucket} - {kind: keyword, name: Key} - package: {distribution: mypy-boto3-s3, version: ">=1.34,<2"} + package: &s3 {distribution: mypy-boto3-s3, version: ">=1.34,<2"} + - id: typed-s3-head-object + symbol: mypy_boto3_s3.client.S3Client.head_object + invocation: instance_method + operation: read + channel: object_storage + package: *s3 - id: typed-s3-put-object symbol: mypy_boto3_s3.client.S3Client.put_object invocation: instance_method @@ -29,7 +38,14 @@ contracts: - {kind: keyword, name: Bucket} - {kind: keyword, name: Key} value: {kind: keyword, name: Body} - package: {distribution: mypy-boto3-s3, version: ">=1.34,<2"} + package: *s3 + - id: typed-s3-copy-object + symbol: mypy_boto3_s3.client.S3Client.copy_object + invocation: instance_method + operation: write + channel: object_storage + value: {kind: keyword, name: CopySource} + package: *s3 - id: typed-s3-delete-object symbol: mypy_boto3_s3.client.S3Client.delete_object invocation: instance_method @@ -40,4 +56,25 @@ contracts: components: - {kind: keyword, name: Bucket} - {kind: keyword, name: Key} - package: {distribution: mypy-boto3-s3, version: ">=1.34,<2"} + package: *s3 + - id: typed-s3-list-objects-v2 + symbol: mypy_boto3_s3.client.S3Client.list_objects_v2 + invocation: instance_method + operation: read + channel: object_storage + resource: {kind: keyword, name: Bucket} + package: *s3 + - id: typed-s3-create-bucket + symbol: mypy_boto3_s3.client.S3Client.create_bucket + invocation: instance_method + operation: write + channel: object_storage + resource: {kind: keyword, name: Bucket} + package: *s3 + - id: typed-s3-delete-bucket + symbol: mypy_boto3_s3.client.S3Client.delete_bucket + invocation: instance_method + operation: delete + channel: object_storage + resource: {kind: keyword, name: Bucket} + package: *s3 diff --git a/src/fastapi_endpoint_detector/presets/effects_redis_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_redis_v1.yaml deleted file mode 100644 index 5556953..0000000 --- a/src/fastapi_endpoint_detector/presets/effects_redis_v1.yaml +++ /dev/null @@ -1,39 +0,0 @@ -schema_version: 1 -preset: - id: redis-py-effects - version: 1.0.0 - provenance: - kind: preset - source: fastapi-endpoint-detector/effects_redis_v1.yaml - revision: "1" -contracts: - - id: redis-get - symbol: redis.commands.core.BasicKeyCommands.get - invocation: instance_method - operation: read - channel: redis - resource: {kind: argument, index: 0} - package: {distribution: redis, version: ">=5,<7"} - - id: redis-set - symbol: redis.commands.core.BasicKeyCommands.set - invocation: instance_method - operation: write - channel: redis - resource: {kind: argument, index: 0} - value: {kind: argument, index: 1} - package: {distribution: redis, version: ">=5,<7"} - - id: redis-delete - symbol: redis.commands.core.BasicKeyCommands.delete - invocation: instance_method - operation: delete - channel: redis - resource: {kind: argument, index: 0} - package: {distribution: redis, version: ">=5,<7"} - - id: redis-publish - symbol: redis.commands.core.PubSubCommands.publish - invocation: instance_method - operation: publish - channel: message_bus - resource: {kind: argument, index: 0} - value: {kind: argument, index: 1} - package: {distribution: redis, version: ">=5,<7"} diff --git a/tests/integration/test_effect_contract_audit_cli.py b/tests/integration/test_effect_contract_audit_cli.py index 0809b1d..7c0e53f 100644 --- a/tests/integration/test_effect_contract_audit_cli.py +++ b/tests/integration/test_effect_contract_audit_cli.py @@ -61,21 +61,21 @@ def test_validate_effect_preset_and_reject_dual_cli_sources(tmp_path: Path) -> N valid = runner.invoke( cli, - ["validate-effect-contracts", "--preset", "redis-v1", "--format", "json"], + ["validate-effect-contracts", "--preset", "filesystem-v1", "--format", "json"], ) conflict = runner.invoke( cli, [ "validate-effect-contracts", "--preset", - "redis-v1", + "filesystem-v1", "--contracts", str(contracts), ], ) assert valid.exit_code == 0, valid.output - assert json.loads(valid.output)["preset"]["id"] == "redis-py-effects" + assert json.loads(valid.output)["preset"]["id"] == "stdlib-filesystem-effects" assert conflict.exit_code != 0 assert "exactly one" in conflict.output @@ -228,7 +228,7 @@ def test_audit_loads_configured_effect_preset(tmp_path: Path) -> None: assert result.exit_code == 0, result.output data = json.loads(result.output) - assert data["summary"]["contracts"] == 8 + assert data["summary"]["contracts"] == 26 assert data["provenance"]["preset_hash"].startswith("sha256:") diff --git a/tests/unit/test_effect_contract_audit.py b/tests/unit/test_effect_contract_audit.py index 773dac4..787ec7a 100644 --- a/tests/unit/test_effect_contract_audit.py +++ b/tests/unit/test_effect_contract_audit.py @@ -12,12 +12,15 @@ CallResolutionStatus, FiniteValueStatus, InvocationKind, + LoadedEffectContracts, ResolvedCallSite, ResourceIdentityEvidence, load_effect_contracts, + load_effect_preset, ) from fastapi_endpoint_detector.models.effect_contract_audit import ( AuditCallStatus, + EffectContractAudit, EffectContractAuditError, ) from fastapi_endpoint_detector.models.endpoint import ( @@ -34,7 +37,7 @@ from pathlib import Path -def _loaded(path: Path): +def _loaded(path: Path) -> LoadedEffectContracts: document = { "schema_version": 1, "preset": { @@ -112,13 +115,13 @@ def _site( def _audit( root: Path, - rows, + rows: list[tuple[Endpoint, list[ResolvedCallSite]]], *, - loaded=None, + loaded: LoadedEffectContracts | None = None, track_transitive: bool = True, max_depth: int = 10, cache_enabled: bool = True, -): +) -> EffectContractAudit: endpoints = [endpoint for endpoint, _sites in rows] return audit_effect_contracts( loaded or _loaded(root / "effects.yaml"), @@ -197,6 +200,82 @@ def test_composite_resource_cartesian_overflow_is_unavailable(tmp_path: Path) -> assert identity.reason_code == "composite_resource_limit_exceeded" +@pytest.mark.parametrize( + ("preset", "symbol", "invocation", "contract_id", "negative_symbol"), + [ + ( + "mongodb-v1", + "motor.core.AgnosticCollection.update_one", + InvocationKind.INSTANCE_METHOD, + "motor-update-one", + "project.Collection.update_one", + ), + ( + "filesystem-v1", + "os.remove", + InvocationKind.FUNCTION, + "os-remove", + "project.files.remove", + ), + ( + "http-clients-v1", + "requests.api.post", + InvocationKind.FUNCTION, + "requests-api-post", + "project.http.post", + ), + ( + "object-storage-v1", + "mypy_boto3_s3.client.S3Client.put_object", + InvocationKind.INSTANCE_METHOD, + "typed-s3-put-object", + "project.S3.put_object", + ), + ( + "message-bus-v1", + "confluent_kafka.cimpl.Producer.produce", + InvocationKind.INSTANCE_METHOD, + "confluent-kafka-produce", + "confluent_kafka.Producer.produce", + ), + ], +) +def test_bundled_presets_match_only_exact_qualified_symbols( + tmp_path: Path, + preset: str, + symbol: str, + invocation: InvocationKind, + contract_id: str, + negative_symbol: str, +) -> None: + endpoint = _endpoint(tmp_path, "handler") + exact = _site( + tmp_path, + column=2, + symbol=symbol, + invocation=invocation, + spelling=symbol.rsplit(".", maxsplit=1)[-1], + ) + unrelated = _site( + tmp_path, + column=30, + symbol=negative_symbol, + invocation=invocation, + spelling=negative_symbol.rsplit(".", maxsplit=1)[-1], + ) + + audit = _audit( + tmp_path, + [(endpoint, [unrelated, exact])], + loaded=load_effect_preset(preset), + ) + + assert audit.summary.matched_calls == 1 + assert audit.summary.unmatched_calls == 1 + matched = [item for item in audit.occurrences if item.contract_id is not None] + assert [item.contract_id for item in matched] == [contract_id] + + def test_exact_matching_is_symbol_and_invocation_only(tmp_path: Path) -> None: endpoint = _endpoint(tmp_path, "handler") rows = [ diff --git a/tests/unit/test_effect_contract_presets.py b/tests/unit/test_effect_contract_presets.py index 43cb55c..68c5f12 100644 --- a/tests/unit/test_effect_contract_presets.py +++ b/tests/unit/test_effect_contract_presets.py @@ -9,8 +9,12 @@ from fastapi_endpoint_detector.config import AnalysisConfig, Config from fastapi_endpoint_detector.models.effect_contract import ( BUNDLED_EFFECT_PRESETS, + AsyncMode, EffectContractError, + EffectTiming, + InvocationKind, ProvenanceKind, + SelectorKind, load_effect_preset, ) @@ -19,11 +23,11 @@ _EXPECTED_PRESET_HASHES = { - "filesystem-v1": "sha256:5acc35da9d989ccafda0960090efefbbaa52ca5b70894882c24c4bf1355c2b96", - "http-clients-v1": "sha256:ab3d88b368db24f4c6c0879c8104105b09f23997997e63dd232856886bca6e2e", - "mongodb-v1": "sha256:7e0f41e452ac61b7340f02215963e8aa765333988b67d441b7aece9dfa53191c", - "object-storage-v1": "sha256:99707cccf212f530bd2437a3cb154d5ebea775857cc7d65c777771f9771cc6c4", - "redis-v1": "sha256:ce681490563300ce01dec68cd42af26c5fe8e06c7d5d45ae652dfce73c531ca2", + "filesystem-v1": "sha256:8e09b0a197a523b701bd18ce042b72bf8e8d5cc5c0741d18e7c1c61937712c40", + "http-clients-v1": "sha256:c1f6bcb56e06525f20e2eac5a68c9bc5812c281c054209fb6feb3c7df63cc890", + "message-bus-v1": "sha256:05c2d745da13fc39984d20b6cd260221eeac40ba7049a492cef6979488ac81a1", + "mongodb-v1": "sha256:1541057fa430ee8ced171b379aa9dab1f4007156fd9b8632784c0ccdfd2f2032", + "object-storage-v1": "sha256:6f33763d1502349481bca4b470f991e4c5a49d6a9b82e34df74a0f4b174161f9", "sqlalchemy-v1": "sha256:132982ba61f04626df531dc80c71ce5d21c12ec583a932d21c220486785c8d04", } @@ -33,10 +37,28 @@ "io-buffered-write", "io-text-read", "io-text-write", + "os-makedirs", + "os-mkdir", + "os-remove", + "os-rename", + "os-replace", + "os-rmdir", + "os-unlink", + "pathlib-mkdir", "pathlib-read-bytes", "pathlib-read-text", + "pathlib-rename", + "pathlib-replace", + "pathlib-rmdir", + "pathlib-touch", + "pathlib-unlink", "pathlib-write-bytes", "pathlib-write-text", + "shutil-copy", + "shutil-copy2", + "shutil-copyfile", + "shutil-move", + "shutil-rmtree", }, "http-clients-v1": { "aiohttp-session-delete", @@ -60,6 +82,20 @@ "httpx-client-patch", "httpx-client-post", "httpx-client-put", + "httpx-delete", + "httpx-get", + "httpx-head", + "httpx-options", + "httpx-patch", + "httpx-post", + "httpx-put", + "requests-api-delete", + "requests-api-get", + "requests-api-head", + "requests-api-options", + "requests-api-patch", + "requests-api-post", + "requests-api-put", "requests-session-delete", "requests-session-get", "requests-session-head", @@ -68,18 +104,35 @@ "requests-session-post", "requests-session-put", }, + "message-bus-v1": {"confluent-kafka-produce"}, "mongodb-v1": { + "motor-delete-many", + "motor-delete-one", + "motor-find-one", + "motor-insert-many", + "motor-insert-one", + "motor-replace-one", + "motor-update-many", + "motor-update-one", + "pymongo-delete-many", "pymongo-delete-one", "pymongo-find-one", + "pymongo-insert-many", "pymongo-insert-one", + "pymongo-replace-one", + "pymongo-update-many", "pymongo-update-one", }, "object-storage-v1": { + "typed-s3-copy-object", + "typed-s3-create-bucket", + "typed-s3-delete-bucket", "typed-s3-delete-object", "typed-s3-get-object", + "typed-s3-head-object", + "typed-s3-list-objects-v2", "typed-s3-put-object", }, - "redis-v1": {"redis-delete", "redis-get", "redis-publish", "redis-set"}, "sqlalchemy-v1": { "sqlalchemy-async-session-add", "sqlalchemy-async-session-add-all", @@ -109,14 +162,16 @@ def test_bundled_effect_presets_are_strict_versioned_snapshots(name: str) -> Non assert loaded.source_path == BUNDLED_EFFECT_PRESETS[name].resolve() expected_version = { - "filesystem-v1": "2.0.0", - "http-clients-v1": "2.0.0", + "filesystem-v1": "3.0.0", + "http-clients-v1": "3.0.0", + "mongodb-v1": "2.0.0", "object-storage-v1": "2.0.0", "sqlalchemy-v1": "3.0.0", }.get(name, "1.0.0") expected_revision = { - "filesystem-v1": "2", - "http-clients-v1": "2", + "filesystem-v1": "3", + "http-clients-v1": "3", + "mongodb-v1": "2", "object-storage-v1": "2", "sqlalchemy-v1": "3", }.get(name, "1") @@ -137,9 +192,11 @@ def test_presets_never_contain_bare_or_generic_method_symbols() -> None: loaded = load_effect_preset(name) for contract in loaded.document.contracts: parts = contract.symbol.split(".") - assert len(parts) >= 3 + assert len(parts) >= 2 assert contract.symbol not in forbidden - if parts[-1] in forbidden: + if contract.invocation != InvocationKind.FUNCTION: + assert len(parts) >= 3 + if parts[-1] in forbidden and contract.invocation != InvocationKind.FUNCTION: assert len(parts) >= 4 or parts[0] == "_io" assert contract.package is not None assert contract.package.python is not None or ( @@ -147,22 +204,6 @@ def test_presets_never_contain_bare_or_generic_method_symbols() -> None: ) -def test_object_storage_preset_uses_bucket_key_composite_identity() -> None: - loaded = load_effect_preset("object-storage-v1") - - assert loaded.document.schema_version == 4 - for contract in loaded.document.contracts: - assert contract.resource.model_dump( - mode="json", exclude_none=True, exclude_defaults=True - ) == { - "kind": "composite", - "components": [ - {"kind": "keyword", "name": "Bucket"}, - {"kind": "keyword", "name": "Key"}, - ], - } - - def test_http_client_preset_declares_exact_methods_for_each_supported_client() -> None: loaded = load_effect_preset("http-clients-v1") methods_by_class: dict[str, set[str]] = {} @@ -174,12 +215,120 @@ def test_http_client_preset_declares_exact_methods_for_each_supported_client() - expected = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} assert methods_by_class == { "aiohttp.client.ClientSession": expected, + "httpx._api": expected, "httpx._client.AsyncClient": expected, "httpx._client.Client": expected, + "requests.api": expected, "requests.sessions.Session": expected, } +def test_dynamic_or_generic_surfaces_are_not_preset_contracts() -> None: + symbols = { + contract.symbol + for name in BUNDLED_EFFECT_PRESETS + for contract in load_effect_preset(name).document.contracts + } + + assert symbols.isdisjoint( + { + "builtins.open", # mode-dependent until a predicate schema exists + "requests.api.request", # method argument is not modeled by schema v3 + "httpx.request", + "httpx.get", # public spelling resolves to the declaration owner below the facade + "aiohttp.client.request", + "boto3.client", + "redis.commands.core.BasicKeyCommands.get", # shared sync/async owner + "redis.Redis.get", + "motor.motor_asyncio.AsyncIOMotorCollection.find", + "pymongo.collection.Collection.update_one", + "pymongo.synchronous.collection.Collection.find", + "redis.commands.core.BasicKeyCommands.incr", + "aiokafka.producer.producer.AIOKafkaProducer.send_and_wait", + "kafka.producer.kafka.KafkaProducer.send", + "pika.channel.Channel.basic_publish", + "kombu.messaging.Producer.publish", + } + ) + + +def test_receiver_http_clients_abstain_from_incomplete_url_identity() -> None: + loaded = load_effect_preset("http-clients-v1") + contracts = {contract.id: contract for contract in loaded.document.contracts} + + receiver_ids = { + contract_id + for contract_id in contracts + if contract_id.startswith(("httpx-client-", "httpx-async-client-", "aiohttp-session-")) + } + assert receiver_ids + assert all(contracts[item].resource.kind == SelectorKind.NONE for item in receiver_ids) + assert contracts["httpx-get"].resource.kind == SelectorKind.ARGUMENT + assert contracts["requests-api-get"].resource.kind == SelectorKind.ARGUMENT + assert contracts["requests-api-get"].package is not None + assert contracts["requests-api-get"].package.version == ">=2.34,<3" + + +def test_object_storage_uses_only_complete_resource_identities() -> None: + loaded = load_effect_preset("object-storage-v1") + contracts = {contract.id: contract for contract in loaded.document.contracts} + + for contract_id in ( + "typed-s3-delete-object", + "typed-s3-get-object", + "typed-s3-put-object", + ): + resource = contracts[contract_id].resource + assert resource.kind == "composite" + assert [(item.kind, item.name) for item in resource.components] == [ + (SelectorKind.KEYWORD, "Bucket"), + (SelectorKind.KEYWORD, "Key"), + ] + + for contract_id in ("typed-s3-copy-object", "typed-s3-head-object"): + resource = contracts[contract_id].resource + assert resource.kind == SelectorKind.NONE + assert resource.name is None + + for contract_id in ( + "typed-s3-create-bucket", + "typed-s3-delete-bucket", + "typed-s3-list-objects-v2", + ): + resource = contracts[contract_id].resource + assert resource.kind == SelectorKind.KEYWORD + assert resource.name == "Bucket" + + +def test_async_presets_declare_only_supported_effect_timing() -> None: + mongodb = load_effect_preset("mongodb-v1") + motor = { + contract.id: contract + for contract in mongodb.document.contracts + if contract.id.startswith("motor-") + } + assert all(contract.behavior.async_mode == AsyncMode.ASYNC for contract in motor.values()) + assert all(contract.behavior.timing == EffectTiming.AWAIT for contract in motor.values()) + + http = load_effect_preset("http-clients-v1") + aiohttp = [ + contract for contract in http.document.contracts if contract.id.startswith("aiohttp-") + ] + assert aiohttp + assert all(contract.behavior.async_mode == AsyncMode.ASYNC for contract in aiohttp) + assert all(contract.behavior.timing == EffectTiming.AWAIT for contract in aiohttp) + + messages = load_effect_preset("message-bus-v1") + assert len(messages.document.contracts) == 1 + confluent = messages.document.contracts[0] + assert confluent.id == "confluent-kafka-produce" + assert confluent.symbol == "confluent_kafka.cimpl.Producer.produce" + assert confluent.behavior.async_mode == AsyncMode.SYNC + assert confluent.behavior.timing == EffectTiming.STAGED + assert confluent.package is not None + assert confluent.package.version == ">=2.13,<3" + + def test_sqlalchemy_preset_declares_exact_transaction_and_savepoint_scopes() -> None: loaded = load_effect_preset("sqlalchemy-v1") scopes = { @@ -224,7 +373,7 @@ def test_effect_preset_and_user_document_are_mutually_exclusive(tmp_path: Path) with pytest.raises(ValueError, match="mutually exclusive"): AnalysisConfig( effect_contracts=tmp_path / "effects.yaml", - effect_preset="redis-v1", + effect_preset="filesystem-v1", ) diff --git a/tests/unit/test_mypy_resolved_call_sites.py b/tests/unit/test_mypy_resolved_call_sites.py index 030fbbf..b34bf6a 100644 --- a/tests/unit/test_mypy_resolved_call_sites.py +++ b/tests/unit/test_mypy_resolved_call_sites.py @@ -2,8 +2,11 @@ import hashlib import json +from importlib.metadata import PackageNotFoundError, version from pathlib import Path +import pytest + from fastapi_endpoint_detector.analyzer.mypy_analyzer import ( EndpointDependencies, MypyAnalyzer, @@ -38,6 +41,80 @@ def _site_by_spelling(sites: list[ResolvedCallSite], spelling: str) -> list[Reso return [site for site in sites if site.source_spelling == spelling] +def _require_distribution(distribution: str, minimum: tuple[int, int]) -> None: + try: + installed = version(distribution) + except PackageNotFoundError: + pytest.skip(f"resolver fixture requires {distribution}") + numeric = tuple(int(part) for part in installed.split(".")[:2]) + if numeric < minimum: + pytest.skip(f"resolver fixture requires {distribution}>={minimum[0]}.{minimum[1]}") + + +def test_httpx_public_imports_resolve_to_declaration_owner(tmp_path: Path) -> None: + _require_distribution("httpx", (0, 27)) + main = tmp_path / "main.py" + main.write_text( + "import httpx\n" + "from httpx import get as public_get\n\n" + "def handler() -> None:\n" + " httpx.get('https://example.test/qualified')\n" + " public_get('https://example.test/imported')\n", + encoding="utf-8", + ) + + sites = MypyAnalyzer(tmp_path).analyze_endpoint(_endpoint(main, line=4)).resolved_call_sites + calls = [*_site_by_spelling(sites, "httpx.get"), *_site_by_spelling(sites, "public_get")] + + assert len(calls) == 2 + assert all(site.status == CallResolutionStatus.EXACT for site in calls) + assert all(site.invocation == InvocationKind.FUNCTION for site in calls) + assert {site.canonical_symbol for site in calls} == {"httpx._api.get"} + assert all(site.canonical_symbol != "httpx.get" for site in calls) + + +def test_requests_public_imports_resolve_to_typed_declaration_owner(tmp_path: Path) -> None: + _require_distribution("requests", (2, 34)) + main = tmp_path / "main.py" + main.write_text( + "import requests\n" + "from requests import post as public_post\n\n" + "def handler() -> None:\n" + " requests.post('https://example.test/qualified')\n" + " public_post('https://example.test/imported')\n", + encoding="utf-8", + ) + + sites = MypyAnalyzer(tmp_path).analyze_endpoint(_endpoint(main, line=4)).resolved_call_sites + calls = [*_site_by_spelling(sites, "requests.post"), *_site_by_spelling(sites, "public_post")] + + assert len(calls) == 2 + assert all(site.status == CallResolutionStatus.EXACT for site in calls) + assert all(site.invocation == InvocationKind.FUNCTION for site in calls) + assert {site.canonical_symbol for site in calls} == {"requests.api.post"} + assert all(site.canonical_symbol != "requests.post" for site in calls) + + +def test_confluent_public_producer_resolves_to_cimpl_owner(tmp_path: Path) -> None: + _require_distribution("confluent-kafka", (2, 13)) + main = tmp_path / "main.py" + main.write_text( + "from confluent_kafka import Producer\n\n" + "def handler(producer: Producer) -> None:\n" + " producer.produce('orders', b'value')\n", + encoding="utf-8", + ) + + sites = MypyAnalyzer(tmp_path).analyze_endpoint(_endpoint(main, line=3)).resolved_call_sites + produce = _site_by_spelling(sites, "producer.produce") + + assert len(produce) == 1 + assert produce[0].status == CallResolutionStatus.EXACT + assert produce[0].invocation == InvocationKind.INSTANCE_METHOD + assert produce[0].canonical_symbol == "confluent_kafka.cimpl.Producer.produce" + assert produce[0].canonical_symbol != "confluent_kafka.Producer.produce" + + def test_captures_exact_functions_constructors_and_method_kinds(tmp_path: Path) -> None: (tmp_path / "helpers.py").write_text( "def emit() -> int:\n return 1\n",