gmail-streamer 0.2.2__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 @@
1
+ __version__ = "0.2.1"
@@ -0,0 +1 @@
1
+ GIT_HASH = "unknown"
gmail_streamer/auth.py ADDED
@@ -0,0 +1,33 @@
1
+ from pathlib import Path
2
+
3
+ from google.auth.transport.requests import Request
4
+ from google.oauth2.credentials import Credentials
5
+ from google_auth_oauthlib.flow import InstalledAppFlow
6
+ from googleapiclient.discovery import build
7
+
8
+ SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
9
+
10
+
11
+ def get_gmail_service(profile_dir: Path):
12
+ creds_path = profile_dir / "credentials.json"
13
+ token_path = profile_dir / "token.json"
14
+
15
+ creds = None
16
+ if token_path.exists():
17
+ creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
18
+
19
+ if not creds or not creds.valid:
20
+ if creds and creds.expired and creds.refresh_token:
21
+ creds.refresh(Request())
22
+ else:
23
+ if not creds_path.exists():
24
+ raise FileNotFoundError(
25
+ f"OAuth credentials not found: {creds_path}\n"
26
+ "Download from Google Cloud Console and place in profile directory."
27
+ )
28
+ flow = InstalledAppFlow.from_client_secrets_file(str(creds_path), SCOPES)
29
+ creds = flow.run_local_server(port=0)
30
+ with open(token_path, "w") as f:
31
+ f.write(creds.to_json())
32
+
33
+ return build("gmail", "v1", credentials=creds)
gmail_streamer/cli.py ADDED
@@ -0,0 +1,145 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+ import yaml
5
+
6
+ from gmail_streamer.auth import get_gmail_service
7
+ from gmail_streamer.config import load_config
8
+ from gmail_streamer.gmail_client import (
9
+ fetch_attachments,
10
+ fetch_message_metadata,
11
+ fetch_raw_message,
12
+ search_messages,
13
+ )
14
+ from gmail_streamer.paths import get_profiles_dir, list_profiles, resolve_profile
15
+ from gmail_streamer.storage import save_attachments, save_eml, save_metadata, scan_downloaded_metadata
16
+
17
+
18
+ @click.group()
19
+ @click.option(
20
+ "--profile-dir",
21
+ envvar="GMAIL_STREAMER_PROFILE_DIR",
22
+ default=None,
23
+ type=click.Path(file_okay=False),
24
+ help="Override profiles directory.",
25
+ )
26
+ @click.pass_context
27
+ def main(ctx, profile_dir):
28
+ """Download Gmail messages matching configurable filters."""
29
+ ctx.ensure_object(dict)
30
+ ctx.obj["profiles_dir"] = get_profiles_dir(profile_dir)
31
+
32
+
33
+ @main.command()
34
+ @click.argument("profile")
35
+ @click.pass_context
36
+ def run(ctx, profile):
37
+ """Download messages for a profile."""
38
+ profiles_dir = ctx.obj["profiles_dir"]
39
+ profile_path = resolve_profile(profile, profiles_dir)
40
+
41
+ if not profile_path.is_dir():
42
+ raise click.ClickException(f"Profile directory not found: {profile_path}")
43
+
44
+ config = load_config(profile_path)
45
+ target = Path(config.target_directory)
46
+ target.mkdir(parents=True, exist_ok=True)
47
+
48
+ click.echo(f"Authenticating profile '{profile_path.name}'...")
49
+ service = get_gmail_service(profile_path)
50
+
51
+ downloaded_ids, most_recent_date = scan_downloaded_metadata(target)
52
+ if most_recent_date:
53
+ click.echo(f"Resuming from {most_recent_date} ({len(downloaded_ids)} already downloaded)")
54
+
55
+ click.echo(f"Searching: {config.filter}")
56
+ msg_ids = search_messages(service, config.filter, after_date=most_recent_date)
57
+ new_ids = [mid for mid in msg_ids if mid not in downloaded_ids]
58
+ click.echo(f"Found {len(msg_ids)} messages, {len(new_ids)} new.")
59
+
60
+ for i, msg_id in enumerate(new_ids, 1):
61
+ click.echo(f"[{i}/{len(new_ids)}] Downloading {msg_id}...")
62
+
63
+ metadata = fetch_message_metadata(service, msg_id)
64
+ date = metadata["date"]
65
+
66
+ if config.mode == "full":
67
+ raw = fetch_raw_message(service, msg_id)
68
+ save_eml(target, msg_id, date, raw)
69
+ attachments = fetch_attachments(service, msg_id)
70
+ if attachments:
71
+ save_attachments(target, msg_id, date, attachments)
72
+
73
+ elif config.mode == "attachments_only":
74
+ attachments = fetch_attachments(service, msg_id)
75
+ if attachments:
76
+ save_attachments(target, msg_id, date, attachments)
77
+ else:
78
+ click.echo(f" No attachments for {msg_id}")
79
+
80
+ save_metadata(target, msg_id, date, metadata)
81
+
82
+ click.echo("Done.")
83
+
84
+
85
+ @main.group("profiles")
86
+ def profiles_group():
87
+ """Manage profiles."""
88
+
89
+
90
+ @profiles_group.command("list")
91
+ @click.pass_context
92
+ def profiles_list(ctx):
93
+ """List available profiles."""
94
+ profiles_dir = ctx.obj["profiles_dir"]
95
+ names = list_profiles(profiles_dir)
96
+ if not names:
97
+ click.echo(f"No profiles found in {profiles_dir}")
98
+ return
99
+ click.echo(f"Profiles in {profiles_dir}:\n")
100
+ for name in names:
101
+ click.echo(f" {name}")
102
+
103
+
104
+ @profiles_group.command("init")
105
+ @click.argument("name")
106
+ @click.pass_context
107
+ def profiles_init(ctx, name):
108
+ """Scaffold a new profile directory with a template config."""
109
+ profiles_dir = ctx.obj["profiles_dir"]
110
+ profile_dir = profiles_dir / name
111
+
112
+ if profile_dir.exists():
113
+ raise click.ClickException(f"Profile already exists: {profile_dir}")
114
+
115
+ profile_dir.mkdir(parents=True)
116
+ config = {
117
+ "filter": 'from:example@gmail.com has:attachment',
118
+ "target_directory": "./downloads",
119
+ "mode": "full",
120
+ }
121
+ (profile_dir / "config.yaml").write_text(yaml.dump(config, default_flow_style=False))
122
+ click.echo(f"Created profile at {profile_dir}")
123
+ click.echo("Next steps:")
124
+ click.echo(f" 1. Edit {profile_dir / 'config.yaml'} with your filter")
125
+ click.echo(f" 2. Copy your credentials.json into {profile_dir}")
126
+ click.echo(f" 3. Run: gmail-streamer run {name}")
127
+
128
+
129
+ @profiles_group.command("show")
130
+ @click.argument("name")
131
+ @click.pass_context
132
+ def profiles_show(ctx, name):
133
+ """Show a profile's configuration."""
134
+ profiles_dir = ctx.obj["profiles_dir"]
135
+ profile_path = resolve_profile(name, profiles_dir)
136
+
137
+ if not profile_path.is_dir():
138
+ raise click.ClickException(f"Profile not found: {profile_path}")
139
+
140
+ config_file = profile_path / "config.yaml"
141
+ if not config_file.exists():
142
+ raise click.ClickException(f"No config.yaml in {profile_path}")
143
+
144
+ click.echo(f"Profile: {profile_path.name} ({profile_path})\n")
145
+ click.echo(config_file.read_text())
@@ -0,0 +1,28 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+ import yaml
5
+
6
+
7
+ @dataclass
8
+ class ProfileConfig:
9
+ filter: str
10
+ target_directory: str
11
+ mode: str = "full" # "full" or "attachments_only"
12
+
13
+ def __post_init__(self):
14
+ if self.mode not in ("full", "attachments_only"):
15
+ raise ValueError(f"Invalid mode: {self.mode!r}. Must be 'full' or 'attachments_only'.")
16
+
17
+
18
+ def load_config(profile_dir: Path) -> ProfileConfig:
19
+ config_path = profile_dir / "config.yaml"
20
+ if not config_path.exists():
21
+ raise FileNotFoundError(f"Config not found: {config_path}")
22
+ with open(config_path) as f:
23
+ data = yaml.safe_load(f)
24
+ config = ProfileConfig(**data)
25
+ target = Path(config.target_directory)
26
+ if not target.is_absolute():
27
+ config.target_directory = str((profile_dir / target).resolve())
28
+ return config
@@ -0,0 +1,68 @@
1
+ import base64
2
+ from datetime import datetime, timezone
3
+
4
+
5
+ def search_messages(service, query: str, after_date: str | None = None) -> list[str]:
6
+ """Return all message IDs matching the query.
7
+
8
+ If after_date (YYYY-MM-DD) is provided, appends 'after:{date}' to narrow results.
9
+ """
10
+ if after_date:
11
+ query = f"{query} after:{after_date}"
12
+ ids = []
13
+ request = service.users().messages().list(userId="me", q=query)
14
+ while request:
15
+ response = request.execute()
16
+ for msg in response.get("messages", []):
17
+ ids.append(msg["id"])
18
+ request = service.users().messages().list_next(request, response)
19
+ return ids
20
+
21
+
22
+ def fetch_raw_message(service, msg_id: str) -> bytes:
23
+ """Fetch the full RFC 2822 message as bytes."""
24
+ msg = service.users().messages().get(userId="me", id=msg_id, format="raw").execute()
25
+ return base64.urlsafe_b64decode(msg["raw"])
26
+
27
+
28
+ def fetch_message_metadata(service, msg_id: str) -> dict:
29
+ """Fetch message metadata and return a dict with key fields."""
30
+ msg = service.users().messages().get(
31
+ userId="me", id=msg_id, format="metadata",
32
+ metadataHeaders=["From", "To", "Subject", "Date"],
33
+ ).execute()
34
+
35
+ headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
36
+ internal_ts = int(msg.get("internalDate", "0")) / 1000
37
+ internal_date = datetime.fromtimestamp(internal_ts, tz=timezone.utc).strftime("%Y-%m-%d")
38
+
39
+ return {
40
+ "id": msg_id,
41
+ "date": internal_date,
42
+ "subject": headers.get("Subject", ""),
43
+ "from": headers.get("From", ""),
44
+ "to": headers.get("To", ""),
45
+ "snippet": msg.get("snippet", ""),
46
+ "label_ids": msg.get("labelIds", []),
47
+ }
48
+
49
+
50
+ def fetch_attachments(service, msg_id: str) -> list[dict]:
51
+ """Return list of {filename, data} for each attachment."""
52
+ msg = service.users().messages().get(userId="me", id=msg_id).execute()
53
+ attachments = []
54
+ for part in msg.get("payload", {}).get("parts", []):
55
+ filename = part.get("filename")
56
+ body = part.get("body", {})
57
+ attachment_id = body.get("attachmentId")
58
+ if filename and attachment_id:
59
+ att = (
60
+ service.users()
61
+ .messages()
62
+ .attachments()
63
+ .get(userId="me", messageId=msg_id, id=attachment_id)
64
+ .execute()
65
+ )
66
+ data = base64.urlsafe_b64decode(att["data"])
67
+ attachments.append({"filename": filename, "data": data})
68
+ return attachments
@@ -0,0 +1,42 @@
1
+ from pathlib import Path
2
+
3
+ DEFAULT_BASE_DIR = Path.home() / ".gmail-streamer"
4
+ DEFAULT_PROFILES_DIR = DEFAULT_BASE_DIR / "profiles"
5
+
6
+
7
+ def get_profiles_dir(override: str | None = None) -> Path:
8
+ """Return the active profiles directory.
9
+
10
+ Priority:
11
+ 1. Explicit override (--profile-dir flag)
12
+ 2. ./profiles/ in CWD (if it exists)
13
+ 3. ~/.gmail-streamer/profiles/ (fallback)
14
+ """
15
+ if override:
16
+ return Path(override).resolve()
17
+ cwd_profiles = Path.cwd() / "profiles"
18
+ if cwd_profiles.is_dir():
19
+ return cwd_profiles.resolve()
20
+ return DEFAULT_PROFILES_DIR
21
+
22
+
23
+ def resolve_profile(name: str, profiles_dir: Path) -> Path:
24
+ """Resolve a profile name or path to a directory.
25
+
26
+ If `name` is an existing directory, use it directly (backward compat).
27
+ Otherwise look it up as {profiles_dir}/{name}/.
28
+ """
29
+ candidate = Path(name)
30
+ if candidate.is_dir():
31
+ return candidate.resolve()
32
+ return profiles_dir / name
33
+
34
+
35
+ def list_profiles(profiles_dir: Path) -> list[str]:
36
+ """Return profile names (subdirectories containing config.yaml)."""
37
+ if not profiles_dir.is_dir():
38
+ return []
39
+ return sorted(
40
+ d.name for d in profiles_dir.iterdir()
41
+ if d.is_dir() and (d / "config.yaml").exists()
42
+ )
@@ -0,0 +1,50 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+
5
+ def _short_id(msg_id: str) -> str:
6
+ return msg_id[:8]
7
+
8
+
9
+ def save_eml(target_dir: Path, msg_id: str, date: str, raw: bytes):
10
+ """Save .eml file with date and short ID."""
11
+ target_dir.mkdir(parents=True, exist_ok=True)
12
+ (target_dir / f"{date} - {_short_id(msg_id)}.eml").write_bytes(raw)
13
+
14
+
15
+ def save_metadata(target_dir: Path, msg_id: str, date: str, metadata: dict):
16
+ """Save metadata JSON with date and short ID."""
17
+ target_dir.mkdir(parents=True, exist_ok=True)
18
+ path = target_dir / f"{date} - {_short_id(msg_id)}.json"
19
+ path.write_text(json.dumps(metadata, indent=2, ensure_ascii=False))
20
+
21
+
22
+ def save_attachments(target_dir: Path, msg_id: str, date: str, attachments: list[dict]):
23
+ """Save attachments flat in target_dir with date and short ID."""
24
+ target_dir.mkdir(parents=True, exist_ok=True)
25
+ sid = _short_id(msg_id)
26
+ for att in attachments:
27
+ (target_dir / f"{date} - {sid} - {att['filename']}").write_bytes(att["data"])
28
+
29
+
30
+ def scan_downloaded_metadata(target_dir: Path) -> tuple[set[str], str | None]:
31
+ """Scan metadata JSON files to derive downloaded IDs and most recent date.
32
+
33
+ Returns (downloaded_ids, most_recent_date_or_none).
34
+ """
35
+ downloaded_ids: set[str] = set()
36
+ most_recent_date: str | None = None
37
+
38
+ for meta_path in target_dir.glob("* - *.json"):
39
+ try:
40
+ meta = json.loads(meta_path.read_text())
41
+ except (json.JSONDecodeError, OSError):
42
+ continue
43
+ msg_id = meta.get("id")
44
+ date = meta.get("date")
45
+ if msg_id:
46
+ downloaded_ids.add(msg_id)
47
+ if date and (most_recent_date is None or date > most_recent_date):
48
+ most_recent_date = date
49
+
50
+ return downloaded_ids, most_recent_date
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: gmail-streamer
3
+ Version: 0.2.2
4
+ Summary: CLI tool to download Gmail messages matching configurable filters via OAuth2
5
+ Project-URL: Homepage, https://github.com/tsilva/gmail-streamer
6
+ Project-URL: Repository, https://github.com/tsilva/gmail-streamer
7
+ Project-URL: Issues, https://github.com/tsilva/gmail-streamer/issues
8
+ Author: Tiago Silva
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: attachments,backup,cli,download,email,gmail,google,oauth2
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Communications :: Email
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: click
23
+ Requires-Dist: google-api-python-client
24
+ Requires-Dist: google-auth-httplib2
25
+ Requires-Dist: google-auth-oauthlib
26
+ Requires-Dist: pyyaml
27
+ Description-Content-Type: text/markdown
28
+
29
+ <div align="center">
30
+ <img src="logo.png" alt="gmail-streamer" width="512"/>
31
+
32
+ [![Python](https://img.shields.io/badge/Python-3.12+-blue.svg)](https://python.org)
33
+ [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
34
+
35
+ **📧 Download Gmail messages matching your filters to local files 📥**
36
+
37
+ </div>
38
+
39
+ ## ✨ Features
40
+
41
+ - **🗂️ Profile-based configuration** — run multiple independent download profiles, each with its own filters, credentials, and output directory
42
+ - **🔐 OAuth2 authentication** — secure Google sign-in with automatic token caching
43
+ - **📧 Full message download** — save complete `.eml` files for archival
44
+ - **📎 Attachments-only mode** — grab just the attachments, skip the rest
45
+ - **🧠 Incremental downloads** — remembers what's already been downloaded, no duplicates across runs
46
+ - **🔍 Gmail search filters** — use any Gmail search query (`from:`, `has:attachment`, `after:`, label filters, etc.)
47
+ - **🏠 Works from anywhere** — install globally with `uv` and run from any directory
48
+
49
+ ## 🚀 Quick Start
50
+
51
+ ### 1. Install
52
+
53
+ ```bash
54
+ git clone https://github.com/tsilva/gmail-streamer.git
55
+ cd gmail-streamer
56
+ uv tool install . --force --no-cache
57
+ ```
58
+
59
+ ### 2. Create a profile
60
+
61
+ ```bash
62
+ gmail-streamer profiles init my-profile
63
+ ```
64
+
65
+ This creates `~/.gmail-streamer/profiles/my-profile/` with a template `config.yaml`.
66
+
67
+ ### 3. Add your Gmail OAuth credentials
68
+
69
+ Place your `credentials.json` (from [Google Cloud Console](https://console.cloud.google.com/apis/credentials)) into the profile directory.
70
+
71
+ ### 4. Configure the profile
72
+
73
+ Edit `~/.gmail-streamer/profiles/my-profile/config.yaml`:
74
+
75
+ ```yaml
76
+ filter: "from:example@gmail.com has:attachment"
77
+ target_directory: "./downloads"
78
+ mode: "full" # or "attachments_only"
79
+ ```
80
+
81
+ ### 5. Run
82
+
83
+ ```bash
84
+ gmail-streamer run my-profile
85
+ ```
86
+
87
+ On first run, a browser window opens for OAuth authorization. Subsequent runs reuse the cached token.
88
+
89
+ ## 📁 Profile Resolution
90
+
91
+ The profiles directory is resolved in this order:
92
+
93
+ 1. `--profile-dir` flag or `GMAIL_STREAMER_PROFILE_DIR` env var
94
+ 2. `./profiles/` in the current working directory (if it exists)
95
+ 3. `~/.gmail-streamer/profiles/` (default)
96
+
97
+ The `profile` argument can be a **name** (looked up in the profiles directory) or a **path** to an existing directory (backward compatible).
98
+
99
+ ## 🛠️ CLI Reference
100
+
101
+ ```bash
102
+ gmail-streamer run <profile> # Download messages
103
+ gmail-streamer --profile-dir /path run <profile> # Custom profiles directory
104
+ gmail-streamer profiles list # List available profiles
105
+ gmail-streamer profiles init <name> # Scaffold a new profile
106
+ gmail-streamer profiles show <name> # Show profile config
107
+ ```
108
+
109
+ ## ⚙️ Profile Structure
110
+
111
+ Each profile lives in its own directory with:
112
+
113
+ | File | Purpose |
114
+ |------|---------|
115
+ | `config.yaml` | Filter query, target directory, download mode |
116
+ | `credentials.json` | OAuth client credentials (you provide this) |
117
+ | `token.json` | Auto-generated after first OAuth flow |
118
+
119
+ ## 🏗️ Architecture
120
+
121
+ | Module | Responsibility |
122
+ |--------|---------------|
123
+ | `cli.py` | Click CLI entry point (group with `run` and `profiles` subcommands) |
124
+ | `paths.py` | Profile directory resolution and discovery |
125
+ | `config.py` | Loads and validates `config.yaml` into a `ProfileConfig` dataclass |
126
+ | `auth.py` | OAuth2 flow with token caching |
127
+ | `gmail_client.py` | Gmail API wrapper: search, fetch messages, fetch attachments |
128
+ | `storage.py` | Saves `.eml` files and attachments to disk |
129
+
130
+ ## 📄 License
131
+
132
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ gmail_streamer/__init__.py,sha256=HfjVOrpTnmZ-xVFCYSVmX50EXaBQeJteUHG-PD6iQs8,22
2
+ gmail_streamer/auth.py,sha256=Mt3UJA4NbCrIgiKbbgODHh9HmuxVhsZPkU7tBzfr9OY,1220
3
+ gmail_streamer/cli.py,sha256=m-0OgJja9Fjj820yZJH80CS04bPsQ8L5lf4ExpnBuD4,4791
4
+ gmail_streamer/config.py,sha256=GRgsuRMQ7uNM6H-iwAbtoDE2aHIV9UY4HvJKYqMuF_w,860
5
+ gmail_streamer/gmail_client.py,sha256=hc0gahvw2rP3qpRlvSX3AB-2Y_2kEX_tQza9jw0j4qU,2544
6
+ gmail_streamer/paths.py,sha256=TYErMHc1Nh5EUImzEh2k1tw7omAnldvmMEnW71t0T3Q,1269
7
+ gmail_streamer/storage.py,sha256=uzUDIXBgQmXd111c-zE7usxZ7Vqfe0KFlpLcDaXMqBI,1773
8
+ gmail_streamer/_build_info.py,sha256=u78KhLyvgyWjUoCs30k7OxpPsuL-xdvmXoUoRn3hk4Q,21
9
+ gmail_streamer-0.2.2.dist-info/METADATA,sha256=3i-EMPwL1_5PYpC8RH74G3AZ3W1vuHoGfA19Gk_UhAg,4689
10
+ gmail_streamer-0.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
11
+ gmail_streamer-0.2.2.dist-info/entry_points.txt,sha256=hfb9lqqUjanoVn2vwdhNefsoZn8c5tYMdNT6Jt4fQFo,59
12
+ gmail_streamer-0.2.2.dist-info/licenses/LICENSE,sha256=gTrdDdqFDu7VtWezebCYIHmebmc-XTVxqWTZGKvDux0,1068
13
+ gmail_streamer-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gmail-streamer = gmail_streamer.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tiago Silva
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.