awslabs.dynamodb-mcp-server 2.0.10__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 (27) hide show
  1. awslabs/__init__.py +17 -0
  2. awslabs/dynamodb_mcp_server/__init__.py +17 -0
  3. awslabs/dynamodb_mcp_server/cdk_generator/__init__.py +19 -0
  4. awslabs/dynamodb_mcp_server/cdk_generator/generator.py +276 -0
  5. awslabs/dynamodb_mcp_server/cdk_generator/models.py +521 -0
  6. awslabs/dynamodb_mcp_server/cdk_generator/templates/README.md +57 -0
  7. awslabs/dynamodb_mcp_server/cdk_generator/templates/stack.ts.j2 +70 -0
  8. awslabs/dynamodb_mcp_server/common.py +94 -0
  9. awslabs/dynamodb_mcp_server/db_analyzer/__init__.py +30 -0
  10. awslabs/dynamodb_mcp_server/db_analyzer/analyzer_utils.py +394 -0
  11. awslabs/dynamodb_mcp_server/db_analyzer/base_plugin.py +355 -0
  12. awslabs/dynamodb_mcp_server/db_analyzer/mysql.py +450 -0
  13. awslabs/dynamodb_mcp_server/db_analyzer/plugin_registry.py +73 -0
  14. awslabs/dynamodb_mcp_server/db_analyzer/postgresql.py +215 -0
  15. awslabs/dynamodb_mcp_server/db_analyzer/sqlserver.py +255 -0
  16. awslabs/dynamodb_mcp_server/markdown_formatter.py +513 -0
  17. awslabs/dynamodb_mcp_server/model_validation_utils.py +845 -0
  18. awslabs/dynamodb_mcp_server/prompts/dynamodb_architect.md +851 -0
  19. awslabs/dynamodb_mcp_server/prompts/json_generation_guide.md +185 -0
  20. awslabs/dynamodb_mcp_server/prompts/transform_model_validation_result.md +168 -0
  21. awslabs/dynamodb_mcp_server/server.py +524 -0
  22. awslabs_dynamodb_mcp_server-2.0.10.dist-info/METADATA +306 -0
  23. awslabs_dynamodb_mcp_server-2.0.10.dist-info/RECORD +27 -0
  24. awslabs_dynamodb_mcp_server-2.0.10.dist-info/WHEEL +4 -0
  25. awslabs_dynamodb_mcp_server-2.0.10.dist-info/entry_points.txt +2 -0
  26. awslabs_dynamodb_mcp_server-2.0.10.dist-info/licenses/LICENSE +175 -0
  27. awslabs_dynamodb_mcp_server-2.0.10.dist-info/licenses/NOTICE +2 -0
@@ -0,0 +1,845 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import boto3
16
+ import os
17
+ import psutil
18
+ import shutil
19
+ import socket
20
+ import subprocess
21
+ import sys
22
+ import tarfile
23
+ import tempfile
24
+ import time
25
+ import urllib.request
26
+ from botocore.exceptions import ClientError, EndpointConnectionError
27
+ from loguru import logger
28
+ from pathlib import Path
29
+ from typing import Any, Dict, Optional
30
+ from urllib.parse import urlparse
31
+
32
+
33
+ class DynamoDBLocalConfig:
34
+ """Configuration constants for DynamoDB Local setup."""
35
+
36
+ DEFAULT_PORT = 8000
37
+ CONTAINER_NAME = 'dynamodb-local-setup-for-data-model-validation'
38
+ DOCKER_IMAGE = 'amazon/dynamodb-local'
39
+ DOWNLOAD_URL = 'https://d1ni2b6xgvw0s0.cloudfront.net/v2.x/dynamodb_local_latest.tar.gz'
40
+ TAR_FILENAME = 'dynamodb_local_latest.tar.gz'
41
+ TEMP_DIR_NAME = 'dynamodb-local-model-validation'
42
+ MAX_ATTEMPTS = 7
43
+ SLEEP_INTERVAL = 5
44
+ JAVA_PROPERTY_NAME = CONTAINER_NAME.replace('-', '.')
45
+ DOWNLOAD_TIMEOUT = 30
46
+ BATCH_SIZE = 25
47
+
48
+
49
+ class ContainerTools:
50
+ """Supported container tools in order of preference."""
51
+
52
+ TOOLS = ['docker', 'finch', 'podman', 'nerdctl']
53
+
54
+
55
+ class DynamoDBClientConfig:
56
+ """Configuration for DynamoDB client setup."""
57
+
58
+ DUMMY_ACCESS_KEY = 'AKIAIOSFODNN7EXAMPLE' # pragma: allowlist secret
59
+ DUMMY_SECRET_KEY = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' # pragma: allowlist secret
60
+ DEFAULT_REGION = 'us-east-1'
61
+
62
+
63
+ def _create_dynamodb_client(endpoint_url: Optional[str] = None):
64
+ """Create a DynamoDB client with appropriate configuration.
65
+
66
+ Args:
67
+ endpoint_url: Optional endpoint URL for local DynamoDB
68
+
69
+ Returns:
70
+ boto3.client: Configured DynamoDB client
71
+ """
72
+ client_kwargs = {'endpoint_url': endpoint_url} if endpoint_url else {}
73
+ if endpoint_url:
74
+ client_kwargs.update(
75
+ {
76
+ 'aws_access_key_id': DynamoDBClientConfig.DUMMY_ACCESS_KEY, # pragma: allowlist secret
77
+ 'aws_secret_access_key': DynamoDBClientConfig.DUMMY_SECRET_KEY, # pragma: allowlist secret
78
+ 'region_name': os.environ.get('AWS_REGION', DynamoDBClientConfig.DEFAULT_REGION),
79
+ }
80
+ )
81
+
82
+ return boto3.client('dynamodb', **client_kwargs)
83
+
84
+
85
+ def _run_subprocess_safely(
86
+ cmd: list, timeout: int = 5, **kwargs
87
+ ) -> Optional[subprocess.CompletedProcess]:
88
+ """Run subprocess with consistent error handling.
89
+
90
+ Args:
91
+ cmd: Command to execute
92
+ timeout: Timeout in seconds
93
+ **kwargs: Additional subprocess arguments
94
+
95
+ Returns:
96
+ Optional[subprocess.CompletedProcess]: Result if successful, None if failed
97
+ """
98
+ # Safeguards against direct calls
99
+ if not cmd or not isinstance(cmd, list):
100
+ logger.warning('Invalid command format')
101
+ return None
102
+
103
+ # Restrict to only allowed commands used in this codebase
104
+ allowed_commands = {
105
+ 'docker',
106
+ 'finch',
107
+ 'podman',
108
+ 'nerdctl', # Container tools
109
+ 'java', # Java executable
110
+ }
111
+
112
+ # Extract base command name (handle both full paths and command names)
113
+ base_cmd = os.path.basename(cmd[0]) if cmd else ''
114
+
115
+ # Remove .exe extension for Windows compatibility
116
+ if base_cmd.endswith('.exe'):
117
+ base_cmd = base_cmd[:-4]
118
+
119
+ if base_cmd not in allowed_commands:
120
+ logger.warning(f'Command not allowed: {base_cmd}')
121
+ return None
122
+
123
+ try:
124
+ return subprocess.run(
125
+ cmd, check=True, timeout=timeout, capture_output=True, text=True, **kwargs
126
+ )
127
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
128
+ logger.debug(f'Subprocess failed: {cmd[0]} - {e}')
129
+ return None
130
+
131
+
132
+ def _parse_container_port(ports_output: str) -> Optional[str]:
133
+ """Parse port from container port mapping output.
134
+
135
+ Args:
136
+ ports_output: Container port mapping string (e.g., "0.0.0.0:8001->8000/tcp")
137
+
138
+ Returns:
139
+ Optional[str]: Host port if found, None otherwise
140
+ """
141
+ if '->' in ports_output:
142
+ return ports_output.split('->')[0].split(':')[-1]
143
+ return None
144
+
145
+
146
+ def _container_exists(container_path: str) -> bool:
147
+ """Check if container exists (running or stopped)."""
148
+ check_cmd = [
149
+ container_path,
150
+ 'ps',
151
+ '-a',
152
+ '-q',
153
+ '-f',
154
+ f'name={DynamoDBLocalConfig.CONTAINER_NAME}',
155
+ ]
156
+ result = _run_subprocess_safely(check_cmd)
157
+ return result is not None and result.stdout.strip()
158
+
159
+
160
+ def _container_is_running(container_path: str) -> bool:
161
+ """Check if container is currently running."""
162
+ running_cmd = [container_path, 'ps', '-q', '-f', f'name={DynamoDBLocalConfig.CONTAINER_NAME}']
163
+ result = _run_subprocess_safely(running_cmd)
164
+ return result is not None and result.stdout.strip()
165
+
166
+
167
+ def _restart_container(container_path: str) -> bool:
168
+ """Restart a stopped container."""
169
+ logger.info(f'Restarting stopped container: {DynamoDBLocalConfig.CONTAINER_NAME}')
170
+ restart_cmd = [container_path, 'start', DynamoDBLocalConfig.CONTAINER_NAME]
171
+ result = _run_subprocess_safely(restart_cmd)
172
+ return result is not None
173
+
174
+
175
+ def _get_container_port(container_path: str) -> Optional[str]:
176
+ """Get the host port for the container."""
177
+ ports_cmd = [
178
+ container_path,
179
+ 'ps',
180
+ '--format',
181
+ '{{.Ports}}',
182
+ '-f',
183
+ f'name={DynamoDBLocalConfig.CONTAINER_NAME}',
184
+ ]
185
+ result = _run_subprocess_safely(ports_cmd)
186
+
187
+ if result and result.stdout.strip():
188
+ return _parse_container_port(result.stdout.strip())
189
+ return None
190
+
191
+
192
+ def _extract_port_from_cmdline(cmdline: list) -> Optional[int]:
193
+ """Extract port number from Java command line arguments.
194
+
195
+ Args:
196
+ cmdline: List of command line arguments from process
197
+
198
+ Returns:
199
+ Optional[int]: Port number if found and valid, None otherwise
200
+ """
201
+ for i, arg in enumerate(cmdline):
202
+ if arg == '-port' and i + 1 < len(cmdline):
203
+ try:
204
+ return int(cmdline[i + 1])
205
+ except ValueError:
206
+ return None
207
+ return None
208
+
209
+
210
+ def _safe_extract_members(members):
211
+ """Filter tar members to prevent path traversal attacks.
212
+
213
+ Args:
214
+ members: Iterable of tar members
215
+
216
+ Yields:
217
+ Safe tar members that don't contain path traversal sequences
218
+ """
219
+ for member in members:
220
+ if os.path.isabs(member.name) or '..' in member.name:
221
+ continue
222
+ yield member
223
+
224
+
225
+ def _get_dynamodb_local_paths() -> tuple[str, str, str]:
226
+ """Get paths for DynamoDB Local artifacts."""
227
+ dynamodb_dir = os.path.join(tempfile.gettempdir(), DynamoDBLocalConfig.TEMP_DIR_NAME)
228
+ jar_path = os.path.join(dynamodb_dir, 'DynamoDBLocal.jar')
229
+ lib_path = os.path.join(dynamodb_dir, 'DynamoDBLocal_lib')
230
+ return dynamodb_dir, jar_path, lib_path
231
+
232
+
233
+ def _validate_download_url(url: str) -> None:
234
+ """Validate download URL to prevent security issues.
235
+
236
+ Args:
237
+ url: URL to validate
238
+
239
+ Raises:
240
+ ValueError: If URL is not safe to use
241
+ """
242
+ # Only allow the exact DynamoDB Local download URL
243
+ if url != DynamoDBLocalConfig.DOWNLOAD_URL:
244
+ raise ValueError(
245
+ f'Only DynamoDB Local download URL is allowed: {DynamoDBLocalConfig.DOWNLOAD_URL}'
246
+ )
247
+
248
+
249
+ def _download_and_extract_jar(dynamodb_dir: str, jar_path: str, lib_path: str) -> None:
250
+ """Download and extract DynamoDB Local JAR."""
251
+ tar_path = os.path.join(dynamodb_dir, DynamoDBLocalConfig.TAR_FILENAME)
252
+
253
+ logger.info('Downloading DynamoDB Local...')
254
+
255
+ try:
256
+ # Validate URL before download
257
+ _validate_download_url(DynamoDBLocalConfig.DOWNLOAD_URL)
258
+
259
+ # Download with timeout
260
+ with urllib.request.urlopen( # nosec B310
261
+ DynamoDBLocalConfig.DOWNLOAD_URL, timeout=DynamoDBLocalConfig.DOWNLOAD_TIMEOUT
262
+ ) as response:
263
+ # Validate content type
264
+ content_type = response.headers.get('content-type', '')
265
+ if content_type and not content_type.startswith(
266
+ (
267
+ 'application/gzip',
268
+ 'application/x-gzip',
269
+ 'application/octet-stream',
270
+ 'application/x-tar',
271
+ )
272
+ ):
273
+ raise ValueError(f'Unexpected content type: {content_type}')
274
+
275
+ with open(tar_path, 'wb') as f:
276
+ f.write(response.read())
277
+
278
+ # Validate tar contents before extraction
279
+ with tarfile.open(tar_path, 'r:gz') as tar:
280
+ if 'DynamoDBLocal.jar' not in tar.getnames():
281
+ raise RuntimeError('DynamoDBLocal.jar not found in archive')
282
+
283
+ if hasattr(tarfile, 'data_filter'):
284
+ tar.extractall(dynamodb_dir, members=_safe_extract_members(tar), filter='data') # nosec B202
285
+ else:
286
+ tar.extractall(dynamodb_dir, members=_safe_extract_members(tar)) # nosec B202
287
+
288
+ # Clean up tar file
289
+ os.remove(tar_path)
290
+
291
+ logger.info(f'Downloaded and extracted DynamoDB Local to {jar_path}')
292
+
293
+ except Exception as e:
294
+ if os.path.exists(dynamodb_dir):
295
+ shutil.rmtree(dynamodb_dir)
296
+ raise RuntimeError(f'Failed to download DynamoDB Local: {e}')
297
+
298
+
299
+ def _try_container_setup() -> Optional[str]:
300
+ """Try to setup DynamoDB Local using container tools."""
301
+ container_path = get_container_path()
302
+ if not container_path:
303
+ return None
304
+
305
+ try:
306
+ # Check if our container is already running
307
+ existing_endpoint = get_existing_container_dynamodb_local_endpoint(container_path)
308
+ if existing_endpoint:
309
+ return existing_endpoint
310
+
311
+ # Find available port and start container
312
+ port = find_available_port(DynamoDBLocalConfig.DEFAULT_PORT)
313
+ return start_container(container_path, port)
314
+
315
+ except RuntimeError as e:
316
+ logger.debug(f'Container setup failed: {e}')
317
+ return None
318
+
319
+
320
+ def _try_java_setup() -> Optional[str]:
321
+ """Try to setup DynamoDB Local using Java."""
322
+ java_path = get_java_path()
323
+ if not java_path:
324
+ return None
325
+
326
+ try:
327
+ # Check if our Java process is already running
328
+ existing_endpoint = get_existing_java_dynamodb_local_endpoint()
329
+ if existing_endpoint:
330
+ return existing_endpoint
331
+
332
+ # Find available port and start Java process
333
+ port = find_available_port(DynamoDBLocalConfig.DEFAULT_PORT)
334
+ return start_java_process(java_path, port)
335
+
336
+ except RuntimeError as e:
337
+ logger.debug(f'Java setup failed: {e}')
338
+ return None
339
+
340
+
341
+ def get_container_path() -> Optional[str]:
342
+ """Get Docker-compatible executable path with running daemon.
343
+
344
+ Searches for available container tools (Docker, Podman, Finch, nerdctl) and
345
+ returns the first one with a running daemon. Tests daemon connectivity by
346
+ running 'tool ps' command.
347
+
348
+ Returns:
349
+ Optional[str]: Path to working container tool executable, or None if no
350
+ working tool is found.
351
+ """
352
+ errors = []
353
+
354
+ for tool in ContainerTools.TOOLS:
355
+ path = shutil.which(tool)
356
+ if path:
357
+ result = _run_subprocess_safely([path, 'ps'])
358
+ if result:
359
+ return path
360
+ else:
361
+ errors.append(f'{tool}: daemon not running or not accessible')
362
+
363
+ if errors:
364
+ logger.debug(f'Container tool errors: {"; ".join(errors)}')
365
+ else:
366
+ logger.debug('No container tools found')
367
+
368
+ return None
369
+
370
+
371
+ def find_available_port(start_port: int = DynamoDBLocalConfig.DEFAULT_PORT) -> int:
372
+ """Find an available port for DynamoDB Local.
373
+
374
+ Args:
375
+ start_port: Preferred port number to try first
376
+
377
+ Returns:
378
+ int: Available port number that can be used for binding
379
+ """
380
+ # First try the requested port
381
+ try:
382
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
383
+ sock.bind(('localhost', start_port))
384
+ return start_port
385
+ except OSError:
386
+ # Requested port not available, let kernel assign one
387
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
388
+ sock.bind(('localhost', 0))
389
+ _, port = sock.getsockname()
390
+ return port
391
+
392
+
393
+ def get_existing_container_dynamodb_local_endpoint(container_path: str) -> Optional[str]:
394
+ """Check if DynamoDB Local container exists and return its endpoint.
395
+
396
+ Args:
397
+ container_path: Path to container tool executable
398
+
399
+ Returns:
400
+ Optional[str]: DynamoDB Local endpoint URL if container exists and is
401
+ accessible, None otherwise.
402
+ """
403
+ try:
404
+ if not _container_exists(container_path):
405
+ return None
406
+
407
+ # Ensure container is running
408
+ if not _container_is_running(container_path):
409
+ if not _restart_container(container_path):
410
+ return None
411
+
412
+ # Get port mapping
413
+ host_port = _get_container_port(container_path)
414
+ if host_port:
415
+ endpoint = f'http://localhost:{host_port}'
416
+ logger.info(f'DynamoDB Local container available at {endpoint}')
417
+ return endpoint
418
+
419
+ except Exception as e:
420
+ logger.debug(f'Error checking for existing container: {e}')
421
+
422
+ return None
423
+
424
+
425
+ def start_container(container_path: str, port: int) -> str:
426
+ """Start DynamoDB Local container and verify readiness.
427
+
428
+ Args:
429
+ container_path: Path to container tool executable
430
+ port: Host port to map to container's DynamoDB port
431
+
432
+ Returns:
433
+ str: DynamoDB Local endpoint URL (http://localhost:port)
434
+
435
+ Raises:
436
+ RuntimeError: If container fails to start or become ready within timeout
437
+ """
438
+ cmd = [
439
+ container_path,
440
+ 'run',
441
+ '-d',
442
+ '--name',
443
+ DynamoDBLocalConfig.CONTAINER_NAME,
444
+ '-p',
445
+ f'127.0.0.1:{port}:{DynamoDBLocalConfig.DEFAULT_PORT}',
446
+ DynamoDBLocalConfig.DOCKER_IMAGE,
447
+ ]
448
+
449
+ logger.info(f'Starting DynamoDB Local container on port {port}')
450
+ result = _run_subprocess_safely(cmd, timeout=30)
451
+
452
+ if not result:
453
+ raise RuntimeError('Failed to start Docker container')
454
+
455
+ endpoint = f'http://localhost:{port}'
456
+ return check_dynamodb_readiness(endpoint)
457
+
458
+
459
+ def get_java_path() -> Optional[str]:
460
+ """Get Java executable path using cross-platform approach.
461
+
462
+ Attempts to locate Java executable by:
463
+ 1. Checking JAVA_HOME environment variable and validating executable exists and is runnable
464
+ 2. Falling back to searching system PATH for 'java' command
465
+
466
+ Returns:
467
+ Optional[str]: Full path to Java executable if found and executable, None otherwise
468
+ """
469
+ # 1. Check JAVA_HOME environment variable
470
+ java_home = os.environ.get('JAVA_HOME')
471
+ if java_home:
472
+ # Determine executable name based on platform
473
+ java_executable = 'java.exe' if sys.platform == 'win32' else 'java'
474
+ java_exe = os.path.join(java_home, 'bin', java_executable)
475
+ if os.path.isfile(java_exe) and os.access(java_exe, os.X_OK):
476
+ return java_exe
477
+
478
+ # 2. Fall back to searching PATH
479
+ return shutil.which('java')
480
+
481
+
482
+ def get_existing_java_dynamodb_local_endpoint() -> Optional[str]:
483
+ """Check if DynamoDB Local Java process is already running and return its endpoint.
484
+
485
+ Returns:
486
+ Optional[str]: DynamoDB Local endpoint URL (http://localhost:port) if found, None otherwise
487
+
488
+ Note:
489
+ Only detects processes started by this tool with the specific system property
490
+ """
491
+ try:
492
+ # Find Java processes with our system property
493
+ for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
494
+ try:
495
+ if proc.info['name'] and 'java' in proc.info['name'].lower():
496
+ cmdline = proc.info['cmdline']
497
+ if cmdline and any(
498
+ DynamoDBLocalConfig.JAVA_PROPERTY_NAME in arg for arg in cmdline
499
+ ):
500
+ port = _extract_port_from_cmdline(cmdline)
501
+ if port:
502
+ endpoint = f'http://localhost:{port}'
503
+ logger.info(
504
+ f'Found existing DynamoDB Local Java process at {endpoint}'
505
+ )
506
+ return endpoint
507
+ except (
508
+ psutil.NoSuchProcess,
509
+ psutil.AccessDenied,
510
+ psutil.ZombieProcess,
511
+ ValueError,
512
+ IndexError,
513
+ ) as e:
514
+ logger.debug(
515
+ f'Error processing Java process {proc.info.get("pid", "unknown")}: {e}'
516
+ )
517
+ continue
518
+ except Exception as e:
519
+ logger.debug(f'Error checking for existing Java process: {e}')
520
+
521
+ return None
522
+
523
+
524
+ def download_dynamodb_local_jar() -> tuple[str, str]:
525
+ """Download DynamoDB Local JAR and return JAR path and lib path.
526
+
527
+ Returns:
528
+ tuple[str, str]: (jar_path, lib_path) where:
529
+ - jar_path: Full path to DynamoDBLocal.jar
530
+ - lib_path: Full path to DynamoDBLocal_lib directory containing native libraries
531
+
532
+ Raises:
533
+ RuntimeError: If download fails, extraction fails, or JAR not found after extraction
534
+ """
535
+ dynamodb_dir, jar_path, lib_path = _get_dynamodb_local_paths()
536
+ os.makedirs(dynamodb_dir, exist_ok=True)
537
+
538
+ # Check if both JAR and lib directory exist
539
+ if os.path.exists(jar_path) and os.path.exists(lib_path):
540
+ return jar_path, lib_path
541
+
542
+ _download_and_extract_jar(dynamodb_dir, jar_path, lib_path)
543
+ return jar_path, lib_path
544
+
545
+
546
+ def start_java_process(java_path: str, port: int) -> str:
547
+ """Start DynamoDB Local using Java and return endpoint URL.
548
+
549
+ Args:
550
+ java_path: Full path to Java executable
551
+ port: Port number for DynamoDB Local to listen on
552
+
553
+ Returns:
554
+ str: DynamoDB Local endpoint URL (http://localhost:port)
555
+
556
+ Raises:
557
+ RuntimeError: If Java process fails to start, JAR download fails, or service
558
+ doesn't become ready within timeout period
559
+ """
560
+ jar_path, lib_path = download_dynamodb_local_jar()
561
+
562
+ cmd = [
563
+ java_path,
564
+ f'-D{DynamoDBLocalConfig.JAVA_PROPERTY_NAME}=true',
565
+ f'-Djava.library.path={lib_path}',
566
+ '-Djava.net.bindAddress=127.0.0.1',
567
+ '-jar',
568
+ jar_path,
569
+ '-port',
570
+ str(port),
571
+ '-inMemory',
572
+ '-sharedDb',
573
+ ]
574
+
575
+ try:
576
+ # Validate command before execution
577
+ base_cmd = os.path.basename(java_path)
578
+ if base_cmd.endswith('.exe'):
579
+ base_cmd = base_cmd[:-4]
580
+ if base_cmd != 'java':
581
+ raise RuntimeError(f'Invalid Java executable: {base_cmd}')
582
+
583
+ logger.info(f'Starting DynamoDB Local with Java on port {port}')
584
+
585
+ process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
586
+
587
+ time.sleep(DynamoDBLocalConfig.SLEEP_INTERVAL)
588
+ if process.poll() is not None:
589
+ _, stderr = process.communicate()
590
+ raise RuntimeError(f'Java process failed to start: {stderr.decode()}')
591
+
592
+ endpoint = f'http://localhost:{port}'
593
+ return check_dynamodb_readiness(endpoint)
594
+
595
+ except Exception as e:
596
+ raise RuntimeError(f'Failed to start DynamoDB Local with Java: {e}')
597
+
598
+
599
+ def check_dynamodb_readiness(endpoint: str) -> str:
600
+ """Check if DynamoDB Local is ready to accept connections and return endpoint.
601
+
602
+ Args:
603
+ endpoint: DynamoDB Local endpoint URL (e.g., 'http://localhost:8000')
604
+
605
+ Returns:
606
+ str: The same endpoint URL if service is ready and responding
607
+
608
+ Raises:
609
+ RuntimeError: If DynamoDB Local doesn't become ready within timeout period
610
+ """
611
+ client = _create_dynamodb_client(endpoint)
612
+
613
+ for i in range(DynamoDBLocalConfig.MAX_ATTEMPTS):
614
+ try:
615
+ client.list_tables()
616
+ logger.info(f'DynamoDB Local ready at {endpoint}')
617
+ return endpoint
618
+ except (ClientError, EndpointConnectionError) as e:
619
+ if i == DynamoDBLocalConfig.MAX_ATTEMPTS - 1:
620
+ total_timeout = (
621
+ DynamoDBLocalConfig.MAX_ATTEMPTS * DynamoDBLocalConfig.SLEEP_INTERVAL
622
+ )
623
+ raise RuntimeError(
624
+ f'DynamoDB Local failed to start at {endpoint} after {total_timeout} seconds. '
625
+ f'Last error: {e}'
626
+ )
627
+ logger.debug(
628
+ f'DynamoDB Local not ready, retrying in {DynamoDBLocalConfig.SLEEP_INTERVAL}s (attempt {i + 1}/{DynamoDBLocalConfig.MAX_ATTEMPTS})'
629
+ )
630
+ time.sleep(DynamoDBLocalConfig.SLEEP_INTERVAL)
631
+
632
+ # This should never be reached due to the exception in the loop, but added for type safety
633
+ raise RuntimeError(f'DynamoDB Local failed to start at {endpoint}')
634
+
635
+
636
+ def setup_dynamodb_local() -> str:
637
+ """Setup DynamoDB Local environment.
638
+
639
+ Returns:
640
+ str: DynamoDB Local endpoint URL
641
+
642
+ Raises:
643
+ RuntimeError: If neither Docker nor Java is available or setup fails
644
+ """
645
+ # Try container setup first
646
+ endpoint = _try_container_setup()
647
+ if endpoint:
648
+ return endpoint
649
+
650
+ # Fallback to Java
651
+ endpoint = _try_java_setup()
652
+ if endpoint:
653
+ return endpoint
654
+
655
+ raise RuntimeError(
656
+ 'No working container tool or Java found. Please install and start a container tool (Docker, Finch, Podman, or nerdctl) or install Java JRE version 17.x or newer and set JAVA_HOME or system PATH to run DynamoDB Local for data model validation.'
657
+ )
658
+
659
+
660
+ def create_validation_resources(
661
+ resources: Dict[str, Any], endpoint_url: Optional[str] = None
662
+ ) -> Dict[str, Any]:
663
+ """Create DynamoDB resources for data model validation.
664
+
665
+ Args:
666
+ resources: Valid dictionary containing tables and items
667
+ endpoint_url: DynamoDB endpoint URL
668
+
669
+ Returns:
670
+ Dictionary with response from both table creation and item insertion
671
+ """
672
+ dynamodb_client = _create_dynamodb_client(endpoint_url)
673
+
674
+ logger.info('Cleaning up existing tables in DynamoDB local for Model Validation')
675
+ cleanup_validation_resources(dynamodb_client)
676
+
677
+ tables = resources.get('tables', [])
678
+ items = resources.get('items', {})
679
+
680
+ # Validate data types
681
+ if not isinstance(tables, list):
682
+ tables = []
683
+ if not isinstance(items, dict):
684
+ items = {}
685
+
686
+ table_creation_response = create_tables(dynamodb_client, tables)
687
+ item_insertion_response = insert_items(dynamodb_client, items)
688
+
689
+ return {'tables': table_creation_response, 'items': item_insertion_response}
690
+
691
+
692
+ def cleanup_validation_resources(dynamodb_client) -> Dict[str, Any]:
693
+ """Clean up all existing tables in DynamoDB Local from previous DynamoDB data model validation.
694
+
695
+ This function removes all tables that were created during previous validation runs,
696
+ ensuring a clean state for new data model validation operations.
697
+
698
+ Args:
699
+ dynamodb_client: Valid boto3 DynamoDB client configured for DynamoDB Local
700
+
701
+ Returns:
702
+ Dictionary with cleanup response for each table, containing status and messages.
703
+ """
704
+ # SAFETY CHECK: Ensure we're only deleting from localhost
705
+ endpoint_url = dynamodb_client.meta.endpoint_url
706
+ if endpoint_url:
707
+ parsed = urlparse(endpoint_url)
708
+ hostname = parsed.hostname
709
+ if hostname not in ('localhost', '127.0.0.1'):
710
+ raise ValueError(
711
+ f'SAFETY VIOLATION: Table deletion must only run on localhost. '
712
+ f'Got endpoint: {endpoint_url}. This prevents accidental production table deletion.'
713
+ )
714
+
715
+ cleanup_response = {}
716
+
717
+ table_names = list_tables(dynamodb_client)
718
+
719
+ for table_name in table_names:
720
+ try:
721
+ dynamodb_client.delete_table(TableName=table_name)
722
+ cleanup_response[table_name] = {
723
+ 'status': 'deleted',
724
+ 'message': f'Table {table_name} deleted successfully',
725
+ }
726
+ except dynamodb_client.exceptions.ResourceNotFoundException:
727
+ cleanup_response[table_name] = {
728
+ 'status': 'not_found',
729
+ 'message': f'Table {table_name} not found',
730
+ }
731
+ except Exception as e:
732
+ cleanup_response[table_name] = {'status': 'error', 'error': str(e)}
733
+
734
+ return cleanup_response
735
+
736
+
737
+ def list_tables(dynamodb_client) -> list:
738
+ """List all DynamoDB tables in the local environment for data model validation.
739
+
740
+ Retrieves all table names from DynamoDB Local to support cleanup and validation operations.
741
+
742
+ Args:
743
+ dynamodb_client: Valid boto3 DynamoDB client configured for DynamoDB Local
744
+
745
+ Returns:
746
+ List of table names, or empty list if the operation fails.
747
+ """
748
+ try:
749
+ response = dynamodb_client.list_tables()
750
+ return response['TableNames']
751
+ except Exception:
752
+ return []
753
+
754
+
755
+ def create_tables(dynamodb_client, tables: list) -> Dict[str, Any]:
756
+ """Create DynamoDB tables.
757
+
758
+ Args:
759
+ dynamodb_client: Valid boto3 DynamoDB client
760
+ tables: Array of table configurations
761
+
762
+ Returns:
763
+ Dictionary with table creation response for each table.
764
+ """
765
+ table_creation_response = {}
766
+
767
+ for table_config in tables:
768
+ if not isinstance(table_config, dict) or 'TableName' not in table_config:
769
+ continue
770
+ table_name = table_config['TableName']
771
+
772
+ try:
773
+ response = dynamodb_client.create_table(**table_config)
774
+ table_creation_response[table_name] = {
775
+ 'status': 'success',
776
+ 'table_arn': response['TableDescription']['TableArn'],
777
+ }
778
+ except dynamodb_client.exceptions.ResourceInUseException:
779
+ table_creation_response[table_name] = {
780
+ 'status': 'exists',
781
+ 'message': f'Table {table_name} already exists',
782
+ }
783
+ except Exception as e:
784
+ table_creation_response[table_name] = {'status': 'error', 'error': str(e)}
785
+
786
+ return table_creation_response
787
+
788
+
789
+ def insert_items(dynamodb_client, items: dict) -> Dict[str, Any]:
790
+ """Insert items into DynamoDB tables using batch_write_item.
791
+
792
+ Args:
793
+ dynamodb_client: Valid boto3 DynamoDB client
794
+ items: Dictionary of table names to item lists
795
+
796
+ Returns:
797
+ Dictionary with insertion response for each table.
798
+ """
799
+ item_insertion_response = {}
800
+
801
+ for table_name, table_items in items.items():
802
+ if not isinstance(table_items, list):
803
+ continue
804
+
805
+ total_items = len(table_items)
806
+ processed_items = 0
807
+
808
+ try:
809
+ # Process items in batches
810
+ for i in range(0, total_items, DynamoDBLocalConfig.BATCH_SIZE):
811
+ batch_items = table_items[i : i + DynamoDBLocalConfig.BATCH_SIZE]
812
+ response = dynamodb_client.batch_write_item(RequestItems={table_name: batch_items})
813
+ processed_items += len(batch_items) - len(
814
+ response.get('UnprocessedItems', {}).get(table_name, [])
815
+ )
816
+
817
+ item_insertion_response[table_name] = {
818
+ 'status': 'success',
819
+ 'items_processed': processed_items,
820
+ }
821
+ except Exception as e:
822
+ item_insertion_response[table_name] = {'status': 'error', 'error': str(e)}
823
+
824
+ return item_insertion_response
825
+
826
+
827
+ def get_validation_result_transform_prompt() -> str:
828
+ """Provides transformation prompt for converting DynamoDB access pattern validation result to markdown format.
829
+
830
+ This tool returns instructions for transforming dynamodb_model_validation.json (generated by execute_access_patterns)
831
+ into a comprehensive, readable markdown report. The transformation includes:
832
+
833
+ - Summary statistics of validation results
834
+ - Detailed breakdown of each access pattern test
835
+ - Success/failure indicators with clear formatting
836
+ - Professional markdown structure with proper code blocks
837
+ - Error details and troubleshooting information
838
+
839
+ Input: Reads dynamodb_model_validation.json from current working directory
840
+ Output: Creates dynamodb_model_validation.md and displays the formatted results
841
+
842
+ Returns: Complete transformation prompt for converting JSON validation results to markdown
843
+ """
844
+ prompt_file = Path(__file__).parent / 'prompts' / 'transform_model_validation_result.md'
845
+ return prompt_file.read_text(encoding='utf-8')