subpeeko 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.
subpeeko/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Create English-learning subtitles from bilingual ASS files."""
2
+
3
+ __version__ = "0.1.0"
subpeeko/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
subpeeko/checkpoint.py ADDED
@@ -0,0 +1,88 @@
1
+ """Local checkpoints for model results already reviewed for an exact input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import sqlite3
8
+ import sys
9
+ from pathlib import Path
10
+ from threading import Lock
11
+
12
+ from . import model, subtitles, vocabulary
13
+ from .config import Config
14
+
15
+
16
+ class CheckpointError(Exception):
17
+ pass
18
+
19
+
20
+ def cache_dir() -> Path:
21
+ if sys.platform == "win32":
22
+ root = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
23
+ else:
24
+ root = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
25
+ if not root.is_absolute():
26
+ root = Path.home() / ".cache"
27
+ return root / "subpeeko" / "checkpoints"
28
+
29
+
30
+ def checkpoint_key(path: Path, source_hash: bytes, config: Config, level: str, *, batch_size: int,
31
+ thinking: str, max_output_tokens: int) -> str:
32
+ digest = hashlib.sha256()
33
+ try:
34
+ values = (
35
+ b"subpeeko-checkpoint-v1", str(path.resolve()).encode(), source_hash,
36
+ config.base_url.encode(), config.model_name.encode(), config.api_key.encode(),
37
+ level.encode(), str(batch_size).encode(), thinking.encode(), str(max_output_tokens).encode(),
38
+ Path(model.__file__).read_bytes(), Path(subtitles.__file__).read_bytes(),
39
+ Path(vocabulary.__file__).read_bytes(),
40
+ vocabulary.files("subpeeko").joinpath("data/vocabulary.json").read_bytes(),
41
+ )
42
+ except OSError as exc:
43
+ raise CheckpointError("Cannot read checkpoint inputs") from exc
44
+ for value in values:
45
+ digest.update(len(value).to_bytes(8, "big"))
46
+ digest.update(value)
47
+ return digest.hexdigest()
48
+
49
+
50
+ class Checkpoint:
51
+ def __init__(self, key: str):
52
+ self.lock = Lock()
53
+ self.connection = None
54
+ try:
55
+ root = cache_dir()
56
+ root.mkdir(mode=0o700, parents=True, exist_ok=True)
57
+ path = root / f"{key}.sqlite3"
58
+ descriptor = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
59
+ os.close(descriptor)
60
+ self.connection = sqlite3.connect(path, timeout=30, check_same_thread=False)
61
+ self.connection.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, note TEXT NOT NULL)")
62
+ self.connection.commit()
63
+ except (OSError, sqlite3.Error) as exc:
64
+ if self.connection is not None:
65
+ self.connection.close()
66
+ raise CheckpointError("Cannot open the local checkpoint; use --no-resume to disable it") from exc
67
+
68
+ def load(self, valid_ids: set[int]) -> dict[int, str]:
69
+ try:
70
+ with self.lock:
71
+ rows = self.connection.execute("SELECT id, note FROM notes").fetchall()
72
+ if any(type(identifier) is not int or identifier not in valid_ids or not isinstance(note, str)
73
+ or any(char in note for char in "{}\\\r\n") for identifier, note in rows):
74
+ raise CheckpointError("Local checkpoint contains invalid results; use --no-resume")
75
+ return dict(rows)
76
+ except sqlite3.Error as exc:
77
+ raise CheckpointError("Cannot read the local checkpoint; use --no-resume") from exc
78
+
79
+ def save(self, notes: dict[int, str]) -> None:
80
+ try:
81
+ with self.lock, self.connection:
82
+ self.connection.executemany("INSERT OR REPLACE INTO notes (id, note) VALUES (?, ?)", notes.items())
83
+ except sqlite3.Error as exc:
84
+ raise CheckpointError("Cannot save the local checkpoint; completed batches remain available") from exc
85
+
86
+ def close(self) -> None:
87
+ if self.connection is not None:
88
+ self.connection.close()
subpeeko/cli.py ADDED
@@ -0,0 +1,266 @@
1
+ """Command-line entry point for SubPeeko."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import math
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from . import __version__
14
+ from .checkpoint import Checkpoint, CheckpointError, checkpoint_key
15
+ from .config import ConfigError, configure, load_config, show_config
16
+ from .model import BATCH_SIZE, DEFAULT_CONCURRENCY, DEFAULT_RPM, DEFAULT_TPM, ModelClient, ModelError
17
+ from .subtitles import AssFile, SubtitleError
18
+ from .vocabulary import LEVELS
19
+
20
+ OUTPUT_SUFFIX = ".subpeeko.ass"
21
+
22
+
23
+ def _batch_size(value: str) -> int:
24
+ try:
25
+ number = int(value)
26
+ if 1 <= number <= 100:
27
+ return number
28
+ except ValueError:
29
+ pass
30
+ raise argparse.ArgumentTypeError("batch size must be an integer between 1 and 100")
31
+
32
+
33
+ def _positive_int(value: str) -> int:
34
+ try:
35
+ number = int(value)
36
+ if number > 0:
37
+ return number
38
+ except ValueError:
39
+ pass
40
+ raise argparse.ArgumentTypeError("must be a positive integer")
41
+
42
+
43
+ def _positive_float(value: str) -> float:
44
+ try:
45
+ number = float(value)
46
+ if math.isfinite(number) and number > 0:
47
+ return number
48
+ except ValueError:
49
+ pass
50
+ raise argparse.ArgumentTypeError("must be a positive finite number")
51
+
52
+
53
+ def _nonnegative_int(value: str) -> int:
54
+ try:
55
+ number = int(value)
56
+ if number >= 0:
57
+ return number
58
+ except ValueError:
59
+ pass
60
+ raise argparse.ArgumentTypeError("must be a nonnegative integer")
61
+
62
+
63
+ def parser() -> argparse.ArgumentParser:
64
+ result = argparse.ArgumentParser(
65
+ prog="subpeeko",
66
+ description="Create English-learning subtitles from bilingual ASS files.",
67
+ epilog="Run 'subpeeko config' to configure the model API."
68
+ )
69
+ result.add_argument("input", type=Path, help="an ASS file or a directory of ASS files")
70
+ result.add_argument("--level", choices=LEVELS, required=True, help="the learner's English level")
71
+ result.add_argument("--recursive", action="store_true", help="include subdirectories")
72
+ result.add_argument("--overwrite", action="store_true", help="replace existing generated output")
73
+ result.add_argument("--dry-run", action="store_true", help="list input and output paths without calling the model")
74
+ result.add_argument("--batch-size", type=_batch_size, default=BATCH_SIZE, help=f"dialogues per model request (default: {BATCH_SIZE})")
75
+ result.add_argument("--concurrency", type=_positive_int, default=DEFAULT_CONCURRENCY, help=f"parallel model requests (default: {DEFAULT_CONCURRENCY})")
76
+ result.add_argument("--rpm", type=_positive_int, default=DEFAULT_RPM, help=f"available requests per minute for this process (default: {DEFAULT_RPM})")
77
+ result.add_argument("--tpm", type=_positive_int, default=DEFAULT_TPM, help=f"available input and output tokens per minute (default: {DEFAULT_TPM})")
78
+ result.add_argument("--qps", type=_positive_float, help="maximum requests per second; defaults to RPM / 60")
79
+ result.add_argument("--max-output-tokens", type=_positive_int, default=2048, help="output token reservation when rate limiting (default: 2048)")
80
+ result.add_argument("--thinking", choices=("off", "on", "auto"), default="off", help="model thinking mode (default: off; auto omits the provider-specific setting)")
81
+ result.add_argument("--reasoning-reserve", type=_nonnegative_int, default=8192, help="additional reasoning-token reservation for providers without a combined cap (default: 8192)")
82
+ result.add_argument("--no-resume", action="store_true", help="do not read or write local checkpoints")
83
+ result.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
84
+ return result
85
+
86
+
87
+ def _config_command(argv: list[str]) -> int:
88
+ p = argparse.ArgumentParser(prog="subpeeko config", description="Manage model API configuration.")
89
+ p.add_argument("action", nargs="?", choices=("set", "show", "test"), default="set")
90
+ args = p.parse_args(argv)
91
+ if args.action == "set":
92
+ configure()
93
+ elif args.action == "show":
94
+ show_config()
95
+ else:
96
+ client = ModelClient(load_config())
97
+ client.test_connection()
98
+ print("Model API connection succeeded.")
99
+ return 0
100
+
101
+
102
+ def input_files(path: Path, recursive: bool) -> list[Path]:
103
+ if path.is_file():
104
+ if path.suffix.casefold() != ".ass":
105
+ raise SubtitleError("Input file must have an .ass extension")
106
+ if path.name.casefold().endswith(OUTPUT_SUFFIX):
107
+ raise SubtitleError("Generated SubPeeko subtitles cannot be used as input")
108
+ return [path]
109
+ if not path.is_dir():
110
+ raise SubtitleError(f"Input path does not exist: {path}")
111
+ items = path.rglob("*") if recursive else path.iterdir()
112
+ try:
113
+ return sorted(
114
+ (item for item in items if item.is_file() and item.suffix.casefold() == ".ass"
115
+ and not item.name.casefold().endswith(OUTPUT_SUFFIX)),
116
+ key=lambda item: str(item).casefold(),
117
+ )
118
+ except OSError as exc:
119
+ raise SubtitleError(f"Cannot read directory: {path}") from exc
120
+
121
+
122
+ def output_path(path: Path, level: str) -> Path:
123
+ return path.with_name(f"{path.stem}.{level}{OUTPUT_SUFFIX}")
124
+
125
+
126
+ def _write_output(path: Path, data: bytes, overwrite: bool) -> None:
127
+ if not overwrite:
128
+ try:
129
+ with path.open("xb") as destination:
130
+ try:
131
+ destination.write(data)
132
+ except OSError:
133
+ destination.close()
134
+ path.unlink(missing_ok=True)
135
+ raise
136
+ except FileExistsError as exc:
137
+ raise SubtitleError(f"Output already exists: {path} (use --overwrite to replace it)") from exc
138
+ except OSError as exc:
139
+ raise SubtitleError(f"Cannot write output: {path}") from exc
140
+ return
141
+ temporary = None
142
+ try:
143
+ with tempfile.NamedTemporaryFile(dir=path.parent, prefix=".subpeeko-", suffix=".tmp", delete=False) as stream:
144
+ temporary = Path(stream.name)
145
+ stream.write(data)
146
+ os.replace(temporary, path)
147
+ except OSError as exc:
148
+ raise SubtitleError(f"Cannot write output: {path}") from exc
149
+ finally:
150
+ if temporary is not None:
151
+ temporary.unlink(missing_ok=True)
152
+
153
+
154
+ def _report_request(event: dict) -> None:
155
+ usage = event["usage"] or {}
156
+ def count(name):
157
+ value = usage.get(name)
158
+ return str(value) if type(value) is int and value >= 0 else "?"
159
+ print(
160
+ f" Request {event['id']}: targets={event['targets']}, status={event['status']}, "
161
+ f"network={event['request_seconds']:.2f}s, wait={event['wait_seconds']:.2f}s, "
162
+ f"tokens in/out/reasoning={count('prompt_tokens')}/{count('completion_tokens')}/{count('reasoning_tokens')}",
163
+ flush=True,
164
+ )
165
+
166
+
167
+ def _convert(args: argparse.Namespace) -> int:
168
+ clients = []
169
+ started = time.monotonic()
170
+ try:
171
+ return _convert_files(args, clients)
172
+ finally:
173
+ if clients:
174
+ client = clients[0]
175
+ def total(key):
176
+ count = client.usage_counts[key]
177
+ if not count:
178
+ return "?"
179
+ value = str(client.usage[key])
180
+ return value if count == client.usage_responses else f"{value} (partial {count}/{client.usage_responses})"
181
+ print(f"Elapsed: {time.monotonic() - started:.2f}s. Usage: input={total('prompt_tokens')}, output={total('completion_tokens')}, "
182
+ f"reasoning={total('reasoning_tokens')}; usage reported for {client.usage_responses}/{client.requests} requests; "
183
+ f"{client.retries} retry/split event(s).")
184
+
185
+
186
+ def _convert_files(args: argparse.Namespace, clients: list[ModelClient]) -> int:
187
+ paths = input_files(args.input, args.recursive)
188
+ if not paths:
189
+ print("No input ASS files found.")
190
+ return 0
191
+ if args.dry_run:
192
+ for path in paths:
193
+ print(f"{path} -> {output_path(path, args.level)}")
194
+ print(f"{len(paths)} file(s); no changes made and no model requests sent.")
195
+ return 0
196
+
197
+ client = None
198
+ succeeded = failed = 0
199
+ for path in paths:
200
+ output = output_path(path, args.level)
201
+ checkpoint = None
202
+ try:
203
+ if output.exists() and not args.overwrite:
204
+ raise SubtitleError(f"Output already exists: {output} (use --overwrite to replace it)")
205
+ subtitles = AssFile.read(path)
206
+ if not subtitles.dialogues:
207
+ raise SubtitleError("No supported bilingual dialogue found")
208
+ if client is None:
209
+ client = ModelClient(
210
+ load_config(), batch_size=args.batch_size, workers=args.concurrency,
211
+ rpm=args.rpm, tpm=args.tpm, qps=args.qps, max_output_tokens=args.max_output_tokens,
212
+ thinking=args.thinking, reasoning_reserve=args.reasoning_reserve, on_request=_report_request,
213
+ )
214
+ clients.append(client)
215
+ cached = {}
216
+ if not args.no_resume:
217
+ key = checkpoint_key(path, subtitles.source_hash, client.config, args.level,
218
+ batch_size=args.batch_size, thinking=client.thinking,
219
+ max_output_tokens=args.max_output_tokens)
220
+ checkpoint = Checkpoint(key)
221
+ cached = checkpoint.load({item.id for item in subtitles.dialogues})
222
+ if cached:
223
+ print(f"Resuming: {len(cached)}/{len(subtitles.dialogues)} dialogue(s) from local checkpoint.")
224
+ print(f"Processing: {path} ({len(subtitles.dialogues)} bilingual dialogue(s))", flush=True)
225
+ notes = client.analyze(
226
+ subtitles.dialogues, args.level,
227
+ progress=lambda done, total: print(f" Reviewed {done}/{total} dialogue(s).", flush=True),
228
+ on_retry=lambda message: print(f" {message}", file=sys.stderr, flush=True),
229
+ initial_results=cached, on_batch=checkpoint.save if checkpoint else None,
230
+ )
231
+ result = subtitles.render(notes)
232
+ _write_output(output, result, args.overwrite)
233
+ succeeded += 1
234
+ print(f"Created: {output} ({len(subtitles.dialogues)} bilingual dialogue(s))")
235
+ if any(len(note) > 90 for note in notes.values()):
236
+ print(" Some annotations may be too long for the available display time.")
237
+ except (CheckpointError, ConfigError, ModelError, SubtitleError) as exc:
238
+ failed += 1
239
+ print(f"Failed: {path}: {exc}", file=sys.stderr)
240
+ finally:
241
+ if checkpoint is not None:
242
+ checkpoint.close()
243
+ print(f"Finished: {succeeded} created, {failed} failed; {client.requests if client else 0} model request(s).")
244
+ return 1 if failed else 0
245
+
246
+
247
+ def main(argv: list[str] | None = None) -> int:
248
+ # Redirected Windows output may use an encoding that cannot represent paths.
249
+ for stream in (sys.stdout, sys.stderr):
250
+ if hasattr(stream, "reconfigure"):
251
+ stream.reconfigure(errors="backslashreplace")
252
+ argv = list(sys.argv[1:] if argv is None else argv)
253
+ try:
254
+ if argv and argv[0] == "config":
255
+ return _config_command(argv[1:])
256
+ p = parser()
257
+ args = p.parse_args(argv)
258
+ if args.concurrency > 32:
259
+ p.error("--concurrency must not exceed 32")
260
+ return _convert(args)
261
+ except (CheckpointError, ConfigError, ModelError, SubtitleError) as exc:
262
+ print(f"Error: {exc}", file=sys.stderr)
263
+ return 1
264
+ except (KeyboardInterrupt, EOFError):
265
+ print("Interrupted.", file=sys.stderr)
266
+ return 130
subpeeko/config.py ADDED
@@ -0,0 +1,171 @@
1
+ """Local configuration and system credential storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import json
7
+ import os
8
+ import sys
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from urllib.parse import urlsplit
12
+
13
+ from dotenv import dotenv_values
14
+
15
+ SERVICE = "subpeeko"
16
+ KEY_NAME = "model-api-key"
17
+ SECURE_BACKENDS = {
18
+ "keyring.backends.Windows",
19
+ "keyring.backends.SecretService",
20
+ "keyring.backends.kwallet",
21
+ "keyring.backends.macOS",
22
+ }
23
+
24
+
25
+ class ConfigError(Exception):
26
+ pass
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Config:
31
+ base_url: str = ""
32
+ model_name: str = ""
33
+ api_key: str = field(default="", repr=False)
34
+
35
+
36
+ def config_path() -> Path:
37
+ if sys.platform == "win32":
38
+ root = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
39
+ elif sys.platform == "darwin":
40
+ root = Path.home() / "Library" / "Application Support"
41
+ else:
42
+ root = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
43
+ if not root.is_absolute():
44
+ root = Path.home() / ".config"
45
+ return root / SERVICE / "config.json"
46
+
47
+
48
+ def _read_saved() -> dict[str, str]:
49
+ path = config_path()
50
+ if not path.exists():
51
+ return {}
52
+ try:
53
+ data = json.loads(path.read_text(encoding="utf-8"))
54
+ except (OSError, ValueError) as exc:
55
+ raise ConfigError(f"Cannot read configuration: {path}") from exc
56
+ if not isinstance(data, dict):
57
+ raise ConfigError(f"Invalid configuration: {path}")
58
+ return {key: value for key, value in data.items() if isinstance(value, str)}
59
+
60
+
61
+ def _keyring():
62
+ try:
63
+ import keyring
64
+
65
+ backend = keyring.get_keyring()
66
+ if type(backend).__module__ == "keyring.backends.chainer":
67
+ backend = next((item for item in backend.backends if type(item).__module__ in SECURE_BACKENDS), None)
68
+ if backend is None:
69
+ return None
70
+ if type(backend).__module__ not in SECURE_BACKENDS:
71
+ return None
72
+ return backend
73
+ except Exception:
74
+ return None
75
+
76
+
77
+ def _stored_key() -> str:
78
+ backend = _keyring()
79
+ if backend is None:
80
+ return ""
81
+ try:
82
+ return backend.get_password(SERVICE, KEY_NAME) or ""
83
+ except Exception:
84
+ return ""
85
+
86
+
87
+ def load_config(require: bool = True) -> Config:
88
+ saved = _read_saved()
89
+ try:
90
+ dotenv = dotenv_values(Path.cwd() / ".env")
91
+ except OSError as exc:
92
+ raise ConfigError("Cannot read the local .env file") from exc
93
+
94
+ def value(name: str, default: str = "") -> str:
95
+ return (os.environ.get(name) or dotenv.get(name) or default).strip()
96
+
97
+ config = Config(
98
+ base_url=value("SUBPEEKO_BASE_URL", saved.get("base_url", "")),
99
+ model_name=value("SUBPEEKO_MODEL_NAME", saved.get("model_name", "")),
100
+ api_key=value("SUBPEEKO_API_KEY") or _stored_key(),
101
+ )
102
+ if require:
103
+ missing = [
104
+ name for name, item in (
105
+ ("base URL", config.base_url),
106
+ ("model name", config.model_name),
107
+ ("API key", config.api_key),
108
+ ) if not item
109
+ ]
110
+ if missing:
111
+ raise ConfigError(
112
+ "Missing " + ", ".join(missing)
113
+ + ". Run 'subpeeko config' or set the SUBPEEKO_* environment variables."
114
+ )
115
+ validate_base_url(config.base_url)
116
+ return config
117
+
118
+
119
+ def validate_base_url(url: str) -> None:
120
+ try:
121
+ parsed = urlsplit(url)
122
+ valid = (parsed.scheme in {"http", "https"} and parsed.hostname
123
+ and not parsed.username and not parsed.password and not parsed.query and not parsed.fragment)
124
+ except ValueError:
125
+ valid = False
126
+ if not valid:
127
+ raise ConfigError("Base URL must be an HTTP(S) API root without credentials or a query string")
128
+
129
+
130
+ def configure() -> None:
131
+ saved = _read_saved()
132
+ print("Configure an OpenAI-compatible Chat Completions API.")
133
+ base = input(f"Base URL [{saved.get('base_url', '')}]: ").strip() or saved.get("base_url", "")
134
+ model = input(f"Model name [{saved.get('model_name', '')}]: ").strip() or saved.get("model_name", "")
135
+ if not base or not model:
136
+ raise ConfigError("Both base URL and model name are required")
137
+ validate_base_url(base)
138
+
139
+ path = config_path()
140
+ try:
141
+ path.parent.mkdir(parents=True, exist_ok=True)
142
+ path.write_text(json.dumps({"base_url": base, "model_name": model}, indent=2) + "\n", encoding="utf-8")
143
+ except OSError as exc:
144
+ raise ConfigError(f"Cannot save configuration: {path}") from exc
145
+
146
+ backend = _keyring()
147
+ if backend is None:
148
+ print("No supported system credential store is available; set SUBPEEKO_API_KEY in the environment.")
149
+ else:
150
+ if not sys.stdin.isatty():
151
+ raise ConfigError("An interactive terminal is required to enter a key; use SUBPEEKO_API_KEY otherwise")
152
+ key = getpass.getpass("API key (leave blank to keep the existing key): ").strip()
153
+ if key:
154
+ try:
155
+ backend.set_password(SERVICE, KEY_NAME, key)
156
+ except Exception as exc:
157
+ raise ConfigError("Cannot save the API key in the system credential store; use SUBPEEKO_API_KEY") from exc
158
+ print(f"Configuration saved to {path}. API keys are never stored in this file.")
159
+
160
+
161
+ def show_config() -> None:
162
+ config = load_config(require=False)
163
+ base_url = config.base_url or "(not set)"
164
+ if config.base_url:
165
+ try:
166
+ validate_base_url(config.base_url)
167
+ except ConfigError:
168
+ base_url = "(invalid URL)"
169
+ print(f"Base URL: {base_url}")
170
+ print(f"Model: {config.model_name or '(not set)'}")
171
+ print(f"API key: {'configured' if config.api_key else '(not set)'}")
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2022-2026, KyleBing
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,4 @@
1
+ {
2
+ "repository": "https://github.com/KyleBing/english-vocabulary",
3
+ "revision": "731240a848dca14e2cf17510f384312b788863c1"
4
+ }