Skip to content

Commit a82bc1f

Browse files
committed
prepare for release
1 parent 002e4d9 commit a82bc1f

File tree

17 files changed

+234
-126
lines changed

17 files changed

+234
-126
lines changed

docs/additional_info/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
Changelog
44
=========
55

6+
4.0.0 (28-Sep-2025)
7+
-------------------
8+
9+
* Require Python 3.10+
10+
* Fully rework typing system
11+
* Make code more solid, enhance error handling
12+
* Switch to uv, black, ruff
13+
614
3.5.1 (14-May-2025)
715
-------------------
816

docs/additional_info/exception_handling.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ To handle an exception you would do the following:
3030
client.project('invalid_id')
3131
except lokalise.errors.NotFound as err:
3232
print(err.message)
33-
print(err.code)
33+
print(err.status_code)
3434
3535
Rate limits
3636
-----------

lokalise/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
This module contains plugin metadata.
55
"""
66

7-
__version__: str = "3.5.1"
7+
__version__: str = "4.0.0"

lokalise/base_client.py

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
lokalise.base_client
33
~~~~~~~~~~~~~~~~~~~~
4-
This module contains base API client definition.
4+
This module contains the base Lokalise API client definition.
55
"""
66

77
from .types import FullClientProto
@@ -10,87 +10,93 @@
1010

1111

1212
class BaseClient(FullClientProto):
13-
"""Base client used to send API requests."""
13+
"""Base client used to configure and send Lokalise API requests."""
1414

1515
def __init__(
1616
self,
1717
token: str,
18-
connect_timeout: int | float | None = None,
19-
read_timeout: int | float | None = None,
20-
enable_compression: bool | None = False,
18+
connect_timeout: Number | None = None,
19+
read_timeout: Number | None = None,
20+
enable_compression: bool = False,
2121
api_host: str | None = None,
2222
) -> None:
23-
"""Instantiate a new Lokalise API client.
24-
25-
:param str token: Your Lokalise API token.
26-
:param connect_timeout: (optional) Server connection timeout
27-
(the value is in seconds). By default, the client will wait indefinitely.
28-
:type connect_timeout: int or float
29-
:param read_timeout: (optional) Server read timeout
30-
(the value is in seconds). By default, the client will wait indefinitely.
31-
:type read_timeout: int or float
32-
:param enable_compression: (optional) Whether to enable gzip compression.
33-
:param api_host: (optional) Custom API host to send requests to.
34-
By default it's off.
35-
:type enable_compression: bool
23+
"""Create a new Lokalise API client instance.
24+
25+
Args:
26+
token: Your Lokalise API token (non-empty string).
27+
connect_timeout: Optional connection timeout in seconds. ``None`` means
28+
wait indefinitely.
29+
read_timeout: Optional read timeout in seconds. ``None`` means
30+
wait indefinitely.
31+
enable_compression: Whether to enable gzip compression (default: False).
32+
api_host: Optional custom API host. Defaults to the official Lokalise API.
3633
"""
37-
self._token = token
38-
self._connect_timeout = connect_timeout
39-
self._read_timeout = read_timeout
40-
self._enable_compression = enable_compression
41-
self._api_host = api_host
42-
self._token_header = "X-Api-Token"
34+
if not token:
35+
raise ValueError("token must be a non-empty string")
36+
37+
self._token: str | None = token
38+
self._connect_timeout: Number | None = connect_timeout
39+
self._read_timeout: Number | None = read_timeout
40+
self._enable_compression: bool = bool(enable_compression)
41+
self._api_host: str | None = api_host.strip() if api_host and api_host.strip() else None
42+
self._token_header: str = "X-Api-Token"
43+
44+
# ---------------------------------------------------------------------
45+
# Properties
46+
# ---------------------------------------------------------------------
4347

4448
@property
45-
def token(self) -> str:
49+
def token(self) -> str | None:
50+
"""Return the current API token, or None if not set."""
4651
return self._token
4752

4853
@token.setter
49-
def token(self, value: str) -> None:
54+
def token(self, value: str | None) -> None:
5055
if not value:
5156
raise ValueError("token must be a non-empty string")
5257
self._token = value
5358

5459
@property
5560
def connect_timeout(self) -> Number | None:
61+
"""Connection timeout in seconds, or ``None`` for no limit."""
5662
return self._connect_timeout
5763

5864
@connect_timeout.setter
5965
def connect_timeout(self, value: Number | None) -> None:
6066
if value is not None and value < 0:
61-
raise ValueError("connect_timeout must be a non-negative number or None")
67+
raise ValueError("connect_timeout must be non-negative or None")
6268
self._connect_timeout = value
6369

6470
@property
6571
def read_timeout(self) -> Number | None:
72+
"""Read timeout in seconds, or ``None`` for no limit."""
6673
return self._read_timeout
6774

6875
@read_timeout.setter
6976
def read_timeout(self, value: Number | None) -> None:
7077
if value is not None and value < 0:
71-
raise ValueError("read_timeout must be a non-negative number or None")
78+
raise ValueError("read_timeout must be non-negative or None")
7279
self._read_timeout = value
7380

7481
@property
75-
def enable_compression(self) -> bool | None:
82+
def enable_compression(self) -> bool:
83+
"""Whether gzip compression is enabled."""
7684
return self._enable_compression
7785

7886
@enable_compression.setter
7987
def enable_compression(self, value: bool | None) -> None:
80-
self._enable_compression = bool(value) if value is not None else False
88+
self._enable_compression = bool(value)
8189

8290
@property
8391
def api_host(self) -> str | None:
92+
"""Custom API host, or ``None`` to use the default Lokalise API."""
8493
return self._api_host
8594

8695
@api_host.setter
8796
def api_host(self, value: str | None) -> None:
88-
if value is not None:
89-
v = value.strip()
90-
if not v:
91-
value = None
92-
self._api_host = value
97+
self._api_host = value.strip() if value and value.strip() else None
9398

9499
@property
95100
def token_header(self) -> str:
101+
"""HTTP header key used for the API token."""
96102
return self._token_header

lokalise/client.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -79,46 +79,53 @@ class Client(
7979
"""
8080

8181
def reset_client(self) -> None:
82-
"""Resets the API client by clearing all attributes."""
83-
self._token = ""
82+
"""Resets the API client by clearing all attributes.
83+
After reset, token is None and client is unusable until you set a new token.
84+
"""
85+
self._token = None
8486
self._connect_timeout = None
8587
self._read_timeout = None
8688
self._enable_compression = False
87-
self.__clear_endpoint_attrs()
89+
self._clear_endpoint_attrs()
8890

8991
# === Endpoint helpers
9092
def get_endpoint(self, name: str) -> BaseEndpoint:
9193
"""Lazily loads an endpoint with a given name and stores it
9294
under a specific instance attribute. For example, if the `name`
9395
is "projects", then it will load .endpoints.projects_endpoint module
9496
and then set attribute like this:
95-
self.__projects_endpoint = ProjectsEndpoint(self)
97+
self._projects_endpoint = ProjectsEndpoint(self)
98+
99+
This is internal; prefer calling mixin methods like client.projects().
96100
97101
:param str name: Endpoint name to load
98102
"""
99103
endpoint_name = name + "_endpoint"
100104
camelized_name = snake_to_camel(endpoint_name)
105+
101106
# Dynamically load the necessary endpoint module
102-
module = importlib.import_module(f".endpoints.{endpoint_name}", package="lokalise")
107+
try:
108+
module = importlib.import_module(f".endpoints.{endpoint_name}", package="lokalise")
109+
endpoint_klass = getattr(module, camelized_name)
110+
except (ModuleNotFoundError, AttributeError) as e:
111+
raise ValueError(f"Unknown endpoint: {name}") from e
112+
103113
# Find endpoint class in the module
104-
endpoint_klass = cast("type[BaseEndpoint]", getattr(module, camelized_name))
105-
return self.__fetch_attr(f"__{endpoint_name}", lambda: endpoint_klass(self))
114+
endpoint_klass: type[BaseEndpoint] = getattr(module, camelized_name)
115+
return self._fetch_attr(f"_{endpoint_name}", lambda: endpoint_klass(self))
106116

107-
def __fetch_attr(self, attr_name: str, populator: Callable[[], T]) -> T:
117+
def _fetch_attr(self, attr_name: str, populator: Callable[[], T]) -> T:
108118
"""Searches for the given attribute.
109119
Uses populator to set the attribute if it cannot be found.
110120
Used to lazy-load endpoints.
111121
"""
112-
if not hasattr(self, attr_name):
113-
setattr(self, attr_name, populator())
114-
else:
115-
val = getattr(self, attr_name)
116-
if val is None:
117-
val = populator()
118-
setattr(self, attr_name, val)
119-
return cast(T, getattr(self, attr_name))
122+
val = getattr(self, attr_name, None)
123+
if val is None:
124+
val = populator()
125+
setattr(self, attr_name, val)
126+
return cast(T, val)
120127

121-
def __clear_endpoint_attrs(self) -> None:
128+
def _clear_endpoint_attrs(self) -> None:
122129
"""Clears all lazily-loaded endpoint attributes"""
123130
for attr in [a for a in vars(self) if a.endswith("_endpoint")]:
124131
delattr(self, attr)

lokalise/collections/base_collection.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,20 @@
44
Collection parent class inherited by specific collections.
55
"""
66

7-
from typing import Any, ClassVar, Generic, TypeVar, cast
7+
from collections.abc import Sequence
8+
from typing import Any, ClassVar, Generic, TypeVar, cast, overload
89

910
from ..models.base_model import BaseModel
1011

1112
TModel = TypeVar("TModel", bound=BaseModel)
1213

1314

14-
class BaseCollection(Generic[TModel]):
15+
class BaseCollection(Sequence[TModel], Generic[TModel]):
1516
"""Abstract base class for resources collections.
1617
1718
:attribute DATA_KEY: contains the key name that should be used to fetch
1819
collection data. Response usually arrives in the following format:
19-
{"project_id": "abc", contributors: [{"user_id": 1}, {"user_id": 2}]}
20+
{"project_id": "abc", "contributors": [{"user_id": 1}, {"user_id": 2}]}
2021
In this case, the DATA_KEY would be "contributors"
2122
2223
:attribute MODEL_KLASS: tells which class to use to produce models for each
@@ -74,6 +75,27 @@ def __init__(self, raw_data: dict[str, Any]) -> None:
7475
self.limit = int(pagination.get("x-pagination-limit", 0) or 0)
7576
self.current_page = int(pagination.get("x-pagination-page", 0) or 0)
7677
self.next_cursor = cast(str | None, pagination.get("x-pagination-next-cursor", None))
78+
else:
79+
self.total_count = 0
80+
self.page_count = 0
81+
self.limit = 0
82+
self.current_page = 0
83+
self.next_cursor = None
84+
85+
86+
def __iter__(self):
87+
return iter(self.items)
88+
89+
def __len__(self) -> int:
90+
return len(self.items)
91+
92+
@overload
93+
def __getitem__(self, index: int) -> TModel: ...
94+
@overload
95+
def __getitem__(self, index: slice) -> list[TModel]: ...
96+
97+
def __getitem__(self, index: int | slice) -> TModel | list[TModel]:
98+
return self.items[index]
7799

78100
def is_last_page(self) -> bool:
79101
"""Checks whether the current collection set is the last page.

lokalise/endpoints/base_endpoint.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,7 @@ def path_with_params(self, **ids: str | int | None) -> str:
127127
on the actual endpoint.
128128
"""
129129
defaults = {"parent_id": "", "resource_id": "", "subresource_id": ""}
130-
return Template(self.PATH).substitute(defaults, **ids)
130+
try:
131+
return Template(self.PATH).substitute(defaults, **ids)
132+
except KeyError as e:
133+
raise ValueError(f"Missing required path parameter: {e}") from None

lokalise/oauth2/request.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import requests
1010

1111
from lokalise._version import __version__
12-
from lokalise.request_utils import format_params, raise_on_error
12+
from lokalise.request_utils import format_params, raise_on_error, join_url
1313

1414
BASE_URL = "https://app.lokalise.com/oauth2/"
1515

@@ -19,7 +19,7 @@ def post(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
1919
Perform an OAuth2 POST request (token exchange/revoke/etc).
2020
Returns parsed JSON or raises a structured error.
2121
"""
22-
url = __join_url(BASE_URL, path)
22+
url = join_url(BASE_URL, path)
2323
resp = requests.post(url, data=format_params(params), **options())
2424
return respond_with(resp)
2525

@@ -28,7 +28,10 @@ def respond_with(response: requests.Response) -> dict[str, Any]:
2828
"""
2929
Parse JSON body and raise on HTTP error or on payload containing 'error'.
3030
"""
31-
data: dict[str, Any] = response.json()
31+
try:
32+
data: dict[str, Any] = response.json()
33+
except ValueError:
34+
data = {"_raw_body": response.text}
3235
raise_on_error(response, data)
3336
return data
3437

@@ -40,13 +43,6 @@ def options() -> dict[str, Any]:
4043
headers: dict[str, Any] = {
4144
"Accept": "application/json",
4245
"User-Agent": f"python-lokalise-api plugin/{__version__}",
46+
"Content-Type": "application/json",
4347
}
44-
return {"headers": headers}
45-
46-
47-
def __join_url(base: str, path: str) -> str:
48-
"""
49-
Safe join for base URL and path
50-
"""
51-
url = base.rstrip("/") + "/" + path.lstrip("/")
52-
return url.rstrip("/")
48+
return {"headers": headers}

lokalise/oauth_client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def __init__(
2222
token: str,
2323
connect_timeout: int | float | None = None,
2424
read_timeout: int | float | None = None,
25-
enable_compression: bool | None = False,
25+
enable_compression: bool = False,
2626
api_host: str | None = None,
2727
) -> None:
2828
"""Instantiate a new Lokalise API client with OAuth 2 token.
@@ -39,7 +39,10 @@ def __init__(
3939
By default it's off.
4040
:type enable_compression: bool
4141
"""
42+
if not token:
43+
raise ValueError("token must be a non-empty string")
4244
super().__init__(token, connect_timeout, read_timeout, enable_compression, api_host)
4345

46+
# Override token representation for OAuth
4447
self._token = f"Bearer {token}"
4548
self._token_header = "Authorization"

0 commit comments

Comments
 (0)