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.
Files changed (74) hide show
  1. nexscope_cli/__init__.py +4 -0
  2. nexscope_cli/api-contract.json +37 -0
  3. nexscope_cli/capabilities/__init__.py +3 -0
  4. nexscope_cli/capabilities/catalog.py +73 -0
  5. nexscope_cli/capabilities/mock.py +53 -0
  6. nexscope_cli/capabilities/plan.py +142 -0
  7. nexscope_cli/catalog.json +69 -0
  8. nexscope_cli/compat/__init__.py +4 -0
  9. nexscope_cli/compat/legacy.py +143 -0
  10. nexscope_cli/core/__init__.py +3 -0
  11. nexscope_cli/core/auth.py +81 -0
  12. nexscope_cli/core/datasets.py +305 -0
  13. nexscope_cli/core/errors.py +11 -0
  14. nexscope_cli/core/evidence.py +134 -0
  15. nexscope_cli/core/execution.py +171 -0
  16. nexscope_cli/core/remote.py +22 -0
  17. nexscope_cli/core/transport.py +61 -0
  18. nexscope_cli/dependencies.lock +282 -0
  19. nexscope_cli/dependency-lock.json +371 -0
  20. nexscope_cli/doctor.py +136 -0
  21. nexscope_cli/main.py +313 -0
  22. nexscope_cli/reports/__init__.py +3 -0
  23. nexscope_cli/reports/compare.py +135 -0
  24. nexscope_cli/reports/formats.py +99 -0
  25. nexscope_cli/resources/__init__.py +3 -0
  26. nexscope_cli/resources/fields.json +33 -0
  27. nexscope_cli/resources/mock-products.json +9 -0
  28. nexscope_cli/resources/products-sample.csv +5 -0
  29. nexscope_cli/resources/products-sample.json +6 -0
  30. nexscope_cli/resources/products-template.csv +1 -0
  31. nexscope_cli/resources/products.schema.json +182 -0
  32. nexscope_cli/resources/report.html +15 -0
  33. nexscope_cli/runner.py +43 -0
  34. nexscope_cli/skills/amazon-product-detail/SKILL.md +170 -0
  35. nexscope_cli/skills/amazon-product-detail/references/api.md +239 -0
  36. nexscope_cli/skills/amazon-product-detail/requirements.txt +1 -0
  37. nexscope_cli/skills/amazon-product-detail/scripts/amazon_product_detail.py +26 -0
  38. nexscope_cli/skills/amazon-search/SKILL.md +197 -0
  39. nexscope_cli/skills/amazon-search/references/api.md +189 -0
  40. nexscope_cli/skills/amazon-search/requirements.txt +1 -0
  41. nexscope_cli/skills/amazon-search/scripts/amazon_search.py +26 -0
  42. nexscope_cli/workflows/__init__.py +3 -0
  43. nexscope_cli/workflows/compare.py +213 -0
  44. nexscope_cli-0.1.0.dist-info/METADATA +257 -0
  45. nexscope_cli-0.1.0.dist-info/RECORD +74 -0
  46. nexscope_cli-0.1.0.dist-info/WHEEL +5 -0
  47. nexscope_cli-0.1.0.dist-info/entry_points.txt +2 -0
  48. nexscope_cli-0.1.0.dist-info/licenses/ASSET-NOTICES.md +9 -0
  49. nexscope_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  50. nexscope_cli-0.1.0.dist-info/licenses/NOTICE +20 -0
  51. nexscope_cli-0.1.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +27 -0
  52. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/backports-tarfile/LICENSE +17 -0
  53. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/certifi/LICENSE +20 -0
  54. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cffi/LICENSE +23 -0
  55. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/charset-normalizer/LICENSE +21 -0
  56. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE +3 -0
  57. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE.APACHE +202 -0
  58. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE.BSD +27 -0
  59. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/idna/LICENSE.md +31 -0
  60. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/importlib-metadata/LICENSE +73 -0
  61. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-classes/LICENSE +17 -0
  62. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-context/LICENSE +18 -0
  63. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-functools/LICENSE +18 -0
  64. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jeepney/LICENSE +21 -0
  65. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/keyring/LICENSE +18 -0
  66. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/more-itertools/LICENSE +19 -0
  67. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/pycparser/LICENSE +27 -0
  68. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/pywin32-ctypes/LICENSE.txt +28 -0
  69. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/requests/LICENSE +175 -0
  70. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/requests/NOTICE +2 -0
  71. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/secretstorage/LICENSE +25 -0
  72. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/urllib3/LICENSE.txt +21 -0
  73. nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/zipp/LICENSE +18 -0
  74. 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,11 @@
1
+ # Copyright 2026 Nexscope
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Shared CLI errors with credential-free messages."""
4
+
5
+
6
+ class Failure(Exception):
7
+ pass
8
+
9
+
10
+ class AuthRequired(Failure):
11
+ pass
@@ -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)