Skip to content

Added initial typehints#494

Open
danielmorell wants to merge 23 commits into
masterfrom
added/typehints
Open

Added initial typehints#494
danielmorell wants to merge 23 commits into
masterfrom
added/typehints

Conversation

@danielmorell
Copy link
Copy Markdown
Collaborator

Description of the change

This PR adds the initial work of getting static type checking with mypy working. Because we support Python 3.9 to 3.14 and have integrations with a lot of frameworks that may or may not exist in the environment, our type imports can be a little messy at times. As much as possible we try not to ignore types.

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Maintenance
  • New release

Related issues

Checklists

Development

  • Lint rules pass locally
  • The code changed/added as part of this pull request has been covered with tests
  • All tests related to the changed code pass in development

Code review

  • This pull request has a descriptive title and information useful to a reviewer. There may be a screenshot or screencast attached
  • "Ready for review" label attached to the PR and reviewers assigned
  • Issue from task tracker has a link to this pull request
  • Changes have been reviewed by at least one other engineer

@danielmorell danielmorell requested a review from brianr April 17, 2026 20:55
@danielmorell danielmorell added this to the v1.4.0 milestone Apr 17, 2026
@brianr
Copy link
Copy Markdown
Member

brianr commented Apr 21, 2026

@claude review

Comment thread pyproject.toml Outdated
Comment thread rollbar/logger.py Outdated
Comment thread rollbar/examples/asgi/app.py Outdated
Comment thread rollbar/__init__.py
Comment thread rollbar/__init__.py Outdated
Comment thread rollbar/__init__.py
Comment thread rollbar/__init__.py Outdated
Comment thread rollbar/lib/transforms/__init__.py Outdated
Comment thread rollbar/logger.py Outdated
@brianr
Copy link
Copy Markdown
Member

brianr commented May 5, 2026

@codex please review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8102b7dca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rollbar/__init__.py
Comment thread rollbar/contrib/fastapi/utils.py Outdated
Comment thread rollbar/__init__.py
Comment thread rollbar/lib/_async.py
Comment thread rollbar/__init__.py Outdated
Comment thread rollbar/lib/type_info.py Outdated
Comment thread rollbar/lib/_async.py
Comment thread pyproject.toml
Comment thread rollbar/__init__.py Outdated
Copy link
Copy Markdown
Contributor

@terencehonles terencehonles left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks pretty extensive so that's nice to see. I see a couple things that you may want to consider.

You may want to consistently use from __future__ import annotations across the code. This does change the meaning of the imports at runtime and will require a consumer to resolve them differently, but since you didn't actually export the type comments I think this is a safe change to make and it makes sense to make it everywhere before you do export the type comments.

You're missing the py.typed marker, and you may want to add that. I'm not sure if you're waiting for more complete type coverage to export it.

You may want to lean into from __future__ import annotations by using modern syntax where not in a runtime position. I commented on a few of these and you can use a tool like pyupgrade to migrate these automatically, but it will change more than just the type definitions.

Comment thread rollbar/lib/type_info.py Outdated
return DEFAULT

# The keys will be coming from data objects in the payload, so they could be just about anything hashable.
KeyType = Union[str, int, float, binary_type, Hashable]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're using from __future__ import annotations does it make sense to just use the newer shorthand syntax:

Suggested change
KeyType = Union[str, int, float, binary_type, Hashable]
KeyType = str | int | float | binary_type | Hashable

Comment thread rollbar/lib/traverse.py Outdated
Comment on lines +91 to +92
allowed_circular_reference_types: Optional[type | tuple[type, ...]] = None,
memo: Optional[dict[int, tuple[KeyType, ...]]] = None,
Copy link
Copy Markdown
Contributor

@terencehonles terencehonles May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're using from __future__ import annotations does it make sense to just use the newer shorthand syntax:

Suggested change
allowed_circular_reference_types: Optional[type | tuple[type, ...]] = None,
memo: Optional[dict[int, tuple[KeyType, ...]]] = None,
allowed_circular_reference_types: type | tuple[type, ...] | None = None,
memo: dict[int, tuple[KeyType, ...]] | None = None,


def transform(obj, transforms, key=None, batch_transforms=False):
class Handlers(TypedDict, total=False):
string_handler: Callable
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are all Callable[[Any, Any | None], Any] right? It might make sense to at least type this so that handlers have the right signature (via a typevar HandlerType would allow deduplication).

Comment thread rollbar/test/fastapi_tests/test_routing.py
Comment thread rollbar/lib/__init__.py
Comment thread pyproject.toml
Comment thread rollbar/lib/transform.py Outdated
Comment on lines +311 to 313
])
app.add_middleware(ReporterMiddleware)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 test_should_return_current_request (rollbar/test/starlette_tests/test_middleware.py:294-316) silently passes without running its assertion. The PR rewrites the test to put the assertEqual(get_public_attrs(request), get_public_attrs(original_request)) inside root(), but the route is registered as Route('/{param}', endpoint=root) while the client calls client.get('/'). Starlette's default path-parameter converter is [^/]+ (requires at least one non-slash character), so / does not match /{param} — the router returns 404, root() is never invoked, and the assertion never runs. Fix: change Route('/{param}', endpoint=root) to Route('/', endpoint=root) (or change the client call to client.get('/foo')).

Extended reasoning...

What the bug is

In rollbar/test/starlette_tests/test_middleware.py, the new test_should_return_current_request registers Route('/{param}', endpoint=root) and then issues client.get('/'). The assertion that exercises the contract under test —

async def root(original_request):
    request = get_current_request()
    self.assertEqual(get_public_attrs(request), get_public_attrs(original_request))
    return PlainTextResponse('OK')

— lives inside the root() handler. Starlette's default string convertor for path parameters is the regex [^/]+, which requires at least one non-slash character. Therefore the path / does not match /{param}, the router returns HTTP 404, root() is never invoked, and the assertEqual inside it never runs. The test passes vacuously while providing zero coverage of get_current_request() — exactly the contract the PR is overhauling for Starlette.

How this regressed

Before the PR, the test used the decorator form @app.route('/') which does match /. The refactor moved to the explicit Route() list style and apparently copy-pasted the '/{param}' pattern from the adjacent test_should_store_current_request. That neighboring test still works because its assertion is on the store_current_request mock (store_current_request.assert_called_once()), which the middleware invokes before routing happens — so it doesn't matter there that root() never executes. test_should_return_current_request has no such fallback: every meaningful assertion is inside the unreached handler.

Empirical reproduction

from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from starlette.responses import PlainTextResponse

called = False
async def root(request):
    global called
    called = True
    return PlainTextResponse('OK')

app = Starlette(routes=[Route('/{param}', endpoint=root)])
client = TestClient(app)
r = client.get('/')
# r.status_code == 404, called == False, r.text == 'Not Found'

r2 = client.get('/foo')
# r2.status_code == 200, called == True

This was independently reproduced by every verifier that examined the bug.

Impact

Silent loss of test coverage for an exact behavior this PR is reworking — get_current_request() consistency in the Starlette ReporterMiddleware. A developer touching this code path could break the implementation and the test would happily continue to pass. CI cannot catch this kind of regression because the test reports success.

How to fix

One-character fix, either side works:

# Option A - fix the route to match
app = Starlette(routes=[Route('/', endpoint=root)])

# Option B - fix the client call to match the route
client.get('/foo')

Option A is preferable since the test has no need for a path parameter.

Step-by-step proof

  1. routes=[Route('/{param}', endpoint=root)] registers a single route whose path regex is ^/(?P<param>[^/]+)$.
  2. client.get('/') issues a request with path '/'.
  3. The path '/' does not match ^/(?P<param>[^/]+)$ (the [^/]+ requires at least one character), so the Starlette router falls through.
  4. With no matching route, Starlette returns a 404 response and never calls the endpoint.
  5. Therefore root() does not execute, and the self.assertEqual(...) on line 305 (inside root()) is never evaluated.
  6. unittest records the test as passing because no assertion was raised — but no assertion was checked either.

Comment thread rollbar/__init__.py
Comment on lines +630 to +632
def report_exc_info(
exc_info: tuple[type[BaseException], BaseException, types.TracebackType] | tuple[None, None, None] | None = None,
request=None, extra_data=None, payload_data=None, level=None, **kw):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new return annotation on report_exc_info declares the third tuple element as types.TracebackType (non-None) when the exception is non-None, but two internal callers in this file pass a potentially-None traceback in the non-None-exception branch: the Twisted log_handler at line 166 passes err.getTracebackObject() (Twisted's Failure.getTracebackObject() returns Optional[TracebackType]), and the lambda_function wrapper at line 624 passes trace.tb_next (TracebackType.tb_next is Optional[TracebackType]). The function body handles None tracebacks correctly (traceback.extract_tb(None) returns []), so runtime is fine and current mypy CI passes — both call sites live in untyped functions and aren't analysed in default mode. Filing as nit: widen to tuple[type[BaseException], BaseException, types.TracebackType | None] | tuple[None, None, None] | None so the annotation matches the actual contract.

Extended reasoning...

What the bug is

The new return annotation at rollbar/__init__.py:630-632:

def report_exc_info(
        exc_info: tuple[type[BaseException], BaseException, types.TracebackType] | tuple[None, None, None] | None = None,
        ...):

declares two alternatives for non-None tuples: either (type, exc, TracebackType) (all non-None) or (None, None, None). The third element must be a real TracebackType when the exception isn't None. But two internal callers in this same file pass tuples whose third element is Optional[TracebackType]:

  1. rollbar/__init__.py:166 (Twisted log_handler)report_exc_info((err.type, err.value, err.getTracebackObject())). Twisted's Failure.getTracebackObject() is documented to return Optional[TracebackType]; it returns None when the failure was constructed without a real traceback (which happens routinely with Failure's pickling/serialization paths).

  2. rollbar/__init__.py:624 (lambda_function wrapper)report_exc_info((cls, exc, trace.tb_next)). Per the stdlib typeshed, TracebackType.tb_next is TracebackType | None — it is None when the exception was raised in the innermost frame, so the originating frame has no further frame to point to. (Also, from mypy's perspective inside an except: block, sys.exc_info()'s static return is Union[tuple[type, exc, tb], tuple[None, None, None]], so cls/exc/trace are all Optional to start.)

Both produce tuple[type, exc, None] — which matches neither alternative of the declared union.

Why the function body is fine

The body delegates to _walk_trace_chain(cls, exc, trace)_trace_data(cls, exc, trace)traceback.extract_tb(trace). Per the Python docs, traceback.extract_tb(None) returns [], so the None case is handled cleanly without any branch needed. The actual contract the function accepts at runtime is therefore wider than the annotation declares.

Why mypy CI passes today (addressing the refutation)

A refuting verifier correctly pointed out that the project's mypy config ([tool.mypy] packages = ["rollbar"] in pyproject.toml) uses default settings without check_untyped_defs, so untyped function bodies are skipped. Both call sites live in untyped nested functions (log_handler and wrapper), so neither call site is currently analysed.

That's accurate, and is why this is filed as nit rather than normal severity — there is no runtime impact and the new types CI job demonstrably passes today. But the refutation does not establish that the annotation is correct; it only establishes that the inconsistency happens to be invisible to today's CI. Two reasons it's still worth fixing here:

  • The PR's stated goal is "the initial work of getting static type checking with mypy working" and it introduces the types job. An annotation that contradicts the function's documented runtime contract is exactly the inconsistency that CI is meant to catch in follow-up work — the moment anyone annotates log_handler/wrapper (a natural next step) or enables check_untyped_defs, real errors surface.

  • External annotated callers using the idiomatic report_exc_info((type(e), e, e.__traceback__)) pattern would be flagged by mypy today, because BaseException.__traceback__ is TracebackType | None per typeshed. So the bug isn't purely internal — it can affect any annotated user code that passes a triple.

Empirical reproduction

One verifier ran:

$ mypy rollbar/__init__.py --check-untyped-defs
rollbar/__init__.py:624: error: Argument 1 to 'report_exc_info' has incompatible type
'tuple[type[BaseException] | None, BaseException | None, TracebackType | Any | None]';
expected 'tuple[type[BaseException], BaseException, TracebackType] | tuple[None, None, None] | None'  [arg-type]

And another verifier reproduced the same with an annotated caller using (type(e), e, e.__traceback__).

Step-by-step proof

  1. Read rollbar/__init__.py:630-632 — third element of the first union arm is the bare types.TracebackType.
  2. Read rollbar/__init__.py:624report_exc_info((cls, exc, trace.tb_next)). Per the stdlib typeshed, TracebackType.tb_next: TracebackType | None.
  3. So a stack with a single frame (e.g. def f(): raise RuntimeError) produces trace.tb_next is None, and the tuple passed is (RuntimeError, RuntimeError(...), None) — matching neither arm of the declared union.
  4. Read rollbar/__init__.py:166report_exc_info((err.type, err.value, err.getTracebackObject())). Twisted typeshed: Failure.getTracebackObject() -> TracebackType | None.
  5. Both call sites construct tuple[type, exc, None] — which violates the declared signature.
  6. Read _trace_data at rollbar/__init__.py — it calls traceback.extract_tb(trace). Per Python docs, traceback.extract_tb(None) == [], so the body handles it without an extra branch.

Suggested fix

One-line widening to match the actual contract:

def report_exc_info(
        exc_info: tuple[type[BaseException], BaseException, types.TracebackType | None] | tuple[None, None, None] | None = None,
        ...):

Comment thread pyproject.toml
Comment on lines 47 to 53
[dependency-groups]
test = [
"blinker",
"httpx",
"pytest",
"python-multipart",
"webob",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Test file rollbar/test/fastapi_tests/test_routing.py:6 directly imports from packaging.version import Version (new in this PR for the version comparison fix), but packaging is not declared in the test dependency group in pyproject.toml. This currently works only transitively because pytest declares packaging>=22 as a runtime requirement; declaring direct imports as direct deps is the same hygiene the PR already applied for typing_extensions (added to runtime deps because the PR newly imports it). One-line fix: add "packaging", to the test group around line 48.

Extended reasoning...

What the bug is

This PR's rollbar/test/fastapi_tests/test_routing.py newly imports from packaging.version import Version at line 6 — the import replaces an earlier lexicographic fastapi.__version__ >= '0.41.0' comparison with a proper Version(...) comparison (line 13), and is also used inside test_should_send_payload_with_request_body to gate on Version(httpx_version) >= Version('0.28.0'). The test dependency group in pyproject.toml (lines 47–54), however, lists only:

test = ["blinker", "httpx", "pytest", "python-multipart", "webob"]

No packaging entry.

Why it works today (and the refutation)

A refuting verifier correctly pointed out that pytest declares packaging>=22 as a hard runtime dependency, so any environment that installs the test group transitively pulls packaging. CI therefore never sees an ImportError. That argument is accurate, and is exactly why this is filed as a nit rather than normal severity — there is no current breakage and the practical risk is near-zero because pytest has required packaging for many releases.

Why it's still worth flagging

The same PR explicitly applied the opposite convention one section above: it added typing_extensions; python_version < "3.11" to the runtime dependencies list (pyproject.toml:44) precisely because the PR newly introduced from typing_extensions import Unpack in rollbar/__init__.py. The 'if you directly import it, declare it' rule is therefore the rule this PR itself sets. Leaving packaging as a transitive-only dep contradicts that rule by one line in the same file.

Relying on transitive dependencies for direct imports is a well-known anti-pattern (PEP 508 / packaging best practices). The failure mode is small but real: if pytest ever drops its packaging dep (theoretical, but not impossible across major-version bumps), or if a downstream user installs the test target with a minimal resolver, the import fails at collection time because the import is at module scope and pytest evaluates it before any skipUnless guard runs.

How to fix

Single-line change in pyproject.toml around line 48:

test = [
    "blinker",
    "httpx",
    "packaging",
    "pytest",
    "python-multipart",
    "webob",
]

Step-by-step proof

  1. Read rollbar/test/fastapi_tests/test_routing.py:6from packaging.version import Version. Unconditional, module-scope import (pytest collection alone triggers it).
  2. Read rollbar/test/fastapi_tests/test_routing.py:13ALLOWED_FASTAPI_VERSION = Version(fastapi.__version__) >= Version('0.41.0'). Confirms the import is actually used.
  3. Read pyproject.toml:47-54test group has blinker, httpx, pytest, python-multipart, webob. No packaging.
  4. Read pyproject.toml:42-45 — runtime dependencies now includes typing_extensions; python_version < "3.11". Confirms this PR's own convention: 'direct import → direct declaration'.
  5. packaging is currently available in every test environment only because pytest (in the test group) declares Requires-Dist: packaging>=22 in its METADATA. That is a transitive guarantee, not a direct one.

Comment thread rollbar/lib/traverse.py
Comment on lines 176 to 179
for handler_type, handler in custom_handlers.items():
if isinstance(obj, handler_type):
# Only attempt isinstance checks when the key is a type (or tuple of types).
if isinstance(handler_type, (type, tuple)) and isinstance(obj, handler_type):
return handler(obj, key=key)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: the new defensive gate at traverse.py:176-179 (isinstance(handler_type, (type, tuple)) and isinstance(obj, handler_type)) is unreachable. **custom_handlers collects kwargs into a dict whose keys Python guarantees to be str (passing a non-string key raises TypeError: keywords must be strings at call time), so isinstance(handler_type, (type, tuple)) is provably always False. No caller in rollbar/ or tests populates this slot today, so there is zero runtime impact — but the gate quietly hides what would previously have surfaced as a TypeError and encodes a contract ("type-keyed handlers") that **kwargs makes impossible to satisfy. Suggest either dropping **custom_handlers from the signature or converting it to an explicit custom_handlers: dict[type, Handler] | None = None parameter so type-keyed entries are actually expressible.

Extended reasoning...

What the bug is

traverse() declares **custom_handlers: Handler (rollbar/lib/traverse.py:94), then near the end of the body (lines 175–178) iterates that dict and applies an isinstance gate:

elif obj_type is DEFAULT:
    for handler_type, handler in custom_handlers.items():
        # Only attempt isinstance checks when the key is a type (or tuple of types).
        if isinstance(handler_type, (type, tuple)) and isinstance(obj, handler_type):
            return handler(obj, key=key)

The new isinstance(handler_type, (type, tuple)) clause was added in this PR (the prior code had only isinstance(obj, handler_type), which would have raised TypeError if handler_type was ever a non-type — but as we will see, it never can be).

Why the inner branch is unreachable

Python enforces at call time that keys absorbed by a **kwargs parameter are str. Empirically reproducing:

>>> def f(**kw): return list(kw.keys())
>>> f(**{int: 'x'})
TypeError: keywords must be strings

So inside traverse(), custom_handlers is statically dict[str, Handler]. handler_type is always a str. isinstance('some_string', (type, tuple)) is unconditionally False, so the return handler(obj, key=key) body is never reached.

The propagation via kw.update(custom_handlers) at line 123 doesn't help either — it preserves the original string keys throughout the recursion.

Why no caller is actually affected today

$ grep -rn 'custom_handlers' rollbar/
rollbar/lib/traverse.py:94:    **custom_handlers: Handler,
rollbar/lib/traverse.py:123:    kw.update(custom_handlers)
rollbar/lib/traverse.py:176:            for handler_type, handler in custom_handlers.items():

The only matches are the definition, the recursive propagation, and the dead loop itself. Every internal caller passes only the named handler arguments (string_handler, tuple_handler, namedtuple_handler, …, circular_reference_handler), which are absorbed by the explicit parameters and never reach custom_handlers. The production call site at rollbar/lib/transforms/__init__.py:110 passes those same named kwargs through. So custom_handlers is always an empty dict at runtime, and the loop is never entered to begin with.

What the PR change actually accomplishes

The new isinstance(handler_type, (type, tuple)) clause is most plausibly a mypy-silencer: isinstance(obj, handler_type) where handler_type: str would be a static-type error under the new types CI job. Adding the guard makes mypy happy without removing the dead loop. The net effect is that the PR papers over an inconsistency rather than fixing it — a reader sees an apparent extensibility hook (**custom_handlers for type-keyed handlers) that, by virtue of **kwargs semantics, cannot actually be used as designed.

Impact

Zero runtime impact. The feature has no callers, and even if someone tried to populate it via traverse(obj, my_extra=some_handler), the key would be the string 'my_extra', not a type, so the gate would skip it and default_handler would run as it does today. Severity: nit — a cleanup concern in code the PR is touching, not a behavior change.

Step-by-step proof

  1. def traverse(..., **custom_handlers: Handler) (traverse.py:80–94) — custom_handlers is collected from caller kwargs.
  2. At call time, Python rejects any non-string kwarg key: f(**{int: 'x'}) raises TypeError: keywords must be strings. So at runtime custom_handlers is dict[str, Handler].
  3. Therefore at traverse.py:176, handler_type is always a str.
  4. isinstance('any string', (type, tuple)) is False for every string (no string is a subclass of type or tuple). The conjunction is unconditionally False.
  5. The return handler(obj, key=key) body at line 178 is therefore unreachable.
  6. grep -rn custom_handlers rollbar/ returns three hits, all internal to traverse.py; no caller populates the slot anyway, so even the outer for loop is never entered with a non-empty dict.

How to fix

Either of:

# Option A: drop the parameter entirely.
def traverse(obj, key=(), string_handler=..., ..., depth_first=True):
    ...
# Option B: convert to an explicit, type-keyed parameter.
def traverse(obj, ..., custom_handlers: dict[type, Handler] | None = None):
    ...
    elif obj_type is DEFAULT:
        for handler_type, handler in (custom_handlers or {}).items():
            if isinstance(obj, handler_type):
                return handler(obj, key=key)

Option B preserves the apparent design intent (extensibility for custom types) and makes it actually reachable. Option A is the minimal cleanup if the feature is genuinely unwanted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add typing stubs for Rollbar SDK

3 participants