Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ __Table of Contents__
* [Usage](#usage)
* [Authentication](#authentication)
* [Web login](#web-login)
* [If web login suddenly stops working](#if-web-login-suddenly-stops-working)
* [Development](#development)
* [Setting Up a Development Environment](#setting-up-a-development-environment)
* [Linting and Code Formatting](#linting-and-code-formatting)
Expand Down Expand Up @@ -116,6 +117,29 @@ Web login uses the public web-login endpoints at `api.traderepublic.com`. Two va
Both variants keep you logged in on your primary device, but you may need to re-authenticate every so often when
running `pytr`.

### If web login suddenly stops working

The web login identifies itself to Trade Republic as their own web frontend, using a build version, a platform name
and a browser `User-Agent` that are pinned in `pytr`. Trade Republic can invalidate any of them at any time, and when
they do, login fails for everyone until a new release goes out. Three environment variables let you fix it yourself
in the meantime:

| Variable | Overrides | Use it when |
|---|---|---|
| `PYTR_TR_APP_VERSION` | The frontend build version sent as `X-TR-App-Version` | Login fails with `426 CLIENT_VERSION_OUTDATED` |
| `PYTR_TR_USER_AGENT` | The `User-Agent` sent on every request | Login is rejected or challenged in a way that looks like bot filtering |
| `PYTR_TR_PLATFORM` | The platform name sent as `X-Tr-Platform` | Login fails with a missing or invalid header error naming the platform |

```sh
PYTR_TR_APP_VERSION=2.2700.4 pytr login --v2
PYTR_TR_USER_AGENT='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36' pytr login
PYTR_TR_PLATFORM=web pytr login --v2
```

Read the current values off [app.traderepublic.com](https://app.traderepublic.com/) in your browser's dev tools, on the
network request to `/api/v2/auth/web/login`. Leaving a variable unset, or setting it to an empty string, keeps the
built-in default. If you need one of these, please also open an issue so the default can be updated for everyone.

## Development

### Setting Up a Development Environment
Expand Down
20 changes: 15 additions & 5 deletions pytr/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,21 @@
# The web frontend's API client identifies itself with this platform on all v2 login calls.
WEB_PLATFORM = "web-pro"

# Sent on every request. Trade Republic can start rejecting a stale one as bot traffic.
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"

# Trade Republic can invalidate any of the three values above at any time, and none of
# those failures needs a code change to fix, only a different string. Overriding them
# from the environment turns "wait for a pytr release" into "export a variable" for a
# user who is locked out today. See the README for when to reach for which. An unset or
# empty variable keeps the built-in default, so an empty assignment sends no empty header.
ENV_APP_VERSION = "PYTR_TR_APP_VERSION"
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

ENV_USER_AGENT = "PYTR_TR_USER_AGENT"


class TradeRepublicApi:
_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": os.environ.get(ENV_USER_AGENT) or USER_AGENT}
_host = "https://api.traderepublic.com"
_waf_login_url = "https://app.traderepublic.com/login"

Expand Down Expand Up @@ -324,8 +334,8 @@ def _login_headers(self):
self._device_info = base64.b64encode(json.dumps(device).encode()).decode()
return {
"X-TR-Device-Info": self._device_info,
"X-TR-App-Version": APP_VERSION,
"X-Tr-Platform": WEB_PLATFORM,
"X-TR-App-Version": os.environ.get(ENV_APP_VERSION) or APP_VERSION,
"X-Tr-Platform": os.environ.get(ENV_PLATFORM) or WEB_PLATFORM,
"Accept-Language": self._locale,
}

Expand Down
90 changes: 89 additions & 1 deletion tests/test_api_urls.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
"""Pin the web login endpoints, their required headers and the login process state machine."""

import base64
import importlib
import json as jsonlib
import re
from typing import Any

import pytest
import requests

from pytr.api import TradeRepublicApi
import pytr.api
from pytr.api import (
APP_VERSION,
ENV_APP_VERSION,
ENV_PLATFORM,
ENV_USER_AGENT,
USER_AGENT,
WEB_PLATFORM,
TradeRepublicApi,
)

LOGIN = "https://api.traderepublic.com/api/v2/auth/web/login"
PROCESS = "https://api.traderepublic.com/api/v2/auth/web/login/processes/pid-1"
Expand Down Expand Up @@ -261,6 +271,84 @@ def test_app_version_and_platform_come_from_the_web_frontend():
assert headers["X-Tr-Platform"] == "web-pro"


# --- environment overrides -----------------------------------------------------------

CHROME_149 = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"


FIREFOX_128 = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"


@pytest.fixture
def reimported_with(monkeypatch):
"""pytr.api re-imported under a patched environment.

X-TR-App-Version and X-Tr-Platform are read per call, so those overrides need
nothing special. The User-Agent is baked into _default_headers when the class
body runs, so seeing a different one means importing the module again.
"""

def _load(**environment):
for name, value in environment.items():
monkeypatch.setenv(name, value)
return importlib.reload(pytr.api)

yield _load
monkeypatch.undo()
importlib.reload(pytr.api)


def _api_of(module):
return module.TradeRepublicApi(phone_no="+490000000000", pin="0000", waf_token=None, use_v2_login=True)


def _device_info_of(module):
return jsonlib.loads(base64.b64decode(_api_of(module)._login_headers()["X-TR-Device-Info"]))


def test_app_version_can_be_overridden_from_the_environment(monkeypatch):
"""A frontend release makes the built-in stale; 426 must be fixable without a release."""
monkeypatch.setenv(ENV_APP_VERSION, "2.9999.1")

assert _api([])._login_headers()["X-TR-App-Version"] == "2.9999.1"


def test_platform_can_be_overridden_from_the_environment(monkeypatch):
monkeypatch.setenv(ENV_PLATFORM, "web")

assert _api([])._login_headers()["X-Tr-Platform"] == "web"


def test_user_agent_can_be_overridden_from_the_environment(reimported_with):
module = reimported_with(**{ENV_USER_AGENT: CHROME_149})

assert module.TradeRepublicApi._default_headers["User-Agent"] == CHROME_149
assert _api_of(module)._websession.headers["User-Agent"] == CHROME_149


def test_an_empty_override_keeps_the_built_in_default(monkeypatch, reimported_with):
"""An empty assignment must not send an empty header for any of the three."""
monkeypatch.setenv(ENV_APP_VERSION, "")
monkeypatch.setenv(ENV_PLATFORM, "")

headers = _api([])._login_headers()
assert headers["X-TR-App-Version"] == APP_VERSION
assert headers["X-Tr-Platform"] == WEB_PLATFORM

module = reimported_with(**{ENV_USER_AGENT: ""})
assert module.TradeRepublicApi._default_headers["User-Agent"] == USER_AGENT


def test_device_info_follows_the_overridden_user_agent(reimported_with):
"""browserVersion is scraped from the User-Agent; the two must not drift apart."""
assert _device_info_of(reimported_with(**{ENV_USER_AGENT: CHROME_149}))["browserVersion"] == "149.0.0.0"


def test_a_non_chrome_user_agent_leaves_the_browser_version_empty(reimported_with):
"""The frontend omits what the browser does not provide, and TR accepts that."""
assert _device_info_of(reimported_with(**{ENV_USER_AGENT: FIREFOX_128}))["browserVersion"] == ""


# --- endpoints that must NOT move ----------------------------------------------------


Expand Down