@hahnpro/flow-sdk 8.0.12 → 9.0.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.
@@ -1,8 +1,8 @@
1
1
  import 'reflect-metadata';
2
2
  import { API, HttpClient, MockAPI } from '@hahnpro/hpc-api';
3
3
  import { NatsConnection, ConnectionOptions as NatsConnectionOptions } from '@nats-io/nats-core';
4
- import { ConsumeMessage } from 'amqplib';
5
4
  import { AmqpConnectionManager } from 'amqp-connection-manager';
5
+ import { CloudEvent } from 'cloudevents';
6
6
  import { PartialObserver } from 'rxjs';
7
7
  import { AmqpConnection, AmqpConnectionConfig } from './amqp';
8
8
  import { ClassType, Flow, FlowElementContext } from './flow.interface';
@@ -42,6 +42,7 @@ export declare class FlowApplication {
42
42
  private readonly skipApi;
43
43
  private readonly apiClient?;
44
44
  private readonly contextManager;
45
+ private natsMessageIterator;
45
46
  constructor(modules: ClassType<any>[], flow: Flow, config?: FlowAppConfig);
46
47
  constructor(modules: ClassType<any>[], flow: Flow, baseLogger?: Logger, amqpConnection?: AmqpConnection, natsConnection?: NatsConnection, skipApi?: boolean, explicitInit?: boolean);
47
48
  get rpcClient(): RpcClient;
@@ -49,6 +50,7 @@ export declare class FlowApplication {
49
50
  get natsConnection(): NatsConnection;
50
51
  getContextManager(): ContextManager;
51
52
  getProperties(): Record<string, any>;
53
+ private consumeNatsMessagesOfConsumer;
52
54
  init(): Promise<void>;
53
55
  private publishLifecycleEvent;
54
56
  private setQueueMetrics;
@@ -56,12 +58,14 @@ export declare class FlowApplication {
56
58
  subscribe: (streamId: string, observer: PartialObserver<FlowEvent>) => import("rxjs").Subscription;
57
59
  emit: (event: FlowEvent) => void;
58
60
  emitPartial: (completeEvent: FlowEvent, partialEvent: FlowEvent) => void;
59
- onMessage: (msg: ConsumeMessage) => Promise<void>;
61
+ onMessage: (cloudEvent: CloudEvent) => Promise<void>;
60
62
  /**
61
63
  * Publish a flow event to the amqp flowlogs exchange.
62
64
  * If the event size exceeds the limit it will be truncated
65
+ *
66
+ * TODO warum darf hier nicht false zurückgegeben werden? -> erzeugt loop
63
67
  */
64
- publishEvent: (event: FlowEvent) => Promise<boolean>;
68
+ publishNatsEventFlowlogs: (event: FlowEvent) => Promise<boolean>;
65
69
  /**
66
70
  * Calls onDestroy lifecycle method on all flow elements,
67
71
  * closes amqp connection after allowing logs to be processed and published
@@ -5,7 +5,6 @@ const tslib_1 = require("tslib");
5
5
  require("reflect-metadata");
6
6
  const hpc_api_1 = require("@hahnpro/hpc-api");
7
7
  const cloudevents_1 = require("cloudevents");
8
- const crypto_1 = require("crypto");
9
8
  const lodash_1 = require("lodash");
10
9
  const object_sizeof_1 = tslib_1.__importDefault(require("object-sizeof"));
11
10
  const perf_hooks_1 = require("perf_hooks");
@@ -31,13 +30,13 @@ class FlowApplication {
31
30
  this.outputStreamMap = new Map();
32
31
  this.outputQueueMetrics = new Map();
33
32
  this.performanceMap = new Map();
34
- this.publishLifecycleEvent = (element, flowEventId, eventType, data = {}) => {
33
+ this.publishLifecycleEvent = async (element, flowEventId, eventType, data = {}) => {
35
34
  if (!this.amqpChannel) {
36
35
  return;
37
36
  }
38
37
  try {
39
38
  const { flowId, deploymentId, id: elementId, functionFqn, inputStreamId } = element.getMetadata();
40
- const event = new cloudevents_1.CloudEvent({
39
+ const natsEvent = {
41
40
  source: `flows/${flowId}/deployments/${deploymentId}/elements/${elementId}`,
42
41
  type: eventType,
43
42
  data: {
@@ -46,10 +45,8 @@ class FlowApplication {
46
45
  inputStreamId,
47
46
  ...data,
48
47
  },
49
- time: new Date().toISOString(),
50
- });
51
- const message = event.toJSON();
52
- return this.amqpChannel.publish('flow', 'lifecycle', message);
48
+ };
49
+ await (0, nats_1.publishNatsEvent)(this.logger, this.natsConnection, natsEvent, `${nats_1.natsFlowsPrefixFlowDeployment}.flowlifecycle.${deploymentId}`);
53
50
  }
54
51
  catch (err) {
55
52
  this.logger.error(err);
@@ -82,7 +79,7 @@ class FlowApplication {
82
79
  this.emit = (event) => {
83
80
  if (event) {
84
81
  try {
85
- this.publishEvent(event);
82
+ this.publishNatsEventFlowlogs(event);
86
83
  if (this.outputStreamMap.has(event.getStreamId())) {
87
84
  this.getOutputStream(event.getStreamId()).next(event);
88
85
  }
@@ -98,27 +95,26 @@ class FlowApplication {
98
95
  this.getOutputStream(completeEvent.getStreamId()).next(completeEvent);
99
96
  }
100
97
  if (partialEvent) {
101
- this.publishEvent(partialEvent);
98
+ this.publishNatsEventFlowlogs(partialEvent);
102
99
  }
103
100
  }
104
101
  catch (err) {
105
102
  this.logger.error(err);
106
103
  }
107
104
  };
108
- this.onMessage = async (msg) => {
109
- let event;
110
- try {
111
- event = JSON.parse(msg.content.toString());
112
- }
113
- catch (err) {
114
- this.logger.error(err);
115
- return;
116
- }
117
- if (event.type === 'com.flowstudio.deployment.update') {
105
+ this.onMessage = async (cloudEvent) => {
106
+ if (cloudEvent.subject.endsWith('.update')) {
107
+ let event;
108
+ try {
109
+ event = JSON.parse(cloudEvent.content.toString());
110
+ }
111
+ catch (err) {
112
+ this.logger.error(err);
113
+ return;
114
+ }
118
115
  try {
119
116
  const flow = event.data;
120
117
  if (!flow) {
121
- this.amqpChannel.nack(msg, false, false);
122
118
  return;
123
119
  }
124
120
  let context = {};
@@ -143,39 +139,33 @@ class FlowApplication {
143
139
  this.elements?.[element.id]?.setPropertiesWithPlaceholders((0, lodash_1.cloneDeep)(element.properties));
144
140
  this.elements?.[element.id]?.onPropertiesChanged(this.contextManager.replaceAllPlaceholderProperties(this.elements[element.id].getPropertiesWithPlaceholders()));
145
141
  }
146
- const statusEvent = {
147
- eventId: (0, crypto_1.randomUUID)(),
148
- eventTime: new Date().toISOString(),
149
- eventType: 'com.hahnpro.event.health',
150
- contentType: 'application/json',
151
- data: { deploymentId: this.context.deploymentId, status: 'updated' },
142
+ const natsEvent = {
143
+ source: `hpc/flow-application`,
144
+ type: `${nats_1.natsFlowsPrefixFlowDeployment}.health`,
145
+ subject: `${this.context.deploymentId}`,
146
+ data: {
147
+ deploymentId: this.context.deploymentId,
148
+ status: 'updated',
149
+ },
152
150
  };
153
- try {
154
- this.amqpChannel?.publish('deployment', 'health', statusEvent);
155
- }
156
- catch (err) {
157
- this.logger.error(err);
158
- }
151
+ await (0, nats_1.publishNatsEvent)(this.logger, this.natsConnection, natsEvent);
159
152
  }
160
153
  catch (err) {
161
154
  this.logger.error(err);
162
- const statusEvent = {
163
- eventId: (0, crypto_1.randomUUID)(),
164
- eventTime: new Date().toISOString(),
165
- eventType: 'com.hahnpro.event.health',
166
- contentType: 'application/json',
167
- data: { deploymentId: this.context.deploymentId, status: 'updating failed' },
155
+ const natsEvent = {
156
+ source: `hpc/flow-application`,
157
+ type: `${nats_1.natsFlowsPrefixFlowDeployment}.health`,
158
+ subject: `${this.context.deploymentId}`,
159
+ data: {
160
+ deploymentId: this.context.deploymentId,
161
+ status: 'updating failed',
162
+ },
168
163
  };
169
- try {
170
- this.amqpChannel?.publish('deployment', 'health', statusEvent);
171
- }
172
- catch (e) {
173
- this.logger.error(e);
174
- }
164
+ await (0, nats_1.publishNatsEvent)(this.logger, this.natsConnection, natsEvent);
175
165
  }
176
166
  }
177
- else if (event.type === 'com.flowstudio.deployment.message') {
178
- const data = event.data;
167
+ else if (cloudEvent.subject.endsWith('.message')) {
168
+ const data = cloudEvent.data;
179
169
  const elementId = data?.elementId;
180
170
  if (elementId) {
181
171
  this.elements?.[elementId]?.onMessage?.(data);
@@ -186,15 +176,12 @@ class FlowApplication {
186
176
  }
187
177
  }
188
178
  }
189
- else if (event.type === 'com.flowstudio.deployment.destroy') {
179
+ else if (cloudEvent.subject.endsWith('.destroy')) {
190
180
  this.destroy();
191
181
  }
192
- else {
193
- this.amqpChannel.nack(msg, false, false);
194
- }
195
182
  };
196
- this.publishEvent = (event) => {
197
- if (!this.amqpChannel) {
183
+ this.publishNatsEventFlowlogs = async (event) => {
184
+ if (!this.natsConnection || this.natsConnection.isClosed()) {
198
185
  return;
199
186
  }
200
187
  try {
@@ -202,7 +189,14 @@ class FlowApplication {
202
189
  if ((0, object_sizeof_1.default)(message) > MAX_EVENT_SIZE_BYTES) {
203
190
  message.data = (0, utils_1.truncate)(message.data);
204
191
  }
205
- return this.amqpChannel.publish('flowlogs', '', message);
192
+ const natsEvent = {
193
+ source: `hpc/flow-application`,
194
+ type: `${nats_1.natsFlowsPrefixFlowDeployment}.flowlogs`,
195
+ subject: `${this.context.deploymentId}`,
196
+ data: message,
197
+ };
198
+ await (0, nats_1.publishNatsEvent)(this.logger, this.natsConnection, natsEvent);
199
+ return true;
206
200
  }
207
201
  catch (err) {
208
202
  this.logger.error(err);
@@ -227,7 +221,7 @@ class FlowApplication {
227
221
  explicitInit = explicitInit || false;
228
222
  this._api = mockApi || null;
229
223
  }
230
- this.logger = new FlowLogger_1.FlowLogger({ id: 'none', functionFqn: 'FlowApplication', ...flow?.context }, this.baseLogger || undefined, this.publishEvent);
224
+ this.logger = new FlowLogger_1.FlowLogger({ id: 'none', functionFqn: 'FlowApplication', ...flow?.context }, this.baseLogger || undefined, this.publishNatsEventFlowlogs);
231
225
  this.contextManager = new ContextManager_1.ContextManager(this.logger, this.flow?.properties);
232
226
  process.once('uncaughtException', (err) => {
233
227
  this.logger.error('Uncaught exception!');
@@ -264,6 +258,33 @@ class FlowApplication {
264
258
  getProperties() {
265
259
  return this.contextManager.getProperties();
266
260
  }
261
+ async consumeNatsMessagesOfConsumer(consumer, consumerOptions) {
262
+ if (this.natsMessageIterator) {
263
+ await this.natsMessageIterator.close();
264
+ }
265
+ this.natsMessageIterator = await consumer.consume(consumerOptions);
266
+ for await (const msg of this.natsMessageIterator) {
267
+ try {
268
+ let event;
269
+ try {
270
+ event = new cloudevents_1.CloudEvent(msg.json());
271
+ event.validate();
272
+ }
273
+ catch (error) {
274
+ this.logger.error('Message is not a valid CloudEvent and will be discarded');
275
+ msg.ack();
276
+ continue;
277
+ }
278
+ await this.onMessage(event);
279
+ msg.ack();
280
+ }
281
+ catch (error) {
282
+ this.logger.error('Error processing message');
283
+ this.logger.error(error);
284
+ msg.nak(1000);
285
+ }
286
+ }
287
+ }
267
288
  async init() {
268
289
  if (this.initialized)
269
290
  return;
@@ -294,25 +315,29 @@ class FlowApplication {
294
315
  await logErrorAndExit(`Could not connect to the NATS-Servers: ${err}`);
295
316
  }
296
317
  }
318
+ if (this._natsConnection && !this._natsConnection.isClosed() && this.context?.deploymentId !== undefined) {
319
+ try {
320
+ const consumerOptions = {
321
+ ...nats_1.defaultConsumerConfig,
322
+ name: `flow-deployment-${this.context.deploymentId}`,
323
+ filter_subject: `${nats_1.natsFlowsPrefixFlowDeployment}.${this.context.deploymentId}.*`,
324
+ };
325
+ const consumer = await (0, nats_1.getOrCreateConsumer)(this.logger, this._natsConnection, nats_1.FLOWS_STREAM_NAME, consumerOptions.name, consumerOptions);
326
+ this.consumeNatsMessagesOfConsumer(consumer, consumerOptions);
327
+ }
328
+ catch (e) {
329
+ await logErrorAndExit(`Could not set up consumer for deployment messages exchanges: ${e}`);
330
+ }
331
+ }
297
332
  this.amqpChannel = this.amqpConnection?.createChannel({
298
333
  json: true,
299
334
  setup: async (channel) => {
300
335
  try {
301
- await channel.assertExchange('deployment', 'direct', { durable: true });
302
- await channel.assertExchange('flowlogs', 'fanout', { durable: true });
303
336
  await channel.assertExchange('flow', 'direct', { durable: true });
304
337
  }
305
338
  catch (e) {
306
339
  await logErrorAndExit(`Could not assert exchanges: ${e}`);
307
340
  }
308
- try {
309
- const queue = await channel.assertQueue(null, { durable: false, exclusive: true });
310
- await channel.bindQueue(queue.queue, 'deployment', this.context.deploymentId);
311
- await channel.consume(queue.queue, (msg) => this.onMessage(msg));
312
- }
313
- catch (err) {
314
- await logErrorAndExit(`Could not subscribe to deployment exchange: ${err}`);
315
- }
316
341
  },
317
342
  });
318
343
  if (this.amqpChannel) {
@@ -421,6 +446,11 @@ class FlowApplication {
421
446
  if (this.amqpConnection) {
422
447
  await this.amqpConnection.close();
423
448
  }
449
+ if (this.natsConnection && !this._natsConnection.isClosed()) {
450
+ await this._natsConnection?.drain();
451
+ }
452
+ await this.natsMessageIterator?.close();
453
+ await this._natsConnection?.close();
424
454
  }
425
455
  catch (err) {
426
456
  console.error(err);
@@ -34,7 +34,7 @@ class FlowElement {
34
34
  this.app = app;
35
35
  this.api = this.app?.api;
36
36
  this.metadata = { ...metadata, functionFqn: this.functionFqn };
37
- this.logger = new FlowLogger_1.FlowLogger(this.metadata, logger || undefined, this.app?.publishEvent);
37
+ this.logger = new FlowLogger_1.FlowLogger(this.metadata, logger || undefined, this.app?.publishNatsEventFlowlogs);
38
38
  this.rpcRoutingKey = (this.metadata.flowId || '') + (this.metadata.deploymentId || '') + this.metadata.id;
39
39
  if (properties) {
40
40
  this.setProperties(properties);
package/dist/nats.d.ts CHANGED
@@ -1,2 +1,11 @@
1
1
  import { ConnectionOptions, NatsConnection } from '@nats-io/nats-core';
2
+ import { CloudEvent } from 'cloudevents';
3
+ import { Consumer, ConsumerConfig, PubAck } from '@nats-io/jetstream';
4
+ import { Logger } from './FlowLogger';
5
+ export type NatsEvent<T> = Pick<CloudEvent<T>, 'type' | 'source' | 'subject' | 'data' | 'datacontenttype' | 'time'>;
6
+ export declare const natsFlowsPrefixFlowDeployment = "com.hahnpro.flows.flowdeployment";
7
+ export declare const defaultConsumerConfig: ConsumerConfig;
8
+ export declare const FLOWS_STREAM_NAME = "flows";
9
+ export declare function getOrCreateConsumer(logger: Logger, natsConnection: NatsConnection, streamName: string, consumerName: string, options: Partial<ConsumerConfig>): Promise<Consumer>;
10
+ export declare function publishNatsEvent<T>(logger: Logger, nc: NatsConnection, event: NatsEvent<T>, subject?: string): Promise<PubAck>;
2
11
  export declare function createNatsConnection(config: ConnectionOptions): Promise<NatsConnection>;
package/dist/nats.js CHANGED
@@ -1,7 +1,72 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FLOWS_STREAM_NAME = exports.defaultConsumerConfig = exports.natsFlowsPrefixFlowDeployment = void 0;
4
+ exports.getOrCreateConsumer = getOrCreateConsumer;
5
+ exports.publishNatsEvent = publishNatsEvent;
3
6
  exports.createNatsConnection = createNatsConnection;
4
7
  const transport_node_1 = require("@nats-io/transport-node");
8
+ const cloudevents_1 = require("cloudevents");
9
+ const jetstream_1 = require("@nats-io/jetstream");
10
+ const lodash_1 = require("lodash");
11
+ exports.natsFlowsPrefixFlowDeployment = `com.hahnpro.flows.flowdeployment`;
12
+ exports.defaultConsumerConfig = {
13
+ ack_policy: jetstream_1.AckPolicy.Explicit,
14
+ ack_wait: 30_000_000_000,
15
+ deliver_policy: jetstream_1.DeliverPolicy.All,
16
+ max_ack_pending: 1000,
17
+ max_deliver: -1,
18
+ max_waiting: 512,
19
+ replay_policy: jetstream_1.ReplayPolicy.Instant,
20
+ num_replicas: 0,
21
+ };
22
+ exports.FLOWS_STREAM_NAME = 'flows';
23
+ async function getOrCreateConsumer(logger, natsConnection, streamName, consumerName, options) {
24
+ if (!natsConnection || natsConnection.isClosed()) {
25
+ throw new Error('NATS connection is not available');
26
+ }
27
+ else if (!streamName) {
28
+ throw new Error('Stream name is not available');
29
+ }
30
+ else if (!consumerName) {
31
+ throw new Error('Consumer name is not available');
32
+ }
33
+ logger.debug(`Creating consumer ${consumerName} for stream ${streamName}`);
34
+ const jsm = await (0, jetstream_1.jetstreamManager)(natsConnection);
35
+ const consumerInfo = await jsm.consumers.info(streamName, consumerName).catch((err) => {
36
+ if (err.status !== 404) {
37
+ logger.error(`Could not get consumer info of stream ${streamName}`, err);
38
+ logger.error(err.message);
39
+ throw err;
40
+ }
41
+ });
42
+ const consumerConfig = { ...exports.defaultConsumerConfig, ...options };
43
+ if (consumerInfo) {
44
+ const compared = (0, lodash_1.omitBy)(consumerConfig, (value, key) => (0, lodash_1.isEqual)(value, consumerInfo.config[key]));
45
+ if (Object.keys(compared).length !== 0) {
46
+ await jsm.consumers.update(streamName, consumerName, consumerConfig);
47
+ }
48
+ }
49
+ else {
50
+ await jsm.consumers.add(streamName, { name: consumerName, ...consumerConfig });
51
+ }
52
+ return await (0, jetstream_1.jetstream)(natsConnection).consumers.get(streamName, consumerName);
53
+ }
54
+ async function publishNatsEvent(logger, nc, event, subject) {
55
+ if (!nc || nc.isClosed()) {
56
+ return;
57
+ }
58
+ const cloudEvent = new cloudevents_1.CloudEvent({ datacontenttype: 'application/json', ...event });
59
+ cloudEvent.validate();
60
+ const js = (0, jetstream_1.jetstream)(nc);
61
+ if (js) {
62
+ return js.publish(subject || `${cloudEvent.type}.${cloudEvent.subject}`, JSON.stringify(cloudEvent.toJSON()), {
63
+ msgID: cloudEvent.id,
64
+ });
65
+ }
66
+ else {
67
+ logger.error(`Could not publish nats event, because jetstream is unavailable / undefined`);
68
+ }
69
+ }
5
70
  async function createNatsConnection(config) {
6
71
  const servers = config?.servers ?? process.env.NATS_SERVERS?.split(',') ?? [];
7
72
  const reconnect = config?.reconnect ?? (process.env.NATS_RECONNECT ?? 'true') === 'true';
package/dist/utils.js CHANGED
@@ -9,6 +9,7 @@ exports.handleApiError = handleApiError;
9
9
  exports.runPyScript = runPyScript;
10
10
  exports.truncate = truncate;
11
11
  const tslib_1 = require("tslib");
12
+ const axios_1 = require("axios");
12
13
  const fs_1 = require("fs");
13
14
  const isPlainObject_1 = tslib_1.__importDefault(require("lodash/isPlainObject"));
14
15
  const path_1 = require("path");
@@ -69,16 +70,23 @@ async function deleteFiles(dir, ...filenames) {
69
70
  }
70
71
  }
71
72
  function handleApiError(error, logger) {
72
- if (error.isAxiosError) {
73
- if (error.response && error.response.data) {
74
- logger.error(error.response.data);
75
- }
76
- else {
77
- logger.error(`Error ${error.code}`);
78
- logger.error(error.config);
79
- if (error.stack) {
80
- logger.error(error.stack);
73
+ if ((0, axios_1.isAxiosError)(error)) {
74
+ const status = error.response?.status ?? error.code ?? '';
75
+ const statusText = error.response?.statusText ?? '';
76
+ const url = error.config?.url ?? '[Unknown URL]';
77
+ const method = error.config?.method?.toUpperCase() ?? '';
78
+ let errorText = error.response?.data ?? '';
79
+ if (typeof errorText !== 'string') {
80
+ try {
81
+ errorText = JSON.stringify(errorText, null, 2);
81
82
  }
83
+ catch (_) {
84
+ errorText = '[Unserializable error body]';
85
+ }
86
+ }
87
+ logger.error(`${status} ${statusText}: ${method} request to ${url} failed\n${errorText}`);
88
+ if (error.stack) {
89
+ logger.error(error.stack);
82
90
  }
83
91
  }
84
92
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hahnpro/flow-sdk",
3
- "version": "8.0.12",
3
+ "version": "9.0.0",
4
4
  "description": "SDK for building Flow Modules",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -24,13 +24,14 @@
24
24
  "access": "public"
25
25
  },
26
26
  "dependencies": {
27
- "@hahnpro/hpc-api": "2025.2.12",
27
+ "@hahnpro/hpc-api": "2025.2.13",
28
+ "@nats-io/jetstream": "3.0.2",
28
29
  "@nats-io/nats-core": "3.0.2",
29
30
  "@nats-io/transport-node": "3.0.2",
30
31
  "amqp-connection-manager": "4.1.14",
31
- "amqplib": "0.10.7",
32
+ "amqplib": "0.10.8",
32
33
  "class-transformer": "0.5.1",
33
- "class-validator": "~0.14.1",
34
+ "class-validator": "~0.14.2",
34
35
  "cloudevents": "9.0.0",
35
36
  "lodash": "4.17.21",
36
37
  "object-sizeof": "~2.6.5",
@@ -42,16 +43,16 @@
42
43
  "devDependencies": {
43
44
  "@types/amqplib": "0.10.7",
44
45
  "@types/jest": "29.5.14",
45
- "@types/lodash": "4.17.16",
46
- "@types/node": "22.14.1",
46
+ "@types/lodash": "4.17.17",
47
+ "@types/node": "22.15.21",
47
48
  "class-validator-jsonschema": "5.0.2",
48
49
  "jest": "29.7.0",
49
50
  "typescript": "5.8.3"
50
51
  },
51
52
  "peerDependencies": {
52
- "axios": "1.8.4",
53
+ "axios": "1.9.0",
53
54
  "class-transformer": "0.5.1",
54
- "class-validator": "0.14.1",
55
+ "class-validator": "0.14.2",
55
56
  "lodash": "4.17.21",
56
57
  "python-shell": "5.x"
57
58
  },