edapitool 0.6.3__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.
- APITool/__init__.py +20 -0
- APITool/auth.py +490 -0
- APITool/capi.py +292 -0
- APITool/cargo.py +148 -0
- APITool/catalog.py +274 -0
- APITool/cli.py +1419 -0
- APITool/constants.py +36 -0
- APITool/construction.py +451 -0
- APITool/daemon.py +668 -0
- APITool/data/fdev_commodities.csv +413 -0
- APITool/data/name_aliases.json +10 -0
- APITool/export.py +987 -0
- APITool/google/__init__.py +10 -0
- APITool/google/exporter.py +517 -0
- APITool/journal.py +429 -0
- APITool/market.py +463 -0
- APITool/matcher.py +279 -0
- APITool/models.py +491 -0
- APITool/service.py +398 -0
- APITool/settings.py +149 -0
- APITool/sheets/__init__.py +41 -0
- APITool/sheets/a1.py +107 -0
- APITool/sheets/destination.py +185 -0
- APITool/sheets/guard.py +77 -0
- APITool/sheets/layout.py +122 -0
- APITool/ship.py +315 -0
- APITool/version.py +130 -0
- APITool/workbook/__init__.py +12 -0
- APITool/workbook/markers.py +440 -0
- APITool/workbook/totals.py +338 -0
- edapitool-0.6.3.dist-info/METADATA +230 -0
- edapitool-0.6.3.dist-info/RECORD +36 -0
- edapitool-0.6.3.dist-info/WHEEL +5 -0
- edapitool-0.6.3.dist-info/entry_points.txt +3 -0
- edapitool-0.6.3.dist-info/licenses/LICENSE +232 -0
- edapitool-0.6.3.dist-info/top_level.txt +1 -0
APITool/capi.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Elite Dangerous Companion API (CAPI) client.
|
|
3
|
+
|
|
4
|
+
Provides access to game data including:
|
|
5
|
+
- Commander profile
|
|
6
|
+
- Market data
|
|
7
|
+
- Fleet carrier information
|
|
8
|
+
- Ship/module data
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Any
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from .constants import (
|
|
20
|
+
CAPI_SERVER_LIVE,
|
|
21
|
+
CAPI_SERVER_LEGACY,
|
|
22
|
+
CAPI_SERVER_BETA,
|
|
23
|
+
CAPI_PATH_PROFILE,
|
|
24
|
+
CAPI_PATH_MARKET,
|
|
25
|
+
CAPI_PATH_SHIPYARD,
|
|
26
|
+
CAPI_PATH_FLEETCARRIER,
|
|
27
|
+
CAPI_PATH_COMMUNITYGOALS,
|
|
28
|
+
DEFAULT_TIMEOUT,
|
|
29
|
+
FLEETCARRIER_TIMEOUT,
|
|
30
|
+
MIN_QUERY_INTERVAL,
|
|
31
|
+
FLEETCARRIER_COOLDOWN,
|
|
32
|
+
)
|
|
33
|
+
from .auth import FrontierAuth
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CAPIError(Exception):
|
|
37
|
+
"""Base exception for CAPI errors."""
|
|
38
|
+
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CAPIAuthError(CAPIError):
|
|
43
|
+
"""Authentication error with CAPI."""
|
|
44
|
+
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CAPIRateLimitError(CAPIError):
|
|
49
|
+
"""Rate limit exceeded."""
|
|
50
|
+
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CAPINoDataError(CAPIError):
|
|
55
|
+
"""No data available (e.g., no fleet carrier owned)."""
|
|
56
|
+
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CAPIClient:
|
|
61
|
+
"""
|
|
62
|
+
Client for the Elite Dangerous Companion API.
|
|
63
|
+
|
|
64
|
+
Example usage:
|
|
65
|
+
auth = FrontierAuth(client_id="your_client_id")
|
|
66
|
+
if not auth.is_authenticated:
|
|
67
|
+
auth.authorize()
|
|
68
|
+
|
|
69
|
+
client = CAPIClient(auth)
|
|
70
|
+
profile = client.get_profile()
|
|
71
|
+
carrier = client.get_fleet_carrier()
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
auth: FrontierAuth,
|
|
77
|
+
server: str = CAPI_SERVER_LIVE,
|
|
78
|
+
debug_dir: Optional[Path] = None,
|
|
79
|
+
fleet_carrier_cooldown: Optional[float] = None,
|
|
80
|
+
):
|
|
81
|
+
"""
|
|
82
|
+
Initialize the CAPI client.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
auth: Authenticated FrontierAuth instance
|
|
86
|
+
server: CAPI server URL (default: live server)
|
|
87
|
+
debug_dir: Optional directory to save debug JSON responses
|
|
88
|
+
fleet_carrier_cooldown: seconds to enforce between fleet-carrier
|
|
89
|
+
queries, defaulting to FLEETCARRIER_COOLDOWN. This is OUR
|
|
90
|
+
politeness, not a limit Frontier imposes -- measured: two
|
|
91
|
+
calls one second apart both succeeded in about a second and
|
|
92
|
+
returned byte-identical cached data. A long-running publisher
|
|
93
|
+
may lower it deliberately; passing a number here is how that
|
|
94
|
+
is said out loud, rather than by building a fresh client per
|
|
95
|
+
call and letting the counter start from zero each time.
|
|
96
|
+
"""
|
|
97
|
+
self.auth = auth
|
|
98
|
+
self.server = server
|
|
99
|
+
self.debug_dir = debug_dir
|
|
100
|
+
self.fleet_carrier_cooldown = (
|
|
101
|
+
FLEETCARRIER_COOLDOWN if fleet_carrier_cooldown is None
|
|
102
|
+
else fleet_carrier_cooldown
|
|
103
|
+
)
|
|
104
|
+
self._last_query_time: float = 0
|
|
105
|
+
self._last_fc_query_time: float = 0
|
|
106
|
+
|
|
107
|
+
def _get_headers(self) -> dict:
|
|
108
|
+
"""Get request headers with authorization."""
|
|
109
|
+
token = self.auth.access_token
|
|
110
|
+
if not token:
|
|
111
|
+
raise CAPIAuthError("No access token available. Please authenticate first.")
|
|
112
|
+
return {
|
|
113
|
+
"Authorization": f"Bearer {token}",
|
|
114
|
+
"User-Agent": "EDAPITool/0.1.0",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
def _check_rate_limit(self, is_fleet_carrier: bool = False) -> None:
|
|
118
|
+
"""Check and enforce rate limiting."""
|
|
119
|
+
now = time.time()
|
|
120
|
+
|
|
121
|
+
if is_fleet_carrier:
|
|
122
|
+
elapsed = now - self._last_fc_query_time
|
|
123
|
+
if elapsed < self.fleet_carrier_cooldown:
|
|
124
|
+
wait_time = self.fleet_carrier_cooldown - elapsed
|
|
125
|
+
raise CAPIRateLimitError(
|
|
126
|
+
f"Fleet carrier query cooldown. Wait {wait_time:.0f} seconds."
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
elapsed = now - self._last_query_time
|
|
130
|
+
if elapsed < MIN_QUERY_INTERVAL:
|
|
131
|
+
# For regular queries, just wait
|
|
132
|
+
time.sleep(MIN_QUERY_INTERVAL - elapsed)
|
|
133
|
+
|
|
134
|
+
def _save_debug(self, endpoint: str, data: dict) -> None:
|
|
135
|
+
"""Save response to debug file if debug_dir is set."""
|
|
136
|
+
if not self.debug_dir:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
self.debug_dir.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
141
|
+
endpoint_name = endpoint.strip("/").replace("/", "_")
|
|
142
|
+
filename = f"{endpoint_name}.{timestamp}.json"
|
|
143
|
+
filepath = self.debug_dir / filename
|
|
144
|
+
|
|
145
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
146
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
147
|
+
|
|
148
|
+
def _request(
|
|
149
|
+
self,
|
|
150
|
+
endpoint: str,
|
|
151
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
152
|
+
is_fleet_carrier: bool = False,
|
|
153
|
+
) -> dict:
|
|
154
|
+
"""
|
|
155
|
+
Make an authenticated request to the CAPI.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
endpoint: API endpoint path
|
|
159
|
+
timeout: Request timeout in seconds
|
|
160
|
+
is_fleet_carrier: Whether this is a fleet carrier query
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
JSON response as dictionary
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
CAPIAuthError: Authentication failed
|
|
167
|
+
CAPIRateLimitError: Rate limit exceeded
|
|
168
|
+
CAPINoDataError: No data available
|
|
169
|
+
CAPIError: Other API errors
|
|
170
|
+
"""
|
|
171
|
+
self._check_rate_limit(is_fleet_carrier)
|
|
172
|
+
|
|
173
|
+
url = f"{self.server}{endpoint}"
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
response = requests.get(
|
|
177
|
+
url,
|
|
178
|
+
headers=self._get_headers(),
|
|
179
|
+
timeout=timeout,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Update query times
|
|
183
|
+
if is_fleet_carrier:
|
|
184
|
+
self._last_fc_query_time = time.time()
|
|
185
|
+
self._last_query_time = time.time()
|
|
186
|
+
|
|
187
|
+
# Handle response codes
|
|
188
|
+
if response.status_code == 200:
|
|
189
|
+
data = response.json()
|
|
190
|
+
self._save_debug(endpoint, data)
|
|
191
|
+
return data
|
|
192
|
+
|
|
193
|
+
elif response.status_code == 204:
|
|
194
|
+
raise CAPINoDataError(f"No data available for {endpoint}")
|
|
195
|
+
|
|
196
|
+
elif response.status_code == 401:
|
|
197
|
+
raise CAPIAuthError("Authentication failed. Token may be expired.")
|
|
198
|
+
|
|
199
|
+
elif response.status_code == 422:
|
|
200
|
+
# Token expired, try to refresh
|
|
201
|
+
if self.auth.refresh():
|
|
202
|
+
# Retry the request
|
|
203
|
+
return self._request(endpoint, timeout, is_fleet_carrier)
|
|
204
|
+
raise CAPIAuthError("Token expired and refresh failed.")
|
|
205
|
+
|
|
206
|
+
elif response.status_code == 429:
|
|
207
|
+
raise CAPIRateLimitError("Rate limit exceeded. Please wait.")
|
|
208
|
+
|
|
209
|
+
else:
|
|
210
|
+
raise CAPIError(
|
|
211
|
+
f"CAPI request failed: {response.status_code} - {response.text}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
except requests.Timeout:
|
|
215
|
+
raise CAPIError(f"Request timed out after {timeout} seconds")
|
|
216
|
+
except requests.RequestException as e:
|
|
217
|
+
raise CAPIError(f"Request failed: {e}")
|
|
218
|
+
|
|
219
|
+
def get_profile(self) -> dict:
|
|
220
|
+
"""
|
|
221
|
+
Get commander profile data.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Commander profile including name, credits, ranks, ships, etc.
|
|
225
|
+
"""
|
|
226
|
+
return self._request(CAPI_PATH_PROFILE)
|
|
227
|
+
|
|
228
|
+
def get_market(self) -> dict:
|
|
229
|
+
"""
|
|
230
|
+
Get current station market data.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Market data for the current docked station.
|
|
234
|
+
"""
|
|
235
|
+
return self._request(CAPI_PATH_MARKET)
|
|
236
|
+
|
|
237
|
+
def get_shipyard(self) -> dict:
|
|
238
|
+
"""
|
|
239
|
+
Get current station shipyard data.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Available ships at the current docked station.
|
|
243
|
+
"""
|
|
244
|
+
return self._request(CAPI_PATH_SHIPYARD)
|
|
245
|
+
|
|
246
|
+
def get_fleet_carrier(self) -> dict:
|
|
247
|
+
"""
|
|
248
|
+
Get fleet carrier data.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Complete fleet carrier data including:
|
|
252
|
+
- Identity (callsign, name)
|
|
253
|
+
- Location and status
|
|
254
|
+
- Cargo and inventory
|
|
255
|
+
- Market orders
|
|
256
|
+
- Financial information
|
|
257
|
+
- Services and crew
|
|
258
|
+
- Travel history
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
CAPINoDataError: Player doesn't own a fleet carrier
|
|
262
|
+
CAPIRateLimitError: Must wait 15 minutes between queries
|
|
263
|
+
"""
|
|
264
|
+
return self._request(
|
|
265
|
+
CAPI_PATH_FLEETCARRIER,
|
|
266
|
+
timeout=FLEETCARRIER_TIMEOUT,
|
|
267
|
+
is_fleet_carrier=True,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def get_community_goals(self) -> dict:
|
|
271
|
+
"""
|
|
272
|
+
Get active community goals.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
List of active community goals with progress.
|
|
276
|
+
"""
|
|
277
|
+
return self._request(CAPI_PATH_COMMUNITYGOALS)
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def use_live_server() -> str:
|
|
281
|
+
"""Get the live server URL."""
|
|
282
|
+
return CAPI_SERVER_LIVE
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def use_legacy_server() -> str:
|
|
286
|
+
"""Get the legacy server URL."""
|
|
287
|
+
return CAPI_SERVER_LEGACY
|
|
288
|
+
|
|
289
|
+
@staticmethod
|
|
290
|
+
def use_beta_server() -> str:
|
|
291
|
+
"""Get the beta/PTS server URL."""
|
|
292
|
+
return CAPI_SERVER_BETA
|
APITool/cargo.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The shared column contract for every generated cargo tab.
|
|
3
|
+
|
|
4
|
+
An inventory tab -- the fleet carrier's hold, the ship's hold, and whatever
|
|
5
|
+
comes next -- publishes the same three columns in the same three places:
|
|
6
|
+
|
|
7
|
+
A margin, always empty
|
|
8
|
+
B Commodity <- the lookup key
|
|
9
|
+
C Quantity <- VLOOKUP index 2
|
|
10
|
+
D Unit Price <- VLOOKUP index 3
|
|
11
|
+
|
|
12
|
+
**Everything right of D is that tab's own business.** The carrier carries a
|
|
13
|
+
total value because it has real prices and someone reads that tab by eye; the
|
|
14
|
+
ship carries symbol and stolen flags. Neither needs the other's tail, and
|
|
15
|
+
forcing one would mean emitting columns a producer cannot fill.
|
|
16
|
+
|
|
17
|
+
Why the first three are fixed, and why this module exists to fix them:
|
|
18
|
+
|
|
19
|
+
A spreadsheet reads these tabs with ``VLOOKUP($B5, <tab>!$B:$D, 3, FALSE)``.
|
|
20
|
+
That ``3`` is a LITERAL, not a reference. Insert a column left of D and Google
|
|
21
|
+
Sheets widens the range to ``$B:$E`` automatically -- and leaves the literal
|
|
22
|
+
alone. Measured 2026-09-08 on a throwaway tab: after inserting one column,
|
|
23
|
+
index 2 returned the newly inserted column and index 3 returned Quantity.
|
|
24
|
+
Every lookup shifted one column right, silently, with no error anywhere.
|
|
25
|
+
|
|
26
|
+
There were 476 such formulas in the live workbook when this was written. So
|
|
27
|
+
the constraint is not "keep the columns tidy" -- it is **never insert a column
|
|
28
|
+
left of D**, and the test that asserts these indices is the only thing
|
|
29
|
+
standing between a refactor and several hundred quietly wrong numbers.
|
|
30
|
+
|
|
31
|
+
Adding a column to the RIGHT of D is always safe, which is where a future
|
|
32
|
+
``Category`` belongs if it is ever wanted.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from typing import Iterable, Sequence
|
|
38
|
+
|
|
39
|
+
# Column A is left empty as a margin, matching FreighterData and MarketData.
|
|
40
|
+
MARGIN = ""
|
|
41
|
+
|
|
42
|
+
# The three columns every cargo tab shares, in order, starting at column B.
|
|
43
|
+
CONTRACT_HEADERS = ["Commodity", "Quantity", "Unit Price"]
|
|
44
|
+
|
|
45
|
+
# What a spreadsheet passes as VLOOKUP's index argument against $B:$D.
|
|
46
|
+
# These are the numbers that appear as literals in the sheet's formulas, so
|
|
47
|
+
# they are part of the published contract and cannot change without editing
|
|
48
|
+
# every formula that uses them.
|
|
49
|
+
INDEX_COMMODITY = 1
|
|
50
|
+
INDEX_QUANTITY = 2
|
|
51
|
+
INDEX_UNIT_PRICE = 3
|
|
52
|
+
|
|
53
|
+
# The A1 range a consumer should look up against.
|
|
54
|
+
LOOKUP_RANGE = "$B:$D"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def header_row(extra: Sequence[str] = ()) -> list:
|
|
58
|
+
"""
|
|
59
|
+
The header row: margin, the three contract headers, then this tab's own.
|
|
60
|
+
|
|
61
|
+
``extra`` lands right of Unit Price, where adding columns is safe.
|
|
62
|
+
"""
|
|
63
|
+
return [MARGIN, *CONTRACT_HEADERS, *extra]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def data_row(
|
|
67
|
+
commodity: str,
|
|
68
|
+
quantity: object,
|
|
69
|
+
unit_price: object = "",
|
|
70
|
+
extra: Sequence[object] = (),
|
|
71
|
+
) -> list:
|
|
72
|
+
"""
|
|
73
|
+
One commodity row.
|
|
74
|
+
|
|
75
|
+
``unit_price`` defaults to empty rather than zero. A blank cell reads as
|
|
76
|
+
"not known" and sums as nothing; a zero reads as "worthless", which is a
|
|
77
|
+
different and wrong claim about cargo whose price this tool has no source
|
|
78
|
+
for. Sheets coerces the empty string to 0 in arithmetic, so a SUM over the
|
|
79
|
+
column still works.
|
|
80
|
+
"""
|
|
81
|
+
return [MARGIN, commodity, quantity, unit_price, *extra]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def lookup_formula(key_cell: str, tab: str, index: int) -> str:
|
|
85
|
+
"""
|
|
86
|
+
The formula a sheet should use against a cargo tab.
|
|
87
|
+
|
|
88
|
+
Rendered here so the documentation, the README and any --show-formula
|
|
89
|
+
output cannot drift from the contract they describe.
|
|
90
|
+
|
|
91
|
+
IFNA rather than IFERROR: IFNA blanks a commodity that is not in the tab,
|
|
92
|
+
but still surfaces a #REF! if the tab is renamed or deleted. IFERROR hides
|
|
93
|
+
that too, and a silently blank column feeds the sheet's arithmetic a
|
|
94
|
+
number that looks deliberate.
|
|
95
|
+
"""
|
|
96
|
+
return f'=IFNA(VLOOKUP({key_cell}, {tab}!{LOOKUP_RANGE}, {index}, FALSE), "")'
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def verify_contract(grid: Iterable[Sequence], header_row_index: int) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Raise unless a built grid honours the contract.
|
|
102
|
+
|
|
103
|
+
Cheap enough to call from a producer before writing, and the thing a test
|
|
104
|
+
asserts. Checks the header text and, crucially, that the contract columns
|
|
105
|
+
sit at the offsets the published VLOOKUP indices name.
|
|
106
|
+
"""
|
|
107
|
+
rows = list(grid)
|
|
108
|
+
if header_row_index >= len(rows):
|
|
109
|
+
raise ValueError(f"no header row at index {header_row_index}")
|
|
110
|
+
header = list(rows[header_row_index])
|
|
111
|
+
|
|
112
|
+
if not header or header[0] != MARGIN:
|
|
113
|
+
raise ValueError(f"column A must be an empty margin, found {header[:1]!r}")
|
|
114
|
+
|
|
115
|
+
# Check the INDICES, not the header list. Comparing the B-D slice against
|
|
116
|
+
# CONTRACT_HEADERS would be equivalent -- and was, in the first draft --
|
|
117
|
+
# but it reports "the headers differ" when what actually broke is "index 3
|
|
118
|
+
# no longer means Unit Price". The index is what the user's formulas carry
|
|
119
|
+
# as a literal, so it is what the error should name.
|
|
120
|
+
#
|
|
121
|
+
# VLOOKUP index N against $B:$D is header[N], because the margin is at 0.
|
|
122
|
+
for index, expected in (
|
|
123
|
+
(INDEX_COMMODITY, "Commodity"),
|
|
124
|
+
(INDEX_QUANTITY, "Quantity"),
|
|
125
|
+
(INDEX_UNIT_PRICE, "Unit Price"),
|
|
126
|
+
):
|
|
127
|
+
found = header[index] if index < len(header) else None
|
|
128
|
+
if found != expected:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"VLOOKUP index {index} must address {expected!r}, found {found!r}. "
|
|
131
|
+
f"Cargo tabs expose {CONTRACT_HEADERS} in columns B-D; a column "
|
|
132
|
+
f"was inserted or renamed left of D, which shifts every lookup "
|
|
133
|
+
f"in the workbook without raising anything in the sheet."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
__all__ = [
|
|
138
|
+
"CONTRACT_HEADERS",
|
|
139
|
+
"INDEX_COMMODITY",
|
|
140
|
+
"INDEX_QUANTITY",
|
|
141
|
+
"INDEX_UNIT_PRICE",
|
|
142
|
+
"LOOKUP_RANGE",
|
|
143
|
+
"MARGIN",
|
|
144
|
+
"data_row",
|
|
145
|
+
"header_row",
|
|
146
|
+
"lookup_formula",
|
|
147
|
+
"verify_contract",
|
|
148
|
+
]
|