modforge-cli 0.2.3__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.
@@ -0,0 +1,416 @@
1
+ from datetime import datetime
2
+ import json
3
+ from pathlib import Path
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import traceback
10
+ import urllib.request
11
+
12
+ import aiohttp
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+ import typer
16
+
17
+ from modforge_cli.api import ModrinthAPIConfig
18
+ from modforge_cli.core import ModDownloader
19
+ from modforge_cli.core.models import Manifest, SearchResult
20
+
21
+ try:
22
+ from modforge_cli.__version__ import __author__, __version__
23
+ except ImportError:
24
+ __version__ = "unknown"
25
+ __author__ = "Frank1o3"
26
+
27
+
28
+ def normalize_search_term(term: str) -> str:
29
+ """
30
+ Normalize a search term for fuzzy matching.
31
+
32
+ - Converts to lowercase
33
+ - Removes spaces, dashes, underscores
34
+ - Removes special characters
35
+
36
+ Examples:
37
+ "Dynamic FPS" -> "dynamicfps"
38
+ "sodium-extra" -> "sodiumextra"
39
+ "3D Skin Layers" -> "3dskinlayers"
40
+ """
41
+ # Convert to lowercase
42
+ normalized = term.lower()
43
+ # Remove spaces, dashes, underscores
44
+ normalized = re.sub(r'[\s\-_]', '', normalized)
45
+ # Remove special characters except alphanumeric
46
+ normalized = re.sub(r'[^a-z0-9]', '', normalized)
47
+ return normalized
48
+
49
+
50
+ def calculate_match_score(search_term: str, hit_slug: str, hit_title: str = "") -> int:
51
+ """
52
+ Calculate a match score between search term and a mod.
53
+ Higher score = better match.
54
+
55
+ Scoring:
56
+ 100: Exact slug match
57
+ 90: Exact title match (case-insensitive)
58
+ 80: Normalized slug match
59
+ 70: Normalized title match
60
+ 60: Slug starts with search term
61
+ 50: Title starts with search term
62
+ 40: Slug contains search term
63
+ 30: Title contains search term
64
+ 0: No match
65
+ """
66
+ search_lower = search_term.lower()
67
+ search_normalized = normalize_search_term(search_term)
68
+
69
+ slug_lower = hit_slug.lower()
70
+ slug_normalized = normalize_search_term(hit_slug)
71
+
72
+ title_lower = hit_title.lower() if hit_title else ""
73
+ title_normalized = normalize_search_term(hit_title) if hit_title else ""
74
+
75
+ # Exact matches (highest priority)
76
+ if search_term == hit_slug:
77
+ return 100
78
+ if search_lower == title_lower:
79
+ return 90
80
+
81
+ # Normalized matches
82
+ if search_normalized == slug_normalized:
83
+ return 80
84
+ if title_normalized and search_normalized == title_normalized:
85
+ return 70
86
+
87
+ # Starts with matches
88
+ if slug_lower.startswith(search_lower):
89
+ return 60
90
+ if title_lower and title_lower.startswith(search_lower):
91
+ return 50
92
+
93
+ # Contains matches
94
+ if search_lower in slug_lower:
95
+ return 40
96
+ if title_lower and search_lower in title_lower:
97
+ return 30
98
+
99
+ # Normalized contains (fallback)
100
+ if search_normalized in slug_normalized:
101
+ return 20
102
+ if title_normalized and search_normalized in title_normalized:
103
+ return 10
104
+
105
+ return 0
106
+
107
+
108
+ def find_best_match(search_term: str, hits: list) -> tuple[object, int]:
109
+ """
110
+ Find the best matching mod from search results.
111
+
112
+ Returns:
113
+ (best_hit, score) tuple
114
+ """
115
+ best_hit = None
116
+ best_score = 0
117
+
118
+ for hit in hits:
119
+ # Get title from hit if available
120
+ title = getattr(hit, 'title', '') or getattr(hit, 'name', '')
121
+ score = calculate_match_score(search_term, hit.slug, title)
122
+
123
+ if score > best_score:
124
+ best_score = score
125
+ best_hit = hit
126
+
127
+ return best_hit, best_score
128
+
129
+
130
+ def ensure_config_file(path: Path, url: str, label: str, console: Console) -> None:
131
+ """Download config file if missing"""
132
+ if path.exists():
133
+ return
134
+
135
+ path.parent.mkdir(parents=True, exist_ok=True)
136
+
137
+ console.print(f"[yellow]Missing {label} config.[/yellow] Downloading default…")
138
+
139
+ try:
140
+ urllib.request.urlretrieve(url, path)
141
+ console.print(f"[green]✓ {label} config installed at {path}[/green]")
142
+ except Exception as e:
143
+ console.print(f"[red]Failed to download {label} config:[/red] {e}")
144
+ raise typer.Exit(1) from e
145
+
146
+
147
+ # --- Async Helper ---
148
+ async def get_api_session() -> aiohttp.ClientSession:
149
+ """Returns a session with the correct ModForge-CLI headers."""
150
+ timeout = aiohttp.ClientTimeout(total=60, connect=10)
151
+ return aiohttp.ClientSession(
152
+ headers={"User-Agent": f"{__author__}/ModForge-CLI/{__version__}"},
153
+ timeout=timeout,
154
+ raise_for_status=False, # Handle errors manually
155
+ )
156
+
157
+
158
+ def get_manifest(console: Console, path: Path = Path.cwd()) -> Manifest | None:
159
+ """Load and validate manifest file"""
160
+ p = path / "ModForge-CLI.json"
161
+ if not p.exists():
162
+ return None
163
+ try:
164
+ return Manifest.model_validate_json(p.read_text())
165
+ except Exception as e:
166
+ console.print(f"[red]Error parsing manifest:[/red] {e}")
167
+ return None
168
+
169
+
170
+ def save_registry_atomic(registry: dict, path: Path) -> None:
171
+ """
172
+ Atomically save registry to prevent corruption from concurrent access.
173
+
174
+ Uses a temp file + atomic rename to ensure the registry is never
175
+ left in a partially-written state.
176
+ """
177
+ path.parent.mkdir(parents=True, exist_ok=True)
178
+
179
+ # Write to temp file in same directory (required for atomic rename)
180
+ with tempfile.NamedTemporaryFile(
181
+ mode="w", delete=False, dir=path.parent, prefix=".registry-", suffix=".tmp"
182
+ ) as f:
183
+ json.dump(registry, f, indent=4)
184
+ temp_path = Path(f.name)
185
+
186
+ # Atomic rename (POSIX guarantees atomicity)
187
+ try:
188
+ temp_path.replace(path)
189
+ except Exception as e:
190
+ temp_path.unlink(missing_ok=True)
191
+ raise RuntimeError(f"Failed to save registry: {e}") from e
192
+
193
+
194
+ def load_registry(path: Path) -> dict[str, str]:
195
+ """Load registry with error handling"""
196
+ if not path.exists():
197
+ return {}
198
+
199
+ try:
200
+ return json.loads(path.read_text())
201
+ except json.JSONDecodeError as e:
202
+ # Registry is corrupted - back it up and start fresh
203
+ backup = path.with_suffix(f".corrupt-{datetime.now():%Y%m%d-%H%M%S}.json")
204
+ shutil.copy(path, backup)
205
+ print(f"Warning: Corrupted registry backed up to {backup}")
206
+ return {}
207
+
208
+
209
+ def setup_crash_logging() -> Path:
210
+ """Configure crash logging for bug reports"""
211
+ log_dir = Path.home() / ".config" / "ModForge-CLI" / "logs"
212
+ log_dir.mkdir(parents=True, exist_ok=True)
213
+
214
+ def excepthook(exc_type, exc_value, exc_traceback) -> None:
215
+ """Log crashes for bug reports"""
216
+
217
+ log_file = log_dir / f"crash-{datetime.now():%Y%m%d-%H%M%S}.log"
218
+
219
+ with open(log_file, "w") as f:
220
+ f.write(f"ModForge-CLI v{__version__}\n")
221
+ f.write(f"Python {sys.version}\n")
222
+ f.write(f"Platform: {sys.platform}\n\n")
223
+ traceback.print_exception(exc_type, exc_value, exc_traceback, file=f)
224
+
225
+ console = Console()
226
+ console.print(f"\n[red bold]ModForge-CLI crashed![/red bold]")
227
+ console.print(f"[yellow]Crash log saved to:[/yellow] {log_file}")
228
+ console.print("[dim]Please include this file when reporting the issue at:")
229
+ console.print("[dim]https://github.com/Frank1o3/ModForge-CLI/issues\n")
230
+
231
+ sys.excepthook = excepthook
232
+ return log_dir
233
+
234
+
235
+ def install_fabric(
236
+ installer: Path,
237
+ mc_version: str,
238
+ loader_version: str,
239
+ game_dir: Path,
240
+ ) -> None:
241
+ """Install Fabric loader with better error handling"""
242
+ try:
243
+ result = subprocess.run(
244
+ [
245
+ "java",
246
+ "-jar",
247
+ str(installer),
248
+ "client",
249
+ "-mcversion",
250
+ mc_version,
251
+ "-loader",
252
+ loader_version,
253
+ "-dir",
254
+ str(game_dir),
255
+ "-noprofile",
256
+ ],
257
+ check=True,
258
+ capture_output=True,
259
+ text=True,
260
+ )
261
+ except subprocess.CalledProcessError as e:
262
+ raise RuntimeError(
263
+ f"Fabric installation failed:\n{e.stderr}\n\n"
264
+ f"Make sure Java is installed and accessible."
265
+ ) from e
266
+ except FileNotFoundError as e:
267
+ raise RuntimeError("Java not found. Please install Java 17 or higher and try again.") from e
268
+
269
+
270
+ def detect_install_method() -> str:
271
+ """Detect how ModForge-CLI was installed"""
272
+ prefix = Path(sys.prefix)
273
+
274
+ if "pipx" in prefix.parts or "pipx" in str(prefix):
275
+ return "pipx"
276
+ return "pip"
277
+
278
+
279
+ def self_update(console: Console) -> None:
280
+ """Update ModForge-CLI to latest version"""
281
+ method = detect_install_method()
282
+
283
+ try:
284
+ if method == "pipx":
285
+ console.print("[cyan]Updating ModForge-CLI using pipx...[/cyan]")
286
+ subprocess.run(["pipx", "upgrade", "ModForge-CLI"], check=True)
287
+ else:
288
+ console.print("[cyan]Updating ModForge-CLI using pip...[/cyan]")
289
+ subprocess.run(
290
+ [sys.executable, "-m", "pip", "install", "--upgrade", "ModForge-CLI"],
291
+ check=True,
292
+ )
293
+
294
+ console.print("[green]✓ ModForge-CLI updated successfully.[/green]")
295
+ except subprocess.CalledProcessError as e:
296
+ console.print(f"[red]Update failed:[/red] {e}")
297
+ raise
298
+
299
+
300
+ async def run(api: ModrinthAPIConfig, manifest: Manifest, mods_dir: Path, index_file: Path) -> None:
301
+ """Download all mods with progress tracking"""
302
+ async with await get_api_session() as session:
303
+ downloader = ModDownloader(
304
+ api=api,
305
+ mc_version=manifest.minecraft,
306
+ loader=manifest.loader,
307
+ output_dir=mods_dir,
308
+ index_file=index_file,
309
+ session=session,
310
+ )
311
+ await downloader.download_all(manifest.mods)
312
+
313
+
314
+ async def perform_add(
315
+ api: ModrinthAPIConfig,
316
+ name: str,
317
+ manifest: Manifest,
318
+ project_type: str,
319
+ console: Console,
320
+ manifest_file: Path,
321
+ ) -> None:
322
+ """
323
+ Search and add a project to the manifest with improved fuzzy matching.
324
+
325
+ This function now:
326
+ - Normalizes search terms to handle spaces, dashes, case variations
327
+ - Scores matches to find the best result
328
+ - Shows multiple options if the match is uncertain
329
+ - Provides helpful feedback about what was found
330
+ """
331
+ async with await get_api_session() as session:
332
+ url = api.search(
333
+ name,
334
+ game_versions=[manifest.minecraft],
335
+ loaders=[manifest.loader],
336
+ project_type=project_type,
337
+ )
338
+
339
+ try:
340
+ async with session.get(url) as response:
341
+ if response.status != 200:
342
+ console.print(f"[red]API request failed with status {response.status}[/red]")
343
+ return
344
+
345
+ results = SearchResult.model_validate_json(await response.text())
346
+ except Exception as e:
347
+ console.print(f"[red]Failed to search Modrinth:[/red] {e}")
348
+ return
349
+
350
+ if not results or not results.hits:
351
+ console.print(f"[red]No {project_type} found for '{name}'[/red]")
352
+ console.print(f"[dim]Try searching on https://modrinth.com/mods?q={name}[/dim]")
353
+ return
354
+
355
+ # Find best match using scoring system
356
+ best_hit, best_score = find_best_match(name, results.hits)
357
+
358
+ if not best_hit:
359
+ console.print(f"[red]No suitable match found for '{name}'[/red]")
360
+ return
361
+
362
+ slug = best_hit.slug
363
+
364
+ # Show what we found with confidence level
365
+ confidence_msg = ""
366
+ if best_score >= 80:
367
+ confidence_msg = "[green](high confidence match)[/green]"
368
+ elif best_score >= 60:
369
+ confidence_msg = "[yellow](medium confidence match)[/yellow]"
370
+ elif best_score >= 40:
371
+ confidence_msg = "[yellow](low confidence match)[/yellow]"
372
+ else:
373
+ confidence_msg = "[red](uncertain match - please verify)[/red]"
374
+
375
+ console.print(f"[cyan]Found:[/cyan] {slug} {confidence_msg}")
376
+
377
+ # If confidence is low and there are multiple results, show alternatives
378
+ if best_score < 60 and len(results.hits) > 1:
379
+ console.print("\n[yellow]Other possible matches:[/yellow]")
380
+ table = Table(show_header=True, header_style="bold cyan")
381
+ table.add_column("#", style="dim", width=3)
382
+ table.add_column("Slug", style="cyan")
383
+ table.add_column("Score", justify="right", style="dim")
384
+
385
+ # Show top 5 alternatives
386
+ scored_hits = []
387
+ for hit in results.hits[:10]:
388
+ title = getattr(hit, 'title', '') or getattr(hit, 'name', '')
389
+ score = calculate_match_score(name, hit.slug, title)
390
+ scored_hits.append((hit, score))
391
+
392
+ scored_hits.sort(key=lambda x: x[1], reverse=True)
393
+
394
+ for idx, (hit, score) in enumerate(scored_hits[:5], 1):
395
+ table.add_row(str(idx), hit.slug, str(score))
396
+
397
+ console.print(table)
398
+ console.print("\n[dim]Tip: Use the exact slug if the match is wrong[/dim]")
399
+ console.print(f"[dim]Example: ModForge-CLI add {scored_hits[1][0].slug if len(scored_hits) > 1 else 'exact-slug'}[/dim]\n")
400
+
401
+ # Add to appropriate list
402
+ target_list = {
403
+ "mod": manifest.mods,
404
+ "resourcepack": manifest.resourcepacks,
405
+ "shaderpack": manifest.shaderpacks,
406
+ }.get(project_type, manifest.mods)
407
+
408
+ if slug not in target_list:
409
+ target_list.append(slug)
410
+ try:
411
+ manifest_file.write_text(manifest.model_dump_json(indent=4))
412
+ console.print(f"[green]✓ Added {slug} to {project_type}s[/green]")
413
+ except Exception as e:
414
+ console.print(f"[red]Failed to save manifest:[/red] {e}")
415
+ else:
416
+ console.print(f"[yellow]{slug} is already in the manifest[/yellow]")
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: modforge-cli
3
+ Version: 0.2.3
4
+ Summary: ModForge-CLI — a Modrinth-based Minecraft modpack builder
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: Frank1o3
8
+ Author-email: jahdy1o3@gmail.com
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Topic :: Software Development :: Build Tools
20
+ Requires-Dist: aiofiles (>=25.1.0,<26.0.0)
21
+ Requires-Dist: aiohttp (>=3.13.3,<4.0.0)
22
+ Requires-Dist: jsonschema (>=4.25.1,<5.0.0)
23
+ Requires-Dist: pydantic (>=2.12.5,<3.0.0)
24
+ Requires-Dist: pyfiglet (>=1.0.4,<2.0.0)
25
+ Requires-Dist: pyzipper (>=0.3.6,<0.4.0)
26
+ Requires-Dist: requests (>=2.32.5,<3.0.0)
27
+ Requires-Dist: rich (>=14.2.0,<15.0.0)
28
+ Requires-Dist: typer (>=0.21.1,<0.22.0)
29
+ Project-URL: Homepage, https://frank1o3.github.io/ModForge-CLI/
30
+ Project-URL: Repository, https://github.com/Frank1o3/ModForge-CLI
31
+ Description-Content-Type: text/markdown
32
+
33
+ # ModForge-CLI ⛏
34
+
35
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13%2B-blue)](https://www.python.org/)
36
+ [![MIT License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
37
+ [![Modrinth API v2](https://img.shields.io/badge/Modrinth-API%20v2-orange)](https://docs.modrinth.com/api-spec)
38
+
39
+ **ModForge-CLI** is a powerful CLI tool for building and managing custom Minecraft modpacks using the Modrinth API v2.
40
+
41
+ Search for projects, fetch versions, validate manifests, download mods with hash checks, and generate complete files — all from the terminal.
42
+
43
+ Ideal for modpack developers, server admins, and automation scripts.
44
+
45
+ ## Terminal Banner
46
+
47
+ When you run ModForge-CLI, you'll be greeted with this colorful Minecraft-themed banner
48
+
49
+ ## Key Features
50
+
51
+ - **Modrinth API v2 Integration**: Search projects, list versions, fetch metadata in bulk.
52
+ - **Modpack Management**: Read/validate `modrinth.index.json`, build packs from metadata.
53
+ - **Validation**: Full JSON Schema checks + optional Pydantic models for strict typing.
54
+
55
+ ## Installation
56
+
57
+ Requires **Python 3.13+**.
58
+
59
+ **Recommended (Poetry)**:
60
+
61
+ ```bash
62
+ poetry install
63
+ ```
64
+
65
+ **Alternative (pip)**:
66
+
67
+ ```bash
68
+ pip install -r requirements.txt
69
+ ```
70
+
@@ -0,0 +1,24 @@
1
+ modforge_cli/__init__.py,sha256=UFmSxUpR5CtjtHIrKKfgWhpZeM559ufyWg3Q03SbJ_g,77
2
+ modforge_cli/__main__.py,sha256=KxLK89lHZn5zaMp8l42d4AAzdqh2I9LSyE9XSWlL18k,4257
3
+ modforge_cli/__version__.py,sha256=339BKq5iPtupYyyjt36PE8YybSEHI19D6hVQAqJvMEU,88
4
+ modforge_cli/api/__init__.py,sha256=5BlfsH345QbdgkwpRCOLKhNLGlgRRWAy7oaceAXgbmU,138
5
+ modforge_cli/api/modrinth.py,sha256=ptxAzaQ309i2uWZd1hLjjQDyZNcbAJr7RxT_13bqCXM,7787
6
+ modforge_cli/cli/__init__.py,sha256=XzI-mK4_ycE-GFkwE4vtGklzzDEAjKDApiiCeq7d9BA,196
7
+ modforge_cli/cli/export.py,sha256=pk6HZmnF-DN15TIu16-SXtq_B2AlhJW2BbbxCMgZO30,9734
8
+ modforge_cli/cli/modpack.py,sha256=MpOjVT9UAnwJ-hliDi2Xtz0PzWRTwpCPMic922oRMp4,4595
9
+ modforge_cli/cli/project.py,sha256=19-rLCAb3xNdEr7z0eq8dW3EfbQNjTufrCBLvVz54HE,1916
10
+ modforge_cli/cli/setup.py,sha256=ZhnfWsH9OnMxf2eSR3ZWFdyK0jIv8zfqeffcLoD7-Yo,2081
11
+ modforge_cli/cli/shared.py,sha256=-YAylKVUIHlPrPXv6dm6KiF7R5DkUcBnPkjqmrhK8zU,1161
12
+ modforge_cli/cli/sklauncher.py,sha256=OaNc3W_5bCHEIN192UWv5T_Yc6kujzU4S5cMjtNI0n8,4408
13
+ modforge_cli/cli/utils.py,sha256=Wn-bXsrVTdsKGZd-P7APycNQWu3mnZZaP9ud4SaThMk,1960
14
+ modforge_cli/core/__init__.py,sha256=WHmCpDLbhIh7I0HL63mNAk3w6opFgJhCwybnFCqIrJk,844
15
+ modforge_cli/core/downloader.py,sha256=LWOwpAZjXXANTGnDJz5IqiLK88oLNzsob6YfHUYmpAU,7134
16
+ modforge_cli/core/models.py,sha256=gGn9f8P89dggMrk-Mu4NYYlylyJ5uF6WVU4PnDNErJk,1782
17
+ modforge_cli/core/policy.py,sha256=mAnIyTd2aRTJTzQBDQWjuas9sFQVxy4fTNTwpCOBt6M,4766
18
+ modforge_cli/core/resolver.py,sha256=qxRH115rQUNSy_7rg3P5knuKx15zLccc04vUyOorPYA,6210
19
+ modforge_cli/core/utils.py,sha256=rTKApM5PNKXjZz3uOIIVReXYu62_v9t0k19h6RPoj30,13938
20
+ modforge_cli-0.2.3.dist-info/METADATA,sha256=PM3rStkpJAJ5JcgbhVbwxMANnsT5mcZsmJGGP8APYKA,2468
21
+ modforge_cli-0.2.3.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
22
+ modforge_cli-0.2.3.dist-info/entry_points.txt,sha256=_6WKjhPS5-J1nNSwGiljk6l9xchIpXmeH7gxrTfse7E,59
23
+ modforge_cli-0.2.3.dist-info/licenses/LICENSE,sha256=SAuHlb0YymKIXKAXl0lwgwwu31REY-3oBdLARIObBbs,1065
24
+ modforge_cli-0.2.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ modforge-cli=modforge_cli.__main__:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Frank1o3
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.