Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
37 changes: 32 additions & 5 deletions pytr/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,32 @@
# 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.

"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.

# invalidate any of them at any moment: a frontend release makes APP_VERSION stale
# (the endpoints then answer 426 CLIENT_VERSION_OUTDATED, which is what #250 was),
# a change to their bot filtering can make the User-Agent the thing being rejected,
# and WEB_PLATFORM is whatever string their API client happens to be configured with.
# None of those failures needs a code change to fix, only a different string, so all
# three are readable from the environment. That turns "wait for a pytr release" into
# "export a variable" for a user who is locked out today.
#
# PYTR_TR_APP_VERSION=2.2700.4 pytr login --v2
# PYTR_TR_USER_AGENT='Mozilla/5.0 ... Chrome/149.0.0.0 Safari/537.36' pytr login
# PYTR_TR_PLATFORM=web pytr login --v2
#
# An unset or empty variable keeps the built-in default, so an empty assignment can
# 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



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": 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?

_host = "https://api.traderepublic.com"
_waf_login_url = "https://app.traderepublic.com/login"

Expand Down Expand Up @@ -144,6 +165,12 @@ def __init__(
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?

# handed straight to the session below, which mutates what it is given.
self._default_headers = dict(self._default_headers)
user_agent = os.environ.get(ENV_USER_AGENT)
if user_agent:
self._default_headers["User-Agent"] = user_agent
self._websession.headers = self._default_headers
if self._save_cookies:
self._websession.cookies = MozillaCookieJar(self._cookies_file)
Expand Down Expand Up @@ -324,8 +351,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
79 changes: 78 additions & 1 deletion tests/test_api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@
import pytest
import requests

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

ENV_APP_VERSION,
ENV_PLATFORM,
ENV_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 +269,75 @@ 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"


def _real_session_api():
"""An instance keeping its real session, so constructor-set headers survive."""
return TradeRepublicApi(phone_no="+490000000000", pin="0000", waf_token=None, use_v2_login=True)


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_user_agent_can_be_overridden_from_the_environment(monkeypatch):
monkeypatch.setenv(ENV_USER_AGENT, CHROME_149)

assert _real_session_api()._websession.headers["User-Agent"] == CHROME_149


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_an_empty_override_keeps_the_built_in_default(monkeypatch):
"""An empty assignment must not send an empty header for any of the three."""
monkeypatch.setenv(ENV_APP_VERSION, "")
monkeypatch.setenv(ENV_USER_AGENT, "")
monkeypatch.setenv(ENV_PLATFORM, "")

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


def test_overriding_the_user_agent_does_not_leak_into_other_instances(monkeypatch):
"""The default lives on the class; overriding it must stay on the instance."""
monkeypatch.setenv(ENV_USER_AGENT, CHROME_149)
_real_session_api()
monkeypatch.delenv(ENV_USER_AGENT)

assert TradeRepublicApi._default_headers["User-Agent"] == DEFAULT_USER_AGENT
assert _real_session_api()._websession.headers["User-Agent"] == DEFAULT_USER_AGENT


def test_device_info_follows_the_overridden_user_agent(monkeypatch):
"""browserVersion is scraped from the User-Agent; the two must not drift apart."""
monkeypatch.setenv(ENV_USER_AGENT, CHROME_149)

device = jsonlib.loads(base64.b64decode(_real_session_api()._login_headers()["X-TR-Device-Info"]))

assert device["browserVersion"] == "149.0.0.0"


def test_a_non_chrome_user_agent_leaves_the_browser_version_empty(monkeypatch):
"""The frontend omits what the browser does not provide, and TR accepts that."""
monkeypatch.setenv(ENV_USER_AGENT, "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0")

device = jsonlib.loads(base64.b64decode(_real_session_api()._login_headers()["X-TR-Device-Info"]))

assert device["browserVersion"] == ""


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


Expand Down