anaplan-sdk 0.2.10__py3-none-any.whl → 0.2.11__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.
- anaplan_sdk/_async_clients/_transactional.py +63 -11
- anaplan_sdk/_auth.py +35 -72
- anaplan_sdk/_base.py +17 -0
- anaplan_sdk/_clients/_transactional.py +63 -10
- {anaplan_sdk-0.2.10.dist-info → anaplan_sdk-0.2.11.dist-info}/METADATA +1 -1
- {anaplan_sdk-0.2.10.dist-info → anaplan_sdk-0.2.11.dist-info}/RECORD +8 -8
- {anaplan_sdk-0.2.10.dist-info → anaplan_sdk-0.2.11.dist-info}/WHEEL +0 -0
- {anaplan_sdk-0.2.10.dist-info → anaplan_sdk-0.2.11.dist-info}/licenses/LICENSE +0 -0
@@ -1,3 +1,6 @@
|
|
1
|
+
import warnings
|
2
|
+
from asyncio import gather
|
3
|
+
from itertools import chain
|
1
4
|
from typing import Any
|
2
5
|
|
3
6
|
import httpx
|
@@ -13,6 +16,8 @@ from anaplan_sdk.models import (
|
|
13
16
|
Module,
|
14
17
|
)
|
15
18
|
|
19
|
+
warnings.filterwarnings("always", category=DeprecationWarning)
|
20
|
+
|
16
21
|
|
17
22
|
class _AsyncTransactionalClient(_AsyncBaseClient):
|
18
23
|
def __init__(self, client: httpx.AsyncClient, model_id: str, retry_count: int) -> None:
|
@@ -85,28 +90,64 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
|
|
85
90
|
)
|
86
91
|
]
|
87
92
|
|
88
|
-
async def
|
89
|
-
self, list_id: int, items: list[dict[str, str | dict]]
|
93
|
+
async def insert_list_items(
|
94
|
+
self, list_id: int, items: list[dict[str, str | int | dict]]
|
90
95
|
) -> InsertionResult:
|
91
96
|
"""
|
92
|
-
|
97
|
+
Insert new items to the given list. The items must be a list of dictionaries with at least
|
98
|
+
the keys `code` and `name`. You can optionally pass further keys for parents, extra
|
99
|
+
properties etc.
|
93
100
|
:param list_id: The ID of the List.
|
94
|
-
:param items: The items to
|
95
|
-
:return: The result of the insertion
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
101
|
+
:param items: The items to insert into the List.
|
102
|
+
:return: The result of the insertion, indicating how many items were added,
|
103
|
+
ignored or failed.
|
104
|
+
"""
|
105
|
+
if len(items) <= 100_000:
|
106
|
+
return InsertionResult.model_validate(
|
107
|
+
await self._post(
|
108
|
+
f"{self._url}/lists/{list_id}/items?action=add", json={"items": items}
|
109
|
+
)
|
110
|
+
)
|
111
|
+
responses = await gather(
|
112
|
+
*(
|
113
|
+
self._post(f"{self._url}/lists/{list_id}/items?action=add", json={"items": chunk})
|
114
|
+
for chunk in (items[i : i + 100_000] for i in range(0, len(items), 100_000))
|
115
|
+
)
|
116
|
+
)
|
117
|
+
failures, added, ignored, total = [], 0, 0, 0
|
118
|
+
for res in responses:
|
119
|
+
failures.append(res.get("failures", []))
|
120
|
+
added += res.get("added", 0)
|
121
|
+
total += res.get("total", 0)
|
122
|
+
ignored += res.get("ignored", 0)
|
123
|
+
|
124
|
+
return InsertionResult(
|
125
|
+
added=added, ignored=ignored, total=total, failures=list(chain.from_iterable(failures))
|
100
126
|
)
|
101
127
|
|
102
|
-
async def delete_list_items(self, list_id: int, items: list[dict[str, str | int]]) ->
|
128
|
+
async def delete_list_items(self, list_id: int, items: list[dict[str, str | int]]) -> int:
|
103
129
|
"""
|
104
130
|
Deletes items from a List.
|
105
131
|
:param list_id: The ID of the List.
|
106
132
|
:param items: The items to delete from the List. Must be a dict with either `code` or `id`
|
107
133
|
as the keys to identify the records to delete.
|
108
134
|
"""
|
109
|
-
|
135
|
+
if len(items) <= 100_000:
|
136
|
+
return (
|
137
|
+
await self._post(
|
138
|
+
f"{self._url}/lists/{list_id}/items?action=delete", json={"items": items}
|
139
|
+
)
|
140
|
+
).get("deleted", 0)
|
141
|
+
|
142
|
+
responses = await gather(
|
143
|
+
*(
|
144
|
+
self._post(
|
145
|
+
f"{self._url}/lists/{list_id}/items?action=delete", json={"items": chunk}
|
146
|
+
)
|
147
|
+
for chunk in (items[i : i + 100_000] for i in range(0, len(items), 100_000))
|
148
|
+
)
|
149
|
+
)
|
150
|
+
return sum(res.get("deleted", 0) for res in responses)
|
110
151
|
|
111
152
|
async def reset_list_index(self, list_id: int) -> None:
|
112
153
|
"""
|
@@ -129,3 +170,14 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
|
|
129
170
|
"""
|
130
171
|
res = await self._post(f"{self._url}/modules/{module_id}/data", json=data)
|
131
172
|
return res if "failures" in res else res["numberOfCellsChanged"]
|
173
|
+
|
174
|
+
async def add_items_to_list(
|
175
|
+
self, list_id: int, items: list[dict[str, str | int | dict]]
|
176
|
+
) -> InsertionResult:
|
177
|
+
warnings.warn(
|
178
|
+
"`add_items_to_list()` is deprecated and will be removed in a future version. "
|
179
|
+
"Use `insert_list_items()` instead.",
|
180
|
+
DeprecationWarning,
|
181
|
+
stacklevel=1,
|
182
|
+
)
|
183
|
+
return await self.insert_list_items(list_id, items)
|
anaplan_sdk/_auth.py
CHANGED
@@ -12,86 +12,67 @@ from cryptography.hazmat.backends import default_backend
|
|
12
12
|
from cryptography.hazmat.primitives import hashes, serialization
|
13
13
|
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
14
14
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
15
|
-
from httpx import HTTPError
|
16
15
|
|
17
|
-
from .exceptions import InvalidCredentialsException, InvalidPrivateKeyException
|
16
|
+
from .exceptions import AnaplanException, InvalidCredentialsException, InvalidPrivateKeyException
|
18
17
|
|
19
18
|
logger = logging.getLogger("anaplan_sdk")
|
20
19
|
|
21
20
|
|
22
|
-
class
|
21
|
+
class _AnaplanAuth(httpx.Auth):
|
23
22
|
requires_response_body = True
|
24
23
|
|
25
|
-
def __init__(
|
26
|
-
self
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
24
|
+
def __init__(self):
|
25
|
+
self._auth_request = self._build_auth_request()
|
26
|
+
logger.info("Creating Authentication Token.")
|
27
|
+
with httpx.Client(timeout=15.0) as client:
|
28
|
+
res = client.send(self._auth_request)
|
29
|
+
self._parse_auth_response(res)
|
30
|
+
|
31
|
+
def _build_auth_request(self) -> httpx.Request:
|
32
|
+
raise NotImplementedError("Must be implemented in subclass.")
|
33
33
|
|
34
34
|
def auth_flow(self, request):
|
35
35
|
request.headers["Authorization"] = f"AnaplanAuthToken {self._token}"
|
36
36
|
response = yield request
|
37
37
|
if response.status_code == 401:
|
38
38
|
logger.info("Token expired, refreshing.")
|
39
|
-
|
40
|
-
|
41
|
-
raise InvalidCredentialsException
|
42
|
-
self._token = response.json().get("tokenInfo").get("tokenValue")
|
39
|
+
auth_res = yield self._auth_request
|
40
|
+
self._parse_auth_response(auth_res)
|
43
41
|
request.headers["Authorization"] = f"AnaplanAuthToken {self._token}"
|
44
42
|
yield request
|
45
43
|
|
46
|
-
def
|
47
|
-
|
44
|
+
def _parse_auth_response(self, response: httpx.Response) -> None:
|
45
|
+
if response.status_code == 401:
|
46
|
+
raise InvalidCredentialsException
|
47
|
+
if not response.is_success:
|
48
|
+
raise AnaplanException(f"Authentication failed: {response.status_code} {response.text}")
|
49
|
+
self._token: str = response.json()["tokenInfo"]["tokenValue"]
|
50
|
+
|
51
|
+
|
52
|
+
class AnaplanBasicAuth(_AnaplanAuth):
|
53
|
+
def __init__(self, user_email: str, password: str):
|
54
|
+
self.user_email = user_email
|
55
|
+
self.password = password
|
56
|
+
super().__init__()
|
57
|
+
|
58
|
+
def _build_auth_request(self) -> httpx.Request:
|
59
|
+
cred = b64encode(f"{self.user_email}:{self.password}".encode()).decode()
|
48
60
|
return httpx.Request(
|
49
61
|
method="post",
|
50
62
|
url="https://auth.anaplan.com/token/authenticate",
|
51
|
-
headers={"Authorization": f"Basic {
|
63
|
+
headers={"Authorization": f"Basic {cred}"},
|
52
64
|
)
|
53
65
|
|
54
|
-
|
55
|
-
|
56
|
-
logger.info("Creating Authentication Token.")
|
57
|
-
credentials = b64encode(f"{self._user_email}:{self._password}".encode()).decode()
|
58
|
-
res = httpx.post(
|
59
|
-
url="https://auth.anaplan.com/token/authenticate",
|
60
|
-
headers={"Authorization": f"Basic {credentials}"},
|
61
|
-
timeout=15,
|
62
|
-
)
|
63
|
-
res.raise_for_status()
|
64
|
-
return res.json().get("tokenInfo").get("tokenValue")
|
65
|
-
except HTTPError as error:
|
66
|
-
raise InvalidCredentialsException from error
|
67
|
-
|
68
|
-
|
69
|
-
class AnaplanCertAuth(httpx.Auth):
|
70
|
-
requires_response_body = True
|
66
|
+
|
67
|
+
class AnaplanCertAuth(_AnaplanAuth):
|
71
68
|
requires_request_body = True
|
72
69
|
|
73
|
-
def __init__(
|
74
|
-
self,
|
75
|
-
certificate: bytes,
|
76
|
-
private_key: RSAPrivateKey,
|
77
|
-
):
|
70
|
+
def __init__(self, certificate: bytes, private_key: RSAPrivateKey):
|
78
71
|
self._certificate = certificate
|
79
72
|
self._private_key = private_key
|
80
|
-
|
73
|
+
super().__init__()
|
81
74
|
|
82
|
-
def
|
83
|
-
request.headers["Authorization"] = f"AnaplanAuthToken {self._token}"
|
84
|
-
response = yield request
|
85
|
-
if response.status_code == 401:
|
86
|
-
logger.info("Token expired or invalid, refreshing.")
|
87
|
-
response = yield self._cert_auth_request()
|
88
|
-
if "tokenInfo" not in response.json():
|
89
|
-
raise InvalidCredentialsException
|
90
|
-
self._token = response.json().get("tokenInfo").get("tokenValue")
|
91
|
-
request.headers["Authorization"] = f"AnaplanAuthToken {self._token}"
|
92
|
-
yield request
|
93
|
-
|
94
|
-
def _cert_auth_request(self):
|
75
|
+
def _build_auth_request(self) -> httpx.Request:
|
95
76
|
encoded_cert, encoded_string, encoded_signed_string = self._prep_credentials()
|
96
77
|
return httpx.Request(
|
97
78
|
method="post",
|
@@ -103,24 +84,6 @@ class AnaplanCertAuth(httpx.Auth):
|
|
103
84
|
json={"encodedData": encoded_string, "encodedSignedData": encoded_signed_string},
|
104
85
|
)
|
105
86
|
|
106
|
-
def _init_token(self) -> str:
|
107
|
-
try:
|
108
|
-
logger.info("Creating Authentication Token.")
|
109
|
-
encoded_cert, encoded_string, encoded_signed_string = self._prep_credentials()
|
110
|
-
res = httpx.post(
|
111
|
-
url="https://auth.anaplan.com/token/authenticate",
|
112
|
-
headers={
|
113
|
-
"Authorization": f"CACertificate {encoded_cert}",
|
114
|
-
"Content-Type": "application/json",
|
115
|
-
},
|
116
|
-
json={"encodedData": encoded_string, "encodedSignedData": encoded_signed_string},
|
117
|
-
timeout=15,
|
118
|
-
)
|
119
|
-
res.raise_for_status()
|
120
|
-
return res.json().get("tokenInfo").get("tokenValue")
|
121
|
-
except HTTPError as error:
|
122
|
-
raise InvalidCredentialsException from error
|
123
|
-
|
124
87
|
def _prep_credentials(self) -> tuple[str, str, str]:
|
125
88
|
message = os.urandom(150)
|
126
89
|
return (
|
anaplan_sdk/_base.py
CHANGED
@@ -2,7 +2,10 @@
|
|
2
2
|
Provides Base Classes for this project.
|
3
3
|
"""
|
4
4
|
|
5
|
+
import asyncio
|
5
6
|
import logging
|
7
|
+
import random
|
8
|
+
import time
|
6
9
|
from gzip import compress
|
7
10
|
from typing import Any, Callable, Coroutine, Literal
|
8
11
|
|
@@ -51,6 +54,13 @@ class _BaseClient:
|
|
51
54
|
for i in range(max(self._retry_count, 1)):
|
52
55
|
try:
|
53
56
|
response = func(*args, **kwargs)
|
57
|
+
if response.status_code == 429:
|
58
|
+
if i >= self._retry_count - 1:
|
59
|
+
raise AnaplanException("Rate limit exceeded.")
|
60
|
+
backoff_time = max(i, 1) * random.randint(2, 5)
|
61
|
+
logger.info(f"Rate limited. Retrying in {backoff_time} seconds.")
|
62
|
+
time.sleep(backoff_time)
|
63
|
+
continue
|
54
64
|
response.raise_for_status()
|
55
65
|
return response
|
56
66
|
except HTTPError as error:
|
@@ -99,6 +109,13 @@ class _AsyncBaseClient:
|
|
99
109
|
for i in range(max(self._retry_count, 1)):
|
100
110
|
try:
|
101
111
|
response = await func(*args, **kwargs)
|
112
|
+
if response.status_code == 429:
|
113
|
+
if i >= self._retry_count - 1:
|
114
|
+
raise AnaplanException("Rate limit exceeded.")
|
115
|
+
backoff_time = (i + 1) * random.randint(3, 5)
|
116
|
+
logger.info(f"Rate limited. Retrying in {backoff_time} seconds.")
|
117
|
+
await asyncio.sleep(backoff_time)
|
118
|
+
continue
|
102
119
|
response.raise_for_status()
|
103
120
|
return response
|
104
121
|
except HTTPError as error:
|
@@ -1,3 +1,6 @@
|
|
1
|
+
import warnings
|
2
|
+
from concurrent.futures import ThreadPoolExecutor
|
3
|
+
from itertools import chain
|
1
4
|
from typing import Any
|
2
5
|
|
3
6
|
import httpx
|
@@ -77,28 +80,67 @@ class _TransactionalClient(_BaseClient):
|
|
77
80
|
)
|
78
81
|
]
|
79
82
|
|
80
|
-
def
|
81
|
-
self, list_id: int, items: list[dict[str, str | dict]]
|
83
|
+
def insert_list_items(
|
84
|
+
self, list_id: int, items: list[dict[str, str | int | dict]]
|
82
85
|
) -> InsertionResult:
|
83
86
|
"""
|
84
|
-
|
87
|
+
Insert new items to the given list. The items must be a list of dictionaries with at least
|
88
|
+
the keys `code` and `name`. You can optionally pass further keys for parents, extra
|
89
|
+
properties etc.
|
85
90
|
:param list_id: The ID of the List.
|
86
|
-
:param items: The items to
|
87
|
-
:return: The result of the insertion
|
91
|
+
:param items: The items to insert into the List.
|
92
|
+
:return: The result of the insertion, indicating how many items were added,
|
93
|
+
ignored or failed.
|
88
94
|
"""
|
89
|
-
|
90
|
-
|
91
|
-
|
95
|
+
if len(items) <= 100_000:
|
96
|
+
return InsertionResult.model_validate(
|
97
|
+
self._post(f"{self._url}/lists/{list_id}/items?action=add", json={"items": items})
|
98
|
+
)
|
99
|
+
|
100
|
+
with ThreadPoolExecutor() as executor:
|
101
|
+
responses = list(
|
102
|
+
executor.map(
|
103
|
+
lambda chunk: self._post(
|
104
|
+
f"{self._url}/lists/{list_id}/items?action=add", json={"items": chunk}
|
105
|
+
),
|
106
|
+
[items[i : i + 100_000] for i in range(0, len(items), 100_000)],
|
107
|
+
)
|
108
|
+
)
|
109
|
+
|
110
|
+
failures, added, ignored, total = [], 0, 0, 0
|
111
|
+
for res in responses:
|
112
|
+
failures.append(res.get("failures", []))
|
113
|
+
added += res.get("added", 0)
|
114
|
+
total += res.get("total", 0)
|
115
|
+
ignored += res.get("ignored", 0)
|
116
|
+
|
117
|
+
return InsertionResult(
|
118
|
+
added=added, ignored=ignored, total=total, failures=list(chain.from_iterable(failures))
|
92
119
|
)
|
93
120
|
|
94
|
-
def delete_list_items(self, list_id: int, items: list[dict[str, str | int]]) ->
|
121
|
+
def delete_list_items(self, list_id: int, items: list[dict[str, str | int]]) -> int:
|
95
122
|
"""
|
96
123
|
Deletes items from a List.
|
97
124
|
:param list_id: The ID of the List.
|
98
125
|
:param items: The items to delete from the List. Must be a dict with either `code` or `id`
|
99
126
|
as the keys to identify the records to delete.
|
100
127
|
"""
|
101
|
-
|
128
|
+
if len(items) <= 100_000:
|
129
|
+
return self._post(
|
130
|
+
f"{self._url}/lists/{list_id}/items?action=delete", json={"items": items}
|
131
|
+
).get("deleted", 0)
|
132
|
+
|
133
|
+
with ThreadPoolExecutor() as executor:
|
134
|
+
responses = list(
|
135
|
+
executor.map(
|
136
|
+
lambda chunk: self._post(
|
137
|
+
f"{self._url}/lists/{list_id}/items?action=delete", json={"items": chunk}
|
138
|
+
),
|
139
|
+
[items[i : i + 100_000] for i in range(0, len(items), 100_000)],
|
140
|
+
)
|
141
|
+
)
|
142
|
+
|
143
|
+
return sum(res.get("deleted", 0) for res in responses)
|
102
144
|
|
103
145
|
def reset_list_index(self, list_id: int) -> None:
|
104
146
|
"""
|
@@ -119,3 +161,14 @@ class _TransactionalClient(_BaseClient):
|
|
119
161
|
"""
|
120
162
|
res = self._post(f"{self._url}/modules/{module_id}/data", json=data)
|
121
163
|
return res if "failures" in res else res["numberOfCellsChanged"]
|
164
|
+
|
165
|
+
def add_items_to_list(
|
166
|
+
self, list_id: int, items: list[dict[str, str | int | dict]]
|
167
|
+
) -> InsertionResult:
|
168
|
+
warnings.warn(
|
169
|
+
"`add_items_to_list()` is deprecated and will be removed in a future version. "
|
170
|
+
"Use `insert_list_items()` instead.",
|
171
|
+
DeprecationWarning,
|
172
|
+
stacklevel=1,
|
173
|
+
)
|
174
|
+
return self.insert_list_items(list_id, items)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: anaplan-sdk
|
3
|
-
Version: 0.2.
|
3
|
+
Version: 0.2.11
|
4
4
|
Summary: Provides pythonic access to the Anaplan API
|
5
5
|
Project-URL: Homepage, https://vinzenzklass.github.io/anaplan-sdk/
|
6
6
|
Project-URL: Repository, https://github.com/VinzenzKlass/anaplan-sdk
|
@@ -1,19 +1,19 @@
|
|
1
1
|
anaplan_sdk/__init__.py,sha256=5fr-SZSsH6f3vkRUTDoK6xdAN31cCpe9Mwz2VNu47Uw,134
|
2
|
-
anaplan_sdk/_auth.py,sha256=
|
3
|
-
anaplan_sdk/_base.py,sha256=
|
2
|
+
anaplan_sdk/_auth.py,sha256=0d495G_iU8vfpk29BJow7Jw2staf18nXqpJlSfaL9h8,5123
|
3
|
+
anaplan_sdk/_base.py,sha256=MEE6LpL788QTkrpAVsYI5hu3RfbzSMLGUj-QSW8-OU0,6160
|
4
4
|
anaplan_sdk/exceptions.py,sha256=ALkA9fBF0NQ7dufFxV6AivjmHyuJk9DOQ9jtJV2n7f0,1809
|
5
5
|
anaplan_sdk/models.py,sha256=ceMaVctpjwQHk7a71Io_-1YcCQshx5i1YYnqxS51nYw,12491
|
6
6
|
anaplan_sdk/_async_clients/__init__.py,sha256=wT6qfi4f_4vLFWTJQTsBw8r3DrHtoTIVqi88p5_j-Cg,259
|
7
7
|
anaplan_sdk/_async_clients/_alm.py,sha256=HtpwKNCc5eb6DUgS8nqNocxzaoaHOAMQPo0SaTMaD-A,4021
|
8
8
|
anaplan_sdk/_async_clients/_audit.py,sha256=wgJx58aDksWJLu4MU-tOz76KjG41AVzBW0v3jAEv9GE,2897
|
9
9
|
anaplan_sdk/_async_clients/_bulk.py,sha256=kviZxMbTolz9ZmbtE_hhOxbnmZghToRo3mxIhVADzho,22299
|
10
|
-
anaplan_sdk/_async_clients/_transactional.py,sha256=
|
10
|
+
anaplan_sdk/_async_clients/_transactional.py,sha256=yhBt5Fzpt07IGIhvMZWcMoXEIGUlSJwAsxvzaq2vT9c,7056
|
11
11
|
anaplan_sdk/_clients/__init__.py,sha256=FsbwvZC1FHrxuRXwbPxUzbhz_lO1DpXIxEOjx6-3QuA,219
|
12
12
|
anaplan_sdk/_clients/_alm.py,sha256=wzhibRuNzsK3PZM2EOI3OnSGHfv8CG2fuY5zLbnSqog,3912
|
13
13
|
anaplan_sdk/_clients/_audit.py,sha256=jqj_sTGNUaM2jAu91Us747pVULntPUkL_qkA4nKc8so,2981
|
14
14
|
anaplan_sdk/_clients/_bulk.py,sha256=hTepWQZ-IxAs2WPqsuTfWF3ExuWWrNiMD9I4viQpUAQ,22314
|
15
|
-
anaplan_sdk/_clients/_transactional.py,sha256
|
16
|
-
anaplan_sdk-0.2.
|
17
|
-
anaplan_sdk-0.2.
|
18
|
-
anaplan_sdk-0.2.
|
19
|
-
anaplan_sdk-0.2.
|
15
|
+
anaplan_sdk/_clients/_transactional.py,sha256=-DuNhhfp2h9fc-ENJQRh9qwXsZiBi3OV1LeH3S7Jzsc,6871
|
16
|
+
anaplan_sdk-0.2.11.dist-info/METADATA,sha256=1GMwYl98joRinRzgF3edxbhnY0FeDFE236OnrYNK8CY,3618
|
17
|
+
anaplan_sdk-0.2.11.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
18
|
+
anaplan_sdk-0.2.11.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
|
19
|
+
anaplan_sdk-0.2.11.dist-info/RECORD,,
|
File without changes
|
File without changes
|