Skip to content

Commit ccf252b

Browse files
feat: [VPNLINUX-1507] add citites to country dataclass
1 parent 426d37c commit ccf252b

3 files changed

Lines changed: 175 additions & 0 deletions

File tree

proton/vpn/session/dataclasses/servers/country.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,37 @@
1919
from __future__ import annotations
2020
from typing import TYPE_CHECKING, List
2121
from dataclasses import dataclass
22+
import itertools
2223

2324
from proton.vpn.session.servers.country_codes import get_country_name_by_code
25+
from proton.vpn.session.servers.types import ServerFeatureEnum
2426

2527
if TYPE_CHECKING:
2628
from proton.vpn.session.servers.logicals import LogicalServer
2729

2830

31+
@dataclass
32+
class City:
33+
"""A city that belongs to a country."""
34+
name: str
35+
servers: list[LogicalServer]
36+
37+
def __init__(self, name: str, servers: list[LogicalServer]):
38+
self.name = name
39+
self.servers = servers
40+
self._features = None
41+
42+
@property
43+
def features(self) -> set[ServerFeatureEnum]:
44+
"""Returns a list with features that are available via to city."""
45+
if self._features is None:
46+
self._features = {
47+
feature for server in self.servers for feature in server.features
48+
}
49+
50+
return self._features
51+
52+
2953
@dataclass
3054
class Country:
3155
"""Group of servers belonging to a country."""
@@ -38,6 +62,19 @@ def name(self):
3862
"""Returns the full country name."""
3963
return get_country_name_by_code(self.code)
4064

65+
@property
66+
def cities(self) -> list[City]:
67+
"""Returns a list of cities."""
68+
cities = []
69+
# Servers have to be organized first otherwise groupby is not able to do it itself
70+
# See: https://docs.python.org/3/library/itertools.html#itertools.groupby
71+
sorted_list = sorted(self.servers, key=lambda server: server.city)
72+
for city_name, servers in itertools.groupby(sorted_list, key=lambda logical: logical.city):
73+
# `servers` have to be stored as list because otherwise its lost on next iteration
74+
cities.append(City(city_name, list(servers)))
75+
76+
return cities
77+
4178
@property
4279
def is_free(self) -> bool:
4380
"""Returns whether the country has servers available to the free tier or not."""
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""
2+
Copyright (c) 2023 Proton AG
3+
4+
This file is part of Proton VPN.
5+
6+
Proton VPN is free software: you can redistribute it and/or modify
7+
it under the terms of the GNU General Public License as published by
8+
the Free Software Foundation, either version 3 of the License, or
9+
(at your option) any later version.
10+
11+
Proton VPN is distributed in the hope that it will be useful,
12+
but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
GNU General Public License for more details.
15+
16+
You should have received a copy of the GNU General Public License
17+
along with ProtonVPN. If not, see <https://www.gnu.org/licenses/>.
18+
"""
19+
import pytest
20+
from proton.vpn.session.servers.types import LogicalServer, ServerFeatureEnum, TierEnum
21+
from proton.vpn.session.dataclasses.servers.country import City, Country
22+
23+
24+
COUNTRY_CODE = "AR"
25+
COUNTRY_NAME = "Argentina"
26+
CITIES = ["Buenos Aires", "Rosario"]
27+
ROSARIO_CITY_FEATURES = {
28+
ServerFeatureEnum.P2P, ServerFeatureEnum.STREAMING, ServerFeatureEnum.IPV6
29+
}
30+
31+
32+
@pytest.fixture()
33+
def servers_raw() -> list[dict]:
34+
return [
35+
{
36+
"ID": 3,
37+
"Name": "AR#13",
38+
"Status": 1,
39+
"Servers": [{"Status": 1}],
40+
"Score": 2.0, # Even though it has a better score than CH#9,
41+
"Tier": 2, # it's not in the user tier (2).
42+
"ExitCountry": "AR",
43+
"City": "Rosario",
44+
"Features": ServerFeatureEnum.P2P | ServerFeatureEnum.STREAMING | ServerFeatureEnum.IPV6
45+
},
46+
{
47+
"ID": 2,
48+
"Name": "AR#11",
49+
"Status": 1,
50+
"Servers": [{"Status": 1}],
51+
"Score": 1.0, # Even though it has a better score than CH#9,
52+
"Tier": 2, # it's not in the user tier (2).
53+
"ExitCountry": "AR",
54+
"City": "Buenos Aires",
55+
"Features": ServerFeatureEnum.P2P | ServerFeatureEnum.STREAMING
56+
},
57+
{
58+
"ID": 4,
59+
"Name": "AR#14",
60+
"Status": 1,
61+
"Servers": [{"Status": 1}],
62+
"Score": 2.0, # Even though it has a better score than CH#9,
63+
"Tier": 2, # it's not in the user tier (2).
64+
"ExitCountry": "AR",
65+
"City": "Rosario",
66+
"Features": ServerFeatureEnum.P2P
67+
}
68+
]
69+
70+
71+
@pytest.fixture()
72+
def non_free_logical_servers(servers_raw) -> list[LogicalServer]:
73+
return [
74+
LogicalServer(servers_raw[0]),
75+
LogicalServer(servers_raw[1]),
76+
LogicalServer(servers_raw[2])
77+
]
78+
79+
80+
@pytest.fixture()
81+
def free_logical_servers(servers_raw) -> list[LogicalServer]:
82+
servers_raw[0]["Tier"] = 0
83+
servers_raw[1]["Tier"] = 0
84+
servers_raw[2]["Tier"] = 0
85+
86+
return [
87+
LogicalServer(servers_raw[0]),
88+
LogicalServer(servers_raw[1]),
89+
LogicalServer(servers_raw[2])
90+
]
91+
92+
93+
@pytest.fixture()
94+
def mixed_free_and_non_logical_servers(servers_raw) -> list[LogicalServer]:
95+
servers_raw[1]["Tier"] = 0
96+
97+
return [
98+
LogicalServer(servers_raw[0]),
99+
LogicalServer(servers_raw[1]),
100+
LogicalServer(servers_raw[2])
101+
]
102+
103+
104+
class TestCountry:
105+
def test_name_is_correctly_returned_when_passing_country_code(self):
106+
country = Country(COUNTRY_CODE, [])
107+
assert country.name == COUNTRY_NAME
108+
109+
def test_is_free_returns_true_if_any_free_servers_are_available(self, mixed_free_and_non_logical_servers):
110+
country_with_some_free_servers = Country(COUNTRY_CODE, mixed_free_and_non_logical_servers)
111+
112+
assert country_with_some_free_servers.is_free
113+
114+
def test_cities_are_grouped_and_sorted(self, non_free_logical_servers):
115+
country = Country(COUNTRY_CODE, non_free_logical_servers)
116+
117+
cities = country.cities
118+
119+
assert cities[0].name == CITIES[0]
120+
assert cities[1].name == CITIES[1]
121+
assert len(cities) == len(CITIES)
122+
123+
124+
class TestCity:
125+
def test_features_are_grouped_when_multiple_servers_have_same_features(self, non_free_logical_servers):
126+
city_name = "Rosario"
127+
city_servers = [server for server in non_free_logical_servers if server.city == city_name]
128+
city = City(name=city_name, servers=city_servers)
129+
assert city.features == ROSARIO_CITY_FEATURES

versions.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
version: 4.14.3
2+
time: 2025/12/12 13:45
3+
author: Alexandru Cheltuitor
4+
email: alexandru.cheltuitor@proton.ch
5+
urgency: low
6+
stability: unstable
7+
description:
8+
- "feat: add cities to country dataclass"
9+
---
110
version: 4.14.2
211
time: 2025/12/09 09:43
312
author: Richard Paterson

0 commit comments

Comments
 (0)