Skip to content

Commit 92b9895

Browse files
Add Ruff formatting & linting (#169)
* add ruff to development dependencies and run in CI * address `ruff check` lints * run `uv run ruff format` * enable Ruff lints for import statements, and format them by running `uv run ruff check --fix` * build-n-publish needs check * fix branch name in CI * update readme, remove black add ruff
1 parent 8b400ea commit 92b9895

17 files changed

+123
-270
lines changed

.github/workflows/publish-pypi.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ jobs:
1616
version: "0.5.21"
1717
- name: Pytest
1818
run: uv run pytest
19+
- name: Ruff check
20+
run: uv run ruff check
21+
- name: Ruff format
22+
run: uv run ruff format --check
1923

2024
build-n-publish:
2125
name: Build and publish Python distributions to PyPI

README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,16 +92,15 @@ If no arguments are supplied pytr will look for them in the file `~/.pytr/creden
9292

9393
## Linting and Code Formatting
9494

95-
This project uses [black](https://github.com/psf/black) for code linting and auto-formatting. You can auto-format the code by running:
95+
This project uses [Ruff](https://astral.sh/ruff) for code linting and auto-formatting. You can auto-format the code by running:
9696

9797
```bash
98-
# Install black if not already installed
99-
pip install black
100-
101-
# Auto-format code
102-
black ./pytr
98+
uv run ruff format # Format code
99+
uv run ruff check --fix-only # Remove unneeded imports, order imports, etc.
103100
```
104101

102+
Ruff runs as part of CI and your Pull Request cannot be merged unless it satisfies the linting and formatting checks.
103+
105104
## Setting Up a Development Environment
106105

107106
1. Clone the repository:

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,12 @@ locale_dir = "pytr/locale"
4242

4343
[dependency-groups]
4444
dev = [
45+
"ruff>=0.9.4",
4546
"pytest>=8.3.4",
4647
]
48+
49+
[tool.ruff]
50+
line-length = 120
51+
52+
[tool.ruff.lint]
53+
extend-select = ["I"]

pytr/account.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
import json
22
import sys
3-
from pygments import highlight, lexers, formatters
43
import time
54
from getpass import getpass
65

7-
from pytr.api import TradeRepublicApi, CREDENTIALS_FILE
6+
from pygments import formatters, highlight, lexers
7+
8+
from pytr.api import CREDENTIALS_FILE, TradeRepublicApi
89
from pytr.utils import get_logger
910

1011

1112
def get_settings(tr):
1213
formatted_json = json.dumps(tr.settings(), indent=2)
1314
if sys.stdout.isatty():
14-
colorful_json = highlight(
15-
formatted_json, lexers.JsonLexer(), formatters.TerminalFormatter()
16-
)
15+
colorful_json = highlight(formatted_json, lexers.JsonLexer(), formatters.TerminalFormatter())
1716
return colorful_json
1817
else:
1918
return formatted_json
@@ -41,9 +40,7 @@ def login(phone_no=None, pin=None, web=True, store_credentials=False):
4140
CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
4241
if phone_no is None:
4342
log.info("Credentials file not found")
44-
print(
45-
"Please enter your TradeRepublic phone number in the format +4912345678:"
46-
)
43+
print("Please enter your TradeRepublic phone number in the format +4912345678:")
4744
phone_no = input()
4845
else:
4946
log.info("Phone number provided as argument")
@@ -74,15 +71,13 @@ def login(phone_no=None, pin=None, web=True, store_credentials=False):
7471
exit(1)
7572
request_time = time.time()
7673
print("Enter the code you received to your mobile app as a notification.")
77-
print(
78-
f"Enter nothing if you want to receive the (same) code as SMS. (Countdown: {countdown})"
79-
)
74+
print(f"Enter nothing if you want to receive the (same) code as SMS. (Countdown: {countdown})")
8075
code = input("Code: ")
8176
if code == "":
8277
countdown = countdown - (time.time() - request_time)
8378
for remaining in range(int(countdown)):
8479
print(
85-
f"Need to wait {int(countdown-remaining)} seconds before requesting SMS...",
80+
f"Need to wait {int(countdown - remaining)} seconds before requesting SMS...",
8681
end="\r",
8782
)
8883
time.sleep(1)

pytr/alarms.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import asyncio
22
from datetime import datetime
33

4-
from pytr.utils import preview, get_logger
4+
from pytr.utils import get_logger, preview
55

66

77
class Alarms:
@@ -19,9 +19,7 @@ async def alarms_loop(self):
1919
recv += 1
2020
self.alarms = response
2121
else:
22-
print(
23-
f"unmatched subscription of type '{subscription['type']}':\n{preview(response)}"
24-
)
22+
print(f"unmatched subscription of type '{subscription['type']}':\n{preview(response)}")
2523

2624
if recv == 1:
2725
return
@@ -36,9 +34,7 @@ async def ticker_loop(self):
3634
recv += 1
3735
self.alarms = response
3836
else:
39-
print(
40-
f"unmatched subscription of type '{subscription['type']}':\n{preview(response)}"
41-
)
37+
print(f"unmatched subscription of type '{subscription['type']}':\n{preview(response)}")
4238

4339
if recv == 1:
4440
return
@@ -47,11 +43,7 @@ def overview(self):
4743
print("ISIN status created target diff% createdAt triggeredAT")
4844
self.log.debug(f"Processing {len(self.alarms)} alarms")
4945

50-
for (
51-
a
52-
) in (
53-
self.alarms
54-
): # sorted(positions, key=lambda x: x['netValue'], reverse=True):
46+
for a in self.alarms: # sorted(positions, key=lambda x: x['netValue'], reverse=True):
5547
self.log.debug(f" Processing {a} alarm")
5648
ts = int(a["createdAt"]) / 1000.0
5749
target_price = float(a["targetPrice"])
@@ -61,9 +53,7 @@ def overview(self):
6153
triggered = "-"
6254
else:
6355
ts = int(a["triggeredAt"]) / 1000.0
64-
triggered = datetime.fromtimestamp(ts).isoformat(
65-
sep=" ", timespec="minutes"
66-
)
56+
triggered = datetime.fromtimestamp(ts).isoformat(sep=" ", timespec="minutes")
6757

6858
if a["createdPrice"] == 0:
6959
diffP = 0.0

pytr/api.py

Lines changed: 23 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,20 @@
2525
import hashlib
2626
import json
2727
import pathlib
28+
import ssl
2829
import time
2930
import urllib.parse
3031
import uuid
32+
from http.cookiejar import MozillaCookieJar
33+
3134
import certifi
32-
import ssl
3335
import requests
3436
import websockets
3537
from ecdsa import NIST256p, SigningKey
3638
from ecdsa.util import sigencode_der
37-
from http.cookiejar import MozillaCookieJar
3839

3940
from pytr.utils import get_logger
4041

41-
4242
home = pathlib.Path.home()
4343
BASE_DIR = home / ".pytr"
4444
CREDENTIALS_FILE = BASE_DIR / "credentials"
@@ -96,9 +96,7 @@ def __init__(
9696
self._locale = locale
9797
self._save_cookies = save_cookies
9898

99-
self._credentials_file = (
100-
pathlib.Path(credentials_file) if credentials_file else CREDENTIALS_FILE
101-
)
99+
self._credentials_file = pathlib.Path(credentials_file) if credentials_file else CREDENTIALS_FILE
102100

103101
if not (phone_no and pin):
104102
try:
@@ -107,18 +105,12 @@ def __init__(
107105
self.phone_no = lines[0].strip()
108106
self.pin = lines[1].strip()
109107
except FileNotFoundError:
110-
raise ValueError(
111-
f"phone_no and pin must be specified explicitly or via {self._credentials_file}"
112-
)
108+
raise ValueError(f"phone_no and pin must be specified explicitly or via {self._credentials_file}")
113109
else:
114110
self.phone_no = phone_no
115111
self.pin = pin
116112

117-
self._cookies_file = (
118-
pathlib.Path(cookies_file)
119-
if cookies_file
120-
else BASE_DIR / f"cookies.{self.phone_no}.txt"
121-
)
113+
self._cookies_file = pathlib.Path(cookies_file) if cookies_file else BASE_DIR / f"cookies.{self.phone_no}.txt"
122114

123115
self.keyfile = keyfile if keyfile else KEY_FILE
124116
try:
@@ -231,9 +223,7 @@ def complete_weblogin(self, verify_code):
231223
if not self._process_id and not self._websession:
232224
raise ValueError("Initiate web login first.")
233225

234-
r = self._websession.post(
235-
f"{self._host}/api/v1/auth/web/login/{self._process_id}/{verify_code}"
236-
)
226+
r = self._websession.post(f"{self._host}/api/v1/auth/web/login/{self._process_id}/{verify_code}")
237227
r.raise_for_status()
238228
self.save_websession()
239229
self._weblogin = True
@@ -270,9 +260,7 @@ def _web_request(self, url_path, payload=None, method="GET"):
270260
r = self._websession.get(f"{self._host}/api/v1/auth/web/session")
271261
r.raise_for_status()
272262
self._web_session_token_expires_at = time.time() + 290
273-
return self._websession.request(
274-
method=method, url=f"{self._host}{url_path}", data=payload
275-
)
263+
return self._websession.request(method=method, url=f"{self._host}{url_path}", data=payload)
276264

277265
async def _get_ws(self):
278266
if self._ws and self._ws.open:
@@ -301,9 +289,7 @@ async def _get_ws(self):
301289
}
302290
connect_id = 31
303291

304-
self._ws = await websockets.connect(
305-
"wss://api.traderepublic.com", ssl=ssl_context, extra_headers=extra_headers
306-
)
292+
self._ws = await websockets.connect("wss://api.traderepublic.com", ssl=ssl_context, extra_headers=extra_headers)
307293
await self._ws.send(f"connect {connect_id} {json.dumps(connection_message)}")
308294
response = await self._ws.recv()
309295

@@ -354,9 +340,7 @@ async def recv(self):
354340

355341
if subscription_id not in self.subscriptions:
356342
if code != "C":
357-
self.log.debug(
358-
f"No active subscription for id {subscription_id}, dropping message"
359-
)
343+
self.log.debug(f"No active subscription for id {subscription_id}, dropping message")
360344
continue
361345
subscription = self.subscriptions[subscription_id]
362346

@@ -408,16 +392,12 @@ async def _receive_one(self, fut, timeout):
408392
subscription_id = await fut
409393

410394
try:
411-
return await asyncio.wait_for(
412-
self._recv_subscription(subscription_id), timeout
413-
)
395+
return await asyncio.wait_for(self._recv_subscription(subscription_id), timeout)
414396
finally:
415397
await self.unsubscribe(subscription_id)
416398

417399
def run_blocking(self, fut, timeout=5.0):
418-
return asyncio.get_event_loop().run_until_complete(
419-
self._receive_one(fut, timeout=timeout)
420-
)
400+
return asyncio.get_event_loop().run_until_complete(self._receive_one(fut, timeout=timeout))
421401

422402
async def portfolio(self):
423403
return await self.subscribe({"type": "portfolio"})
@@ -437,21 +417,14 @@ async def cash(self):
437417
async def available_cash_for_payout(self):
438418
return await self.subscribe({"type": "availableCashForPayout"})
439419

440-
async def portfolio_status(self):
441-
return await self.subscribe({"type": "portfolioStatus"})
442-
443420
async def portfolio_history(self, timeframe):
444-
return await self.subscribe(
445-
{"type": "portfolioAggregateHistory", "range": timeframe}
446-
)
421+
return await self.subscribe({"type": "portfolioAggregateHistory", "range": timeframe})
447422

448423
async def instrument_details(self, isin):
449424
return await self.subscribe({"type": "instrument", "id": isin})
450425

451426
async def instrument_suitability(self, isin):
452-
return await self.subscribe(
453-
{"type": "instrumentSuitability", "instrumentId": isin}
454-
)
427+
return await self.subscribe({"type": "instrumentSuitability", "instrumentId": isin})
455428

456429
async def stock_details(self, isin):
457430
return await self.subscribe({"type": "stockDetails", "id": isin})
@@ -460,19 +433,15 @@ async def add_watchlist(self, isin):
460433
return await self.subscribe({"type": "addToWatchlist", "instrumentId": isin})
461434

462435
async def remove_watchlist(self, isin):
463-
return await self.subscribe(
464-
{"type": "removeFromWatchlist", "instrumentId": isin}
465-
)
436+
return await self.subscribe({"type": "removeFromWatchlist", "instrumentId": isin})
466437

467438
async def ticker(self, isin, exchange="LSX"):
468439
return await self.subscribe({"type": "ticker", "id": f"{isin}.{exchange}"})
469440

470441
async def performance(self, isin, exchange="LSX"):
471442
return await self.subscribe({"type": "performance", "id": f"{isin}.{exchange}"})
472443

473-
async def performance_history(
474-
self, isin, timeframe, exchange="LSX", resolution=None
475-
):
444+
async def performance_history(self, isin, timeframe, exchange="LSX", resolution=None):
476445
parameters = {
477446
"type": "aggregateHistory",
478447
"id": f"{isin}.{exchange}",
@@ -501,9 +470,7 @@ async def timeline_detail_order(self, order_id):
501470
return await self.subscribe({"type": "timelineDetail", "orderId": order_id})
502471

503472
async def timeline_detail_savings_plan(self, savings_plan_id):
504-
return await self.subscribe(
505-
{"type": "timelineDetail", "savingsPlanId": savings_plan_id}
506-
)
473+
return await self.subscribe({"type": "timelineDetail", "savingsPlanId": savings_plan_id})
507474

508475
async def timeline_transactions(self, after=None):
509476
return await self.subscribe({"type": "timelineTransactions", "after": after})
@@ -518,9 +485,7 @@ async def search_tags(self):
518485
return await self.subscribe({"type": "neonSearchTags"})
519486

520487
async def search_suggested_tags(self, query):
521-
return await self.subscribe(
522-
{"type": "neonSearchSuggestedTags", "data": {"q": query}}
523-
)
488+
return await self.subscribe({"type": "neonSearchSuggestedTags", "data": {"q": query}})
524489

525490
async def search(
526491
self,
@@ -546,17 +511,11 @@ async def search(
546511
if filter_index:
547512
search_parameters["filter"].append({"key": "index", "value": filter_index})
548513
if filter_country:
549-
search_parameters["filter"].append(
550-
{"key": "country", "value": filter_country}
551-
)
514+
search_parameters["filter"].append({"key": "country", "value": filter_country})
552515
if filter_region:
553-
search_parameters["filter"].append(
554-
{"key": "region", "value": filter_region}
555-
)
516+
search_parameters["filter"].append({"key": "region", "value": filter_region})
556517
if filter_sector:
557-
search_parameters["filter"].append(
558-
{"key": "sector", "value": filter_sector}
559-
)
518+
search_parameters["filter"].append({"key": "sector", "value": filter_sector})
560519

561520
search_type = "neonSearch" if not aggregate else "neonSearchAggregations"
562521
return await self.subscribe({"type": search_type, "data": search_parameters})
@@ -750,17 +709,13 @@ async def change_savings_plan(
750709
return await self.subscribe(parameters)
751710

752711
async def cancel_savings_plan(self, savings_plan_id):
753-
return await self.subscribe(
754-
{"type": "cancelSavingsPlan", "id": savings_plan_id}
755-
)
712+
return await self.subscribe({"type": "cancelSavingsPlan", "id": savings_plan_id})
756713

757714
async def price_alarm_overview(self):
758715
return await self.subscribe({"type": "priceAlarms"})
759716

760717
async def create_price_alarm(self, isin, price):
761-
return await self.subscribe(
762-
{"type": "createPriceAlarm", "instrumentId": isin, "targetPrice": price}
763-
)
718+
return await self.subscribe({"type": "createPriceAlarm", "instrumentId": isin, "targetPrice": price})
764719

765720
async def cancel_price_alarm(self, price_alarm_id):
766721
return await self.subscribe({"type": "cancelPriceAlarm", "id": price_alarm_id})

0 commit comments

Comments
 (0)