Skip to content

Commit d3b8f3f

Browse files
feat(teams): package primitive subpaths (lazy __init__) + divergence docs (chat@4.31 8c71411) (#162)
Make `chat_sdk.adapters.teams.__init__` PEP-562 lazy so the six SDK-free primitive subpaths (`format`, `webhook`, `api`, `cards_input`, `graph`, `modals`) import without pulling in the high-level adapter or the `microsoft_teams` SDK -- mirroring the 0.4.30 Slack subpath pattern. The public contract is preserved byte-identically: `TeamsAdapter`, `create_teams_adapter`, `TeamsAdapterConfig`, and `TeamsAuthCertificate` still import from the package root (lazily resolved, same objects). - Add `tests/test_teams_primitives_packaging.py`: fresh-interpreter boundary test proving each of the six subpaths leaves `microsoft_teams` (+ submodules), `httpx`, `aiohttp`, and the adapter module out of `sys.modules` -- the runtime-free guarantee the per-PR source-scans deferred. Also pins the public-API surface. - Dedup `TeamsFieldElement`: canonical definition now lives in `cards_input.py`; `modals.py` re-imports it (re-exported for its public surface), mirroring upstream's `modals-primitives` importing it from `../cards-primitives`. - Consolidate the divergence ledger in `docs/UPSTREAM_SYNC.md`: add the graph SSRF/token-leak gate row plus benign notes (cards_input `or []`, graph `quote(safe='')` over-encoding, graph defensive `isinstance` coercion); clarify the `_hoist_dividers` (#45) note; record the six new primitive subpaths in the module-mapping section. - Refresh the per-PR boundary-test docstrings that pointed forward to this packaging PR.
1 parent b514fe6 commit d3b8f3f

9 files changed

Lines changed: 295 additions & 42 deletions

docs/UPSTREAM_SYNC.md

Lines changed: 31 additions & 1 deletion
Large diffs are not rendered by default.
Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,48 @@
1-
"""Teams adapter for chat-sdk."""
1+
"""Teams adapter for chat-sdk.
22
3-
from chat_sdk.adapters.teams.adapter import TeamsAdapter, create_teams_adapter
4-
from chat_sdk.adapters.teams.types import (
5-
TeamsAdapterConfig,
6-
TeamsAuthCertificate,
7-
)
3+
The high-level adapter is loaded lazily (PEP 562) so that the low-level
4+
primitive subpaths (``chat_sdk.adapters.teams.format`` / ``.webhook`` /
5+
``.api`` / ``.cards_input`` / ``.graph`` / ``.modals``) can be imported
6+
without pulling in the full adapter runtime or the Microsoft Teams SDK —
7+
mirroring upstream's ``@chat-adapter/teams/*`` subpath export boundary
8+
and the 0.4.30 Slack subpath pattern (vercel/chat#538).
9+
10+
The public contract below MUST stay byte-identical: every name in
11+
``__all__`` resolves to exactly the same object it did when this module
12+
imported eagerly.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import importlib
18+
from typing import TYPE_CHECKING
19+
20+
if TYPE_CHECKING:
21+
from chat_sdk.adapters.teams.adapter import TeamsAdapter as TeamsAdapter
22+
from chat_sdk.adapters.teams.adapter import create_teams_adapter as create_teams_adapter
23+
from chat_sdk.adapters.teams.types import TeamsAdapterConfig as TeamsAdapterConfig
24+
from chat_sdk.adapters.teams.types import TeamsAuthCertificate as TeamsAuthCertificate
25+
26+
# Maps each public export to the module that defines it. Resolving lazily
27+
# keeps the primitive subpaths free of the adapter/SDK runtime.
28+
_EXPORT_MODULES: dict[str, str] = {
29+
"TeamsAdapter": "chat_sdk.adapters.teams.adapter",
30+
"create_teams_adapter": "chat_sdk.adapters.teams.adapter",
31+
"TeamsAdapterConfig": "chat_sdk.adapters.teams.types",
32+
"TeamsAuthCertificate": "chat_sdk.adapters.teams.types",
33+
}
834

935
__all__ = [
1036
"TeamsAdapter",
1137
"TeamsAdapterConfig",
1238
"TeamsAuthCertificate",
1339
"create_teams_adapter",
1440
]
41+
42+
43+
def __getattr__(name: str) -> object:
44+
module_path = _EXPORT_MODULES.get(name)
45+
if module_path is not None:
46+
module = importlib.import_module(module_path)
47+
return getattr(module, name)
48+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

src/chat_sdk/adapters/teams/cards_input.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
__all__ = [
3131
"TEAMS_FREEFORM_ACTION_ID",
3232
"TEAMS_INPUT_ACTION_PREFIX",
33+
"TeamsFieldElement",
3334
"TeamsInputAction",
3435
"TeamsInputOption",
3536
"TeamsInputRequest",
@@ -38,6 +39,22 @@
3839
"parse_teams_input_response",
3940
]
4041

42+
43+
class TeamsFieldElement(TypedDict):
44+
"""One ``label`` / ``value`` row of a ``fields`` element.
45+
46+
Canonical home for the shared field shape. Upstream defines it in
47+
``cards-primitives/types.ts`` and re-exports it from the cards primitive;
48+
``modals-primitives/types.ts`` imports it from ``../cards-primitives``. We
49+
define it once here (the SDK-free cards-primitives input subpath) and
50+
:mod:`chat_sdk.adapters.teams.modals` re-imports it, so both primitives
51+
share a single definition rather than duplicating the two-key dict.
52+
"""
53+
54+
label: str
55+
value: str
56+
57+
4158
# Upstream constants (cards-primitives/input.ts). The option ChoiceSet id and
4259
# every Action.Submit ``actionId`` are prefixed with this; the freeform
4360
# Input.Text element uses the fixed freeform id below. Both are exact — the

src/chat_sdk/adapters/teams/modals.py

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@
2020
format primitive), exactly as upstream's ``index.ts`` imports
2121
``convertTeamsEmojiPlaceholders`` from ``../format``.
2222
* The ``fields`` modal child renders :class:`TeamsFieldElement` (``label`` /
23-
``value``) — the same field shape used by the cards primitive
24-
(``TeamsFieldElement`` from ``../cards-primitives`` upstream). It is a plain
25-
two-key ``TypedDict``; the modal-specific input element shapes
26-
(``text_input`` / ``select`` / ``radio_select``) are defined here because
27-
upstream defines them in ``modals-primitives/types.ts``, distinct from the
28-
``cards-primitives`` input request shapes in
23+
``value``) — the same field shape used by the cards primitive. We import the
24+
single canonical definition from :mod:`chat_sdk.adapters.teams.cards_input`
25+
(re-exported here for the public surface), exactly as upstream's
26+
``modals-primitives/types.ts`` imports ``TeamsFieldElement`` from
27+
``../cards-primitives`` rather than redefining it. The modal-specific input
28+
element shapes (``text_input`` / ``select`` / ``radio_select``) are defined
29+
here because upstream defines them in ``modals-primitives/types.ts``, distinct
30+
from the ``cards-primitives`` input request shapes in
2931
:mod:`chat_sdk.adapters.teams.cards_input`.
3032
3133
Wire-key fidelity (HAZARDS):
@@ -54,6 +56,7 @@
5456

5557
from typing import Any, Literal, NotRequired, TypedDict
5658

59+
from chat_sdk.adapters.teams.cards_input import TeamsFieldElement
5760
from chat_sdk.adapters.teams.format import convert_teams_emoji_placeholders
5861

5962
__all__ = [
@@ -83,17 +86,6 @@
8386
_ADAPTIVE_CARD_VERSION = "1.4"
8487

8588

86-
class TeamsFieldElement(TypedDict):
87-
"""One ``label`` / ``value`` row of a ``fields`` modal child.
88-
89-
Mirrors the ``TeamsFieldElement`` shared with the cards primitive
90-
(upstream ``../cards-primitives``).
91-
"""
92-
93-
label: str
94-
value: str
95-
96-
9789
class TeamsModalTextElement(TypedDict):
9890
"""A block of (optionally styled) text in a modal."""
9991

tests/test_teams_api_primitive.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -432,10 +432,11 @@ class TestApiImportBoundary:
432432
Upstream's boundary test is a **static source-scan**: it reads every
433433
non-test ``.ts`` in the ``api/`` directory and asserts the source never
434434
imports the full adapter, the shared runtime, or ``@microsoft/teams.apps``.
435-
We port that source-scan over the ``api/`` package's ``.py`` files — this
436-
is the right granularity while the package's ``teams/__init__.py`` is still
437-
eager (the PEP 562 lazy-subpath registration that would let a *runtime*
438-
``sys.modules`` check pass is deferred to the packaging PR T7).
435+
We port that source-scan over the ``api/`` package's ``.py`` files. The
436+
complementary *runtime* ``sys.modules`` boundary (the package's
437+
``teams/__init__.py`` is PEP-562 lazy as of the packaging PR, so importing
438+
the api subpath no longer pulls in the adapter) is proven once for all six
439+
primitive subpaths in ``tests/test_teams_primitives_packaging.py``.
439440
"""
440441

441442
def test_api_source_does_not_import_the_adapter_sdk_or_runtime(self) -> None:
@@ -469,10 +470,10 @@ def test_importing_api_does_not_eagerly_import_an_http_client(self) -> None:
469470
"""Importing the api subpath in a fresh interpreter must not load an
470471
HTTP client — the default fetch imports ``httpx`` lazily.
471472
472-
(We do not assert the high-level adapter is absent here: the eager
473-
``teams/__init__.py`` pulls it in transitively until packaging PR T7
474-
makes the subpath lazy. The source-scan above already proves the api
475-
sources themselves never import the adapter.)
473+
(The full runtime boundary — including that the high-level adapter is
474+
absent now that ``teams/__init__.py`` is lazy — is asserted in
475+
``tests/test_teams_primitives_packaging.py``. This narrower check stays
476+
as the api subpath's own HTTP-client guard.)
476477
"""
477478
code = (
478479
"import sys\n"

tests/test_teams_cards_primitive.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,11 @@ def test_importing_input_module_does_not_load_sdk_or_http_client(self) -> None:
257257
258258
Run in a *subprocess* so the shared test process's ``sys.modules`` is
259259
never mutated (a global mutation here would pollute downstream
260-
module-identity assertions). Mirrors the Round-1 api/webhook boundary
261-
tests: we do NOT assert the high-level adapter is absent, because the
262-
eager ``teams/__init__.py`` pulls it in transitively until the lazy
263-
subpath registration lands in packaging PR T7 — the source scan above
264-
already proves the input module itself never imports the adapter.
260+
module-identity assertions). This narrower check stays as the input
261+
module's own SDK/HTTP-client guard; the full runtime boundary —
262+
including that the high-level adapter is absent now that
263+
``teams/__init__.py`` is PEP-562 lazy — is asserted for all six
264+
primitive subpaths in ``tests/test_teams_primitives_packaging.py``.
265265
"""
266266
import subprocess
267267
import sys

tests/test_teams_format_primitives.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,10 @@ class TestFormatImportBoundary:
171171
reference the ``microsoft_teams`` SDK, an HTTP client (``httpx`` /
172172
``aiohttp``), or the high-level adapter / chat runtime. (Upstream's
173173
boundary test reads the module source and asserts it does not contain the
174-
forbidden imports — runtime ``sys.modules`` inspection is deferred until
175-
the ``teams/__init__.py`` lazy-subpath migration in the packaging PR.)
174+
forbidden imports; the complementary runtime ``sys.modules`` inspection —
175+
now enabled by the ``teams/__init__.py`` PEP-562 lazy-subpath migration —
176+
is asserted for all six primitive subpaths in
177+
``tests/test_teams_primitives_packaging.py``.)
176178
"""
177179

178180
def test_source_does_not_import_the_sdk_runtime_or_adapter(self):
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Packaging boundary for the Teams primitive subpaths (Wave-B T7).
2+
3+
The six SDK-free Teams primitive subpaths --
4+
5+
chat_sdk.adapters.teams.format
6+
chat_sdk.adapters.teams.webhook
7+
chat_sdk.adapters.teams.api
8+
chat_sdk.adapters.teams.cards_input
9+
chat_sdk.adapters.teams.graph
10+
chat_sdk.adapters.teams.modals
11+
12+
-- each landed with a *static source-scan* boundary test that asserted the
13+
module's own source never imports the Teams SDK, an HTTP client, or the
14+
high-level adapter. Those per-PR tests explicitly *deferred* the runtime
15+
``sys.modules`` guarantee to this packaging PR, because importing any subpath
16+
ran the package's ``teams/__init__.py``, which (while still eager) pulled the
17+
adapter in transitively.
18+
19+
Now that ``teams/__init__.py`` is PEP-562 lazy (mirroring the 0.4.30 Slack
20+
subpath pattern), importing a primitive subpath in a *fresh interpreter* must
21+
leave the Teams SDK, the HTTP clients, and the adapter module entirely out of
22+
``sys.modules``. This is the runtime-free guarantee the source-scans deferred,
23+
proven once here for all six subpaths.
24+
25+
It also pins the public-API contract: making ``teams/__init__.py`` lazy must
26+
not change that ``TeamsAdapter`` / ``create_teams_adapter`` /
27+
``TeamsAdapterConfig`` / ``TeamsAuthCertificate`` still import from the package
28+
root (the 0.4.30 Teams SDK migration's public surface is coupled downstream).
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import subprocess
34+
import sys
35+
36+
import pytest
37+
38+
# The six SDK-free primitive subpaths packaged by T7.
39+
_PRIMITIVE_SUBPATHS = [
40+
"chat_sdk.adapters.teams.format",
41+
"chat_sdk.adapters.teams.webhook",
42+
"chat_sdk.adapters.teams.api",
43+
"chat_sdk.adapters.teams.cards_input",
44+
"chat_sdk.adapters.teams.graph",
45+
"chat_sdk.adapters.teams.modals",
46+
]
47+
48+
# Modules that must NEVER load when a primitive subpath is imported: the
49+
# Microsoft Teams SDK (root + every submodule the adapter reaches for), both
50+
# HTTP clients, and the high-level adapter module itself.
51+
_FORBIDDEN_MODULES = [
52+
"microsoft_teams",
53+
"microsoft_teams.apps",
54+
"microsoft_teams.api",
55+
"microsoft_teams.common",
56+
"httpx",
57+
"aiohttp",
58+
"chat_sdk.adapters.teams.adapter",
59+
]
60+
61+
62+
def _import_in_fresh_interpreter(subpath: str) -> subprocess.CompletedProcess[str]:
63+
"""Import ``subpath`` in a brand-new interpreter and assert the forbidden
64+
runtime modules are absent from ``sys.modules`` afterwards."""
65+
forbidden = repr(_FORBIDDEN_MODULES)
66+
code = (
67+
"import sys\n"
68+
f"import {subpath}\n"
69+
f"forbidden = {forbidden}\n"
70+
"loaded = [name for name in forbidden if name in sys.modules]\n"
71+
f"assert not loaded, "
72+
f"'{subpath} eagerly imported runtime modules: ' + repr(loaded)\n"
73+
)
74+
return subprocess.run(
75+
[sys.executable, "-c", code],
76+
capture_output=True,
77+
text=True,
78+
check=False,
79+
)
80+
81+
82+
class TestTeamsPrimitiveSubpathBoundary:
83+
"""The runtime-free guarantee the per-PR source-scans deferred to T7."""
84+
85+
@pytest.mark.parametrize("subpath", _PRIMITIVE_SUBPATHS)
86+
def test_subpath_import_is_runtime_free(self, subpath: str) -> None:
87+
result = _import_in_fresh_interpreter(subpath)
88+
assert result.returncode == 0, (
89+
f"{subpath} pulled in a forbidden runtime module\nstdout: {result.stdout}\nstderr: {result.stderr}"
90+
)
91+
92+
def test_all_six_subpaths_are_covered(self) -> None:
93+
# Guard against the list silently shrinking: T7 packages exactly the
94+
# six SDK-free primitive subpaths.
95+
assert len(_PRIMITIVE_SUBPATHS) == 6
96+
assert len(set(_PRIMITIVE_SUBPATHS)) == 6
97+
98+
99+
class TestTeamsPublicApiSurvivesLazyInit:
100+
"""The lazy ``teams/__init__.py`` must preserve the public contract."""
101+
102+
def test_public_adapter_api_still_imports_from_package_root(self) -> None:
103+
# Fresh interpreter: the four public names must resolve from the
104+
# package root exactly as they did when __init__ imported eagerly.
105+
code = (
106+
"from chat_sdk.adapters.teams import (\n"
107+
" TeamsAdapter,\n"
108+
" create_teams_adapter,\n"
109+
" TeamsAdapterConfig,\n"
110+
" TeamsAuthCertificate,\n"
111+
")\n"
112+
"assert TeamsAdapter.__name__ == 'TeamsAdapter'\n"
113+
"assert create_teams_adapter.__name__ == 'create_teams_adapter'\n"
114+
"assert TeamsAdapterConfig.__name__ == 'TeamsAdapterConfig'\n"
115+
"assert TeamsAuthCertificate.__name__ == 'TeamsAuthCertificate'\n"
116+
)
117+
result = subprocess.run(
118+
[sys.executable, "-c", code],
119+
capture_output=True,
120+
text=True,
121+
check=False,
122+
)
123+
assert result.returncode == 0, result.stderr
124+
125+
def test_lazily_resolved_objects_are_identical_to_direct_imports(self) -> None:
126+
from chat_sdk.adapters.teams import (
127+
TeamsAdapter,
128+
TeamsAdapterConfig,
129+
TeamsAuthCertificate,
130+
create_teams_adapter,
131+
)
132+
from chat_sdk.adapters.teams.adapter import (
133+
TeamsAdapter as DirectAdapter,
134+
)
135+
from chat_sdk.adapters.teams.adapter import (
136+
create_teams_adapter as direct_factory,
137+
)
138+
from chat_sdk.adapters.teams.types import (
139+
TeamsAdapterConfig as DirectConfig,
140+
)
141+
from chat_sdk.adapters.teams.types import (
142+
TeamsAuthCertificate as DirectCert,
143+
)
144+
145+
assert TeamsAdapter is DirectAdapter
146+
assert create_teams_adapter is direct_factory
147+
assert TeamsAdapterConfig is DirectConfig
148+
assert TeamsAuthCertificate is DirectCert
149+
150+
def test_public_all_is_unchanged(self) -> None:
151+
import chat_sdk.adapters.teams as teams_pkg
152+
153+
assert teams_pkg.__all__ == [
154+
"TeamsAdapter",
155+
"TeamsAdapterConfig",
156+
"TeamsAuthCertificate",
157+
"create_teams_adapter",
158+
]
159+
160+
def test_unknown_attribute_raises_attribute_error(self) -> None:
161+
import chat_sdk.adapters.teams as teams_pkg
162+
163+
with pytest.raises(AttributeError):
164+
_ = teams_pkg.NoSuchExport # type: ignore[attr-defined]
165+
166+
def test_top_level_adapters_package_still_resolves_teams(self) -> None:
167+
from chat_sdk.adapters import TeamsAdapter, create_teams_adapter
168+
from chat_sdk.adapters.teams.adapter import (
169+
TeamsAdapter as DirectAdapter,
170+
)
171+
from chat_sdk.adapters.teams.adapter import (
172+
create_teams_adapter as direct_factory,
173+
)
174+
175+
assert TeamsAdapter is DirectAdapter
176+
assert create_teams_adapter is direct_factory

tests/test_teams_webhook_primitive.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -465,10 +465,11 @@ def test_source_does_not_import_sdk_or_adapter(self):
465465
files must not import the ``microsoft_teams`` SDK, an HTTP client, the
466466
shared runtime, or the high-level Teams adapter.
467467
468-
DIVERGENCE FROM SLACK: a runtime ``sys.modules`` boundary check is not
469-
usable yet because the Teams package ``__init__`` still eagerly imports
470-
the adapter (the PEP-562 lazy conversion lands in packaging PR T7).
471468
Upstream's boundary test is itself a source scan, so this mirrors it.
469+
The complementary runtime ``sys.modules`` boundary — now usable because
470+
the Teams package ``__init__`` is PEP-562 lazy and no longer eagerly
471+
imports the adapter — is asserted for all six primitive subpaths in
472+
``tests/test_teams_primitives_packaging.py``.
472473
"""
473474
directory = Path(__file__).resolve().parents[1] / "src" / "chat_sdk" / "adapters" / "teams" / "webhook"
474475
sources = [path for path in directory.glob("*.py")]

0 commit comments

Comments
 (0)