@voxgig/sdkgen 4.2.7 → 4.3.0
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.
- package/bin/voxgig-sdkgen +1 -1
- package/dist/helpers/naming.d.ts +2 -1
- package/dist/helpers/naming.js +50 -12
- package/dist/helpers/naming.js.map +1 -1
- package/dist/sdkgen.d.ts +2 -2
- package/dist/sdkgen.js +4 -3
- package/dist/sdkgen.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/project/.sdk/tm/go/test/custom_utility_test.go +103 -0
- package/project/.sdk/tm/go/test/feature_corpus_test.go +550 -0
- package/project/.sdk/tm/go/utility/make_options.go +7 -1
- package/project/.sdk/tm/go/utility/register.go +194 -0
- package/project/.sdk/tm/java/test/CustomUtilityTest.java +55 -0
- package/project/.sdk/tm/java/test/FeatureCorpusTest.java +463 -0
- package/project/.sdk/tm/java/utility/MakeOptions.java +11 -2
- package/project/.sdk/tm/java/utility/Register.java +91 -0
- package/project/.sdk/tm/js/test/feature/Corpus.test.js +24 -3
- package/project/.sdk/tm/perl/t/feature_corpus.t +345 -0
- package/project/.sdk/tm/perl/utility/make_options.pm +33 -1
- package/project/.sdk/tm/php/core/Context.php +3 -0
- package/project/.sdk/tm/php/core/Control.php +13 -0
- package/project/.sdk/tm/php/core/Error.php +12 -0
- package/project/.sdk/tm/php/test/FeatureCorpusTest.php +376 -0
- package/project/.sdk/tm/php/utility/MakeOptions.php +30 -1
- package/project/.sdk/tm/py/pkg/utility/make_options.py +42 -1
- package/project/.sdk/tm/py/test/test_feature_corpus.py +309 -0
- package/project/.sdk/tm/rb/test/feature_corpus_test.rb +281 -0
- package/project/.sdk/tm/rb/utility/make_options.rb +32 -1
- package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +24 -3
- package/project/sdkgen-package.json +1 -1
- package/src/helpers/naming.ts +54 -12
- package/src/sdkgen.ts +2 -1
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
// ProjectName SDK feature corpus test
|
|
4
|
+
//
|
|
5
|
+
// Feature behaviour, driven by the SHARED corpus.
|
|
6
|
+
//
|
|
7
|
+
// The same route PrimaryUtilityTest.php takes for the utilities:
|
|
8
|
+
// language-neutral cases in .sdk/test/test.json, executed against THIS
|
|
9
|
+
// generated SDK. The feature is the ordinary class, built by the generated
|
|
10
|
+
// config, installed by the generated constructor, and driven by a real entity
|
|
11
|
+
// operation. Not a miniature of the pipeline, which can only be as right as
|
|
12
|
+
// the miniature.
|
|
13
|
+
//
|
|
14
|
+
// Everything in a case is data. The one piece php writes for itself is
|
|
15
|
+
// turning scripted responses into a fetcher, through the documented
|
|
16
|
+
// `utility.fetcher` override.
|
|
17
|
+
|
|
18
|
+
declare(strict_types=1);
|
|
19
|
+
|
|
20
|
+
require_once __DIR__ . '/../projectname_sdk.php';
|
|
21
|
+
|
|
22
|
+
use PHPUnit\Framework\TestCase;
|
|
23
|
+
|
|
24
|
+
class FeatureCorpusTest extends TestCase
|
|
25
|
+
{
|
|
26
|
+
// Features with a corpus section. A name here with no section is a skip,
|
|
27
|
+
// not a failure: an SDK generated without the feature has nothing to run.
|
|
28
|
+
private const FEATURE_CORPUS_NAMES = ['cost'];
|
|
29
|
+
|
|
30
|
+
// The standard operation names, in the order the runner prefers them.
|
|
31
|
+
private const FEATURE_CORPUS_OPS = ['load', 'list', 'create', 'update', 'remove'];
|
|
32
|
+
|
|
33
|
+
private static function corpus(): array
|
|
34
|
+
{
|
|
35
|
+
$path = __DIR__ . '/../../.sdk/test/test.json';
|
|
36
|
+
$raw = file_get_contents($path);
|
|
37
|
+
if (false === $raw) {
|
|
38
|
+
throw new RuntimeException("Failed to load test.json: $path");
|
|
39
|
+
}
|
|
40
|
+
return json_decode($raw, true);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A scripted transport built from a case's `res` list. Responses are
|
|
45
|
+
* consumed in order and the last one repeats, so a case that does not care
|
|
46
|
+
* how many attempts happen need only declare one.
|
|
47
|
+
*
|
|
48
|
+
* Returns the shape the real fetcher returns: a [response, err] PAIR, with
|
|
49
|
+
* the parsed body behind a `json` closure and `body` as the raw string. A
|
|
50
|
+
* script that only set `body` would look like an empty result, which reads
|
|
51
|
+
* as a feature defect rather than a mis-shaped script.
|
|
52
|
+
*/
|
|
53
|
+
private static function scriptedFetcher($res): callable
|
|
54
|
+
{
|
|
55
|
+
$n = -1;
|
|
56
|
+
return function ($ctx, $fullurl, $fetchdef) use ($res, &$n) {
|
|
57
|
+
$n++;
|
|
58
|
+
$spec = [];
|
|
59
|
+
if (is_array($res) && count($res) > 0) {
|
|
60
|
+
$i = $n >= count($res) ? count($res) - 1 : $n;
|
|
61
|
+
$spec = $res[$i] ?? [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (($spec['throw'] ?? null) === true) {
|
|
65
|
+
return [null, new RuntimeException('scripted transport failure')];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
$status = isset($spec['status']) ? (int)$spec['status'] : 200;
|
|
69
|
+
$body = $spec['body'] ?? [];
|
|
70
|
+
|
|
71
|
+
return [
|
|
72
|
+
[
|
|
73
|
+
'status' => $status,
|
|
74
|
+
'statusText' => $status < 400 ? 'OK' : 'ERR',
|
|
75
|
+
'headers' => (array)($spec['headers'] ?? []),
|
|
76
|
+
'json' => function () use ($body) { return $body; },
|
|
77
|
+
'body' => json_encode($body),
|
|
78
|
+
],
|
|
79
|
+
null,
|
|
80
|
+
];
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a client the way a caller would.
|
|
86
|
+
*
|
|
87
|
+
* The plain constructor, not the test-mode one: the `test` feature is
|
|
88
|
+
* transport: 'base' and REPLACES the transport, so a client in test mode
|
|
89
|
+
* would shadow the script.
|
|
90
|
+
*/
|
|
91
|
+
private static function buildClient(array $kase)
|
|
92
|
+
{
|
|
93
|
+
$opts = ['utility' => ['fetcher' => self::scriptedFetcher($kase['res'] ?? null)]];
|
|
94
|
+
if (isset($kase['feature'])) {
|
|
95
|
+
$opts['feature'] = $kase['feature'];
|
|
96
|
+
}
|
|
97
|
+
return new ProjectNameSDK($opts);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Every operation this SDK declares, in a stable order.
|
|
102
|
+
*
|
|
103
|
+
* The corpus cannot name an entity - it is shared by SDKs with none in
|
|
104
|
+
* common - so the runner finds them here. An entity accessor is a
|
|
105
|
+
* capitalised client method whose result answers get_name().
|
|
106
|
+
*/
|
|
107
|
+
private static function candidates($client): array
|
|
108
|
+
{
|
|
109
|
+
$found = [];
|
|
110
|
+
foreach (get_class_methods($client) as $m) {
|
|
111
|
+
if (!preg_match('/^[A-Z]/', $m)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
$ent = $client->$m();
|
|
116
|
+
} catch (\Throwable $e) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!is_object($ent) || !method_exists($ent, 'get_name')) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
$entname = $ent->get_name();
|
|
124
|
+
} catch (\Throwable $e) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!is_string($entname) || '' === $entname) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
$found[$entname] = [$m, $ent];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
ksort($found);
|
|
134
|
+
|
|
135
|
+
$out = [];
|
|
136
|
+
foreach ($found as $entname => $pair) {
|
|
137
|
+
[$accessor, $ent] = $pair;
|
|
138
|
+
foreach (self::FEATURE_CORPUS_OPS as $opname) {
|
|
139
|
+
if (method_exists($ent, $opname)) {
|
|
140
|
+
$out[] = [
|
|
141
|
+
'key' => $entname . '.' . $opname,
|
|
142
|
+
'accessor' => $accessor,
|
|
143
|
+
'op' => $opname,
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return $out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private static function invoke($client, array $op, array $ctrl)
|
|
152
|
+
{
|
|
153
|
+
$acc = $op['accessor'];
|
|
154
|
+
$ent = $client->$acc();
|
|
155
|
+
$fn = $op['op'];
|
|
156
|
+
return $ent->$fn([], $ctrl);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Pick operations by DRIVING them: an op is usable when it completes
|
|
161
|
+
* against a plain 200 with no feature active. Declared operations are not
|
|
162
|
+
* all callable with no arguments, and a case failing for that reason would
|
|
163
|
+
* read as a feature defect.
|
|
164
|
+
*/
|
|
165
|
+
private static function usableOps(int $want): array
|
|
166
|
+
{
|
|
167
|
+
$picked = [];
|
|
168
|
+
foreach (self::candidates(self::buildClient([])) as $cand) {
|
|
169
|
+
try {
|
|
170
|
+
self::invoke(self::buildClient([]), $cand, []);
|
|
171
|
+
} catch (\Throwable $e) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
$picked[] = $cand;
|
|
175
|
+
if (count($picked) >= $want) {
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return $picked;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Replace #OPn throughout a case, keys included. */
|
|
183
|
+
private static function resolve($node, array $tokens)
|
|
184
|
+
{
|
|
185
|
+
if (is_string($node)) {
|
|
186
|
+
return strtr($node, $tokens);
|
|
187
|
+
}
|
|
188
|
+
if (is_array($node)) {
|
|
189
|
+
$out = [];
|
|
190
|
+
foreach ($node as $k => $v) {
|
|
191
|
+
$key = is_string($k) ? strtr($k, $tokens) : $k;
|
|
192
|
+
$out[$key] = self::resolve($v, $tokens);
|
|
193
|
+
}
|
|
194
|
+
return $out;
|
|
195
|
+
}
|
|
196
|
+
return $node;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The highest #OPn a case mentions. */
|
|
200
|
+
private static function tokensUsed(array $kase): int
|
|
201
|
+
{
|
|
202
|
+
preg_match_all('/#OP(\d+)/', json_encode($kase), $m);
|
|
203
|
+
$nums = array_map('intval', $m[1] ?? []);
|
|
204
|
+
return count($nums) > 0 ? max($nums) : 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private static function member($actual, string $key): array
|
|
208
|
+
{
|
|
209
|
+
if (null === $actual) {
|
|
210
|
+
return [null, false];
|
|
211
|
+
}
|
|
212
|
+
if (is_array($actual)) {
|
|
213
|
+
return array_key_exists($key, $actual) ? [$actual[$key], true] : [null, false];
|
|
214
|
+
}
|
|
215
|
+
if (is_object($actual) && property_exists($actual, $key)) {
|
|
216
|
+
return [$actual->$key, true];
|
|
217
|
+
}
|
|
218
|
+
return [null, false];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Assert that `actual` contains `expect`, recursively. Cases assert only
|
|
223
|
+
* the fields they are about, so a full equality check would force every
|
|
224
|
+
* case to restate the whole record.
|
|
225
|
+
*/
|
|
226
|
+
private function subset($actual, $expect, string $path): void
|
|
227
|
+
{
|
|
228
|
+
if (is_array($expect) && (0 === count($expect) || !array_is_list($expect))) {
|
|
229
|
+
foreach ($expect as $k => $want) {
|
|
230
|
+
[$got, $found] = self::member($actual, (string)$k);
|
|
231
|
+
$this->assertTrue($found, "$path.$k: no such member");
|
|
232
|
+
$this->subset($got, $want, "$path.$k");
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (is_int($expect) || is_float($expect)) {
|
|
238
|
+
$this->assertTrue(is_int($actual) || is_float($actual),
|
|
239
|
+
"$path: expected a number, got " . var_export($actual, true));
|
|
240
|
+
// Money is float arithmetic; compare with a tolerance far below
|
|
241
|
+
// any amount a case states.
|
|
242
|
+
$this->assertLessThan(1e-9, abs((float)$actual - (float)$expect), $path);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
$this->assertSame($expect, $actual, $path);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private static function record($client, string $name)
|
|
250
|
+
{
|
|
251
|
+
$prop = '_' . $name;
|
|
252
|
+
return $client->$prop ?? null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
public function testCorpusCarriesAFeatureSection(): void
|
|
256
|
+
{
|
|
257
|
+
// A corpus with no `feature` section is a SKIP, not a failure. Each
|
|
258
|
+
// project carries its OWN materialised copy of .sdk/test/test.json, so a
|
|
259
|
+
// project scaffolded before the section existed legitimately has no cases
|
|
260
|
+
// to run - and a hard assertion here turned that into a red suite in every
|
|
261
|
+
// SDK on the fleet, for a corpus the project had simply not re-pulled yet.
|
|
262
|
+
// The strict check belongs where the corpus is CONTROLLED: sdkgen's own
|
|
263
|
+
// end-to-end lane supplies one and requires the cases to actually run.
|
|
264
|
+
if (null === (self::corpus()['feature'] ?? null)) {
|
|
265
|
+
$this->markTestSkipped(
|
|
266
|
+
"this project's test.json has no `feature` section - recompile "
|
|
267
|
+
. 'the corpus (create-sdkgen .sdk/test/feature/) to run these cases');
|
|
268
|
+
}
|
|
269
|
+
$this->assertNotNull(self::corpus()['feature']);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// At least one operation, or every case would skip and this suite would
|
|
273
|
+
// report green having run nothing.
|
|
274
|
+
public function testSdkHasAnOperationTheCorpusCanDrive(): void
|
|
275
|
+
{
|
|
276
|
+
$this->assertNotEmpty(self::usableOps(2),
|
|
277
|
+
'no declared operation completed against a plain 200 - the corpus '
|
|
278
|
+
. 'cannot exercise a feature without one');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
public function testFeatureCorpus(): void
|
|
282
|
+
{
|
|
283
|
+
// Skip rather than run vacuously: with no section this asserts
|
|
284
|
+
// nothing, which PHPUnit reports as RISKY - a third state that reads
|
|
285
|
+
// like neither a pass nor a skip.
|
|
286
|
+
if (null === (self::corpus()['feature'] ?? null)) {
|
|
287
|
+
$this->markTestSkipped(
|
|
288
|
+
"this project's test.json has no `feature` section - recompile "
|
|
289
|
+
. 'the corpus (create-sdkgen .sdk/test/feature/) to run these cases');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
foreach (self::FEATURE_CORPUS_NAMES as $name) {
|
|
293
|
+
$section = self::corpus()['feature'][$name] ?? null;
|
|
294
|
+
if (null === $section) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
$cases = $section['basic']['set'] ?? [];
|
|
299
|
+
$this->assertNotEmpty($cases,
|
|
300
|
+
"corpus section feature.$name ran ZERO cases - a renamed "
|
|
301
|
+
. 'section or an emptied fixture must fail loudly');
|
|
302
|
+
|
|
303
|
+
// Probed by ACTIVATING it: the feature defaults to inactive, so an
|
|
304
|
+
// idle client never builds it and its absence says nothing.
|
|
305
|
+
$probe = self::buildClient(['feature' => [['name' => $name, 'active' => true]]]);
|
|
306
|
+
if (null === self::record($probe, $name)) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
$ops = self::usableOps(2);
|
|
311
|
+
$byKey = [];
|
|
312
|
+
foreach ($ops as $o) {
|
|
313
|
+
$byKey[$o['key']] = $o;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
$ran = 0;
|
|
317
|
+
foreach ($cases as $raw) {
|
|
318
|
+
$need = self::tokensUsed($raw);
|
|
319
|
+
if ($need > count($ops)) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
$tokens = [];
|
|
324
|
+
for ($i = 0; $i < $need; $i++) {
|
|
325
|
+
$tokens['#OP' . ($i + 1)] = $ops[$i]['key'];
|
|
326
|
+
}
|
|
327
|
+
$kase = self::resolve($raw, $tokens);
|
|
328
|
+
|
|
329
|
+
$client = self::buildClient($kase);
|
|
330
|
+
$label = $kase['name'] ?? '';
|
|
331
|
+
|
|
332
|
+
foreach (($kase['op'] ?? []) as $step) {
|
|
333
|
+
$op = $byKey[$step['op']] ?? null;
|
|
334
|
+
$this->assertNotNull($op, "$label: no operation {$step['op']}");
|
|
335
|
+
$ctrl = $step['ctrl'] ?? [];
|
|
336
|
+
$wanterr = $step['err'] ?? null;
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
self::invoke($client, $op, $ctrl);
|
|
340
|
+
$this->assertNull($wanterr,
|
|
341
|
+
"$label: {$step['op']} was expected to fail, and did not");
|
|
342
|
+
} catch (\PHPUnit\Framework\AssertionFailedError $e) {
|
|
343
|
+
throw $e;
|
|
344
|
+
} catch (\Throwable $err) {
|
|
345
|
+
$this->assertNotNull($wanterr,
|
|
346
|
+
"$label: {$step['op']} failed unexpectedly: " . $err->getMessage());
|
|
347
|
+
if (is_string($wanterr)) {
|
|
348
|
+
// The CODE, not the message: make_error prefixes
|
|
349
|
+
// and humanises the text, so matching it would
|
|
350
|
+
// pass on any error that mentioned the word.
|
|
351
|
+
//
|
|
352
|
+
// `sdk_code`, not `code`: Exception::$code is a
|
|
353
|
+
// protected int on every PHP throwable, so the
|
|
354
|
+
// SDK carries its own string code beside it.
|
|
355
|
+
$code = property_exists($err, 'sdk_code') ? $err->sdk_code : null;
|
|
356
|
+
$this->assertSame($wanterr, $code,
|
|
357
|
+
"$label: wrong error code (" . $err->getMessage() . ')');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
$this->subset(self::record($client, $name), $kase['out'] ?? [],
|
|
363
|
+
"$label: _$name");
|
|
364
|
+
$ran++;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
$this->assertGreaterThan(0, $ran, "every feature.$name case was skipped");
|
|
368
|
+
// Say how many ran. A partial run is legitimate (an SDK with one
|
|
369
|
+
// operation skips the cases needing two) but it should be visible
|
|
370
|
+
// rather than inferred from a green tick.
|
|
371
|
+
fwrite(STDERR, sprintf(
|
|
372
|
+
"feature.%s: ran %d of %d case(s) against %d operation(s)\n",
|
|
373
|
+
$name, $ran, count($cases), count($ops)));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -27,10 +27,39 @@ class ProjectNameMakeOptions
|
|
|
27
27
|
{
|
|
28
28
|
$options = $ctx->options ?? [];
|
|
29
29
|
|
|
30
|
+
// Merge custom utility overrides.
|
|
31
|
+
//
|
|
32
|
+
// A key naming a real utility member REPLACES it; anything else is
|
|
33
|
+
// attached as a custom extra. This mirrors ts, where the utility is an
|
|
34
|
+
// open object and one setprop does both.
|
|
35
|
+
//
|
|
36
|
+
// Without the replace half this was a no-op: every entry went to
|
|
37
|
+
// `utility->custom`, which nothing reads, so a caller passing
|
|
38
|
+
// `'utility' => ['fetcher' => $myTransport]` - the documented way to
|
|
39
|
+
// script the transport, and the seam the shared feature corpus runs
|
|
40
|
+
// on - was silently ignored while ts and js honoured it.
|
|
41
|
+
//
|
|
42
|
+
// Option keys are camelCase, as ts spells them; members here are
|
|
43
|
+
// snake_case. Converting rather than listing keeps the mapping to one
|
|
44
|
+
// rule, so a utility added later is overridable without touching this.
|
|
30
45
|
$custom_utils = \Voxgig\Struct\Struct::getprop($options, 'utility');
|
|
31
46
|
if ((is_array($custom_utils) || is_object($custom_utils)) && $ctx->utility) {
|
|
47
|
+
$utility = $ctx->utility;
|
|
32
48
|
foreach ((array)$custom_utils as $k => $v) {
|
|
33
|
-
|
|
49
|
+
// Public utility names are camelCase and carry no underscore,
|
|
50
|
+
// so an underscore means the caller named something of their
|
|
51
|
+
// own - possibly the INTERNAL spelling of a real member.
|
|
52
|
+
// `make_error` must stay an extension in `custom`; replacing
|
|
53
|
+
// the pipeline function with it (ts, js and go all keep it)
|
|
54
|
+
// would break the error path on the next request, silently.
|
|
55
|
+
$public_name = false === strpos((string)$k, '_');
|
|
56
|
+
$member = strtolower(preg_replace('/([A-Z])/', '_$1', (string)$k));
|
|
57
|
+
if ($public_name && 'custom' !== $member
|
|
58
|
+
&& property_exists($utility, $member)) {
|
|
59
|
+
$utility->$member = $v;
|
|
60
|
+
} else {
|
|
61
|
+
$utility->custom[$k] = $v;
|
|
62
|
+
}
|
|
34
63
|
}
|
|
35
64
|
}
|
|
36
65
|
|
|
@@ -4,16 +4,57 @@ from __future__ import annotations
|
|
|
4
4
|
from projectname_sdk.utility.voxgig_struct import voxgig_struct as vs
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
|
|
8
|
+
def _util_member(key):
|
|
9
|
+
"""Public camelCase option key -> snake_case utility member name.
|
|
10
|
+
|
|
11
|
+
Returns None for a key that is NOT a public name. Public utility names are
|
|
12
|
+
camelCase and contain no underscore, so an underscore means the caller
|
|
13
|
+
named something of their own - possibly the INTERNAL spelling of a real
|
|
14
|
+
member. `make_error` must stay an extension in `custom`; replacing the
|
|
15
|
+
pipeline function with it (ts, js and go all keep it) would break the
|
|
16
|
+
error path on the next request, silently.
|
|
17
|
+
"""
|
|
18
|
+
if "_" in key:
|
|
19
|
+
return None
|
|
20
|
+
out = []
|
|
21
|
+
for ch in key:
|
|
22
|
+
if ch.isupper():
|
|
23
|
+
out.append("_")
|
|
24
|
+
out.append(ch.lower())
|
|
25
|
+
else:
|
|
26
|
+
out.append(ch)
|
|
27
|
+
return "".join(out)
|
|
28
|
+
|
|
29
|
+
|
|
7
30
|
def make_options_util(ctx):
|
|
8
31
|
options = ctx.options or {}
|
|
9
32
|
|
|
10
33
|
# Merge custom utility overrides.
|
|
34
|
+
#
|
|
35
|
+
# A key naming a real utility member REPLACES it; anything else is
|
|
36
|
+
# attached as a custom extra. This mirrors ts, where the utility is an
|
|
37
|
+
# open object and one setprop does both.
|
|
38
|
+
#
|
|
39
|
+
# Without the replace half this was a no-op: every entry went to
|
|
40
|
+
# `utility.custom`, which nothing reads, so a caller passing
|
|
41
|
+
# `utility={"fetcher": my_transport}` - the documented way to script the
|
|
42
|
+
# transport, and the seam the shared feature corpus runs on - was
|
|
43
|
+
# silently ignored while ts and js honoured it.
|
|
44
|
+
#
|
|
45
|
+
# Option keys are camelCase, as ts spells them; members here are
|
|
46
|
+
# snake_case. Converting rather than listing keeps the mapping to one
|
|
47
|
+
# rule, so a utility added later is overridable without touching this.
|
|
11
48
|
custom_utils = vs.getprop(options, "utility")
|
|
12
49
|
if isinstance(custom_utils, dict):
|
|
13
50
|
utility = ctx.utility
|
|
14
51
|
if utility is not None:
|
|
15
52
|
for key, val in custom_utils.items():
|
|
16
|
-
|
|
53
|
+
member = _util_member(key)
|
|
54
|
+
if member is not None and member != "custom" and hasattr(utility, member):
|
|
55
|
+
setattr(utility, member, val)
|
|
56
|
+
else:
|
|
57
|
+
utility.custom[key] = val
|
|
17
58
|
|
|
18
59
|
# Feature INSTANCES supplied at construction (the station adopt path)
|
|
19
60
|
# are consumed by the constructor's feature-add loop straight from the
|