sitemap2atom 0.1.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.
@@ -0,0 +1,22 @@
1
+ """sitemap2atom: convert an XML sitemap into an enriched Atom feed."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .core import (
6
+ enrich_atom_entry,
7
+ enrich_url_list_to_atom,
8
+ extract_metadata,
9
+ feed_to_pretty_xml,
10
+ fetch_sitemap_urls,
11
+ parse_metadata,
12
+ )
13
+
14
+ __all__ = [
15
+ "__version__",
16
+ "enrich_atom_entry",
17
+ "enrich_url_list_to_atom",
18
+ "extract_metadata",
19
+ "feed_to_pretty_xml",
20
+ "fetch_sitemap_urls",
21
+ "parse_metadata",
22
+ ]
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m sitemap2atom``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
sitemap2atom/cli.py ADDED
@@ -0,0 +1,83 @@
1
+ """Command-line interface for sitemap2atom."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ import click
7
+ import requests
8
+
9
+ from . import __version__
10
+ from .core import (
11
+ DEFAULT_FEED_TITLE,
12
+ enrich_url_list_to_atom,
13
+ feed_to_pretty_xml,
14
+ fetch_sitemap_urls,
15
+ )
16
+
17
+
18
+ @click.command()
19
+ @click.argument("sitemap_url")
20
+ @click.option(
21
+ "-o",
22
+ "--output",
23
+ type=click.Path(dir_okay=False, writable=True),
24
+ default=None,
25
+ help="Write the Atom feed to this file (default: stdout).",
26
+ )
27
+ @click.option(
28
+ "--limit",
29
+ type=int,
30
+ default=None,
31
+ help="Maximum number of sitemap URLs to process (default: all).",
32
+ )
33
+ @click.option(
34
+ "--feed-title",
35
+ default=DEFAULT_FEED_TITLE,
36
+ show_default=True,
37
+ help="Title for the generated Atom feed.",
38
+ )
39
+ @click.option(
40
+ "--timeout",
41
+ type=int,
42
+ default=10,
43
+ show_default=True,
44
+ help="Per-request timeout in seconds.",
45
+ )
46
+ @click.option("-v", "--verbose", is_flag=True, help="Enable info-level logging.")
47
+ @click.version_option(__version__, prog_name="sitemap2atom")
48
+ def main(sitemap_url, output, limit, feed_title, timeout, verbose):
49
+ """Convert the XML sitemap at SITEMAP_URL into an enriched Atom feed.
50
+
51
+ Each URL in the sitemap is fetched and its OpenGraph/Twitter metadata is
52
+ used to build a rich Atom entry (title, summary, image, author, dates).
53
+ """
54
+ logging.basicConfig(
55
+ level=logging.INFO if verbose else logging.WARNING,
56
+ format="%(levelname)s: %(message)s",
57
+ stream=sys.stderr,
58
+ )
59
+
60
+ try:
61
+ urls = fetch_sitemap_urls(sitemap_url, timeout=timeout)
62
+ except requests.RequestException as e:
63
+ raise click.ClickException(f"Failed to fetch sitemap {sitemap_url}: {e}")
64
+
65
+ if limit is not None:
66
+ urls = urls[:limit]
67
+
68
+ if not urls:
69
+ raise click.ClickException(f"No <loc> URLs found in sitemap: {sitemap_url}")
70
+
71
+ feed = enrich_url_list_to_atom(urls, feed_title=feed_title, timeout=timeout)
72
+ xml = feed_to_pretty_xml(feed)
73
+
74
+ if output:
75
+ with open(output, "w", encoding="utf-8") as f:
76
+ f.write(xml + "\n")
77
+ click.echo(f"Wrote {output}", err=True)
78
+ else:
79
+ click.echo(xml)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
sitemap2atom/core.py ADDED
@@ -0,0 +1,330 @@
1
+ """Core sitemap-to-Atom conversion logic.
2
+
3
+ The functions here are split so that the HTML/XML parsing is pure (no network)
4
+ and therefore unit-testable, while the network-facing helpers wrap them.
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ import uuid
10
+ from datetime import datetime
11
+ from urllib.parse import urljoin, urlparse
12
+ from xml.dom import minidom
13
+ from xml.etree.ElementTree import Element, SubElement, tostring
14
+
15
+ import dateutil.parser
16
+ import requests
17
+ from bs4 import BeautifulSoup
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # A browser-like User-Agent; some sites reject the default requests UA.
22
+ USER_AGENT = (
23
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
24
+ "(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
25
+ )
26
+
27
+ ATOM_NS = "http://www.w3.org/2005/Atom"
28
+ DEFAULT_FEED_TITLE = "Enriched URL Feed"
29
+
30
+
31
+ def parse_metadata(html, url):
32
+ """Extract Twitter and OpenGraph metadata from an HTML document.
33
+
34
+ This is the pure, network-free half of :func:`extract_metadata` and can be
35
+ tested against static HTML.
36
+
37
+ Args:
38
+ html (str | bytes): The raw HTML to parse.
39
+ url (str): The URL the HTML came from (used to resolve relative image
40
+ URLs and as a fallback site name).
41
+
42
+ Returns:
43
+ dict: Dictionary containing the extracted metadata.
44
+ """
45
+ soup = BeautifulSoup(html, "html.parser")
46
+
47
+ metadata = {
48
+ "url": url,
49
+ "title": None,
50
+ "description": None,
51
+ "image": None,
52
+ "site_name": None,
53
+ "twitter": {},
54
+ "opengraph": {},
55
+ }
56
+
57
+ # Extract OpenGraph metadata
58
+ # Collect both `og:*` and `article:*` properties. The `og:` prefix is
59
+ # stripped (e.g. og:title -> title), but `article:*` keys are kept intact
60
+ # because enrich_atom_entry() looks them up by their full name
61
+ # (article:published_time, article:modified_time, article:author).
62
+ og_tags = soup.find_all("meta", property=re.compile(r"^(og|article):"))
63
+ for tag in og_tags:
64
+ prop = tag.get("property", "")
65
+ content = tag.get("content", "")
66
+ if not prop or not content:
67
+ continue
68
+ if prop.startswith("og:"):
69
+ prop = prop[len("og:"):]
70
+ metadata["opengraph"][prop] = content
71
+
72
+ # Extract Twitter metadata
73
+ twitter_tags = soup.find_all("meta", attrs={"name": re.compile(r"^twitter:")})
74
+ for tag in twitter_tags:
75
+ name = tag.get("name", "").replace("twitter:", "")
76
+ content = tag.get("content", "")
77
+ if name and content:
78
+ metadata["twitter"][name] = content
79
+
80
+ # Populate main fields from OG or Twitter data
81
+ metadata["title"] = (
82
+ metadata["opengraph"].get("title")
83
+ or metadata["twitter"].get("title")
84
+ or (soup.find("title").get_text().strip() if soup.find("title") else None)
85
+ )
86
+
87
+ metadata["description"] = (
88
+ metadata["opengraph"].get("description")
89
+ or metadata["twitter"].get("description")
90
+ or (soup.find("meta", attrs={"name": "description"}) or {}).get("content")
91
+ )
92
+
93
+ # Handle image URLs (make absolute if relative)
94
+ image_url = metadata["opengraph"].get("image") or metadata["twitter"].get("image")
95
+ if image_url:
96
+ metadata["image"] = urljoin(url, image_url)
97
+
98
+ metadata["site_name"] = (
99
+ metadata["opengraph"].get("site_name")
100
+ or metadata["twitter"].get("site")
101
+ or urlparse(url).netloc
102
+ )
103
+
104
+ return metadata
105
+
106
+
107
+ def extract_metadata(url, timeout=10):
108
+ """Fetch ``url`` and extract Twitter and OpenGraph metadata.
109
+
110
+ Args:
111
+ url (str): The URL to extract metadata from.
112
+ timeout (int): Request timeout in seconds.
113
+
114
+ Returns:
115
+ dict: Metadata from :func:`parse_metadata`, or ``{'error': ..., 'url': ...}``
116
+ if the request or parsing fails.
117
+ """
118
+ try:
119
+ # Add scheme if missing
120
+ if not url.startswith(("http://", "https://")):
121
+ url = "https://" + url
122
+
123
+ response = requests.get(
124
+ url, headers={"User-Agent": USER_AGENT}, timeout=timeout
125
+ )
126
+ response.raise_for_status()
127
+
128
+ return parse_metadata(response.content, url)
129
+
130
+ except requests.RequestException as e:
131
+ return {"error": f"Request failed: {str(e)}", "url": url}
132
+ except Exception as e:
133
+ return {"error": f"Parsing failed: {str(e)}", "url": url}
134
+
135
+
136
+ def _utcnow_iso():
137
+ """Return the current UTC time as an Atom-friendly ISO 8601 string."""
138
+ return datetime.now().replace(microsecond=0).isoformat() + "Z"
139
+
140
+
141
+ def enrich_atom_entry(metadata, base_entry=None):
142
+ """Create or enrich an Atom ``<entry>`` element from extracted metadata.
143
+
144
+ Args:
145
+ metadata (dict): Metadata from :func:`extract_metadata`.
146
+ base_entry (Element, optional): Existing entry to enrich.
147
+
148
+ Returns:
149
+ Element: The Atom entry element.
150
+ """
151
+ if base_entry is None:
152
+ entry = Element("entry")
153
+ else:
154
+ entry = base_entry
155
+
156
+ # Title
157
+ if metadata.get("title"):
158
+ title_elem = entry.find("title")
159
+ if title_elem is None:
160
+ title_elem = SubElement(entry, "title")
161
+ title_elem.text = metadata["title"]
162
+ title_elem.set("type", "text")
163
+
164
+ # Summary/Description
165
+ if metadata.get("description"):
166
+ summary_elem = entry.find("summary")
167
+ if summary_elem is None:
168
+ summary_elem = SubElement(entry, "summary")
169
+ summary_elem.text = metadata["description"]
170
+ summary_elem.set("type", "text")
171
+
172
+ # Link to original content
173
+ if metadata.get("url"):
174
+ link_elem = SubElement(entry, "link")
175
+ link_elem.set("rel", "alternate")
176
+ link_elem.set("type", "text/html")
177
+ link_elem.set("href", metadata["url"])
178
+
179
+ # Image as enclosure
180
+ if metadata.get("image"):
181
+ enclosure_elem = SubElement(entry, "link")
182
+ enclosure_elem.set("rel", "enclosure")
183
+ # TODO: detect the actual image type instead of assuming JPEG.
184
+ enclosure_elem.set("type", "image/jpeg")
185
+ enclosure_elem.set("href", metadata["image"])
186
+
187
+ # Content type as category
188
+ og_type = metadata.get("opengraph", {}).get("type")
189
+ if og_type:
190
+ category_elem = SubElement(entry, "category")
191
+ category_elem.set("term", og_type)
192
+ category_elem.set("scheme", "http://ogp.me/ns#")
193
+
194
+ # Published date (from article metadata)
195
+ published_time = metadata.get("opengraph", {}).get("article:published_time")
196
+ if published_time:
197
+ try:
198
+ pub_date = dateutil.parser.parse(published_time)
199
+ published_elem = SubElement(entry, "published")
200
+ published_elem.text = pub_date.isoformat()
201
+ except Exception:
202
+ pass
203
+
204
+ # Updated date
205
+ modified_time = metadata.get("opengraph", {}).get("article:modified_time")
206
+ if modified_time:
207
+ try:
208
+ mod_date = dateutil.parser.parse(modified_time)
209
+ updated_elem = entry.find("updated")
210
+ if updated_elem is None:
211
+ updated_elem = SubElement(entry, "updated")
212
+ updated_elem.text = mod_date.isoformat()
213
+ except Exception:
214
+ pass
215
+
216
+ # Author (from article metadata)
217
+ author_name = metadata.get("opengraph", {}).get("article:author")
218
+ twitter_creator = metadata.get("twitter", {}).get("creator")
219
+
220
+ # Always add author element (required by Atom spec)
221
+ author_elem = SubElement(entry, "author")
222
+ SubElement(author_elem, "name").text = (
223
+ author_name or twitter_creator or metadata.get("site_name", "Unknown")
224
+ )
225
+
226
+ # Site name as source - properly structured according to Atom spec
227
+ if metadata.get("site_name"):
228
+ source_elem = SubElement(entry, "source")
229
+ # URI is required
230
+ if metadata.get("url"):
231
+ source_link = SubElement(source_elem, "link")
232
+ source_link.set("rel", "alternate")
233
+ source_link.set("type", "text/html")
234
+ source_link.set("href", metadata["url"])
235
+
236
+ # Required sub-elements for source
237
+ source_title = SubElement(source_elem, "title")
238
+ source_title.text = metadata["site_name"]
239
+
240
+ source_id = SubElement(source_elem, "id")
241
+ source_id.text = "urn:source:" + urlparse(metadata.get("url", "")).netloc
242
+
243
+ source_updated = SubElement(source_elem, "updated")
244
+ source_updated.text = _utcnow_iso()
245
+
246
+ return entry
247
+
248
+
249
+ def enrich_url_list_to_atom(urls, feed_title=DEFAULT_FEED_TITLE, timeout=10):
250
+ """Convert a list of URLs into an enriched Atom feed element.
251
+
252
+ Args:
253
+ urls (Iterable[str]): URLs to fetch and enrich.
254
+ feed_title (str): The feed's ``<title>``.
255
+ timeout (int): Per-request timeout in seconds.
256
+
257
+ Returns:
258
+ Element: The Atom ``<feed>`` root element.
259
+ """
260
+ # Create feed root
261
+ feed = Element("feed")
262
+ feed.set("xmlns", ATOM_NS)
263
+
264
+ # Feed metadata
265
+ SubElement(feed, "title").text = feed_title
266
+ # Generate a unique UUID for the feed
267
+ SubElement(feed, "id").text = "urn:uuid:" + str(uuid.uuid4())
268
+ SubElement(feed, "updated").text = _utcnow_iso()
269
+
270
+ # Add required self link (required by validators)
271
+ self_link = SubElement(feed, "link")
272
+ self_link.set("rel", "self")
273
+ self_link.set("type", "application/atom+xml")
274
+ self_link.set("href", "file:///enriched_feed.atom")
275
+
276
+ for url in urls:
277
+ metadata = extract_metadata(url, timeout=timeout)
278
+ if "error" in metadata:
279
+ logger.warning("Skipping %s: %s", url, metadata["error"])
280
+ continue
281
+
282
+ entry = enrich_atom_entry(metadata)
283
+ # Add required ID and updated if missing
284
+ if entry.find("id") is None:
285
+ id_elem = SubElement(entry, "id")
286
+ id_elem.text = url.strip() # Ensure no whitespace
287
+ if entry.find("updated") is None:
288
+ updated_elem = SubElement(entry, "updated")
289
+ updated_elem.text = _utcnow_iso()
290
+
291
+ feed.append(entry)
292
+
293
+ return feed
294
+
295
+
296
+ def fetch_sitemap_urls(sitemap_url, timeout=10):
297
+ """Fetch a sitemap and return the list of ``<loc>`` URLs it contains.
298
+
299
+ Args:
300
+ sitemap_url (str): URL of the XML sitemap.
301
+ timeout (int): Request timeout in seconds.
302
+
303
+ Returns:
304
+ list[str]: The URLs found in the sitemap.
305
+ """
306
+ logger.info("Fetching sitemap: %s", sitemap_url)
307
+ response = requests.get(
308
+ sitemap_url, timeout=timeout, headers={"User-Agent": USER_AGENT}
309
+ )
310
+ response.raise_for_status()
311
+ soup = BeautifulSoup(response.content, "xml")
312
+ urls = [loc.text for loc in soup.find_all("loc")]
313
+ logger.info("Found %d URLs in the sitemap.", len(urls))
314
+ return urls
315
+
316
+
317
+ def feed_to_pretty_xml(feed):
318
+ """Serialise an Atom feed element to a pretty-printed XML string.
319
+
320
+ Args:
321
+ feed (Element): The Atom feed element.
322
+
323
+ Returns:
324
+ str: Indented, UTF-8 XML with blank lines collapsed.
325
+ """
326
+ rough_string = tostring(feed, encoding="utf-8")
327
+ pretty = minidom.parseString(rough_string)
328
+ formatted = pretty.toprettyxml(indent=" ", encoding="utf-8").decode("utf-8")
329
+ # Remove the extra blank lines minidom adds
330
+ return "\n".join(line for line in formatted.split("\n") if line.strip())
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: sitemap2atom
3
+ Version: 0.1.0
4
+ Summary: A tool to convert XML sitemaps to Atom feeds
5
+ Project-URL: homepage, https://github.com/darkflib/sitemap2atom
6
+ Project-URL: repository, https://github.com/darkflib/sitemap2atom
7
+ Project-URL: issues, https://github.com/darkflib/sitemap2atom/issues
8
+ Author-email: Mike Preston <darkflib@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: atom,feed,opengraph,rss,sitemap,syndication
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Topic :: Text Processing :: Markup :: XML
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: beautifulsoup4>=4.9.3
23
+ Requires-Dist: click>=7.1.2
24
+ Requires-Dist: lxml>=4.6.3
25
+ Requires-Dist: python-dateutil>=2.8.1
26
+ Requires-Dist: requests>=2.25.1
27
+ Description-Content-Type: text/markdown
28
+
29
+ # sitemap2atom
30
+
31
+ A simple tool to convert an XML sitemap into an [Atom](https://datatracker.ietf.org/doc/html/rfc4287)
32
+ feed — especially useful for sites that don't have a CMS, or where the CMS
33
+ doesn't produce a feed. Each URL in the sitemap is fetched and its OpenGraph and
34
+ Twitter Card metadata (title, description, image, author, dates) is used to build
35
+ a rich Atom entry.
36
+
37
+ ## Installation
38
+
39
+ ### Run without installing (uvx)
40
+
41
+ Once published to PyPI you can run it directly with
42
+ [uv](https://docs.astral.sh/uv/):
43
+
44
+ ```bash
45
+ uvx sitemap2atom https://example.com/sitemap.xml -o feed.atom
46
+ ```
47
+
48
+ To run the latest code straight from GitHub (before a release, or to try `main`):
49
+
50
+ ```bash
51
+ uvx --from git+https://github.com/darkflib/sitemap2atom sitemap2atom https://example.com/sitemap.xml
52
+ ```
53
+
54
+ ### Install as a tool / library
55
+
56
+ ```bash
57
+ uv tool install sitemap2atom # installs the `sitemap2atom` command
58
+ # or
59
+ pip install sitemap2atom
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ```bash
65
+ sitemap2atom SITEMAP_URL [OPTIONS]
66
+ ```
67
+
68
+ By default the feed is written to standard output; redirect it or use `-o` to
69
+ save it to a file:
70
+
71
+ ```bash
72
+ # Print to stdout
73
+ sitemap2atom https://example.com/sitemap.xml
74
+
75
+ # Write to a file, limiting to the first 20 URLs
76
+ sitemap2atom https://example.com/sitemap.xml -o feed.atom --limit 20
77
+ ```
78
+
79
+ ### Options
80
+
81
+ - `-o, --output PATH` — write the Atom feed to this file (default: stdout).
82
+ - `--limit N` — maximum number of sitemap URLs to process (default: all).
83
+ - `--feed-title TEXT` — title for the generated feed (default: `Enriched URL Feed`).
84
+ - `--timeout SECONDS` — per-request timeout in seconds (default: `10`).
85
+ - `-v, --verbose` — enable info-level logging on stderr.
86
+ - `--version` — show the version and exit.
87
+
88
+ ### As a library
89
+
90
+ ```python
91
+ from sitemap2atom import fetch_sitemap_urls, enrich_url_list_to_atom, feed_to_pretty_xml
92
+
93
+ urls = fetch_sitemap_urls("https://example.com/sitemap.xml")
94
+ feed = enrich_url_list_to_atom(urls[:10], feed_title="My Feed")
95
+ print(feed_to_pretty_xml(feed))
96
+ ```
97
+
98
+ ## Example output
99
+
100
+ See this gist for a sample of the kind of enriched Atom feed produced:
101
+ <https://gist.github.com/Darkflib/989b8f3a5a1ea995e8e294669d5e282a>
102
+
103
+ ## Limitations
104
+
105
+ This is a simple tool aimed at basic use cases. It does not support
106
+ authentication, sitemap index files / pagination, or dynamic sitemaps, and may
107
+ not handle every sitemap or page format. Treat the sitemap and the pages it
108
+ references as untrusted input and run it against sources you trust.
109
+
110
+ ## Development
111
+
112
+ This project uses [uv](https://docs.astral.sh/uv/).
113
+
114
+ ```bash
115
+ git clone https://github.com/darkflib/sitemap2atom.git
116
+ cd sitemap2atom
117
+ uv sync
118
+ uv run pytest
119
+ ```
120
+
121
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for more, and
122
+ [CHANGELOG.md](CHANGELOG.md) for release notes.
123
+
124
+ ## License
125
+
126
+ This project is licensed under the MIT License — see the [LICENSE](LICENSE) file
127
+ for details.
128
+
129
+ PS. If you do anything interesting with this code, please let me know! I'd love
130
+ to hear about it.
@@ -0,0 +1,9 @@
1
+ sitemap2atom/__init__.py,sha256=j9PPwb-KmQ1X738FtMKaWWtZtVknr-6K1eD7zAsCuGQ,447
2
+ sitemap2atom/__main__.py,sha256=rsK2pRG6HCvqTIbbg4KFBZ9YpDqSDvdaXwglXttcyyU,103
3
+ sitemap2atom/cli.py,sha256=82bl8kNXdeY6sbWl9GPa-63GFpTJs_IUwskxZWPYC2E,2194
4
+ sitemap2atom/core.py,sha256=YDQZQO6Z-8GkM6jMID6enbNBGuo-ixw5HhE7PF94QFo,11120
5
+ sitemap2atom-0.1.0.dist-info/METADATA,sha256=fpEm1aGEUox53g9Rgc52JaW29-CBBHmuJOONUELDN50,4066
6
+ sitemap2atom-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ sitemap2atom-0.1.0.dist-info/entry_points.txt,sha256=hr-tATkjEjaYlqqn1D8zALcDkaDjGnvig-nKBT8wVks,55
8
+ sitemap2atom-0.1.0.dist-info/licenses/LICENSE,sha256=Rb2AZV2we4Key-5FTjEh9ip-0Rao6s5Raj4_vVrxHgk,1069
9
+ sitemap2atom-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sitemap2atom = sitemap2atom.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mike Preston
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.