kabukit 0.1.0__py3-none-any.whl → 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kabukit/__init__.py +3 -4
- kabukit/cli/__init__.py +0 -0
- kabukit/cli/app.py +22 -0
- kabukit/cli/auth.py +86 -0
- kabukit/concurrent.py +40 -0
- kabukit/config.py +26 -0
- kabukit/edinet/__init__.py +0 -0
- kabukit/edinet/client.py +111 -0
- kabukit/jquants/__init__.py +3 -0
- kabukit/jquants/client.py +218 -180
- kabukit/jquants/info.py +23 -0
- kabukit/jquants/prices.py +0 -0
- kabukit/jquants/schema.py +169 -0
- kabukit/jquants/statements.py +69 -0
- kabukit/jquants/stream.py +122 -0
- kabukit/params.py +47 -0
- kabukit/py.typed +0 -0
- {kabukit-0.1.0.dist-info → kabukit-0.1.1.dist-info}/METADATA +13 -16
- kabukit-0.1.1.dist-info/RECORD +21 -0
- {kabukit-0.1.0.dist-info → kabukit-0.1.1.dist-info}/WHEEL +1 -1
- kabukit-0.1.1.dist-info/entry_points.txt +3 -0
- kabukit/cli.py +0 -40
- kabukit-0.1.0.dist-info/RECORD +0 -8
- kabukit-0.1.0.dist-info/entry_points.txt +0 -3
kabukit/jquants/client.py
CHANGED
@@ -1,324 +1,362 @@
|
|
1
|
-
"""This module provides a client for the J-Quants API.
|
2
|
-
|
3
|
-
It handles authentication and provides methods to interact with
|
4
|
-
the API endpoints.
|
5
|
-
"""
|
6
|
-
|
7
1
|
from __future__ import annotations
|
8
2
|
|
9
3
|
import datetime
|
10
4
|
import os
|
11
5
|
from enum import StrEnum
|
12
|
-
from functools import cached_property
|
13
|
-
from pathlib import Path
|
14
6
|
from typing import TYPE_CHECKING
|
15
7
|
|
16
8
|
import polars as pl
|
17
|
-
from
|
18
|
-
from httpx import Client
|
19
|
-
from platformdirs import user_config_dir
|
9
|
+
from httpx import AsyncClient
|
20
10
|
from polars import DataFrame
|
21
11
|
|
12
|
+
from kabukit.config import load_dotenv, set_key
|
13
|
+
from kabukit.params import get_params
|
14
|
+
|
15
|
+
from . import statements
|
16
|
+
|
22
17
|
if TYPE_CHECKING:
|
23
|
-
from collections.abc import
|
24
|
-
from typing import Any
|
18
|
+
from collections.abc import AsyncIterator
|
19
|
+
from typing import Any, Self
|
25
20
|
|
26
21
|
from httpx import HTTPStatusError # noqa: F401
|
27
22
|
from httpx._types import QueryParamTypes
|
28
23
|
|
29
24
|
API_VERSION = "v1"
|
30
|
-
|
31
|
-
|
32
|
-
class AuthenticationError(Exception):
|
33
|
-
"""Custom exception for authentication failures."""
|
25
|
+
BASE_URL = f"https://api.jquants.com/{API_VERSION}"
|
34
26
|
|
35
27
|
|
36
28
|
class AuthKey(StrEnum):
|
37
|
-
"""
|
29
|
+
"""J-Quants認証のための環境変数キー。"""
|
38
30
|
|
39
31
|
REFRESH_TOKEN = "JQUANTS_REFRESH_TOKEN" # noqa: S105
|
40
32
|
ID_TOKEN = "JQUANTS_ID_TOKEN" # noqa: S105
|
41
33
|
|
42
34
|
|
43
35
|
class JQuantsClient:
|
44
|
-
"""
|
36
|
+
"""J-Quants APIと対話するためのクライアント。
|
45
37
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
user's standard config directory.
|
38
|
+
API認証トークン(リフレッシュトークンおよびIDトークン)を管理し、
|
39
|
+
各種J-Quants APIエンドポイントへアクセスするメソッドを提供する。
|
40
|
+
トークンは設定ファイルから読み込まれ、またファイルに保存される。
|
50
41
|
|
51
42
|
Attributes:
|
52
|
-
client:
|
53
|
-
refresh_token: The refresh token for authentication.
|
54
|
-
id_token: The ID token for API requests.
|
43
|
+
client: APIリクエストを行うための `AsyncClient` インスタンス。
|
55
44
|
"""
|
56
45
|
|
57
|
-
client:
|
58
|
-
refresh_token: str | None
|
59
|
-
id_token: str | None
|
46
|
+
client: AsyncClient
|
60
47
|
|
61
|
-
def __init__(self) -> None:
|
62
|
-
|
48
|
+
def __init__(self, id_token: str | None = None) -> None:
|
49
|
+
self.client = AsyncClient(base_url=BASE_URL)
|
50
|
+
self.set_id_token(id_token)
|
63
51
|
|
64
|
-
|
65
|
-
|
66
|
-
|
52
|
+
def set_id_token(self, id_token: str | None = None) -> None:
|
53
|
+
"""IDトークンをヘッダーに設定する。
|
54
|
+
|
55
|
+
Args:
|
56
|
+
id_token (str | None, optional): 設定するIDトークン。
|
57
|
+
Noneの場合、環境変数から読み込む。
|
67
58
|
"""
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
def
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
self.client.headers["Authorization"] = f"Bearer {self.id_token}"
|
95
|
-
# Clear header if no ID token is available
|
96
|
-
elif "Authorization" in self.client.headers:
|
97
|
-
del self.client.headers["Authorization"]
|
98
|
-
|
99
|
-
def auth(self, mailaddress: str, password: str) -> None:
|
100
|
-
"""Authenticates, saves tokens, and sets the auth header.
|
59
|
+
|
60
|
+
if id_token is None:
|
61
|
+
load_dotenv()
|
62
|
+
id_token = os.environ.get(AuthKey.ID_TOKEN)
|
63
|
+
|
64
|
+
if id_token:
|
65
|
+
self.client.headers["Authorization"] = f"Bearer {id_token}"
|
66
|
+
|
67
|
+
async def aclose(self) -> None:
|
68
|
+
"""HTTPクライアントを閉じる。"""
|
69
|
+
await self.client.aclose()
|
70
|
+
|
71
|
+
async def __aenter__(self) -> Self:
|
72
|
+
return self
|
73
|
+
|
74
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: # pyright: ignore[reportMissingParameterType, reportUnknownParameterType] # noqa: ANN001
|
75
|
+
await self.aclose()
|
76
|
+
|
77
|
+
async def auth(
|
78
|
+
self,
|
79
|
+
mailaddress: str,
|
80
|
+
password: str,
|
81
|
+
*,
|
82
|
+
save: bool = False,
|
83
|
+
) -> Self:
|
84
|
+
"""認証を行い、トークンを保存する。
|
101
85
|
|
102
86
|
Args:
|
103
|
-
mailaddress:
|
104
|
-
password:
|
87
|
+
mailaddress (str): J-Quantsに登録したメールアドレス。
|
88
|
+
password (str): J-Quantsのパスワード。
|
89
|
+
save (bool, optional): トークンを環境変数に保存するかどうか。
|
105
90
|
|
106
91
|
Raises:
|
107
|
-
HTTPStatusError:
|
92
|
+
HTTPStatusError: APIリクエストが失敗した場合。
|
108
93
|
"""
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
94
|
+
refresh_token = await self.get_refresh_token(mailaddress, password)
|
95
|
+
id_token = await self.get_id_token(refresh_token)
|
96
|
+
|
97
|
+
if save:
|
98
|
+
set_key(AuthKey.REFRESH_TOKEN, refresh_token)
|
99
|
+
set_key(AuthKey.ID_TOKEN, id_token)
|
114
100
|
|
115
|
-
|
116
|
-
|
101
|
+
self.set_id_token(id_token)
|
102
|
+
return self
|
103
|
+
|
104
|
+
async def post(self, url: str, json: Any | None = None) -> Any:
|
105
|
+
"""指定されたURLにPOSTリクエストを送信する。
|
117
106
|
|
118
107
|
Args:
|
119
|
-
url:
|
120
|
-
json:
|
108
|
+
url: POSTリクエストのURLパス。
|
109
|
+
json: リクエストボディのJSONペイロード。
|
121
110
|
|
122
111
|
Returns:
|
123
|
-
|
112
|
+
APIからのJSONレスポンス。
|
124
113
|
|
125
114
|
Raises:
|
126
|
-
|
127
|
-
HTTPStatusError: If the API request fails.
|
115
|
+
HTTPStatusError: APIリクエストが失敗した場合。
|
128
116
|
"""
|
129
|
-
|
130
|
-
msg = "ID token is not available. Please authenticate first."
|
131
|
-
raise AuthenticationError(msg)
|
132
|
-
|
133
|
-
resp = self.client.post(url, json=json)
|
117
|
+
resp = await self.client.post(url, json=json)
|
134
118
|
resp.raise_for_status()
|
135
119
|
return resp.json()
|
136
120
|
|
137
|
-
def get_refresh_token(self, mailaddress: str, password: str) -> str:
|
138
|
-
"""
|
121
|
+
async def get_refresh_token(self, mailaddress: str, password: str) -> str:
|
122
|
+
"""APIから新しいリフレッシュトークンを取得する。
|
139
123
|
|
140
124
|
Args:
|
141
|
-
mailaddress:
|
142
|
-
password:
|
125
|
+
mailaddress (str): ユーザーのメールアドレス。
|
126
|
+
password (str): ユーザーのパスワード。
|
143
127
|
|
144
128
|
Returns:
|
145
|
-
|
129
|
+
新しいリフレッシュトークン。
|
146
130
|
|
147
131
|
Raises:
|
148
|
-
|
132
|
+
HTTPStatusError: APIリクエストが失敗した場合。
|
149
133
|
"""
|
150
134
|
json_data = {"mailaddress": mailaddress, "password": password}
|
151
|
-
|
135
|
+
data = await self.post("/token/auth_user", json=json_data)
|
136
|
+
return data["refreshToken"]
|
152
137
|
|
153
|
-
def get_id_token(self, refresh_token: str) -> str:
|
154
|
-
"""
|
138
|
+
async def get_id_token(self, refresh_token: str) -> str:
|
139
|
+
"""APIから新しいIDトークンを取得する。
|
155
140
|
|
156
141
|
Args:
|
157
|
-
refresh_token:
|
142
|
+
refresh_token (str): 使用するリフレッシュトークン。
|
158
143
|
|
159
144
|
Returns:
|
160
|
-
|
145
|
+
新しいIDトークン。
|
161
146
|
|
162
147
|
Raises:
|
163
|
-
HTTPStatusError:
|
148
|
+
HTTPStatusError: APIリクエストが失敗した場合。
|
164
149
|
"""
|
165
150
|
url = f"/token/auth_refresh?refreshtoken={refresh_token}"
|
166
|
-
|
151
|
+
data = await self.post(url)
|
152
|
+
return data["idToken"]
|
167
153
|
|
168
|
-
def get(self, url: str, params: QueryParamTypes | None = None) -> Any:
|
169
|
-
"""
|
154
|
+
async def get(self, url: str, params: QueryParamTypes | None = None) -> Any:
|
155
|
+
"""指定されたURLにGETリクエストを送信する。
|
170
156
|
|
171
157
|
Args:
|
172
|
-
url:
|
173
|
-
params
|
158
|
+
url (str): GETリクエストのURLパス。
|
159
|
+
params (QueryParamTypes | None, optional): リクエストのクエリパラメータ。
|
174
160
|
|
175
161
|
Returns:
|
176
|
-
|
162
|
+
APIからのJSONレスポンス。
|
177
163
|
|
178
164
|
Raises:
|
179
|
-
|
180
|
-
HTTPStatusError: If the API request fails.
|
165
|
+
HTTPStatusError: APIリクエストが失敗した場合。
|
181
166
|
"""
|
182
|
-
|
183
|
-
msg = "ID token is not available. Please authenticate first."
|
184
|
-
raise AuthenticationError(msg)
|
185
|
-
|
186
|
-
resp = self.client.get(url, params=params)
|
167
|
+
resp = await self.client.get(url, params=params)
|
187
168
|
resp.raise_for_status()
|
188
169
|
return resp.json()
|
189
170
|
|
190
|
-
def
|
171
|
+
async def get_info(
|
191
172
|
self,
|
192
173
|
code: str | None = None,
|
193
174
|
date: str | datetime.date | None = None,
|
194
175
|
) -> DataFrame:
|
195
|
-
"""
|
176
|
+
"""銘柄情報を取得する。
|
196
177
|
|
197
178
|
Args:
|
198
|
-
code
|
199
|
-
date
|
200
|
-
or datetime.date object).
|
179
|
+
code (str | None, optional): 情報を取得する銘柄のコード。
|
180
|
+
date (str | datetime.date | None, optional): 情報を取得する日付。
|
201
181
|
|
202
182
|
Returns:
|
203
|
-
|
183
|
+
銘柄情報を含むPolars DataFrame。
|
204
184
|
|
205
185
|
Raises:
|
206
|
-
|
207
|
-
HTTPStatusError: If the API request fails.
|
186
|
+
HTTPStatusError: APIリクエストが失敗した場合。
|
208
187
|
"""
|
209
|
-
params =
|
188
|
+
params = get_params(code=code, date=date)
|
210
189
|
url = "/listed/info"
|
211
|
-
data = self.get(url, params)
|
190
|
+
data = await self.get(url, params)
|
212
191
|
df = DataFrame(data["info"])
|
213
|
-
return df.with_columns(pl.col("Date").str.to_date())
|
214
192
|
|
215
|
-
|
193
|
+
return df.with_columns(
|
194
|
+
pl.col("Date").str.to_date("%Y-%m-%d"),
|
195
|
+
pl.col("^.*CodeName$", "ScaleCategory").cast(pl.Categorical),
|
196
|
+
).drop("^.+Code$", "CompanyNameEnglish")
|
197
|
+
|
198
|
+
async def iter_pages(
|
216
199
|
self,
|
217
200
|
url: str,
|
218
201
|
params: dict[str, Any] | None,
|
219
202
|
name: str,
|
220
|
-
) ->
|
221
|
-
"""
|
203
|
+
) -> AsyncIterator[DataFrame]:
|
204
|
+
"""ページ分割されたAPIレスポンスを反復処理する。
|
222
205
|
|
223
206
|
Args:
|
224
|
-
url:
|
225
|
-
params
|
226
|
-
name:
|
207
|
+
url (str): APIエンドポイントのベースURL。
|
208
|
+
params (dict[str, Any]): クエリパラメータの辞書。
|
209
|
+
name (str): アイテムのリストを含むJSONレスポンスのキー。
|
227
210
|
|
228
211
|
Yields:
|
229
|
-
|
212
|
+
データの各ページに対応するPolars DataFrame。
|
230
213
|
|
231
214
|
Raises:
|
232
|
-
|
233
|
-
HTTPStatusError: If the API request fails.
|
215
|
+
HTTPStatusError: APIリクエストが失敗した場合。
|
234
216
|
"""
|
235
217
|
params = params or {}
|
236
218
|
|
237
219
|
while True:
|
238
|
-
data = self.get(url, params)
|
220
|
+
data = await self.get(url, params)
|
239
221
|
yield DataFrame(data[name])
|
240
222
|
if "pagination_key" in data:
|
241
223
|
params["pagination_key"] = data["pagination_key"]
|
242
224
|
else:
|
243
225
|
break
|
244
226
|
|
245
|
-
def get_prices(
|
227
|
+
async def get_prices(
|
246
228
|
self,
|
247
229
|
code: str | None = None,
|
248
230
|
date: str | datetime.date | None = None,
|
249
231
|
from_: str | datetime.date | None = None,
|
250
232
|
to: str | datetime.date | None = None,
|
251
233
|
) -> DataFrame:
|
252
|
-
"""
|
234
|
+
"""日々の株価四本値を取得する。
|
253
235
|
|
254
236
|
Args:
|
255
|
-
code:
|
256
|
-
date:
|
257
|
-
|
258
|
-
|
259
|
-
Requires `to` if `date` is not specified.
|
260
|
-
to: Optional. The end date for a price range.
|
261
|
-
Requires `from_` if `date` is not specified.
|
237
|
+
code: 株価を取得する銘柄のコード。
|
238
|
+
date: 株価を取得する特定の日付。`from_`または`to`とは併用不可。
|
239
|
+
from_: 取得期間の開始日。`date`とは併用不可。
|
240
|
+
to: 取得期間の終了日。`date`とは併用不可。
|
262
241
|
|
263
242
|
Returns:
|
264
|
-
|
243
|
+
日々の株価四本値を含むPolars DataFrame。
|
265
244
|
|
266
245
|
Raises:
|
267
|
-
ValueError:
|
268
|
-
|
269
|
-
HTTPStatusError: If the API request fails.
|
246
|
+
ValueError: `date`と`from_`/`to`の両方が指定された場合。
|
247
|
+
HTTPStatusError: APIリクエストが失敗した場合。
|
270
248
|
"""
|
271
|
-
|
249
|
+
if not date and not code:
|
250
|
+
return await self.get_latest_available_prices()
|
272
251
|
|
273
252
|
if date and (from_ or to):
|
274
253
|
msg = "Cannot specify both date and from/to parameters."
|
275
254
|
raise ValueError(msg)
|
276
255
|
|
277
|
-
|
278
|
-
params["from"] = date_to_str(from_)
|
279
|
-
if not date and to:
|
280
|
-
params["to"] = date_to_str(to)
|
256
|
+
params = get_params(code=code, date=date, from_=from_, to=to)
|
281
257
|
|
282
258
|
url = "/prices/daily_quotes"
|
283
259
|
name = "daily_quotes"
|
284
260
|
|
285
|
-
|
261
|
+
dfs = [df async for df in self.iter_pages(url, params, name)]
|
262
|
+
df = pl.concat(dfs)
|
263
|
+
|
286
264
|
if df.is_empty():
|
287
265
|
return df
|
288
266
|
|
289
|
-
return df.with_columns(
|
267
|
+
return df.with_columns(
|
268
|
+
pl.col("Date").str.to_date("%Y-%m-%d"),
|
269
|
+
pl.col("^.*Limit$").cast(pl.Int8).cast(pl.Boolean),
|
270
|
+
)
|
290
271
|
|
272
|
+
async def get_latest_available_prices(self) -> DataFrame:
|
273
|
+
"""直近利用可能な日付の株価を取得する。"""
|
274
|
+
today = datetime.date.today() # noqa: DTZ011
|
291
275
|
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
) -> dict[str, str]:
|
296
|
-
"""Constructs a dictionary of parameters for code and date filtering.
|
276
|
+
for days in range(30):
|
277
|
+
date = today - datetime.timedelta(days)
|
278
|
+
df = await self.get_prices(date=date)
|
297
279
|
|
298
|
-
|
299
|
-
|
300
|
-
date: Optional. The date (string or datetime.date object).
|
280
|
+
if not df.is_empty():
|
281
|
+
return df
|
301
282
|
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
return params
|
283
|
+
return DataFrame()
|
284
|
+
|
285
|
+
async def get_statements(
|
286
|
+
self,
|
287
|
+
code: str | None = None,
|
288
|
+
date: str | datetime.date | None = None,
|
289
|
+
) -> DataFrame:
|
290
|
+
"""財務情報を取得する。
|
311
291
|
|
292
|
+
Args:
|
293
|
+
code: 財務情報を取得する銘柄のコード。
|
294
|
+
date: 財務情報を取得する日付。
|
312
295
|
|
313
|
-
|
314
|
-
|
296
|
+
Returns:
|
297
|
+
財務情報を含むPolars DataFrame。
|
315
298
|
|
316
|
-
|
317
|
-
|
299
|
+
Raises:
|
300
|
+
HTTPStatusError: APIリクエストが失敗した場合。
|
301
|
+
"""
|
302
|
+
params = get_params(code=code, date=date)
|
303
|
+
url = "/fins/statements"
|
304
|
+
name = "statements"
|
318
305
|
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
306
|
+
dfs = [df async for df in self.iter_pages(url, params, name)]
|
307
|
+
df = pl.concat(dfs)
|
308
|
+
|
309
|
+
if df.is_empty():
|
310
|
+
return df
|
311
|
+
|
312
|
+
return statements.clean(df)
|
313
|
+
|
314
|
+
async def get_announcement(self) -> DataFrame:
|
315
|
+
"""翌日発表予定の決算情報を取得する。
|
316
|
+
|
317
|
+
Returns:
|
318
|
+
開示情報を含むPolars DataFrame。
|
319
|
+
|
320
|
+
Raises:
|
321
|
+
HTTPStatusError: APIリクエストが失敗した場合。
|
322
|
+
"""
|
323
|
+
url = "fins/announcement"
|
324
|
+
name = "announcement"
|
325
|
+
|
326
|
+
dfs = [df async for df in self.iter_pages(url, {}, name)]
|
327
|
+
df = pl.concat(dfs)
|
328
|
+
if df.is_empty():
|
329
|
+
return df
|
330
|
+
|
331
|
+
return df.with_columns(pl.col("Date").str.to_date("%Y-%m-%d", strict=False))
|
332
|
+
|
333
|
+
async def get_trades_spec(
|
334
|
+
self,
|
335
|
+
section: str | None = None,
|
336
|
+
from_: str | datetime.date | None = None,
|
337
|
+
to: str | datetime.date | None = None,
|
338
|
+
) -> DataFrame:
|
339
|
+
"""投資部門別の情報を取得する。
|
340
|
+
|
341
|
+
Args:
|
342
|
+
section: 絞り込み対象のセクション。
|
343
|
+
from_: 取得期間の開始日。
|
344
|
+
to: 取得期間の終了日。
|
345
|
+
|
346
|
+
Returns:
|
347
|
+
投資部門別の情報を含むPolars DataFrame。
|
348
|
+
|
349
|
+
Raises:
|
350
|
+
HTTPStatusError: APIリクエストが失敗した場合。
|
351
|
+
"""
|
352
|
+
params = get_params(section=section, from_=from_, to=to)
|
353
|
+
|
354
|
+
url = "/markets/trades_spec"
|
355
|
+
name = "trades_spec"
|
356
|
+
|
357
|
+
dfs = [df async for df in self.iter_pages(url, params, name)]
|
358
|
+
df = pl.concat(dfs)
|
359
|
+
if df.is_empty():
|
360
|
+
return df
|
361
|
+
|
362
|
+
return df.with_columns(pl.col("^.*Date$").str.to_date("%Y-%m-%d", strict=False))
|
kabukit/jquants/info.py
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import polars as pl
|
4
|
+
|
5
|
+
from .client import JQuantsClient
|
6
|
+
|
7
|
+
|
8
|
+
async def get_codes() -> list[str]:
|
9
|
+
"""銘柄コードのリストを返す。
|
10
|
+
|
11
|
+
市場「TOKYO PRO MARKET」と業種「その他」を除外した銘柄を対象とする。
|
12
|
+
"""
|
13
|
+
async with JQuantsClient() as client:
|
14
|
+
info = await client.get_info()
|
15
|
+
|
16
|
+
return (
|
17
|
+
info.filter(
|
18
|
+
pl.col("MarketCodeName") != "TOKYO PRO MARKET",
|
19
|
+
pl.col("Sector17CodeName") != "その他",
|
20
|
+
)
|
21
|
+
.get_column("Code")
|
22
|
+
.to_list()
|
23
|
+
)
|
File without changes
|