Allow overriding the TR app version and User-Agent from the environment - #382
Allow overriding the TR app version and User-Agent from the environment#382twannooitmeer wants to merge 5 commits into
Conversation
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>
|
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 |
|
Since I've made additional changes upstream, you also need to sync and likely resolve your PR branch. |
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>
|
Both points addressed, and the branch is synced with current The failing check was the README one, not a test. The new One thing worth flagging: the CI step for this uses
Verified locally against all five CI steps: pytest (226 passed), The workflow run is sitting at |
RealCLanger
left a comment
There was a problem hiding this comment.
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.
| # The web frontend's API client identifies itself with this platform on all v2 login calls. | ||
| WEB_PLATFORM = "web-pro" | ||
|
|
||
| DEFAULT_USER_AGENT = ( |
There was a problem hiding this comment.
Maybe we should just name it USER_AGENT which is more inline with the names APP_VERSION and WEB_PLATFORM above.
| "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 |
There was a problem hiding this comment.
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.
| _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} |
There was a problem hiding this comment.
| _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?
| # never send an empty header. | ||
| ENV_APP_VERSION = "PYTR_TR_APP_VERSION" | ||
| ENV_USER_AGENT = "PYTR_TR_USER_AGENT" | ||
| ENV_PLATFORM = "PYTR_TR_PLATFORM" |
There was a problem hiding this comment.
Please sort the variables like above, e.g. ENV_APP_VERSION, ENV_PLATFORM, ENV_USER_AGENT
| 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 |
There was a problem hiding this comment.
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?
| from pytr.api import TradeRepublicApi | ||
| from pytr.api import ( | ||
| APP_VERSION, | ||
| DEFAULT_USER_AGENT, |
There was a problem hiding this comment.
Please also fix the ordering here in the sense of APP_VERSION, PLATFORM, USER_AGENT
* 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>
|
All six addressed, and the branch is synced with current
On the clone: you were right, and my stated reason for it was wrong. I claimed One consequence worth naming, in case you would rather avoid it: the User-Agent is now resolved when the class body executes, so 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), The mksync fix is #390. |
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 asX-TR-App-VersionUser-Agentin_default_headersTrade Republic can invalidate either at any moment. A frontend release makes
APP_VERSIONstale, and the login endpoints then answer426 CLIENT_VERSION_OUTDATED, which is exactly what #250 was. A change to their bot filtering can make theUser-Agentthe 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_VERSIONalready 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:
PYTR_TR_APP_VERSIONX-TR-App-Version426 CLIENT_VERSION_OUTDATEDPYTR_TR_USER_AGENTUser-Agenton every requestUnset 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-Agentoverride applies to a per-instance copy of_default_headers. That dict is a class attribute and was previously passed straight torequests.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-Infostays consistent with whateverUser-Agentis in force.browserVersionis 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 leavesbrowserVersionempty 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 --checkandruff checkclean.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_PLATFORMto the same mechanism if you want it, though it seems much less likely to move than the build version.