article-cli 1.0.0__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.
article_cli/zotero.py ADDED
@@ -0,0 +1,359 @@
1
+ """
2
+ Zotero bibliography synchronization module
3
+
4
+ Handles fetching BibTeX entries from Zotero API with robust pagination,
5
+ error handling, and rate limiting.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ from typing import Optional, Tuple, List
11
+ import time
12
+
13
+ try:
14
+ import requests
15
+ except ImportError:
16
+ print("Error: 'requests' library is required. Install with: pip install requests")
17
+ sys.exit(1)
18
+
19
+
20
+ class Colors:
21
+ """ANSI color codes for terminal output"""
22
+
23
+ HEADER = "\033[95m"
24
+ OKBLUE = "\033[94m"
25
+ OKCYAN = "\033[96m"
26
+ OKGREEN = "\033[92m"
27
+ WARNING = "\033[93m"
28
+ FAIL = "\033[91m"
29
+ ENDC = "\033[0m"
30
+ BOLD = "\033[1m"
31
+ UNDERLINE = "\033[4m"
32
+
33
+
34
+ def print_success(msg: str):
35
+ """Print success message in green"""
36
+ print(f"{Colors.OKGREEN}✓ {msg}{Colors.ENDC}")
37
+
38
+
39
+ def print_error(msg: str):
40
+ """Print error message in red"""
41
+ print(f"{Colors.FAIL}✗ Error: {msg}{Colors.ENDC}", file=sys.stderr)
42
+
43
+
44
+ def print_warning(msg: str):
45
+ """Print warning message in yellow"""
46
+ print(f"{Colors.WARNING}⚠ Warning: {msg}{Colors.ENDC}")
47
+
48
+
49
+ def print_info(msg: str):
50
+ """Print info message in cyan"""
51
+ print(f"{Colors.OKCYAN}ℹ {msg}{Colors.ENDC}")
52
+
53
+
54
+ class ZoteroBibTexUpdater:
55
+ """Handle Zotero BibTeX synchronization with robust error handling and rate limiting"""
56
+
57
+ def __init__(
58
+ self,
59
+ api_key: str,
60
+ user_id: Optional[str] = None,
61
+ group_id: Optional[str] = None,
62
+ output_file: str = "references.bib",
63
+ ):
64
+ self.api_key = api_key
65
+ self.user_id = user_id
66
+ self.group_id = group_id
67
+ self.output_file = output_file
68
+ self.base_url = "https://api.zotero.org"
69
+ self.limit = 100
70
+ self._group_name: Optional[str] = None
71
+
72
+ # Create session with persistent headers
73
+ self.session = requests.Session()
74
+ self.session.headers.update(
75
+ {
76
+ "Zotero-API-Version": "3",
77
+ "Authorization": f"Bearer {self.api_key}",
78
+ "User-Agent": "ArticleCLI/1.0",
79
+ }
80
+ )
81
+
82
+ def _build_url(self) -> str:
83
+ """Build the Zotero API URL based on user or group ID"""
84
+ if self.group_id:
85
+ return f"{self.base_url}/groups/{self.group_id}/items"
86
+ elif self.user_id:
87
+ return f"{self.base_url}/users/{self.user_id}/items"
88
+ else:
89
+ raise ValueError("Either user_id or group_id must be provided")
90
+
91
+ def _get_group_info(self) -> Optional[dict]:
92
+ """Get group information including name"""
93
+ if not self.group_id:
94
+ return None
95
+
96
+ try:
97
+ url = f"{self.base_url}/groups/{self.group_id}"
98
+ response = self.session.get(url, timeout=10)
99
+ response.raise_for_status()
100
+ return response.json()
101
+ except Exception as e:
102
+ print_warning(f"Could not fetch group info: {e}")
103
+ return None
104
+
105
+ def get_group_name(self) -> Optional[str]:
106
+ """Get the name of the Zotero group"""
107
+ if self._group_name is not None:
108
+ return self._group_name
109
+
110
+ if not self.group_id:
111
+ return None
112
+
113
+ group_info = self._get_group_info()
114
+ if group_info and "data" in group_info:
115
+ self._group_name = group_info["data"].get("name", "Unknown Group")
116
+ return self._group_name
117
+
118
+ return None
119
+
120
+ def _fetch_page(self, url: str, start: int = 0) -> Tuple[str, dict]:
121
+ """
122
+ Fetch a single page of results from Zotero API
123
+
124
+ Args:
125
+ url: Base API URL
126
+ start: Starting index for pagination
127
+
128
+ Returns:
129
+ Tuple of (response_content, headers_dict)
130
+ """
131
+ params = {"format": "bibtex", "start": start, "limit": self.limit}
132
+
133
+ try:
134
+ response = self.session.get(url, params=params, timeout=30)
135
+ response.raise_for_status()
136
+
137
+ # Check rate limiting
138
+ rate_limit_remaining = response.headers.get("X-RateLimit-Remaining")
139
+ if rate_limit_remaining and int(rate_limit_remaining) < 5:
140
+ rate_limit_reset = int(response.headers.get("X-RateLimit-Reset", 0))
141
+ wait_time = max(0, rate_limit_reset - time.time())
142
+ if wait_time > 0:
143
+ print_warning(
144
+ f"Rate limit low ({rate_limit_remaining} remaining). Waiting {wait_time:.1f}s..."
145
+ )
146
+ time.sleep(wait_time + 1)
147
+
148
+ return response.text, dict(response.headers)
149
+
150
+ except requests.exceptions.HTTPError as e:
151
+ if e.response.status_code == 429:
152
+ retry_after = int(e.response.headers.get("Retry-After", 60))
153
+ print_error(f"Rate limit exceeded. Retry after {retry_after}s")
154
+ raise
155
+ elif e.response.status_code == 403:
156
+ print_error("Access forbidden. Check your API key and permissions.")
157
+ raise
158
+ elif e.response.status_code == 404:
159
+ print_error("Resource not found. Check your user/group ID.")
160
+ raise
161
+ else:
162
+ print_error(f"HTTP error {e.response.status_code}: {e.response.reason}")
163
+ raise
164
+ except requests.exceptions.RequestException as e:
165
+ print_error(f"Network error: {str(e)}")
166
+ raise
167
+
168
+ def _count_bibtex_entries(self, content: str) -> int:
169
+ """Count the number of BibTeX entries in the content"""
170
+ return (
171
+ content.count("@article")
172
+ + content.count("@book")
173
+ + content.count("@inproceedings")
174
+ + content.count("@incollection")
175
+ + content.count("@misc")
176
+ + content.count("@phdthesis")
177
+ + content.count("@techreport")
178
+ + content.count("@mastersthesis")
179
+ )
180
+
181
+ def _filter_empty_entries(self, content: str) -> Tuple[str, int]:
182
+ """
183
+ Filter out empty or near-empty BibTeX entries
184
+
185
+ Args:
186
+ content: BibTeX content to filter
187
+
188
+ Returns:
189
+ Tuple of (filtered_content, num_removed)
190
+ """
191
+ import re
192
+
193
+ # Pattern to match BibTeX entries
194
+ entry_pattern = r"@\w+\{[^,]*,\s*\}"
195
+
196
+ # Find all empty entries (those with just the citation key and closing brace)
197
+ empty_entries = re.findall(entry_pattern, content)
198
+
199
+ if not empty_entries:
200
+ return content, 0
201
+
202
+ # Remove empty entries
203
+ filtered_content = content
204
+ for entry in empty_entries:
205
+ filtered_content = filtered_content.replace(entry, "")
206
+
207
+ # Clean up multiple blank lines
208
+ filtered_content = re.sub(r"\n\s*\n\s*\n+", "\n\n", filtered_content)
209
+
210
+ return filtered_content, len(empty_entries)
211
+
212
+ def _backup_existing_file(self):
213
+ """Create a backup of the existing BibTeX file"""
214
+ if os.path.exists(self.output_file):
215
+ backup_file = f"{self.output_file}.backup"
216
+ try:
217
+ with open(self.output_file, "r", encoding="utf-8") as src:
218
+ with open(backup_file, "w", encoding="utf-8") as dst:
219
+ dst.write(src.read())
220
+ print_info(f"Created backup: {backup_file}")
221
+ except Exception as e:
222
+ print_warning(f"Could not create backup: {e}")
223
+
224
+ def update(self, backup: bool = True) -> bool:
225
+ """
226
+ Fetch and update BibTeX file from Zotero with improved pagination
227
+
228
+ Args:
229
+ backup: Whether to create a backup of existing file
230
+
231
+ Returns:
232
+ True if successful, False otherwise
233
+ """
234
+ try:
235
+ # Validate inputs
236
+ if not self.api_key:
237
+ print_error("Zotero API key is required")
238
+ return False
239
+
240
+ if not self.user_id and not self.group_id:
241
+ print_error("Either user_id or group_id must be provided")
242
+ return False
243
+
244
+ # Create backup if requested
245
+ if backup:
246
+ self._backup_existing_file()
247
+
248
+ # Build URL
249
+ base_url = self._build_url()
250
+
251
+ # Show what we're connecting to
252
+ if self.group_id:
253
+ group_name = self.get_group_name()
254
+ if group_name:
255
+ print_info(
256
+ f"Connecting to Zotero group: {group_name} (ID: {self.group_id})"
257
+ )
258
+ else:
259
+ print_info(f"Connecting to Zotero group ID: {self.group_id}")
260
+ elif self.user_id:
261
+ print_info(f"Connecting to Zotero user library (ID: {self.user_id})")
262
+
263
+ # First request to get total count
264
+ print_info("Fetching BibTeX entries from Zotero...")
265
+
266
+ try:
267
+ initial_content, initial_headers = self._fetch_page(base_url, start=0)
268
+ except Exception as e:
269
+ print_error(f"Failed to connect to Zotero: {e}")
270
+ return False
271
+
272
+ # Get total number of items from header
273
+ total_results = int(initial_headers.get("Total-Results", 0))
274
+
275
+ if total_results == 0:
276
+ print_warning("No items found in Zotero library")
277
+ return False
278
+
279
+ print_info(f"Found {total_results} total items in library")
280
+
281
+ # Collect all BibTeX content
282
+ all_bibtex_content: List[str] = []
283
+
284
+ # Add initial page
285
+ if initial_content.strip():
286
+ all_bibtex_content.append(initial_content)
287
+
288
+ # Calculate pages needed
289
+ pages_needed = (total_results + self.limit - 1) // self.limit
290
+ print_info(
291
+ f"Fetching {pages_needed} pages ({self.limit} items per page)..."
292
+ )
293
+
294
+ # Fetch remaining pages
295
+ for page in range(1, pages_needed):
296
+ start = page * self.limit
297
+ progress = ((page + 1) / pages_needed) * 100
298
+
299
+ print(
300
+ f" Progress: {progress:.0f}% ({start + self.limit if start + self.limit < total_results else total_results}/{total_results} items)",
301
+ end="\r",
302
+ )
303
+
304
+ try:
305
+ content, _ = self._fetch_page(base_url, start=start)
306
+ if content.strip():
307
+ all_bibtex_content.append(content)
308
+ except Exception as e:
309
+ print_error(f"\nFailed to fetch page {page + 1}: {e}")
310
+ return False
311
+
312
+ print() # New line after progress
313
+
314
+ # Combine all content
315
+ combined_content = "\n\n".join(all_bibtex_content)
316
+
317
+ # Filter out empty entries
318
+ filtered_content, num_removed = self._filter_empty_entries(combined_content)
319
+ if num_removed > 0:
320
+ print_warning(
321
+ f"Filtered out {num_removed} empty/incomplete entries from Zotero"
322
+ )
323
+
324
+ # Count total valid entries
325
+ total_entries = self._count_bibtex_entries(filtered_content)
326
+
327
+ if total_entries == 0:
328
+ print_warning("No valid BibTeX entries found after filtering")
329
+ return False
330
+
331
+ # Write to file
332
+ print_info(
333
+ f"Writing {total_entries} valid entries to {self.output_file}..."
334
+ )
335
+
336
+ try:
337
+ with open(self.output_file, "w", encoding="utf-8") as f:
338
+ # Add header comment
339
+ f.write(f"% BibTeX entries from Zotero\n")
340
+ f.write(f"% Total entries: {total_entries}\n")
341
+ if num_removed > 0:
342
+ f.write(f"% Filtered out: {num_removed} empty entries\n")
343
+ f.write(f"% Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n")
344
+ f.write(filtered_content)
345
+ if not filtered_content.endswith("\n"):
346
+ f.write("\n")
347
+
348
+ print_success(
349
+ f"Successfully updated {self.output_file} with {total_entries} entries"
350
+ )
351
+ return True
352
+
353
+ except IOError as e:
354
+ print_error(f"Failed to write to {self.output_file}: {e}")
355
+ return False
356
+
357
+ except Exception as e:
358
+ print_error(f"Unexpected error during update: {e}")
359
+ return False
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.4
2
+ Name: article-cli
3
+ Version: 1.0.0
4
+ Summary: CLI tool for managing LaTeX articles with git integration and Zotero bibliography
5
+ Project-URL: Homepage, https://github.com/feelpp/article.cli
6
+ Project-URL: Documentation, https://github.com/feelpp/article.cli#readme
7
+ Project-URL: Repository, https://github.com/feelpp/article.cli.git
8
+ Project-URL: Issues, https://github.com/feelpp/article.cli/issues
9
+ Author-email: Christophe Prud'homme <prudhomm@cemosis.fr>
10
+ License: MIT License
11
+
12
+ Copyright (c) 2024 Christophe Prud'homme
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: academic,bibliography,git,latex,research,zotero
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Science/Research
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.8
39
+ Classifier: Programming Language :: Python :: 3.9
40
+ Classifier: Programming Language :: Python :: 3.10
41
+ Classifier: Programming Language :: Python :: 3.11
42
+ Classifier: Programming Language :: Python :: 3.12
43
+ Classifier: Topic :: Scientific/Engineering
44
+ Classifier: Topic :: Text Processing :: Markup :: LaTeX
45
+ Requires-Python: >=3.8
46
+ Requires-Dist: requests>=2.28.0
47
+ Provides-Extra: dev
48
+ Requires-Dist: black>=23.0; extra == 'dev'
49
+ Requires-Dist: flake8>=6.0; extra == 'dev'
50
+ Requires-Dist: pytest-cov; extra == 'dev'
51
+ Requires-Dist: pytest>=7.0; extra == 'dev'
52
+ Description-Content-Type: text/markdown
53
+
54
+ # Article CLI
55
+
56
+ [![CI](https://github.com/feelpp/article.cli/actions/workflows/ci.yml/badge.svg)](https://github.com/feelpp/article.cli/actions/workflows/ci.yml)
57
+ [![PyPI version](https://badge.fury.io/py/article-cli.svg)](https://badge.fury.io/py/article-cli)
58
+ [![Python Support](https://img.shields.io/pypi/pyversions/article-cli.svg)](https://pypi.org/project/article-cli/)
59
+
60
+ A command-line tool for managing LaTeX articles with git integration and Zotero bibliography synchronization.
61
+
62
+ ## Features
63
+
64
+ - **Git Release Management**: Create, list, and delete releases with gitinfo2 support
65
+ - **Zotero Integration**: Synchronize bibliography from Zotero with robust pagination and error handling
66
+ - **LaTeX Build Management**: Clean build files and manage LaTeX compilation artifacts
67
+ - **Git Hooks Setup**: Automated setup of git hooks for gitinfo2 integration
68
+ - **Local Configuration**: Support for project-specific configuration files
69
+
70
+ ## Installation
71
+
72
+ ### From PyPI (recommended)
73
+
74
+ ```bash
75
+ pip install article-cli
76
+ ```
77
+
78
+ ### From Source
79
+
80
+ ```bash
81
+ git clone https://github.com/feelpp/article.cli.git
82
+ cd article.cli
83
+ pip install -e .
84
+ ```
85
+
86
+ ## Quick Start
87
+
88
+ 1. **Setup git hooks** (run once per repository):
89
+ ```bash
90
+ article-cli setup
91
+ ```
92
+
93
+ 2. **Configure Zotero credentials** (one-time setup):
94
+ ```bash
95
+ export ZOTERO_API_KEY="your_api_key_here"
96
+ export ZOTERO_GROUP_ID="your_group_id" # or ZOTERO_USER_ID
97
+ ```
98
+
99
+ 3. **Update bibliography from Zotero**:
100
+ ```bash
101
+ article-cli update-bibtex
102
+ ```
103
+
104
+ 4. **Create a release**:
105
+ ```bash
106
+ article-cli create v1.0.0
107
+ ```
108
+
109
+ ## Configuration
110
+
111
+ ### Environment Variables
112
+
113
+ - `ZOTERO_API_KEY`: Your Zotero API key (required for bibliography updates)
114
+ - `ZOTERO_USER_ID`: Your Zotero user ID (alternative to group ID)
115
+ - `ZOTERO_GROUP_ID`: Your Zotero group ID (alternative to user ID)
116
+
117
+ ### Local Configuration File
118
+
119
+ Create a `.article-cli.toml` file in your project root for project-specific settings:
120
+
121
+ ```toml
122
+ [zotero]
123
+ api_key = "your_api_key_here"
124
+ group_id = "6219333"
125
+ # user_id = "your_user_id" # alternative to group_id
126
+ output_file = "references.bib"
127
+
128
+ [git]
129
+ auto_push = true
130
+ default_branch = "main"
131
+
132
+ [latex]
133
+ clean_extensions = [".aux", ".bbl", ".blg", ".log", ".out", ".synctex.gz"]
134
+ ```
135
+
136
+ ## Usage
137
+
138
+ ### Git Release Management
139
+
140
+ ```bash
141
+ # Create a new release
142
+ article-cli create v1.2.3
143
+
144
+ # List recent releases
145
+ article-cli list --count 10
146
+
147
+ # Delete a release
148
+ article-cli delete v1.2.3
149
+ ```
150
+
151
+ ### Bibliography Management
152
+
153
+ ```bash
154
+ # Update bibliography from Zotero
155
+ article-cli update-bibtex
156
+
157
+ # Specify custom output file
158
+ article-cli update-bibtex --output my-refs.bib
159
+
160
+ # Skip backup creation
161
+ article-cli update-bibtex --no-backup
162
+ ```
163
+
164
+ ### Project Setup
165
+
166
+ ```bash
167
+ # Setup git hooks for gitinfo2
168
+ article-cli setup
169
+
170
+ # Clean LaTeX build files
171
+ article-cli clean
172
+ ```
173
+
174
+ ### Advanced Usage
175
+
176
+ ```bash
177
+ # Override configuration via command line
178
+ article-cli update-bibtex --api-key YOUR_KEY --group-id YOUR_GROUP
179
+
180
+ # Specify custom configuration file
181
+ article-cli --config custom-config.toml update-bibtex
182
+ ```
183
+
184
+ ## Version Format
185
+
186
+ Release versions must follow the semantic versioning format:
187
+ - `vX.Y.Z` for stable releases (e.g., `v1.2.3`)
188
+ - `vX.Y.Z-pre.N` for pre-releases (e.g., `v1.2.3-pre.1`)
189
+
190
+ ## Requirements
191
+
192
+ - Python 3.8+
193
+ - Git repository with gitinfo2 package (for LaTeX integration)
194
+ - Zotero account with API access (for bibliography features)
195
+
196
+ ## License
197
+
198
+ MIT License - see LICENSE file for details.
199
+
200
+ ## Contributing
201
+
202
+ 1. Fork the repository
203
+ 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
204
+ 3. Commit your changes (`git commit -m 'Add amazing feature'`)
205
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
206
+ 5. Open a Pull Request
207
+
208
+ ## Changelog
209
+
210
+ ### v1.0.0
211
+ - Initial release
212
+ - Git release management
213
+ - Zotero bibliography synchronization
214
+ - LaTeX build file cleanup
215
+ - Configuration file support
@@ -0,0 +1,11 @@
1
+ article_cli/__init__.py,sha256=LBaDt3ml_owdcQghSIsChwgqmhuZ76O4aXTVq-rvxG0,521
2
+ article_cli/__main__.py,sha256=tKzgx5NUMWBgIxJz23H7pSPF2G0xG6waivcNTmrb4eg,134
3
+ article_cli/cli.py,sha256=cbAoPv3bfRgo7MQFRUJUFt_BYv39ucRvvyDdFam_SCw,10120
4
+ article_cli/config.py,sha256=g4xPvzpHJk-Y0fPEXsgrPOdJ90b5zBpN5yiG3saF6ns,9152
5
+ article_cli/git_manager.py,sha256=uJtzgjRmPpcXDuCBMdbf0BWpsvAtoCQtSxDSFB0836E,12017
6
+ article_cli/zotero.py,sha256=GFbetgSvfLQS9U-EA2q9sb7GKgR3jTEOhPEDV1OLmKM,12439
7
+ article_cli-1.0.0.dist-info/METADATA,sha256=gaBMHKioyy1N2nTpAnPXjZ-Ul2Wxm7wMB-RcC5a6Qhw,6537
8
+ article_cli-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ article_cli-1.0.0.dist-info/entry_points.txt,sha256=TTD3ZO2r7w6Om13VFoFu_rW1LTmYzknhNsIep-uAvBg,53
10
+ article_cli-1.0.0.dist-info/licenses/LICENSE,sha256=SjACEUOvqClPlozgN4K7uHFc39TDDikcH0YEot056l0,1077
11
+ article_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ article-cli = article_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Christophe Prud'homme
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.