nexscope-cli 0.1.0__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.
- nexscope_cli/__init__.py +4 -0
- nexscope_cli/api-contract.json +37 -0
- nexscope_cli/capabilities/__init__.py +3 -0
- nexscope_cli/capabilities/catalog.py +73 -0
- nexscope_cli/capabilities/mock.py +53 -0
- nexscope_cli/capabilities/plan.py +142 -0
- nexscope_cli/catalog.json +69 -0
- nexscope_cli/compat/__init__.py +4 -0
- nexscope_cli/compat/legacy.py +143 -0
- nexscope_cli/core/__init__.py +3 -0
- nexscope_cli/core/auth.py +81 -0
- nexscope_cli/core/datasets.py +305 -0
- nexscope_cli/core/errors.py +11 -0
- nexscope_cli/core/evidence.py +134 -0
- nexscope_cli/core/execution.py +171 -0
- nexscope_cli/core/remote.py +22 -0
- nexscope_cli/core/transport.py +61 -0
- nexscope_cli/dependencies.lock +282 -0
- nexscope_cli/dependency-lock.json +371 -0
- nexscope_cli/doctor.py +136 -0
- nexscope_cli/main.py +313 -0
- nexscope_cli/reports/__init__.py +3 -0
- nexscope_cli/reports/compare.py +135 -0
- nexscope_cli/reports/formats.py +99 -0
- nexscope_cli/resources/__init__.py +3 -0
- nexscope_cli/resources/fields.json +33 -0
- nexscope_cli/resources/mock-products.json +9 -0
- nexscope_cli/resources/products-sample.csv +5 -0
- nexscope_cli/resources/products-sample.json +6 -0
- nexscope_cli/resources/products-template.csv +1 -0
- nexscope_cli/resources/products.schema.json +182 -0
- nexscope_cli/resources/report.html +15 -0
- nexscope_cli/runner.py +43 -0
- nexscope_cli/skills/amazon-product-detail/SKILL.md +170 -0
- nexscope_cli/skills/amazon-product-detail/references/api.md +239 -0
- nexscope_cli/skills/amazon-product-detail/requirements.txt +1 -0
- nexscope_cli/skills/amazon-product-detail/scripts/amazon_product_detail.py +26 -0
- nexscope_cli/skills/amazon-search/SKILL.md +197 -0
- nexscope_cli/skills/amazon-search/references/api.md +189 -0
- nexscope_cli/skills/amazon-search/requirements.txt +1 -0
- nexscope_cli/skills/amazon-search/scripts/amazon_search.py +26 -0
- nexscope_cli/workflows/__init__.py +3 -0
- nexscope_cli/workflows/compare.py +213 -0
- nexscope_cli-0.1.0.dist-info/METADATA +257 -0
- nexscope_cli-0.1.0.dist-info/RECORD +74 -0
- nexscope_cli-0.1.0.dist-info/WHEEL +5 -0
- nexscope_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nexscope_cli-0.1.0.dist-info/licenses/ASSET-NOTICES.md +9 -0
- nexscope_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- nexscope_cli-0.1.0.dist-info/licenses/NOTICE +20 -0
- nexscope_cli-0.1.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +27 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/backports-tarfile/LICENSE +17 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/certifi/LICENSE +20 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cffi/LICENSE +23 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/charset-normalizer/LICENSE +21 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE +3 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE.APACHE +202 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE.BSD +27 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/idna/LICENSE.md +31 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/importlib-metadata/LICENSE +73 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-classes/LICENSE +17 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-context/LICENSE +18 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-functools/LICENSE +18 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jeepney/LICENSE +21 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/keyring/LICENSE +18 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/more-itertools/LICENSE +19 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/pycparser/LICENSE +27 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/pywin32-ctypes/LICENSE.txt +28 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/requests/LICENSE +175 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/requests/NOTICE +2 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/secretstorage/LICENSE +25 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/urllib3/LICENSE.txt +21 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/zipp/LICENSE +18 -0
- nexscope_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# Copyright 2026 Nexscope
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Validate user-owned UTF-8 data without accessing credentials or the network."""
|
|
4
|
+
import csv
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from decimal import Decimal, DecimalException, InvalidOperation
|
|
7
|
+
import hashlib
|
|
8
|
+
import io
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from .errors import Failure
|
|
14
|
+
from .evidence import MAX_FILE_BYTES, decode, read_bundle, timestamp, validate_request
|
|
15
|
+
|
|
16
|
+
MAX_BYTES = 10 * 1024 * 1024
|
|
17
|
+
MAX_ROWS = 10000
|
|
18
|
+
FIELDS = (
|
|
19
|
+
'product_id', 'title', 'marketplace', 'price', 'currency', 'rating',
|
|
20
|
+
'rating_scale', 'review_count', 'source_url', 'observed_at',
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DatasetError(ValueError):
|
|
25
|
+
def __init__(self, issues):
|
|
26
|
+
super().__init__('Input validation failed; no rows were silently discarded.')
|
|
27
|
+
self.issues = issues
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def safe_url(value):
|
|
31
|
+
try:
|
|
32
|
+
url = urlsplit(value)
|
|
33
|
+
return (url.scheme in ('https', 'http') and bool(url.hostname)
|
|
34
|
+
and not url.username and not url.password
|
|
35
|
+
and not any(c.isspace() or ord(c) < 32 for c in value))
|
|
36
|
+
except ValueError:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def product(result, market):
|
|
41
|
+
"""Map documented remote fields once, preserving explicit zero values."""
|
|
42
|
+
raw = result['response']['data']['products'][0]
|
|
43
|
+
price = raw['extractedPrice'] if raw.get('extractedPrice') is not None else raw.get('price')
|
|
44
|
+
raw_currency = raw.get('currency')
|
|
45
|
+
currency = raw_currency if isinstance(raw_currency, str) and re.fullmatch(r'[A-Z]{3}', raw_currency) else None
|
|
46
|
+
row = dict.fromkeys(FIELDS)
|
|
47
|
+
row.update(product_id=raw['asin'], title=raw['title'], marketplace=market,
|
|
48
|
+
price=price if currency else None,
|
|
49
|
+
currency=currency, rating=raw.get('rating') if raw.get('ratingScale') else None,
|
|
50
|
+
rating_scale=raw.get('ratingScale'), review_count=raw.get('ratings', raw.get('reviews')))
|
|
51
|
+
link = raw.get('asinUrl') or raw.get('linkClean') or raw.get('link')
|
|
52
|
+
row['source_url'] = link if isinstance(link, str) and safe_url(link) else None
|
|
53
|
+
row['source_row'] = 1
|
|
54
|
+
row['extra'] = {'evidenceFile': 'request-' + str(result['sequence']) + '.json',
|
|
55
|
+
'sourcePointer': '/response/data/products/0', 'fetchedAt': result['fetchedAt'],
|
|
56
|
+
'cacheHit': result['cacheHit'], 'contractVersion': result['contractVersion'],
|
|
57
|
+
'missingFields': [field for field in FIELDS if row[field] is None]}
|
|
58
|
+
if raw_currency is not None and currency is None:
|
|
59
|
+
row['extra'].update(unresolvedCurrency=raw_currency, unresolvedPrice=price)
|
|
60
|
+
return row
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def read_dataset(filename):
|
|
64
|
+
path = Path(filename)
|
|
65
|
+
limit = MAX_BYTES if path.suffix.lower() == '.csv' else MAX_FILE_BYTES
|
|
66
|
+
with path.open('rb') as stream:
|
|
67
|
+
raw = stream.read(limit + 1)
|
|
68
|
+
if len(raw) > limit:
|
|
69
|
+
raise DatasetError([{'row': 0, 'field': 'file', 'message': 'Input exceeds its size limit (10 MiB user input, 32 MiB versioned dataset).'}])
|
|
70
|
+
envelope = None
|
|
71
|
+
try:
|
|
72
|
+
text = raw.decode('utf-8-sig')
|
|
73
|
+
if path.suffix.lower() == '.csv':
|
|
74
|
+
previous_limit = csv.field_size_limit(MAX_BYTES)
|
|
75
|
+
try:
|
|
76
|
+
reader = csv.DictReader(io.StringIO(text, newline=''), strict=True)
|
|
77
|
+
headers = reader.fieldnames or []
|
|
78
|
+
if len(headers) != len(set(headers)) or not {'product_id', 'title'} <= set(headers):
|
|
79
|
+
raise ValueError('CSV needs unique headers including product_id and title.')
|
|
80
|
+
rows = []
|
|
81
|
+
for row in reader:
|
|
82
|
+
if len(rows) >= MAX_ROWS:
|
|
83
|
+
raise ValueError('Maximum input is 10000 products; split the file.')
|
|
84
|
+
rows.append(row)
|
|
85
|
+
finally:
|
|
86
|
+
csv.field_size_limit(previous_limit)
|
|
87
|
+
elif path.suffix.lower() == '.json':
|
|
88
|
+
rows = decode(raw)
|
|
89
|
+
if isinstance(rows, dict):
|
|
90
|
+
envelope, rows = rows, rows.get('products')
|
|
91
|
+
if not isinstance(rows, list):
|
|
92
|
+
raise ValueError('JSON must be an array of products or a versioned dataset with products.')
|
|
93
|
+
else:
|
|
94
|
+
raise ValueError('Use a .csv or .json file.')
|
|
95
|
+
except (UnicodeError, ValueError, csv.Error, RecursionError, Failure) as error:
|
|
96
|
+
raise DatasetError([{'row': 0, 'field': 'file', 'message': str(error)}]) from None
|
|
97
|
+
if envelope is None and len(raw) > MAX_BYTES:
|
|
98
|
+
raise DatasetError([{'row': 0, 'field': 'file', 'message': 'Maximum input size is 10 MiB; split the file.'}])
|
|
99
|
+
products = validate_rows(rows, csv_input=path.suffix.lower() == '.csv', versioned=envelope is not None)
|
|
100
|
+
if envelope is not None:
|
|
101
|
+
try:
|
|
102
|
+
validate_dataset(envelope, path, raw)
|
|
103
|
+
except (Failure, OSError, KeyError, TypeError, ValueError):
|
|
104
|
+
raise DatasetError([{'row': 0, 'field': 'evidence', 'message': 'Invalid or incomplete versioned dataset evidence; keep the complete original output directory.'}]) from None
|
|
105
|
+
return envelope, raw
|
|
106
|
+
return {
|
|
107
|
+
'schemaVersion': 1, 'sourceType': 'user_import',
|
|
108
|
+
'inputName': 'input' + path.suffix.lower(), 'originalInputName': path.name, 'inputSha256': hashlib.sha256(raw).hexdigest(),
|
|
109
|
+
'importedAt': datetime.now(timezone.utc).isoformat(), 'products': products,
|
|
110
|
+
}, raw
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def validate_rows(rows, *, csv_input=False, versioned=False):
|
|
114
|
+
if not isinstance(rows, list) or (not rows and not versioned) or len(rows) > MAX_ROWS:
|
|
115
|
+
raise DatasetError([{'row': 0, 'field': 'file', 'message': 'Provide 1 to 10000 products; no account is required.'}])
|
|
116
|
+
issues, products, seen = [], [], set()
|
|
117
|
+
for index, row in enumerate(rows, 1):
|
|
118
|
+
# CSV row is a logical record number, with its header counted as row 1.
|
|
119
|
+
row_number = index + csv_input
|
|
120
|
+
def issue(field, message):
|
|
121
|
+
issues.append({'row': row_number, 'field': field, 'message': message})
|
|
122
|
+
if not isinstance(row, dict):
|
|
123
|
+
issue('record', 'Expected a product object.')
|
|
124
|
+
continue
|
|
125
|
+
if None in row:
|
|
126
|
+
issue('record', 'CSV record has more values than headers.')
|
|
127
|
+
product = {}
|
|
128
|
+
for field in FIELDS:
|
|
129
|
+
value = row.get(field)
|
|
130
|
+
if value is None or value == '':
|
|
131
|
+
product[field] = None
|
|
132
|
+
elif field in ('price', 'rating', 'rating_scale', 'review_count'):
|
|
133
|
+
try:
|
|
134
|
+
if isinstance(value, bool):
|
|
135
|
+
raise InvalidOperation
|
|
136
|
+
number = Decimal(str(value).strip())
|
|
137
|
+
if not number.is_finite() or number < 0:
|
|
138
|
+
raise InvalidOperation
|
|
139
|
+
if field == 'rating_scale' and number == 0:
|
|
140
|
+
raise InvalidOperation
|
|
141
|
+
if field == 'review_count' and number != number.to_integral_value():
|
|
142
|
+
raise InvalidOperation
|
|
143
|
+
product[field] = str(number.normalize()) if field == 'rating_scale' else str(number)
|
|
144
|
+
except (DecimalException, ValueError):
|
|
145
|
+
product[field] = None
|
|
146
|
+
issue(field, 'Expected a finite non-negative number, positive scale, or integer count as applicable.')
|
|
147
|
+
elif not isinstance(value, str):
|
|
148
|
+
product[field] = None
|
|
149
|
+
issue(field, 'Expected a string; identifiers must remain strings.')
|
|
150
|
+
else:
|
|
151
|
+
product[field] = value.strip() or None
|
|
152
|
+
for field in ('product_id', 'title'):
|
|
153
|
+
if not product[field]:
|
|
154
|
+
issue(field, 'Required non-empty string.')
|
|
155
|
+
identity = (product['marketplace'], product['product_id'])
|
|
156
|
+
if product['product_id'] and identity in seen:
|
|
157
|
+
issue('product_id', 'Duplicate identifier within this marketplace.')
|
|
158
|
+
seen.add(identity)
|
|
159
|
+
if product['price'] is not None and not product['currency']:
|
|
160
|
+
issue('currency', 'Required when price is supplied.')
|
|
161
|
+
if product['currency'] and not re.fullmatch(r'[A-Z]{3}', product['currency']):
|
|
162
|
+
issue('currency', 'Use a three-letter uppercase currency code.')
|
|
163
|
+
if product['rating'] is not None:
|
|
164
|
+
if product['rating_scale'] is None:
|
|
165
|
+
issue('rating_scale', 'Required when rating is supplied.')
|
|
166
|
+
elif Decimal(product['rating']) > Decimal(product['rating_scale']):
|
|
167
|
+
issue('rating', 'Rating exceeds its scale.')
|
|
168
|
+
if product['source_url'] and not safe_url(product['source_url']):
|
|
169
|
+
issue('source_url', 'Use an HTTP(S) URL without credentials or whitespace.')
|
|
170
|
+
if product['observed_at']:
|
|
171
|
+
try:
|
|
172
|
+
moment = datetime.fromisoformat(product['observed_at'].replace('Z', '+00:00'))
|
|
173
|
+
if moment.tzinfo is None:
|
|
174
|
+
raise ValueError
|
|
175
|
+
except ValueError:
|
|
176
|
+
issue('observed_at', 'Use an ISO 8601 timestamp with a timezone.')
|
|
177
|
+
if versioned:
|
|
178
|
+
if (not set(FIELDS) <= set(row) or type(row.get('source_row')) is not int or row['source_row'] < 1
|
|
179
|
+
or not isinstance(row.get('extra'), dict)):
|
|
180
|
+
issue('record', 'Versioned products require all fields, a positive source_row and an extra object.')
|
|
181
|
+
product['source_row'] = row.get('source_row')
|
|
182
|
+
product['extra'] = row.get('extra')
|
|
183
|
+
else:
|
|
184
|
+
product['source_row'] = row_number
|
|
185
|
+
product['extra'] = {k: v for k, v in row.items() if k is not None and k not in FIELDS}
|
|
186
|
+
products.append(product)
|
|
187
|
+
if issues:
|
|
188
|
+
raise DatasetError(issues)
|
|
189
|
+
return products
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def validate_dataset(dataset, path, raw):
|
|
193
|
+
if (type(dataset.get('schemaVersion')) is not int or dataset['schemaVersion'] != 1
|
|
194
|
+
or dataset.get('sourceType') not in ('user_import', 'mock', 'remote')
|
|
195
|
+
or not timestamp(dataset.get('importedAt'))
|
|
196
|
+
or dataset.get('inputName') not in ('plan.json', 'input.csv', 'input.json')
|
|
197
|
+
or not isinstance(dataset.get('inputSha256'), str)
|
|
198
|
+
or not re.fullmatch('[0-9a-f]{64}', dataset['inputSha256'])):
|
|
199
|
+
raise Failure('Invalid dataset metadata.')
|
|
200
|
+
bundle = read_bundle(path.parent)
|
|
201
|
+
if (bundle.get('dataset.json') != raw or dataset['inputName'] not in bundle
|
|
202
|
+
or hashlib.sha256(bundle[dataset['inputName']]).hexdigest() != dataset['inputSha256']):
|
|
203
|
+
raise Failure('Dataset input hash mismatch.')
|
|
204
|
+
if dataset['sourceType'] == 'user_import':
|
|
205
|
+
if dataset['inputName'] not in ('input.csv', 'input.json'):
|
|
206
|
+
raise Failure('Invalid user-import evidence.')
|
|
207
|
+
if dataset['inputName'] == 'input.json' and not isinstance(decode(bundle['input.json']), list):
|
|
208
|
+
raise Failure('Original imported JSON must be a product array.')
|
|
209
|
+
original, _ = read_dataset(path.parent / dataset['inputName'])
|
|
210
|
+
if original['products'] != validate_rows(dataset['products'], versioned=True):
|
|
211
|
+
raise Failure('Imported products disagree with original input.')
|
|
212
|
+
return bundle
|
|
213
|
+
if dataset['inputName'] != 'plan.json':
|
|
214
|
+
raise Failure('Remote or mock datasets require their comparison plan.')
|
|
215
|
+
from ..capabilities.plan import plan
|
|
216
|
+
|
|
217
|
+
saved_plan = decode(bundle['plan.json'])
|
|
218
|
+
if (not isinstance(saved_plan, dict) or saved_plan.get('schemaVersion') != 'nexscope.comparison-plan.v1'
|
|
219
|
+
or not isinstance(saved_plan.get('execution'), dict)
|
|
220
|
+
or saved_plan['execution'].get('sourceType') != dataset['sourceType']):
|
|
221
|
+
raise Failure('Invalid dataset plan.')
|
|
222
|
+
requests = [validate_request(decode(bundle[name])) for name in sorted(bundle) if re.fullmatch(r'request-[1-4]\.json', name)]
|
|
223
|
+
if not requests or [item['sequence'] for item in requests] != list(range(1, len(requests) + 1)):
|
|
224
|
+
raise Failure('Incomplete request evidence.')
|
|
225
|
+
mode, base, market = saved_plan.get('authMode'), saved_plan['execution'].get('origin'), saved_plan.get('marketplace')
|
|
226
|
+
chosen = saved_plan.get('asins') or saved_plan.get('selectedAsins')
|
|
227
|
+
rebuilt = plan('amazon-product-detail', {'asins': chosen, 'amazonDomain': market},
|
|
228
|
+
max_calls=saved_plan.get('maxCalls'), auth_mode=mode)
|
|
229
|
+
if (len(rebuilt['requests']) < 2
|
|
230
|
+
or sum(bool(saved_plan.get(key)) for key in ('asins', 'keyword', 'importedSearch')) != 1
|
|
231
|
+
or (saved_plan.get('asins') and saved_plan.get('selectedAsins') is not None)):
|
|
232
|
+
raise Failure('Invalid comparison input scope.')
|
|
233
|
+
details = [item for item in requests if item['capability'] == 'amazon-product-detail']
|
|
234
|
+
if ([item['params'] for item in details] != [item['body'] for item in rebuilt['requests']]
|
|
235
|
+
or any(item['authMode'] != mode or item['origin'] != base for item in requests)
|
|
236
|
+
or any(item['params'].get('amazonDomain') != market for item in requests)
|
|
237
|
+
or (dataset['sourceType'] == 'mock' and base != 'https://mock.invalid')):
|
|
238
|
+
raise Failure('Dataset request scope mismatch.')
|
|
239
|
+
searches = [item for item in requests if item['capability'] == 'amazon-search']
|
|
240
|
+
planned_steps = [rebuilt]
|
|
241
|
+
if searches:
|
|
242
|
+
search = searches[0]
|
|
243
|
+
search_plan = decode(bundle['search-plan.json']) if saved_plan.get('importedSearch') else saved_plan
|
|
244
|
+
planned = plan('amazon-search', {'keyword': search_plan.get('keyword'), 'amazonDomain': market},
|
|
245
|
+
auth_mode=mode, max_calls=saved_plan['maxCalls'])
|
|
246
|
+
if not saved_plan.get('importedSearch'):
|
|
247
|
+
planned_steps.insert(0, planned)
|
|
248
|
+
if (len(searches) != 1 or search['sequence'] != 1 or search['status'] != 'SUCCESS'
|
|
249
|
+
or search['params'] != planned['requests'][0]['body']
|
|
250
|
+
or not set(chosen.split(',')) <= {row['asin'] for row in search['response']['data']['products']}
|
|
251
|
+
or decode(bundle['candidates.json']) != search['response']['data']['products']):
|
|
252
|
+
raise Failure('Invalid search selection evidence.')
|
|
253
|
+
imported = saved_plan.get('importedSearch')
|
|
254
|
+
if imported:
|
|
255
|
+
original_result = decode(bundle['search-result.json'])
|
|
256
|
+
if (not isinstance(imported, dict) or imported.get('historical') is not True
|
|
257
|
+
or imported.get('sourceType') != dataset['sourceType'] or imported.get('authMode') != mode
|
|
258
|
+
or imported.get('origin') != base or imported.get('fetchedAt') != search['fetchedAt']
|
|
259
|
+
or imported.get('cacheHit') is not False or imported.get('requestsSent') != 1
|
|
260
|
+
or imported.get('evidenceFile') != 'request-1.json' or imported.get('planFile') != 'search-plan.json'
|
|
261
|
+
or imported.get('planSha256') != hashlib.sha256(bundle['search-plan.json']).hexdigest()
|
|
262
|
+
or not isinstance(original_result, dict) or original_result.get('status') != 'CANDIDATES_READY'
|
|
263
|
+
or original_result.get('sourceType') != dataset['sourceType']
|
|
264
|
+
or original_result.get('candidates') != search['response']['data']['products']):
|
|
265
|
+
raise Failure('Invalid imported search provenance.')
|
|
266
|
+
elif saved_plan.get('keyword') or saved_plan.get('importedSearch'):
|
|
267
|
+
raise Failure('Search evidence is missing.')
|
|
268
|
+
steps = saved_plan.get('steps')
|
|
269
|
+
if (not isinstance(steps, list) or len(steps) != len(planned_steps)
|
|
270
|
+
or saved_plan.get('maximumRequests') != len(requests)):
|
|
271
|
+
raise Failure('Invalid dataset request plan.')
|
|
272
|
+
for step, expected_step in zip(steps, planned_steps):
|
|
273
|
+
if (not isinstance(step, dict)
|
|
274
|
+
or any(step.get(key) != expected_step[key] for key in ('capability', 'contractVersion', 'source', 'requests'))
|
|
275
|
+
or not isinstance(step.get('auth'), dict) or step['auth'].get('mode') != mode
|
|
276
|
+
or step['auth'].get('origin') != base):
|
|
277
|
+
raise Failure('Dataset plan disagrees with registered request contracts.')
|
|
278
|
+
successful = [item for item in details if item['status'] == 'SUCCESS']
|
|
279
|
+
expected = [product(item, market) for item in successful]
|
|
280
|
+
for row, item in zip(expected, successful):
|
|
281
|
+
row['extra']['evidenceSha256'] = hashlib.sha256(bundle['request-' + str(item['sequence']) + '.json']).hexdigest()
|
|
282
|
+
if validate_rows(dataset['products'], versioned=True) != validate_rows(expected, versioned=True):
|
|
283
|
+
raise Failure('Dataset products disagree with their request evidence.')
|
|
284
|
+
errors = [{'asin': item['params']['asins'], 'status': item['status'], 'error': item['error']}
|
|
285
|
+
for item in details if item['status'] != 'SUCCESS']
|
|
286
|
+
status = ('COMPLETE' if not errors else 'PARTIAL') if len(successful) >= 2 else 'INSUFFICIENT_DATA'
|
|
287
|
+
budget = dataset.get('budget')
|
|
288
|
+
sent = sum(item['status'] != 'SKIPPED' and not item['cacheHit'] for item in requests)
|
|
289
|
+
if (dataset.get('errors') != errors or dataset.get('collectionStatus') != status
|
|
290
|
+
or not isinstance(budget, dict) or type(budget.get('requestsSent')) is not int
|
|
291
|
+
or budget['requestsSent'] != sent or budget.get('maxCalls') != saved_plan['maxCalls']
|
|
292
|
+
or budget.get('cacheHits') != sum(item['cacheHit'] for item in requests)
|
|
293
|
+
or budget.get('importedRequests') != (1 if saved_plan.get('importedSearch') else 0)
|
|
294
|
+
or budget.get('requestsSentThisRun') != sent - budget.get('importedRequests', 0)
|
|
295
|
+
or budget.get('automaticRetries') != 0 or budget.get('estimatedCredits') is not None
|
|
296
|
+
or budget.get('billingCap') is not None or budget.get('pricingStatus') != 'UNKNOWN'
|
|
297
|
+
or sent > saved_plan['maxCalls']):
|
|
298
|
+
raise Failure('Dataset collection status or accounting mismatch.')
|
|
299
|
+
result = decode(bundle['result.json'])
|
|
300
|
+
if (not isinstance(result, dict) or result.get('schemaVersion') != 'nexscope.comparison-result.v1'
|
|
301
|
+
or result.get('status') != status or result.get('sourceType') != dataset['sourceType']
|
|
302
|
+
or result.get('budget') != budget or result.get('errors') != errors
|
|
303
|
+
or result.get('succeeded') != len(successful) or result.get('requested') != len(details)):
|
|
304
|
+
raise Failure('Dataset disagrees with its collection result.')
|
|
305
|
+
return bundle
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Copyright 2026 Nexscope
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Bounded, portable evidence bundles. Hashes detect damage, not authorship."""
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
import math
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
|
|
12
|
+
from .errors import Failure
|
|
13
|
+
|
|
14
|
+
MAX_FILE_BYTES = 32 * 1024 * 1024
|
|
15
|
+
MAX_BUNDLE_BYTES = 64 * 1024 * 1024
|
|
16
|
+
NAMES = re.compile(r'(?:plan|result|candidates|dataset|search-plan|search-result|report-input|input)\.json|input\.csv|request-[1-4]\.json')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def decode(raw):
|
|
20
|
+
def unique(pairs):
|
|
21
|
+
value = {}
|
|
22
|
+
for key, item in pairs:
|
|
23
|
+
if key in value:
|
|
24
|
+
raise ValueError('Duplicate JSON key.')
|
|
25
|
+
value[key] = item
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
def reject(value):
|
|
29
|
+
raise ValueError('Non-finite JSON number.')
|
|
30
|
+
|
|
31
|
+
def finite_float(value):
|
|
32
|
+
number = float(value)
|
|
33
|
+
if not math.isfinite(number):
|
|
34
|
+
raise ValueError('Non-finite JSON number.')
|
|
35
|
+
return number
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(raw.decode('utf-8-sig'), object_pairs_hook=unique,
|
|
39
|
+
parse_constant=reject, parse_float=finite_float)
|
|
40
|
+
except (UnicodeError, ValueError, RecursionError):
|
|
41
|
+
raise Failure('Evidence must be valid UTF-8 JSON without duplicate keys or non-finite numbers.') from None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def bounded_read(path, limit=MAX_FILE_BYTES):
|
|
45
|
+
info = path.lstat()
|
|
46
|
+
if not stat.S_ISREG(info.st_mode) or getattr(info, 'st_file_attributes', 0) & 0x400:
|
|
47
|
+
raise Failure('Evidence files must be regular files, without links or reparse points.')
|
|
48
|
+
with path.open('rb') as stream:
|
|
49
|
+
raw = stream.read(limit + 1)
|
|
50
|
+
if len(raw) > limit:
|
|
51
|
+
raise Failure('Evidence exceeds its local size limit.')
|
|
52
|
+
return raw
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_bundle(folder):
|
|
56
|
+
root = Path(folder)
|
|
57
|
+
info = root.lstat()
|
|
58
|
+
if not stat.S_ISDIR(info.st_mode) or getattr(info, 'st_file_attributes', 0) & 0x400:
|
|
59
|
+
raise Failure('Choose a regular evidence directory, without links or reparse points.')
|
|
60
|
+
manifest = decode(bounded_read(root / 'manifest.json', 65536))
|
|
61
|
+
if (not isinstance(manifest, dict) or manifest.get('schemaVersion') != 'nexscope.evidence.v1'
|
|
62
|
+
or not isinstance(manifest.get('files'), dict) or not 1 <= len(manifest['files']) <= 16):
|
|
63
|
+
raise Failure('Unsupported evidence manifest; use a complete CLI output directory.')
|
|
64
|
+
bundle, total = {}, 0
|
|
65
|
+
for name, digest in manifest['files'].items():
|
|
66
|
+
if not NAMES.fullmatch(name) or not isinstance(digest, str) or not re.fullmatch('[0-9a-f]{64}', digest):
|
|
67
|
+
raise Failure('Invalid evidence filename or digest.')
|
|
68
|
+
raw = bounded_read(root / name)
|
|
69
|
+
total += len(raw)
|
|
70
|
+
if total > MAX_BUNDLE_BYTES or hashlib.sha256(raw).hexdigest() != digest:
|
|
71
|
+
raise Failure('Evidence size limit or SHA-256 verification failed.')
|
|
72
|
+
bundle[name] = raw
|
|
73
|
+
return bundle
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def timestamp(value):
|
|
77
|
+
try:
|
|
78
|
+
return isinstance(value, str) and datetime.fromisoformat(value.replace('Z', '+00:00')).tzinfo is not None
|
|
79
|
+
except ValueError:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_request(value):
|
|
84
|
+
from ..capabilities.plan import plan
|
|
85
|
+
from .auth import MODES, origin
|
|
86
|
+
from .execution import validate_response
|
|
87
|
+
|
|
88
|
+
if (not isinstance(value, dict) or value.get('schemaVersion') != 'nexscope.request.v1'
|
|
89
|
+
or type(value.get('sequence')) is not int or not 1 <= value['sequence'] <= 4
|
|
90
|
+
or value.get('authMode') not in MODES or type(value.get('cacheHit')) is not bool
|
|
91
|
+
or value.get('status') not in ('SUCCESS', 'FAILED', 'SKIPPED')
|
|
92
|
+
or value.get('credits') is not None):
|
|
93
|
+
raise Failure('Invalid request evidence structure.')
|
|
94
|
+
origin(value.get('origin'))
|
|
95
|
+
current = plan(value.get('capability'), value.get('params'), auth_mode=value['authMode'], max_calls=4)
|
|
96
|
+
if (len(current['requests']) != 1 or value.get('endpoint') != current['requests'][0]['endpoint']
|
|
97
|
+
or value['params'] != current['requests'][0]['body']
|
|
98
|
+
or value.get('contractVersion') != current['contractVersion'] or value.get('source') != current['source']):
|
|
99
|
+
raise Failure('Request evidence does not match its registered capability contract.')
|
|
100
|
+
if value['status'] == 'SUCCESS':
|
|
101
|
+
error, _ = validate_response(200, value.get('response'), value['capability'], value['params'])
|
|
102
|
+
if error or value.get('error') is not None or not timestamp(value.get('fetchedAt')):
|
|
103
|
+
raise Failure('Invalid successful request evidence.')
|
|
104
|
+
elif (not isinstance(value.get('error'), str) or not re.fullmatch('[A-Z_0-9]+', value['error'])
|
|
105
|
+
or value['cacheHit'] or (value['status'] == 'FAILED' and not timestamp(value.get('fetchedAt')))
|
|
106
|
+
or (value['status'] == 'SKIPPED' and value.get('fetchedAt') is not None)):
|
|
107
|
+
raise Failure('Invalid failed or skipped request evidence.')
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Artifacts:
|
|
112
|
+
def __init__(self, folder, execution_plan=None):
|
|
113
|
+
self.root = Path(folder).resolve()
|
|
114
|
+
self.root.mkdir(mode=0o700)
|
|
115
|
+
self.files = {}
|
|
116
|
+
if execution_plan is not None:
|
|
117
|
+
self.write('plan.json', execution_plan)
|
|
118
|
+
|
|
119
|
+
def write_bytes(self, name, raw):
|
|
120
|
+
if not NAMES.fullmatch(name):
|
|
121
|
+
raise Failure('Invalid evidence filename.')
|
|
122
|
+
with (self.root / name).open('xb') as stream:
|
|
123
|
+
stream.write(raw)
|
|
124
|
+
self.files[name] = hashlib.sha256(raw).hexdigest()
|
|
125
|
+
|
|
126
|
+
def write(self, name, value):
|
|
127
|
+
self.write_bytes(name, json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False).encode('utf-8'))
|
|
128
|
+
|
|
129
|
+
def record(self, result):
|
|
130
|
+
self.write('request-' + str(result['sequence']) + '.json', result)
|
|
131
|
+
|
|
132
|
+
def finish(self):
|
|
133
|
+
with (self.root / 'manifest.json').open('x', encoding='utf-8') as stream:
|
|
134
|
+
json.dump({'schemaVersion': 'nexscope.evidence.v1', 'files': self.files}, stream, indent=2)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Copyright 2026 Nexscope
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Bounded execution with injected transport and session-only success caching."""
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from .errors import Failure
|
|
11
|
+
from ..capabilities.catalog import describe
|
|
12
|
+
from ..capabilities.plan import plan
|
|
13
|
+
|
|
14
|
+
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def redact(value, context):
|
|
18
|
+
if isinstance(value, str):
|
|
19
|
+
return context.redact(value)
|
|
20
|
+
if isinstance(value, list):
|
|
21
|
+
return [redact(item, context) for item in value]
|
|
22
|
+
if isinstance(value, dict):
|
|
23
|
+
return {context.redact(str(key)): redact(item, context) for key, item in value.items()}
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SessionCache:
|
|
28
|
+
"""Never persists credentials, identities or responses across processes."""
|
|
29
|
+
def __init__(self, ttl=300, clock=time.time):
|
|
30
|
+
if type(ttl) not in (int, float) or not 0 < ttl <= 86400:
|
|
31
|
+
raise Failure('Cache TTL must be greater than zero and at most 86400 seconds.')
|
|
32
|
+
self.ttl, self.clock, self.entries = ttl, clock, {}
|
|
33
|
+
|
|
34
|
+
def get(self, key):
|
|
35
|
+
entry = self.entries.get(key)
|
|
36
|
+
if entry is None:
|
|
37
|
+
return None
|
|
38
|
+
age = self.clock() - entry['created']
|
|
39
|
+
if age < 0 or age >= self.ttl:
|
|
40
|
+
self.entries.pop(key, None)
|
|
41
|
+
return None
|
|
42
|
+
return deepcopy(entry['value'])
|
|
43
|
+
|
|
44
|
+
def put(self, key, value):
|
|
45
|
+
self.entries[key] = {'created': self.clock(), 'value': deepcopy(value)}
|
|
46
|
+
|
|
47
|
+
def invalidate(self, partition):
|
|
48
|
+
self.entries = {key: value for key, value in self.entries.items() if key[0] != partition}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_response(status, payload, capability, body):
|
|
52
|
+
"""Strict documented envelope; live sample verification remains a separate acceptance gate."""
|
|
53
|
+
if type(status) is not int or not 100 <= status <= 599:
|
|
54
|
+
return 'INVALID_HTTP_STATUS', False
|
|
55
|
+
if status != 200:
|
|
56
|
+
return 'HTTP_' + str(status), status in (401, 402, 403, 429)
|
|
57
|
+
if not isinstance(payload, dict) or type(payload.get('code')) is not int:
|
|
58
|
+
return 'INVALID_ENVELOPE', False
|
|
59
|
+
if payload['code'] != 0:
|
|
60
|
+
return 'BUSINESS_' + str(payload['code']), payload['code'] in (12001, 12003, 13011)
|
|
61
|
+
data = payload.get('data')
|
|
62
|
+
if not isinstance(data, dict) or not isinstance(data.get('products'), list):
|
|
63
|
+
return 'INVALID_DATA', False
|
|
64
|
+
products = data['products']
|
|
65
|
+
if not products:
|
|
66
|
+
return 'EMPTY_DATA', False
|
|
67
|
+
seen = set()
|
|
68
|
+
for row in products:
|
|
69
|
+
if not isinstance(row, dict) or not isinstance(row.get('asin'), str) or not row['asin'] or not isinstance(row.get('title'), str) or not row['title'].strip():
|
|
70
|
+
return 'INVALID_PRODUCT', False
|
|
71
|
+
if row['asin'] in seen:
|
|
72
|
+
return 'DUPLICATE_PRODUCT', False
|
|
73
|
+
seen.add(row['asin'])
|
|
74
|
+
for field in ('extractedPrice', 'price', 'rating', 'ratingScale', 'reviews', 'ratings'):
|
|
75
|
+
value = row.get(field)
|
|
76
|
+
if value is not None:
|
|
77
|
+
try:
|
|
78
|
+
if type(value) not in (int, float) or not math.isfinite(value) or value < 0:
|
|
79
|
+
return 'INVALID_PRODUCT', False
|
|
80
|
+
except OverflowError:
|
|
81
|
+
return 'INVALID_PRODUCT', False
|
|
82
|
+
if row.get('ratingScale') is not None and row['ratingScale'] <= 0:
|
|
83
|
+
return 'INVALID_PRODUCT', False
|
|
84
|
+
if row.get('rating') is not None and row.get('ratingScale') is not None and row['rating'] > row['ratingScale']:
|
|
85
|
+
return 'INVALID_PRODUCT', False
|
|
86
|
+
if any(row.get(f) is not None and int(row[f]) != row[f] for f in ('reviews', 'ratings')):
|
|
87
|
+
return 'INVALID_PRODUCT', False
|
|
88
|
+
currency = row.get('currency')
|
|
89
|
+
if currency is not None and not isinstance(currency, str):
|
|
90
|
+
return 'INVALID_PRODUCT', False
|
|
91
|
+
if capability == 'amazon-product-detail' and (len(products) != 1 or products[0]['asin'] != body['asins']):
|
|
92
|
+
return 'MISMATCHED_PRODUCT', False
|
|
93
|
+
return None, False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Executor:
|
|
97
|
+
"""One explicit context and one shared request budget across workflow steps.
|
|
98
|
+
|
|
99
|
+
The transport receives (context, endpoint, body), returning (http_status,
|
|
100
|
+
parsed_json). Remote transport is explicitly gated; mock transports use no credentials.
|
|
101
|
+
"""
|
|
102
|
+
def __init__(self, context, transport, *, max_calls=3, cache=None, clock=time.time, record=None):
|
|
103
|
+
if type(max_calls) is not int or max_calls < 1:
|
|
104
|
+
raise Failure('max-calls must be a positive integer.')
|
|
105
|
+
self.context, self.transport = context, transport
|
|
106
|
+
self.max_calls, self.clock = max_calls, clock
|
|
107
|
+
self.cache = cache if cache is not None else SessionCache(clock=clock)
|
|
108
|
+
self.sent, self.results, self.stopped = 0, [], False
|
|
109
|
+
self.imported_results = []
|
|
110
|
+
self.record = record
|
|
111
|
+
|
|
112
|
+
def execute(self, capability, params):
|
|
113
|
+
# Rebuild from validated input, never trust a serialized plan to execute.
|
|
114
|
+
current = plan(capability, params, auth_mode=self.context.mode, max_calls=self.max_calls)
|
|
115
|
+
description = describe(capability)
|
|
116
|
+
batch = []
|
|
117
|
+
for request in current['requests']:
|
|
118
|
+
body = request['body']
|
|
119
|
+
result = {'schemaVersion': 'nexscope.request.v1',
|
|
120
|
+
'sequence': len(self.imported_results) + len(self.results) + 1, 'capability': capability,
|
|
121
|
+
'origin': self.context.base, 'authMode': self.context.mode,
|
|
122
|
+
'contractVersion': description['contractVersion'], 'source': description['source'],
|
|
123
|
+
'endpoint': request['endpoint'], 'params': redact(body, self.context),
|
|
124
|
+
'cacheHit': False, 'fetchedAt': None, 'status': 'SKIPPED',
|
|
125
|
+
'error': None, 'response': None, 'credits': None}
|
|
126
|
+
key = (self.context.cache_partition, self.transport.cache_namespace,
|
|
127
|
+
capability, description['contractVersion'], description['source']['sha256'],
|
|
128
|
+
json.dumps(body, sort_keys=True, separators=(',', ':')))
|
|
129
|
+
if self.stopped:
|
|
130
|
+
result['error'] = 'STOPPED_AFTER_FAILURE'
|
|
131
|
+
else:
|
|
132
|
+
cached = self.cache.get(key)
|
|
133
|
+
if cached is not None:
|
|
134
|
+
result.update(cached, cacheHit=True, status='SUCCESS')
|
|
135
|
+
elif self.sent + len(self.imported_results) >= self.max_calls:
|
|
136
|
+
result['error'] = 'CALL_BUDGET_EXHAUSTED'
|
|
137
|
+
else:
|
|
138
|
+
# An ambiguous failed call still consumes the local budget.
|
|
139
|
+
self.sent += 1
|
|
140
|
+
result['fetchedAt'] = datetime.fromtimestamp(self.clock(), timezone.utc).isoformat()
|
|
141
|
+
try:
|
|
142
|
+
http_status, raw = self.transport.send(self.context, request['endpoint'], deepcopy(body))
|
|
143
|
+
encoded = json.dumps(raw, allow_nan=False).encode('utf-8')
|
|
144
|
+
if len(encoded) > MAX_RESPONSE_BYTES:
|
|
145
|
+
raise ValueError
|
|
146
|
+
error, stop = validate_response(http_status, raw, capability, body)
|
|
147
|
+
result.update(status='FAILED' if error else 'SUCCESS', error=error,
|
|
148
|
+
response=redact(raw, self.context))
|
|
149
|
+
self.stopped = stop
|
|
150
|
+
if stop:
|
|
151
|
+
self.cache.invalidate(self.context.cache_partition)
|
|
152
|
+
except (TimeoutError, ConnectionError, OSError):
|
|
153
|
+
result.update(status='FAILED', error='NETWORK_UNCERTAIN')
|
|
154
|
+
self.stopped = True
|
|
155
|
+
except (TypeError, ValueError, RecursionError):
|
|
156
|
+
result.update(status='FAILED', error='INVALID_RESPONSE')
|
|
157
|
+
if result['status'] == 'SUCCESS':
|
|
158
|
+
self.cache.put(key, {'response': result['response'], 'fetchedAt': result['fetchedAt']})
|
|
159
|
+
if self.record is not None:
|
|
160
|
+
self.record(deepcopy(result))
|
|
161
|
+
self.results.append(deepcopy(result))
|
|
162
|
+
batch.append(result)
|
|
163
|
+
return batch
|
|
164
|
+
|
|
165
|
+
def summary(self):
|
|
166
|
+
return {'maxCalls': self.max_calls, 'requestsSent': self.sent + len(self.imported_results),
|
|
167
|
+
'requestsSentThisRun': self.sent, 'importedRequests': len(self.imported_results),
|
|
168
|
+
'cacheHits': sum(r['cacheHit'] for r in self.results),
|
|
169
|
+
'automaticRetries': 0, 'estimatedCredits': None, 'billingCap': None,
|
|
170
|
+
'pricingStatus': 'UNKNOWN', 'cachePolicy': 'memory-only-session-ttl',
|
|
171
|
+
'stoppedAfterFailure': self.stopped}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Copyright 2026 Nexscope
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Resolve a selected authentication context only after an explicit execution decision."""
|
|
4
|
+
from .auth import DEFAULT_ORIGIN, resolve_auth
|
|
5
|
+
from .errors import Failure
|
|
6
|
+
from .execution import Executor
|
|
7
|
+
from .transport import HttpTransport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def engine_for(args):
|
|
11
|
+
if args.mock:
|
|
12
|
+
from ..capabilities.mock import executor
|
|
13
|
+
return executor(args.auth_mode, args.mock, args.max_calls)
|
|
14
|
+
if not args.allow_paid:
|
|
15
|
+
raise Failure('Remote execution requires --allow-paid.')
|
|
16
|
+
loader = None
|
|
17
|
+
if args.auth_mode == 'browser-account':
|
|
18
|
+
from ..main import Client
|
|
19
|
+
client = Client(args.api_base or DEFAULT_ORIGIN)
|
|
20
|
+
loader = lambda: client.load('credential')
|
|
21
|
+
context = resolve_auth(args.auth_mode, base=args.api_base, credential_loader=loader)
|
|
22
|
+
return Executor(context, HttpTransport(), max_calls=args.max_calls)
|