Skip to content

Allow overriding the TR app version and User-Agent from the environment - #382

Open
twannooitmeer wants to merge 5 commits into
pytr-org:masterfrom
twannooitmeer:feat/tr-version-env-overrides
Open

Allow overriding the TR app version and User-Agent from the environment#382
twannooitmeer wants to merge 5 commits into
pytr-org:masterfrom
twannooitmeer:feat/tr-version-env-overrides

Conversation

@twannooitmeer

Copy link
Copy Markdown
Contributor

Summary

The v2 web login identifies itself to Trade Republic as their own web frontend, using two values that describe someone else's deployment:

  • APP_VERSION (2.2631.13), sent as X-TR-App-Version
  • the browser User-Agent in _default_headers

Trade Republic can invalidate either at any moment. A frontend release makes APP_VERSION stale, and the login endpoints then answer 426 CLIENT_VERSION_OUTDATED, which is exactly what #250 was. A change to their bot filtering can make the User-Agent the thing being rejected.

Neither failure needs a code change to fix. It needs a different string. But because both are baked into the module, a user locked out today has to wait for a release, and you have to cut one under time pressure. The comment above APP_VERSION already anticipates this ("bump when TR starts rejecting stale versions"); this PR just makes the bump something a user can do locally.

What changed

Two environment variables, read at the point of use:

Variable Overrides Use it when
PYTR_TR_APP_VERSION X-TR-App-Version Login fails with 426 CLIENT_VERSION_OUTDATED
PYTR_TR_USER_AGENT User-Agent on every request Login is rejected in a way that looks like bot filtering
PYTR_TR_APP_VERSION=2.2700.4 pytr login --v2

Unset or empty keeps the built-in default, so an empty assignment can never send an empty header. The defaults are unchanged, so this is a no-op for anyone who does not set the variables.

Two details worth a look

The User-Agent override applies to a per-instance copy of _default_headers. That dict is a class attribute and was previously passed straight to requests.Session, which mutates what it is given. Copying it is needed for the override to stay on one instance, and it independently stops a session from writing into state shared by every instance. There is a test pinning the no-leak property.

X-TR-Device-Info stays consistent with whatever User-Agent is in force. browserVersion is scraped out of the UA with a regex, so an override has to flow through to the fingerprint or the two would contradict each other on the wire. A non-Chrome override leaves browserVersion empty rather than reporting a version the browser never claimed, which matches how the frontend omits fields it cannot fill.

Tests

Six tests added to tests/test_api_urls.py, covering both overrides, the empty-value fallback, the absence of cross-instance leakage, and the device-info consistency in both the Chrome and non-Chrome cases. Full suite: 224 passed. ruff format --check and ruff check clean.

I verified the four behavioural tests fail if the override logic is removed while the new constants are kept, so they are pinning behaviour rather than just imports.

Docs

A short "If web login suddenly stops working" section under Authentication, documenting both variables, where to read the current values off the live frontend in dev tools, and asking anyone who needs one to open an issue so the default gets updated for everyone.

Notes

Split out of #355, which is now documentation only after #380 landed the v2 login. This is the one idea from that PR that did not survive the merge, and #380 hardcodes both values, so the gap is still open on master.

Happy to add WEB_PLATFORM to the same mechanism if you want it, though it seems much less likely to move than the build version.

The v2 login identifies itself as Trade Republic's own web frontend, using
two values that describe someone else's deployment: APP_VERSION, sent as
X-TR-App-Version, and the browser User-Agent. Trade Republic can invalidate
either one at any moment. A frontend release makes APP_VERSION stale, at
which point every login returns 426 CLIENT_VERSION_OUTDATED, which is exactly
what pytr-org#250 was; a change to their bot filtering can make the User-Agent the
thing being rejected.

Neither failure needs a code change to fix. It needs a different string. But
because both are baked into the module, a user locked out today has to wait
for a release, and the maintainers have to cut one under time pressure.

PYTR_TR_APP_VERSION and PYTR_TR_USER_AGENT override them, so the fix becomes
an exported variable and the release can happen at its own pace. An unset or
empty variable keeps the built-in default, so an empty assignment can never
send an empty header.

The User-Agent override is applied to a per-instance copy of
_default_headers. That dict is a class attribute and was previously handed
straight to requests.Session, which mutates what it is given, so copying it
also stops one instance's session from writing into state shared by all of
them.

X-TR-Device-Info stays consistent with whatever User-Agent is in force:
browserVersion is scraped from it, and a non-Chrome override leaves that
field empty rather than reporting a version the browser never claimed. The
frontend omits fields it cannot fill too, and the server accepts their
absence.

Tests cover both overrides, the empty-value fallback, the absence of
cross-instance leakage, and the device-info consistency. README documents
both variables, where to read current values from the live frontend, and
asks users who need one to open an issue so the default gets updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@RealCLanger

Copy link
Copy Markdown
Collaborator

Thanks for this contribution, this makes sense and I'd like to merge it. However, there are errors in the checks with mksync. Can you please check and repair?

It would certainly also be nice if the environment override handling for WEB_PLATFORM would be added as well, though the value is less likely to change.

@RealCLanger

Copy link
Copy Markdown
Collaborator

Since I've made additional changes upstream, you also need to sync and likely resolve your PR branch.

twannooitmeer and others added 2 commits August 7, 2026 22:14
Addresses the review on pytr-org#382.

PYTR_TR_PLATFORM overrides WEB_PLATFORM, the same way the other two work: an
unset or empty value keeps the built-in "web-pro", so an empty assignment can
never send an empty X-Tr-Platform. It is the least likely of the three to
move, but it is the same class of value, pinned to someone else's deployment
and unfixable without a release.

The failing check was the README one, not a test. The repository generates
its table of contents with mksync and CI diffs the committed file against a
fresh run. The new "If web login suddenly stops working" heading was missing
its entry, so the two differed. Regenerated with the documented command,
`uvx mksync@0.1.5 -i README.md`, which adds the entry and nothing else.

Also documents PYTR_TR_PLATFORM in the same table.

Verified against all five CI steps rather than assuming: pytest (226),
ruff 0.9.6 check and format, mypy, and the mksync diff. Note that the CI
diff command uses `sed -z`, which does not exist on macOS, so running it
verbatim there silently compares two empty streams and always passes; the
check above used a portable equivalent and was confirmed to fail when the
table-of-contents entry is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@twannooitmeer

Copy link
Copy Markdown
Contributor Author

Both points addressed, and the branch is synced with current master (merge, no force-push).

The failing check was the README one, not a test. The new If web login suddenly stops working heading was missing its entry in the mksync-generated table of contents, so diff -u README.md <(mksync README.md) differed by exactly that one line. Regenerated with the documented uvx mksync@0.1.5 -i README.md, which adds the entry and nothing else.

One thing worth flagging: the CI step for this uses sed -z, which does not exist on BSD/macOS sed. Running that command verbatim on a Mac makes both sides of the diff fail, so it compares two empty streams and always exits 0. It looks like a pass locally while CI fails, which is a confusing way to lose half an hour. I checked mine with a portable equivalent (perl -0pe 's/\n*$//') and confirmed it fails when I remove the TOC entry, so the check is really passing now and not just silently degenerate. Happy to send a separate PR making that step portable if you would like.

WEB_PLATFORM override added as PYTR_TR_PLATFORM, same semantics as the other two: unset or empty keeps the built-in web-pro, so an empty assignment can never send an empty X-Tr-Platform. Documented in the same table, with a test.

Verified locally against all five CI steps: pytest (226 passed), ruff@0.9.6 check, ruff@0.9.6 format --check --diff, mypy ., and the mksync diff. I also mutation-tested the new test, confirming it fails when the override is removed, so it pins behaviour rather than just the constant.

The workflow run is sitting at action_required and needs your approval to execute.

@RealCLanger RealCLanger left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for following up. We're getting there. I made a few hopefully final review comments. And, yes, another PR to repair the mksync check on mac would be more than welcome.

Comment thread pytr/api.py Outdated
# The web frontend's API client identifies itself with this platform on all v2 login calls.
WEB_PLATFORM = "web-pro"

DEFAULT_USER_AGENT = (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe we should just name it USER_AGENT which is more inline with the names APP_VERSION and WEB_PLATFORM above.

Comment thread pytr/api.py Outdated
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
)

# All three values above describe someone else's deployment, and Trade Republic can

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the comment here in the code is too verbose, given that we more or less write the same in README.md. I suggest to make less words here.

Comment thread pytr/api.py Outdated
_default_headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
}
_default_headers = {"User-Agent": DEFAULT_USER_AGENT}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
_default_headers = {"User-Agent": DEFAULT_USER_AGENT}
_default_headers = {"User-Agent": os.environ.get(ENV_USER_AGENT) or DEFAULT_USER_AGENT}

Initialize _default_headers using the environment in the central place, no?

Comment thread pytr/api.py
# never send an empty header.
ENV_APP_VERSION = "PYTR_TR_APP_VERSION"
ENV_USER_AGENT = "PYTR_TR_USER_AGENT"
ENV_PLATFORM = "PYTR_TR_PLATFORM"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please sort the variables like above, e.g. ENV_APP_VERSION, ENV_PLATFORM, ENV_USER_AGENT

Comment thread pytr/api.py Outdated
self._cookies_file = pathlib.Path(cookies_file) if cookies_file else BASE_DIR / f"cookies.{self.phone_no}.txt"

self._websession = requests.Session()
# Copy before overriding: `_default_headers` is a class attribute, and it is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If you follow my suggestion to initialize _default_headers using ENV_USER_AGENT, this whole block is not necessary. Or is there any other reason to clone the dict here for usage?

Comment thread tests/test_api_urls.py Outdated
from pytr.api import TradeRepublicApi
from pytr.api import (
APP_VERSION,
DEFAULT_USER_AGENT,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please also fix the ordering here in the sense of APP_VERSION, PLATFORM, USER_AGENT

twannooitmeer and others added 2 commits August 12, 2026 22:36
* DEFAULT_USER_AGENT is now USER_AGENT, in line with APP_VERSION and
  WEB_PLATFORM next to it.
* The ENV_ constants are sorted APP_VERSION, PLATFORM, USER_AGENT, and so is
  the import in the test module.
* The block comment is cut to the point, since the README already explains
  when to reach for which variable.
* _default_headers reads ENV_USER_AGENT directly, and the clone in __init__
  is gone.

On the clone: there was no reason to keep it. It existed because __init__
wrote into _default_headers, and writing into a class attribute would have
leaked the override into every other instance. Reading the environment where
the attribute is defined removes the write, and with it the need to copy. The
justification originally given for the copy, that requests mutates the header
dict it is handed, was wrong: requests merges headers into a new dict per
request and leaves the session's own dict alone.

One consequence worth naming: the User-Agent is now resolved when the class
body executes, so setting os.environ after importing pytr.api no longer
affects it, where APP_VERSION and WEB_PLATFORM are still read per call. For
the CLI the two are indistinguishable, since the environment is set before
the process starts. The tests reimport the module to observe it, in a fixture
that restores the module afterwards.

Verified against all five CI steps: pytest (228), ruff 0.9.6 check and
format, mypy, and the mksync diff. The three User-Agent tests were confirmed
to fail when the override is removed, so the reimport does not hide them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@twannooitmeer

Copy link
Copy Markdown
Contributor Author

All six addressed, and the branch is synced with current master.

  • DEFAULT_USER_AGENT is now USER_AGENT.
  • ENV_ constants sorted APP_VERSION, PLATFORM, USER_AGENT, and the test import too.
  • Block comment cut down, since the README covers when to use which.
  • _default_headers initialised from the environment as you suggested, and the __init__ block removed.

On the clone: you were right, and my stated reason for it was wrong. I claimed requests mutates the header dict it is handed. It does not; it merges headers into a new dict per request and leaves the session's own dict alone. I checked rather than assuming this time. The clone only existed because __init__ wrote into _default_headers, and writing into a class attribute would have leaked the override into every instance. Reading the environment where the attribute is defined removes the write, so the copy has no reason to exist. Removed, along with the test that pinned the non-leaking.

One consequence worth naming, in case you would rather avoid it: the User-Agent is now resolved when the class body executes, so os.environ changes made after pytr.api is imported no longer affect it, whereas APP_VERSION and WEB_PLATFORM are still read per call. For the CLI the two are indistinguishable, since the environment is set before the process starts, so I went with your version. The three User-Agent tests reimport the module to observe the override, in a fixture that restores it afterwards. If you would prefer all three to read at the same time, this one line in __init__ does it without bringing the block back:

self._default_headers = {**self._default_headers, "User-Agent": os.environ.get(ENV_USER_AGENT) or USER_AGENT}

Happy either way, it is your call.

Verified against all five CI steps locally: pytest (228 passed), ruff@0.9.6 check, ruff@0.9.6 format --check --diff, mypy ., and the mksync diff. I confirmed the three User-Agent tests still fail when the override is removed, so the reimport is not hiding anything.

The mksync fix is #390.

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.

2 participants