canvas 0.35.0__py3-none-any.whl → 0.35.1__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.
Potentially problematic release.
This version of canvas might be problematic. Click here for more details.
- {canvas-0.35.0.dist-info → canvas-0.35.1.dist-info}/METADATA +2 -2
- {canvas-0.35.0.dist-info → canvas-0.35.1.dist-info}/RECORD +7 -7
- plugin_runner/plugin_runner.py +57 -89
- pubsub/pubsub.py +10 -1
- settings.py +4 -0
- {canvas-0.35.0.dist-info → canvas-0.35.1.dist-info}/WHEEL +0 -0
- {canvas-0.35.0.dist-info → canvas-0.35.1.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: canvas
|
|
3
|
-
Version: 0.35.
|
|
3
|
+
Version: 0.35.1
|
|
4
4
|
Summary: SDK to customize event-driven actions in your Canvas instance
|
|
5
5
|
Author-email: Canvas Team <engineering@canvasmedical.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,7 +18,7 @@ Requires-Dist: ipython<9,>=8.21.0
|
|
|
18
18
|
Requires-Dist: jsonschema<5,>=4.21.1
|
|
19
19
|
Requires-Dist: keyring
|
|
20
20
|
Requires-Dist: protobuf<5,>=4.25.3
|
|
21
|
-
Requires-Dist: psycopg[binary]<4,>=3.2.2
|
|
21
|
+
Requires-Dist: psycopg[binary,pool]<4,>=3.2.2
|
|
22
22
|
Requires-Dist: pydantic<3,>=2.6.1
|
|
23
23
|
Requires-Dist: pyjwt==2.10.1
|
|
24
24
|
Requires-Dist: python-dotenv<2,>=1.0.1
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
settings.py,sha256=
|
|
1
|
+
settings.py,sha256=EuxOQSYcYw6pLJs9pFMFJbwsDCCB2uWaEdYTOilbmX4,3946
|
|
2
2
|
canvas_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
3
|
canvas_cli/main.py,sha256=L6JQkt1yxy30cA3-M9v7JD8WMW4i0M5GPr9kZetAito,2728
|
|
4
4
|
canvas_cli/apps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -266,15 +266,15 @@ plugin_runner/aws_headers.py,sha256=wZ8584E1fTW0CdGxOCnLSF8alH27z-URcUyoc6y6ohg,
|
|
|
266
266
|
plugin_runner/exceptions.py,sha256=YnRZiQVzbU3HrVlmEXLje_np99009YnhTRVHHyBCtqc,433
|
|
267
267
|
plugin_runner/installation.py,sha256=LLjtnzPk-w4go3UbXnBItJTKz1ajR_5kGQbFXTaWTFU,7693
|
|
268
268
|
plugin_runner/load_all_plugins.py,sha256=4T2gW2YljhIx4xfwf1c0F_8oIbE1ubsLj0ShkHRtlVY,5847
|
|
269
|
-
plugin_runner/plugin_runner.py,sha256=
|
|
269
|
+
plugin_runner/plugin_runner.py,sha256=bW9SdYG8sCjRhJTUHlPiCROz4HsWkA3FQgZjRkiOVJs,20930
|
|
270
270
|
plugin_runner/sandbox.py,sha256=JJYbAY7-iwNQoOluG-kSX_1b-FhOc547WKUp4XOF4qw,28244
|
|
271
271
|
protobufs/canvas_generated/messages/effects.proto,sha256=8mdrujbJWj_V6liOYgGYtpMXzC2qARwKRDQYicVs4eI,8258
|
|
272
272
|
protobufs/canvas_generated/messages/events.proto,sha256=NEoXEnU1VqQwPSwyo0I0xUAvsiI-1qQViCqp9hvduQ8,48503
|
|
273
273
|
protobufs/canvas_generated/messages/plugins.proto,sha256=oNainUPWFYQjgCX7bJEPI9_VnHC5VZduzOqgR4Q7dNM,109
|
|
274
274
|
protobufs/canvas_generated/services/plugin_runner.proto,sha256=doadBKn5k4xAtOgR-q_pEvW4yzxpUaHNOowMG6CL5GY,304
|
|
275
275
|
pubsub/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
276
|
-
pubsub/pubsub.py,sha256=
|
|
277
|
-
canvas-0.35.
|
|
278
|
-
canvas-0.35.
|
|
279
|
-
canvas-0.35.
|
|
280
|
-
canvas-0.35.
|
|
276
|
+
pubsub/pubsub.py,sha256=PHIvJ5SD3M-jQSYeGGSj1FuG6CvP6BQffAoGax9Uudk,1423
|
|
277
|
+
canvas-0.35.1.dist-info/METADATA,sha256=fW7BvPsdhFrcPBVesE5tNFXekRDGe_mvSF1dNCMSayo,4447
|
|
278
|
+
canvas-0.35.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
279
|
+
canvas-0.35.1.dist-info/entry_points.txt,sha256=0Vs_9GmTVUNniH6eDBlRPgofmADMV4BES6Ao26M4AbM,47
|
|
280
|
+
canvas-0.35.1.dist-info/RECORD,,
|
plugin_runner/plugin_runner.py
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
|
-
import asyncio
|
|
2
1
|
import json
|
|
3
2
|
import os
|
|
4
3
|
import pathlib
|
|
5
4
|
import pickle
|
|
6
5
|
import pkgutil
|
|
7
6
|
import sys
|
|
7
|
+
import threading
|
|
8
8
|
import traceback
|
|
9
9
|
from collections import defaultdict
|
|
10
|
-
from collections.abc import
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
12
|
from http import HTTPStatus
|
|
13
|
+
from time import sleep
|
|
12
14
|
from typing import Any, TypedDict
|
|
13
15
|
|
|
14
16
|
import grpc
|
|
15
|
-
import redis
|
|
17
|
+
import redis
|
|
16
18
|
import sentry_sdk
|
|
17
|
-
from
|
|
18
|
-
from
|
|
19
|
+
from django.core.signals import request_finished, request_started
|
|
20
|
+
from redis.backoff import ExponentialBackoff
|
|
21
|
+
from redis.exceptions import ConnectionError, TimeoutError
|
|
22
|
+
from redis.retry import Retry
|
|
19
23
|
from sentry_sdk.integrations.logging import ignore_logger
|
|
20
24
|
|
|
25
|
+
import settings
|
|
21
26
|
from canvas_generated.messages.effects_pb2 import EffectType
|
|
22
27
|
from canvas_generated.messages.plugins_pb2 import ReloadPluginsRequest, ReloadPluginsResponse
|
|
23
28
|
from canvas_generated.services.plugin_runner_pb2_grpc import (
|
|
@@ -29,7 +34,7 @@ from canvas_sdk.effects.simple_api import Response
|
|
|
29
34
|
from canvas_sdk.events import Event, EventRequest, EventResponse, EventType
|
|
30
35
|
from canvas_sdk.protocols import ClinicalQualityMeasure
|
|
31
36
|
from canvas_sdk.utils import metrics
|
|
32
|
-
from canvas_sdk.utils.metrics import measured
|
|
37
|
+
from canvas_sdk.utils.metrics import measured
|
|
33
38
|
from logger import log
|
|
34
39
|
from plugin_runner.authentication import token_for_plugin
|
|
35
40
|
from plugin_runner.installation import install_plugins
|
|
@@ -39,7 +44,6 @@ from settings import (
|
|
|
39
44
|
CUSTOMER_IDENTIFIER,
|
|
40
45
|
ENV,
|
|
41
46
|
IS_PRODUCTION_CUSTOMER,
|
|
42
|
-
IS_TESTING,
|
|
43
47
|
MANIFEST_FILE_NAME,
|
|
44
48
|
PLUGIN_DIRECTORY,
|
|
45
49
|
REDIS_ENDPOINT,
|
|
@@ -149,33 +153,12 @@ class PluginManifest(TypedDict):
|
|
|
149
153
|
readme: str
|
|
150
154
|
|
|
151
155
|
|
|
152
|
-
@sync_to_async
|
|
153
|
-
def reconnect_if_needed() -> None:
|
|
154
|
-
"""
|
|
155
|
-
Reconnect to the database if the connection has been closed.
|
|
156
|
-
|
|
157
|
-
NOTE: Django database functions are not async-safe and must be called
|
|
158
|
-
via e.g. sync_to_async. They will silently fail and block the handler
|
|
159
|
-
if called in an async context directly.
|
|
160
|
-
"""
|
|
161
|
-
if IS_TESTING:
|
|
162
|
-
return
|
|
163
|
-
|
|
164
|
-
connection = connections["default"]
|
|
165
|
-
|
|
166
|
-
if not connection.is_usable():
|
|
167
|
-
log.debug("Connection was unusable, reconnecting...")
|
|
168
|
-
connection.connect()
|
|
169
|
-
|
|
170
|
-
|
|
171
156
|
class PluginRunner(PluginRunnerServicer):
|
|
172
157
|
"""This process runs provided plugins that register interest in incoming events."""
|
|
173
158
|
|
|
174
159
|
sandbox: Sandbox
|
|
175
160
|
|
|
176
|
-
|
|
177
|
-
self, request: EventRequest, context: Any
|
|
178
|
-
) -> AsyncGenerator[EventResponse, None]:
|
|
161
|
+
def HandleEvent(self, request: EventRequest, context: Any) -> Iterable[EventResponse]:
|
|
179
162
|
"""This is invoked when an event comes in."""
|
|
180
163
|
event = Event(request)
|
|
181
164
|
with metrics.measure(
|
|
@@ -190,7 +173,8 @@ class PluginRunner(PluginRunnerServicer):
|
|
|
190
173
|
sentry_sdk.set_tag("event-name", event_name)
|
|
191
174
|
|
|
192
175
|
if relevant_plugins:
|
|
193
|
-
|
|
176
|
+
# Send the Django request_started signal
|
|
177
|
+
request_started.send(sender=self.__class__)
|
|
194
178
|
|
|
195
179
|
if event_type in [EventType.PLUGIN_CREATED, EventType.PLUGIN_UPDATED]:
|
|
196
180
|
plugin_name = event.target.id
|
|
@@ -238,7 +222,7 @@ class PluginRunner(PluginRunnerServicer):
|
|
|
238
222
|
"event": event_name,
|
|
239
223
|
},
|
|
240
224
|
):
|
|
241
|
-
_effects =
|
|
225
|
+
_effects = handler.compute()
|
|
242
226
|
effects = [
|
|
243
227
|
Effect(
|
|
244
228
|
type=effect.type,
|
|
@@ -286,24 +270,30 @@ class PluginRunner(PluginRunnerServicer):
|
|
|
286
270
|
|
|
287
271
|
# Don't log anything if a plugin handler didn't actually run.
|
|
288
272
|
if relevant_plugins:
|
|
273
|
+
# Send the Django request_finished signal
|
|
274
|
+
request_finished.send(sender=self.__class__)
|
|
275
|
+
|
|
289
276
|
log.info(f"Responded to Event {event_name}.")
|
|
290
277
|
|
|
291
278
|
yield EventResponse(success=True, effects=effect_list)
|
|
292
279
|
|
|
293
|
-
|
|
280
|
+
def ReloadPlugins(
|
|
294
281
|
self, request: ReloadPluginsRequest, context: Any
|
|
295
|
-
) ->
|
|
282
|
+
) -> Iterable[ReloadPluginsResponse]:
|
|
296
283
|
"""This is invoked when we need to reload plugins."""
|
|
297
284
|
log.info("Reloading plugins...")
|
|
298
285
|
try:
|
|
299
|
-
|
|
286
|
+
publish_message(message={"action": "reload"})
|
|
300
287
|
except ImportError:
|
|
301
288
|
yield ReloadPluginsResponse(success=False)
|
|
302
289
|
else:
|
|
303
290
|
yield ReloadPluginsResponse(success=True)
|
|
304
291
|
|
|
305
292
|
|
|
306
|
-
|
|
293
|
+
STOP_SYNCHRONIZER = threading.Event()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def synchronize_plugins(run_once: bool = False) -> None:
|
|
307
297
|
"""
|
|
308
298
|
Listen for messages on the pubsub channel that will indicate it is
|
|
309
299
|
necessary to reinstall and reload plugins.
|
|
@@ -312,16 +302,10 @@ async def synchronize_plugins(run_once: bool = False) -> None:
|
|
|
312
302
|
|
|
313
303
|
_, pubsub = get_client()
|
|
314
304
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
while True:
|
|
318
|
-
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=5.0)
|
|
319
|
-
|
|
320
|
-
await pubsub.check_health()
|
|
305
|
+
pubsub.psubscribe(CHANNEL_NAME)
|
|
321
306
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
await pubsub.connection.connect() # type: ignore
|
|
307
|
+
while not STOP_SYNCHRONIZER.is_set():
|
|
308
|
+
message = pubsub.get_message(ignore_subscribe_messages=True, timeout=5.0)
|
|
325
309
|
|
|
326
310
|
if message is None:
|
|
327
311
|
continue
|
|
@@ -357,21 +341,21 @@ async def synchronize_plugins(run_once: bool = False) -> None:
|
|
|
357
341
|
break
|
|
358
342
|
|
|
359
343
|
|
|
360
|
-
|
|
344
|
+
def synchronize_plugins_and_report_errors() -> None:
|
|
361
345
|
"""
|
|
362
346
|
Run synchronize_plugins() in perpetuity and report any encountered errors.
|
|
363
347
|
"""
|
|
364
348
|
log.info("synchronize_plugins: starting loop...")
|
|
365
349
|
|
|
366
|
-
while
|
|
350
|
+
while not STOP_SYNCHRONIZER.is_set():
|
|
367
351
|
try:
|
|
368
|
-
|
|
352
|
+
synchronize_plugins()
|
|
369
353
|
except Exception as e:
|
|
370
354
|
log.error(f"synchronize_plugins: error: {e}")
|
|
371
355
|
sentry_sdk.capture_exception(e)
|
|
372
356
|
|
|
373
357
|
# don't crush redis if we're retrying in a tight loop
|
|
374
|
-
|
|
358
|
+
sleep(0.5)
|
|
375
359
|
|
|
376
360
|
|
|
377
361
|
def validate_effects(effects: list[Effect]) -> list[Effect]:
|
|
@@ -434,17 +418,22 @@ def find_modules(base_path: pathlib.Path, prefix: str | None = None) -> list[str
|
|
|
434
418
|
return modules
|
|
435
419
|
|
|
436
420
|
|
|
437
|
-
|
|
421
|
+
def publish_message(message: dict) -> None:
|
|
438
422
|
"""Publish a message to the pubsub channel."""
|
|
439
423
|
log.info(f'Publishing message to pubsub channel "{CHANNEL_NAME}"')
|
|
440
424
|
client, _ = get_client()
|
|
441
425
|
|
|
442
|
-
|
|
426
|
+
client.publish(CHANNEL_NAME, pickle.dumps(message))
|
|
443
427
|
|
|
444
428
|
|
|
445
429
|
def get_client() -> tuple[redis.Redis, redis.client.PubSub]:
|
|
446
|
-
"""Return
|
|
447
|
-
client = redis.
|
|
430
|
+
"""Return a Redis client and pubsub object."""
|
|
431
|
+
client = redis.from_url(
|
|
432
|
+
REDIS_ENDPOINT,
|
|
433
|
+
retry=Retry(backoff=ExponentialBackoff(), retries=10),
|
|
434
|
+
retry_on_error=[ConnectionError, TimeoutError, ConnectionResetError],
|
|
435
|
+
health_check_interval=1,
|
|
436
|
+
)
|
|
448
437
|
pubsub = client.pubsub()
|
|
449
438
|
|
|
450
439
|
return client, pubsub
|
|
@@ -594,62 +583,41 @@ def load_plugins(specified_plugin_paths: list[str] | None = None) -> None:
|
|
|
594
583
|
|
|
595
584
|
refresh_event_type_map()
|
|
596
585
|
|
|
597
|
-
for key in EventType.keys(): # noqa: SIM118
|
|
598
|
-
value = len(EVENT_HANDLER_MAP[key]) if key in EVENT_HANDLER_MAP else 0
|
|
599
|
-
statsd_client.gauge("plugins.event_handler_count", value, tags={"event": key})
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
_cleanup_coroutines = []
|
|
603
586
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
"""Run the server."""
|
|
587
|
+
# NOTE: specified_plugin_paths powers the `canvas run-plugins` command
|
|
588
|
+
def main(specified_plugin_paths: list[str] | None = None) -> None:
|
|
589
|
+
"""Run the server and the synchronize_plugins loop."""
|
|
607
590
|
port = "50051"
|
|
608
591
|
|
|
609
|
-
|
|
592
|
+
executor = ThreadPoolExecutor(max_workers=settings.PLUGIN_RUNNER_MAX_WORKERS)
|
|
593
|
+
server = grpc.server(thread_pool=executor)
|
|
610
594
|
server.add_insecure_port("127.0.0.1:" + port)
|
|
611
595
|
|
|
612
596
|
add_PluginRunnerServicer_to_server(PluginRunner(), server)
|
|
613
597
|
|
|
614
598
|
log.info(f"Starting server, listening on port {port}")
|
|
615
599
|
|
|
616
|
-
# Only install plugins if the plugin runner was not started
|
|
600
|
+
# Only install plugins and start the synchronizer thread if the plugin runner was not started
|
|
601
|
+
# from the CLI
|
|
602
|
+
synchronizer_thread = threading.Thread(target=synchronize_plugins_and_report_errors)
|
|
617
603
|
if specified_plugin_paths is None:
|
|
618
604
|
install_plugins()
|
|
605
|
+
STOP_SYNCHRONIZER.clear()
|
|
606
|
+
synchronizer_thread.start()
|
|
619
607
|
|
|
620
608
|
load_plugins(specified_plugin_paths)
|
|
621
609
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
async def server_graceful_shutdown() -> None:
|
|
625
|
-
log.info("Starting graceful shutdown...")
|
|
626
|
-
await server.stop(5)
|
|
627
|
-
|
|
628
|
-
_cleanup_coroutines.append(server_graceful_shutdown())
|
|
629
|
-
|
|
630
|
-
await server.wait_for_termination()
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
# NOTE: specified_plugin_paths powers the `canvas run-plugins` command
|
|
634
|
-
def main(specified_plugin_paths: list[str] | None = None) -> None:
|
|
635
|
-
"""Run the server and the synchronize_plugins loop."""
|
|
636
|
-
loop = asyncio.new_event_loop()
|
|
637
|
-
|
|
638
|
-
asyncio.set_event_loop(loop)
|
|
610
|
+
server.start()
|
|
639
611
|
|
|
640
612
|
try:
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
# Only start the synchronizer if the plugin runner was not started from the CLI
|
|
644
|
-
if specified_plugin_paths is None:
|
|
645
|
-
coroutines.append(synchronize_plugins_and_report_errors())
|
|
646
|
-
|
|
647
|
-
loop.run_until_complete(asyncio.gather(*coroutines))
|
|
613
|
+
server.wait_for_termination()
|
|
648
614
|
except KeyboardInterrupt:
|
|
649
615
|
pass
|
|
650
616
|
finally:
|
|
651
|
-
|
|
652
|
-
|
|
617
|
+
executor.shutdown(wait=True, cancel_futures=True)
|
|
618
|
+
if synchronizer_thread.is_alive():
|
|
619
|
+
STOP_SYNCHRONIZER.set()
|
|
620
|
+
synchronizer_thread.join()
|
|
653
621
|
|
|
654
622
|
|
|
655
623
|
if __name__ == "__main__":
|
pubsub/pubsub.py
CHANGED
|
@@ -2,6 +2,9 @@ import os
|
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
4
|
import redis
|
|
5
|
+
from redis.backoff import ExponentialBackoff
|
|
6
|
+
from redis.exceptions import ConnectionError, TimeoutError
|
|
7
|
+
from redis.retry import Retry
|
|
5
8
|
|
|
6
9
|
REDIS_ENDPOINT = os.getenv("REDIS_ENDPOINT")
|
|
7
10
|
CUSTOMER_IDENTIFIER = os.getenv("CUSTOMER_IDENTIFIER")
|
|
@@ -24,7 +27,13 @@ class PubSubBase:
|
|
|
24
27
|
|
|
25
28
|
def _create_client(self) -> redis.Redis | None:
|
|
26
29
|
if self.redis_endpoint and self.channel:
|
|
27
|
-
return redis.Redis.from_url(
|
|
30
|
+
return redis.Redis.from_url(
|
|
31
|
+
self.redis_endpoint,
|
|
32
|
+
decode_responses=True,
|
|
33
|
+
retry=Retry(backoff=ExponentialBackoff(), retries=10),
|
|
34
|
+
retry_on_error=[ConnectionError, TimeoutError, ConnectionResetError],
|
|
35
|
+
health_check_interval=1,
|
|
36
|
+
)
|
|
28
37
|
|
|
29
38
|
return None
|
|
30
39
|
|
settings.py
CHANGED
|
@@ -46,12 +46,15 @@ CANVAS_SDK_DB_PASSWORD = os.getenv("CANVAS_SDK_DB_PASSWORD", "app")
|
|
|
46
46
|
CANVAS_SDK_DB_HOST = os.getenv("CANVAS_SDK_DB_HOST", "home-app-db")
|
|
47
47
|
CANVAS_SDK_DB_PORT = os.getenv("CANVAS_SDK_DB_PORT", "5432")
|
|
48
48
|
|
|
49
|
+
PLUGIN_RUNNER_MAX_WORKERS = int(os.getenv("PLUGIN_RUNNER_MAX_WORKERS", 5))
|
|
50
|
+
|
|
49
51
|
if os.getenv("DATABASE_URL"):
|
|
50
52
|
parsed_url = parse.urlparse(os.getenv("DATABASE_URL"))
|
|
51
53
|
|
|
52
54
|
DATABASES = {
|
|
53
55
|
"default": {
|
|
54
56
|
"ENGINE": "django.db.backends.postgresql",
|
|
57
|
+
"OPTIONS": {"pool": {"min_size": 2, "max_size": PLUGIN_RUNNER_MAX_WORKERS}},
|
|
55
58
|
"NAME": parsed_url.path[1:],
|
|
56
59
|
"USER": os.getenv("CANVAS_SDK_DATABASE_ROLE"),
|
|
57
60
|
"PASSWORD": os.getenv("CANVAS_SDK_DATABASE_ROLE_PASSWORD"),
|
|
@@ -63,6 +66,7 @@ else:
|
|
|
63
66
|
DATABASES = {
|
|
64
67
|
"default": {
|
|
65
68
|
"ENGINE": "django.db.backends.postgresql",
|
|
69
|
+
"OPTIONS": {"pool": {"min_size": 2, "max_size": PLUGIN_RUNNER_MAX_WORKERS}},
|
|
66
70
|
"NAME": CANVAS_SDK_DB_NAME,
|
|
67
71
|
"USER": CANVAS_SDK_DB_USERNAME,
|
|
68
72
|
"PASSWORD": CANVAS_SDK_DB_PASSWORD,
|
|
File without changes
|
|
File without changes
|