kaeris 0.1.0__tar.gz

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.
kaeris-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: kaeris
3
+ Version: 0.1.0
4
+ Summary: AI localization CLI — translate JSON/YAML/.strings/.po/ARB/Android XML into 42 languages
5
+ Author: KAERIS
6
+ License: MIT
7
+ Project-URL: Homepage, https://kaeris.dev
8
+ Project-URL: Documentation, https://kaeris.dev/developer.html
9
+ Keywords: i18n,l10n,localization,translation,ai,cli
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Topic :: Software Development :: Localization
13
+ Classifier: Environment :: Console
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+
17
+ # KAERIS i18n — CLI
18
+
19
+ AI localization from your terminal. Translate your app's strings files into **42 languages** —
20
+ locally or in CI/CD. Format-aware, placeholder-safe, and **incremental** (only new keys).
21
+
22
+ - **Zero dependencies** — pure Python stdlib, installs in a second
23
+ - **6 formats** — JSON, YAML, `.strings`, `.po`, ARB, Android XML
24
+ - **Incremental** — `--only-new` translates just the keys you added, merges the rest
25
+ - **CI-ready** — GitHub Action included; open a PR with fresh translations on every push
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install kaeris
31
+ # or, without installing:
32
+ pipx run kaeris --help
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ ```bash
38
+ # Translate a whole file into Spanish, French and Japanese
39
+ kaeris translate locales/en.json --langs es,fr,ja --out locales
40
+
41
+ # Only translate keys that are missing from the existing target files
42
+ kaeris translate locales/en.json --langs es,fr,ja --out locales --only-new
43
+
44
+ # List all supported languages
45
+ kaeris languages
46
+ ```
47
+
48
+ Output files are written next to the source (or into `--out`), named by language:
49
+ `es.json`, `fr.json`, `ja.json` (or `values-es/strings.xml` for Android, etc.).
50
+
51
+ ## Authentication & tiers
52
+
53
+ | Tier | How | Limit |
54
+ |------|-----|-------|
55
+ | **Free** (anonymous) | no key | 10,000 chars/file |
56
+ | **Pro / Team** | `--key kaerisp_…` or `KAERIS_API_KEY` | 200k / 500k chars/file |
57
+ | **Lifetime (BYOK)** | `--key` **and** `--openrouter-key sk-or-…` | unlimited (you pay OpenRouter for tokens) |
58
+
59
+ ```bash
60
+ export KAERIS_API_KEY=kaerisp_xxxxxxxx
61
+ export KAERIS_OPENROUTER_KEY=sk-or-v1-xxxx # Lifetime/BYOK only
62
+ kaeris translate en.json --langs de,uk
63
+ ```
64
+
65
+ Get a key at <https://kaeris.dev/pricing.html>. A free OpenRouter key: <https://openrouter.ai/keys>.
66
+
67
+ ## CI/CD (GitHub Actions)
68
+
69
+ Translate new keys and open a PR automatically on every push — see
70
+ [`translate.example.yml`](../.github/workflows/translate.example.yml):
71
+
72
+ ```yaml
73
+ - uses: kaeris-dev/i18n_tool/.github/actions/kaeris-translate@main
74
+ with:
75
+ source: locales/en.json
76
+ languages: es,fr,de,ja
77
+ out: locales
78
+ only-new: "true"
79
+ ```
80
+
81
+ ## How incremental mode works
82
+
83
+ `--only-new` (JSON) parses your source and each existing translation, finds the keys present
84
+ in the source but missing from the target, translates **only those**, and merges them back —
85
+ preserving your existing translations and any non-string values (numbers, booleans). No more
86
+ re-translating (and re-paying for) the whole file every time you add one string.
87
+
88
+ ## Environment variables
89
+
90
+ - `KAERIS_API_KEY` — API key
91
+ - `KAERIS_OPENROUTER_KEY` — OpenRouter key (BYOK)
92
+ - `KAERIS_API_URL` — override the API base URL
93
+ - `NO_COLOR` — disable coloured output
94
+
95
+ ## License
96
+
97
+ MIT
kaeris-0.1.0/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # KAERIS i18n — CLI
2
+
3
+ AI localization from your terminal. Translate your app's strings files into **42 languages** —
4
+ locally or in CI/CD. Format-aware, placeholder-safe, and **incremental** (only new keys).
5
+
6
+ - **Zero dependencies** — pure Python stdlib, installs in a second
7
+ - **6 formats** — JSON, YAML, `.strings`, `.po`, ARB, Android XML
8
+ - **Incremental** — `--only-new` translates just the keys you added, merges the rest
9
+ - **CI-ready** — GitHub Action included; open a PR with fresh translations on every push
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install kaeris
15
+ # or, without installing:
16
+ pipx run kaeris --help
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ # Translate a whole file into Spanish, French and Japanese
23
+ kaeris translate locales/en.json --langs es,fr,ja --out locales
24
+
25
+ # Only translate keys that are missing from the existing target files
26
+ kaeris translate locales/en.json --langs es,fr,ja --out locales --only-new
27
+
28
+ # List all supported languages
29
+ kaeris languages
30
+ ```
31
+
32
+ Output files are written next to the source (or into `--out`), named by language:
33
+ `es.json`, `fr.json`, `ja.json` (or `values-es/strings.xml` for Android, etc.).
34
+
35
+ ## Authentication & tiers
36
+
37
+ | Tier | How | Limit |
38
+ |------|-----|-------|
39
+ | **Free** (anonymous) | no key | 10,000 chars/file |
40
+ | **Pro / Team** | `--key kaerisp_…` or `KAERIS_API_KEY` | 200k / 500k chars/file |
41
+ | **Lifetime (BYOK)** | `--key` **and** `--openrouter-key sk-or-…` | unlimited (you pay OpenRouter for tokens) |
42
+
43
+ ```bash
44
+ export KAERIS_API_KEY=kaerisp_xxxxxxxx
45
+ export KAERIS_OPENROUTER_KEY=sk-or-v1-xxxx # Lifetime/BYOK only
46
+ kaeris translate en.json --langs de,uk
47
+ ```
48
+
49
+ Get a key at <https://kaeris.dev/pricing.html>. A free OpenRouter key: <https://openrouter.ai/keys>.
50
+
51
+ ## CI/CD (GitHub Actions)
52
+
53
+ Translate new keys and open a PR automatically on every push — see
54
+ [`translate.example.yml`](../.github/workflows/translate.example.yml):
55
+
56
+ ```yaml
57
+ - uses: kaeris-dev/i18n_tool/.github/actions/kaeris-translate@main
58
+ with:
59
+ source: locales/en.json
60
+ languages: es,fr,de,ja
61
+ out: locales
62
+ only-new: "true"
63
+ ```
64
+
65
+ ## How incremental mode works
66
+
67
+ `--only-new` (JSON) parses your source and each existing translation, finds the keys present
68
+ in the source but missing from the target, translates **only those**, and merges them back —
69
+ preserving your existing translations and any non-string values (numbers, booleans). No more
70
+ re-translating (and re-paying for) the whole file every time you add one string.
71
+
72
+ ## Environment variables
73
+
74
+ - `KAERIS_API_KEY` — API key
75
+ - `KAERIS_OPENROUTER_KEY` — OpenRouter key (BYOK)
76
+ - `KAERIS_API_URL` — override the API base URL
77
+ - `NO_COLOR` — disable coloured output
78
+
79
+ ## License
80
+
81
+ MIT
@@ -0,0 +1,7 @@
1
+ """KAERIS i18n — command-line client for AI localization.
2
+
3
+ Translate your app's strings files (JSON, YAML, .strings, .po, ARB, Android XML)
4
+ into 42 languages from your terminal or CI/CD pipeline.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,189 @@
1
+ """KAERIS i18n command-line interface."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .client import KaerisClient, KaerisError, DEFAULT_API
10
+ from . import incremental as inc
11
+
12
+
13
+ def _c(text, code):
14
+ if os.environ.get("NO_COLOR") or not sys.stderr.isatty():
15
+ return text
16
+ return f"\033[{code}m{text}\033[0m"
17
+
18
+
19
+ def info(msg): print(_c("→", "36"), msg, file=sys.stderr)
20
+ def ok(msg): print(_c("✓", "32"), msg, file=sys.stderr)
21
+ def warn(msg): print(_c("!", "33"), msg, file=sys.stderr)
22
+ def err(msg): print(_c("✗", "31"), msg, file=sys.stderr)
23
+
24
+
25
+ def _client(args):
26
+ return KaerisClient(
27
+ api_url=args.api_url,
28
+ api_key=args.key or os.environ.get("KAERIS_API_KEY"),
29
+ openrouter_key=args.openrouter_key or os.environ.get("KAERIS_OPENROUTER_KEY"),
30
+ )
31
+
32
+
33
+ def cmd_languages(args):
34
+ langs = _client(args).languages()
35
+ for code, name in sorted(langs.items(), key=lambda kv: kv[1]):
36
+ print(f" {code:5} {name}")
37
+ return 0
38
+
39
+
40
+ def _progress_printer():
41
+ last = [-1]
42
+ def show(status):
43
+ total = status.get("total") or 0
44
+ done = status.get("progress") or 0
45
+ if total and done != last[0]:
46
+ pct = int(done / total * 100)
47
+ lang = status.get("current_lang") or ""
48
+ print(_c("→", "36") + f" {pct:3}% {lang}", file=sys.stderr)
49
+ last[0] = done
50
+ return show
51
+
52
+
53
+ def cmd_translate(args):
54
+ path = args.file
55
+ if not os.path.isfile(path):
56
+ err(f"File not found: {path}")
57
+ return 1
58
+ langs = [l.strip() for l in args.langs.split(",") if l.strip()]
59
+ if not langs:
60
+ err("No target languages given (use --langs es,fr,de)")
61
+ return 1
62
+
63
+ out_dir = args.out or os.path.dirname(os.path.abspath(path))
64
+ os.makedirs(out_dir, exist_ok=True)
65
+ fname = os.path.basename(path)
66
+ is_json = fname.lower().endswith(".json")
67
+ client = _client(args)
68
+
69
+ # ── incremental (delta) mode — JSON only ──────────────────────────────────
70
+ if args.only_new:
71
+ if not is_json:
72
+ warn("--only-new supports JSON only; translating the whole file instead")
73
+ else:
74
+ return _translate_incremental(client, path, out_dir, langs, args)
75
+
76
+ with open(path, "rb") as f:
77
+ content = f.read()
78
+
79
+ info(f"Translating {fname} → {', '.join(langs)}")
80
+ job = client.submit(fname, content, langs)
81
+ client.poll(job, on_progress=None if args.quiet else _progress_printer())
82
+ members = client.download(job)
83
+ written = _write_members(members, out_dir)
84
+ ok(f"Wrote {len(written)} file(s) to {out_dir}")
85
+ for w in written:
86
+ print(w)
87
+ return 0
88
+
89
+
90
+ def _translate_incremental(client, path, out_dir, langs, args):
91
+ source_obj = inc.load_json(path)
92
+ source_flat = inc.flatten(source_obj)
93
+ flat_style = inc.is_flat(source_obj)
94
+ any_work = False
95
+
96
+ for lang in langs:
97
+ target_path = os.path.join(out_dir, f"{lang}.json")
98
+ existing = inc.load_json(target_path) if os.path.isfile(target_path) else {}
99
+ existing_flat = inc.flatten(existing) if existing else {}
100
+ todo = inc.missing_keys(source_flat, existing_flat)
101
+ if not todo:
102
+ ok(f"{lang}: up to date ({len(existing_flat)} keys)")
103
+ continue
104
+ any_work = True
105
+ info(f"{lang}: {len(todo)} new key(s) to translate")
106
+ subset = inc.build_subset(source_obj, set(todo), flat_style)
107
+ content = json.dumps(subset, ensure_ascii=False).encode()
108
+ job = client.submit(os.path.basename(path), content, [lang])
109
+ client.poll(job, on_progress=None if args.quiet else _progress_printer())
110
+ members = client.download(job)
111
+ translated = _find_json_member(members, lang)
112
+ if translated is None:
113
+ err(f"{lang}: no output returned")
114
+ continue
115
+ merged = inc.merge_translation(existing, translated)
116
+ inc.dump_json(merged, target_path)
117
+ ok(f"{lang}: merged {len(todo)} key(s) → {target_path}")
118
+
119
+ if not any_work:
120
+ ok("Everything already up to date — nothing to translate")
121
+ return 0
122
+
123
+
124
+ def _find_json_member(members, lang):
125
+ for name, data in members.items():
126
+ if name.endswith(".json"):
127
+ try:
128
+ return json.loads(data.decode("utf-8"))
129
+ except Exception:
130
+ continue
131
+ return None
132
+
133
+
134
+ def _write_members(members, out_dir):
135
+ written = []
136
+ for name, data in members.items():
137
+ dest = os.path.join(out_dir, name)
138
+ os.makedirs(os.path.dirname(dest) or out_dir, exist_ok=True)
139
+ with open(dest, "wb") as f:
140
+ f.write(data)
141
+ written.append(dest)
142
+ return written
143
+
144
+
145
+ def build_parser():
146
+ p = argparse.ArgumentParser(
147
+ prog="kaeris",
148
+ description="AI localization from your terminal — translate strings files into 42 languages.",
149
+ )
150
+ p.add_argument("--version", action="version", version=f"kaeris {__version__}")
151
+ p.add_argument("--api-url", default=os.environ.get("KAERIS_API_URL", DEFAULT_API),
152
+ help="API base URL (default: https://kaeris.dev)")
153
+ p.add_argument("--key", "-k", help="API key (or env KAERIS_API_KEY)")
154
+ p.add_argument("--openrouter-key", help="OpenRouter key for Lifetime/BYOK (or env KAERIS_OPENROUTER_KEY)")
155
+
156
+ sub = p.add_subparsers(dest="command")
157
+
158
+ t = sub.add_parser("translate", help="Translate a strings file")
159
+ t.add_argument("file", help="Source file (.json/.yml/.strings/.po/.arb/.xml)")
160
+ t.add_argument("--langs", "-l", required=True, help="Comma-separated target languages, e.g. es,fr,de")
161
+ t.add_argument("--out", "-o", help="Output directory (default: alongside the source)")
162
+ t.add_argument("--only-new", action="store_true",
163
+ help="Incremental: translate only keys missing from existing targets (JSON)")
164
+ t.add_argument("--quiet", "-q", action="store_true", help="Suppress progress output")
165
+ t.set_defaults(func=cmd_translate)
166
+
167
+ ls = sub.add_parser("languages", help="List supported target languages")
168
+ ls.set_defaults(func=cmd_languages)
169
+
170
+ return p
171
+
172
+
173
+ def main(argv=None):
174
+ args = build_parser().parse_args(argv)
175
+ if not getattr(args, "command", None):
176
+ build_parser().print_help()
177
+ return 1
178
+ try:
179
+ return args.func(args)
180
+ except KaerisError as e:
181
+ err(str(e))
182
+ return 2
183
+ except KeyboardInterrupt:
184
+ err("Interrupted")
185
+ return 130
186
+
187
+
188
+ if __name__ == "__main__":
189
+ sys.exit(main())
@@ -0,0 +1,125 @@
1
+ """Zero-dependency API client for the KAERIS i18n service (stdlib only)."""
2
+
3
+ import io
4
+ import json
5
+ import os
6
+ import time
7
+ import uuid
8
+ import zipfile
9
+ import urllib.request
10
+ import urllib.error
11
+
12
+ DEFAULT_API = "https://kaeris.dev"
13
+
14
+
15
+ class KaerisError(Exception):
16
+ """Any error talking to the KAERIS API."""
17
+
18
+
19
+ class KaerisClient:
20
+ def __init__(self, api_url=DEFAULT_API, api_key=None, openrouter_key=None, timeout=180):
21
+ self.api_url = api_url.rstrip("/")
22
+ self.api_key = api_key
23
+ self.openrouter_key = openrouter_key
24
+ self.timeout = timeout
25
+
26
+ # ── low-level ────────────────────────────────────────────────────────────
27
+ def _headers(self, extra=None):
28
+ h = {"User-Agent": "kaeris-cli"}
29
+ if self.api_key:
30
+ h["X-API-Key"] = self.api_key
31
+ if self.openrouter_key:
32
+ h["X-OpenRouter-Key"] = self.openrouter_key
33
+ if extra:
34
+ h.update(extra)
35
+ return h
36
+
37
+ def _get(self, path):
38
+ req = urllib.request.Request(self.api_url + path, headers=self._headers())
39
+ try:
40
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
41
+ return r.read()
42
+ except urllib.error.HTTPError as e:
43
+ raise KaerisError(self._err_message(e))
44
+ except urllib.error.URLError as e:
45
+ raise KaerisError(f"Cannot reach {self.api_url}: {e.reason}")
46
+
47
+ @staticmethod
48
+ def _err_message(e):
49
+ try:
50
+ body = json.loads(e.read().decode())
51
+ detail = body.get("detail") or body.get("error") or str(body)
52
+ except Exception:
53
+ detail = e.reason
54
+ return f"HTTP {e.code}: {detail}"
55
+
56
+ # ── public API ───────────────────────────────────────────────────────────
57
+ def languages(self):
58
+ return json.loads(self._get("/api/languages").decode())
59
+
60
+ def config(self):
61
+ return json.loads(self._get("/api/config").decode())
62
+
63
+ def _multipart(self, filename, content, languages):
64
+ boundary = "----kaeris" + uuid.uuid4().hex
65
+ crlf = b"\r\n"
66
+ parts = []
67
+ parts.append(b"--" + boundary.encode())
68
+ parts.append(
69
+ b'Content-Disposition: form-data; name="file"; filename="'
70
+ + filename.encode() + b'"'
71
+ )
72
+ parts.append(b"Content-Type: application/octet-stream")
73
+ parts.append(b"")
74
+ parts.append(content if isinstance(content, bytes) else content.encode())
75
+ parts.append(b"--" + boundary.encode())
76
+ parts.append(b'Content-Disposition: form-data; name="languages"')
77
+ parts.append(b"")
78
+ parts.append(",".join(languages).encode())
79
+ parts.append(b"--" + boundary.encode() + b"--")
80
+ parts.append(b"")
81
+ body = crlf.join(parts)
82
+ return body, "multipart/form-data; boundary=" + boundary
83
+
84
+ def submit(self, filename, content, languages):
85
+ """POST a file for translation; returns job_id."""
86
+ body, ctype = self._multipart(filename, content, languages)
87
+ req = urllib.request.Request(
88
+ self.api_url + "/api/translate", data=body, method="POST",
89
+ headers=self._headers({"Content-Type": ctype}),
90
+ )
91
+ try:
92
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
93
+ data = json.loads(r.read().decode())
94
+ except urllib.error.HTTPError as e:
95
+ raise KaerisError(self._err_message(e))
96
+ except urllib.error.URLError as e:
97
+ raise KaerisError(f"Cannot reach {self.api_url}: {e.reason}")
98
+ job_id = data.get("job_id")
99
+ if not job_id:
100
+ raise KaerisError(f"No job_id in response: {data}")
101
+ return job_id
102
+
103
+ def poll(self, job_id, on_progress=None, interval=1.0, max_wait=1800):
104
+ """Poll a job until done/error. Returns the final status dict."""
105
+ deadline = time.time() + max_wait
106
+ while time.time() < deadline:
107
+ status = json.loads(self._get(f"/api/status/{job_id}").decode())
108
+ if on_progress:
109
+ on_progress(status)
110
+ state = status.get("status")
111
+ if state == "done":
112
+ return status
113
+ if state == "error":
114
+ raise KaerisError(status.get("error") or "Translation failed")
115
+ time.sleep(interval)
116
+ raise KaerisError("Timed out waiting for translation")
117
+
118
+ def download(self, job_id):
119
+ """Download the result ZIP; returns {member_name: bytes}."""
120
+ raw = self._get(f"/api/download/{job_id}")
121
+ out = {}
122
+ with zipfile.ZipFile(io.BytesIO(raw)) as zf:
123
+ for name in zf.namelist():
124
+ out[name] = zf.read(name)
125
+ return out
@@ -0,0 +1,89 @@
1
+ """Incremental (delta) translation helpers for JSON files.
2
+
3
+ Only new/changed keys are sent to the API; existing translations are preserved.
4
+ This mirrors the server's flatten/unflatten so round-trips stay faithful.
5
+ """
6
+
7
+ import json
8
+
9
+
10
+ def flatten(obj, prefix=""):
11
+ """Flatten nested dict to dotted keys; only string leaves are returned
12
+ (these are the translatable keys used for delta detection)."""
13
+ out = {}
14
+ for k, v in obj.items():
15
+ key = f"{prefix}.{k}" if prefix else k
16
+ if isinstance(v, dict):
17
+ out.update(flatten(v, key))
18
+ elif isinstance(v, str):
19
+ out[key] = v
20
+ return out
21
+
22
+
23
+ def flatten_all(obj, prefix=""):
24
+ """Flatten keeping every scalar leaf (strings, numbers, booleans, null) —
25
+ used for merging so non-string values survive round-trips."""
26
+ out = {}
27
+ for k, v in obj.items():
28
+ key = f"{prefix}.{k}" if prefix else k
29
+ if isinstance(v, dict):
30
+ out.update(flatten_all(v, key))
31
+ else:
32
+ out[key] = v
33
+ return out
34
+
35
+
36
+ def unflatten(flat):
37
+ result = {}
38
+ for key, value in flat.items():
39
+ parts = key.split(".")
40
+ d = result
41
+ for p in parts[:-1]:
42
+ if not isinstance(d.get(p), dict):
43
+ d[p] = {}
44
+ d = d[p]
45
+ d[parts[-1]] = value
46
+ return result
47
+
48
+
49
+ def is_flat(obj):
50
+ """True if the object has no nested dict values (dotted-key style)."""
51
+ return not any(isinstance(v, dict) for v in obj.values())
52
+
53
+
54
+ def missing_keys(source_flat, existing_flat):
55
+ """Keys present in source but not in an existing translation."""
56
+ return {k: v for k, v in source_flat.items() if k not in existing_flat}
57
+
58
+
59
+ def build_subset(source_obj, keys, flat_style):
60
+ """Build a JSON document containing only `keys` (dotted), matching the source style."""
61
+ subset_flat = {k: v for k, v in flatten(source_obj).items() if k in keys}
62
+ if flat_style:
63
+ return subset_flat
64
+ return unflatten(subset_flat)
65
+
66
+
67
+ def merge_translation(existing_obj, translated_obj):
68
+ """Merge freshly translated keys into an existing target document (new keys win).
69
+ Preserves all existing leaves, including non-string scalars."""
70
+ ex = flatten_all(existing_obj) if existing_obj else {}
71
+ tr = flatten_all(translated_obj)
72
+ ex.update(tr)
73
+ return ex if is_flat_dict(existing_obj, translated_obj) else unflatten(ex)
74
+
75
+
76
+ def is_flat_dict(existing_obj, translated_obj):
77
+ ref = existing_obj if existing_obj else translated_obj
78
+ return is_flat(ref) if ref else True
79
+
80
+
81
+ def load_json(path):
82
+ with open(path, encoding="utf-8") as f:
83
+ return json.load(f)
84
+
85
+
86
+ def dump_json(obj, path):
87
+ with open(path, "w", encoding="utf-8") as f:
88
+ json.dump(obj, f, ensure_ascii=False, indent=2)
89
+ f.write("\n")
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: kaeris
3
+ Version: 0.1.0
4
+ Summary: AI localization CLI — translate JSON/YAML/.strings/.po/ARB/Android XML into 42 languages
5
+ Author: KAERIS
6
+ License: MIT
7
+ Project-URL: Homepage, https://kaeris.dev
8
+ Project-URL: Documentation, https://kaeris.dev/developer.html
9
+ Keywords: i18n,l10n,localization,translation,ai,cli
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Topic :: Software Development :: Localization
13
+ Classifier: Environment :: Console
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+
17
+ # KAERIS i18n — CLI
18
+
19
+ AI localization from your terminal. Translate your app's strings files into **42 languages** —
20
+ locally or in CI/CD. Format-aware, placeholder-safe, and **incremental** (only new keys).
21
+
22
+ - **Zero dependencies** — pure Python stdlib, installs in a second
23
+ - **6 formats** — JSON, YAML, `.strings`, `.po`, ARB, Android XML
24
+ - **Incremental** — `--only-new` translates just the keys you added, merges the rest
25
+ - **CI-ready** — GitHub Action included; open a PR with fresh translations on every push
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install kaeris
31
+ # or, without installing:
32
+ pipx run kaeris --help
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ ```bash
38
+ # Translate a whole file into Spanish, French and Japanese
39
+ kaeris translate locales/en.json --langs es,fr,ja --out locales
40
+
41
+ # Only translate keys that are missing from the existing target files
42
+ kaeris translate locales/en.json --langs es,fr,ja --out locales --only-new
43
+
44
+ # List all supported languages
45
+ kaeris languages
46
+ ```
47
+
48
+ Output files are written next to the source (or into `--out`), named by language:
49
+ `es.json`, `fr.json`, `ja.json` (or `values-es/strings.xml` for Android, etc.).
50
+
51
+ ## Authentication & tiers
52
+
53
+ | Tier | How | Limit |
54
+ |------|-----|-------|
55
+ | **Free** (anonymous) | no key | 10,000 chars/file |
56
+ | **Pro / Team** | `--key kaerisp_…` or `KAERIS_API_KEY` | 200k / 500k chars/file |
57
+ | **Lifetime (BYOK)** | `--key` **and** `--openrouter-key sk-or-…` | unlimited (you pay OpenRouter for tokens) |
58
+
59
+ ```bash
60
+ export KAERIS_API_KEY=kaerisp_xxxxxxxx
61
+ export KAERIS_OPENROUTER_KEY=sk-or-v1-xxxx # Lifetime/BYOK only
62
+ kaeris translate en.json --langs de,uk
63
+ ```
64
+
65
+ Get a key at <https://kaeris.dev/pricing.html>. A free OpenRouter key: <https://openrouter.ai/keys>.
66
+
67
+ ## CI/CD (GitHub Actions)
68
+
69
+ Translate new keys and open a PR automatically on every push — see
70
+ [`translate.example.yml`](../.github/workflows/translate.example.yml):
71
+
72
+ ```yaml
73
+ - uses: kaeris-dev/i18n_tool/.github/actions/kaeris-translate@main
74
+ with:
75
+ source: locales/en.json
76
+ languages: es,fr,de,ja
77
+ out: locales
78
+ only-new: "true"
79
+ ```
80
+
81
+ ## How incremental mode works
82
+
83
+ `--only-new` (JSON) parses your source and each existing translation, finds the keys present
84
+ in the source but missing from the target, translates **only those**, and merges them back —
85
+ preserving your existing translations and any non-string values (numbers, booleans). No more
86
+ re-translating (and re-paying for) the whole file every time you add one string.
87
+
88
+ ## Environment variables
89
+
90
+ - `KAERIS_API_KEY` — API key
91
+ - `KAERIS_OPENROUTER_KEY` — OpenRouter key (BYOK)
92
+ - `KAERIS_API_URL` — override the API base URL
93
+ - `NO_COLOR` — disable coloured output
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ kaeris/__init__.py
4
+ kaeris/__main__.py
5
+ kaeris/cli.py
6
+ kaeris/client.py
7
+ kaeris/incremental.py
8
+ kaeris.egg-info/PKG-INFO
9
+ kaeris.egg-info/SOURCES.txt
10
+ kaeris.egg-info/dependency_links.txt
11
+ kaeris.egg-info/entry_points.txt
12
+ kaeris.egg-info/top_level.txt
13
+ tests/test_incremental.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kaeris = kaeris.cli:main
@@ -0,0 +1 @@
1
+ kaeris
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kaeris"
7
+ version = "0.1.0"
8
+ description = "AI localization CLI — translate JSON/YAML/.strings/.po/ARB/Android XML into 42 languages"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "KAERIS" }]
13
+ keywords = ["i18n", "l10n", "localization", "translation", "ai", "cli"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Topic :: Software Development :: Localization",
18
+ "Environment :: Console",
19
+ ]
20
+ # Zero runtime dependencies — stdlib only.
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Homepage = "https://kaeris.dev"
25
+ Documentation = "https://kaeris.dev/developer.html"
26
+
27
+ [project.scripts]
28
+ kaeris = "kaeris.cli:main"
29
+
30
+ [tool.setuptools]
31
+ packages = ["kaeris"]
kaeris-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,58 @@
1
+ """Offline tests for the incremental (delta) logic — no API calls."""
2
+
3
+ import sys, os
4
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5
+
6
+ from kaeris import incremental as inc
7
+
8
+
9
+ def test_flatten_strings_only():
10
+ src = {"a": "x", "n": 5, "b": {"c": "y"}, "ok": True}
11
+ assert inc.flatten(src) == {"a": "x", "b.c": "y"}
12
+
13
+
14
+ def test_flatten_all_keeps_scalars():
15
+ src = {"a": "x", "n": 5, "ok": True, "z": None}
16
+ assert inc.flatten_all(src) == {"a": "x", "n": 5, "ok": True, "z": None}
17
+
18
+
19
+ def test_missing_keys():
20
+ source = {"a": "1", "b": "2", "c": "3"}
21
+ existing = {"a": "uno"}
22
+ assert set(inc.missing_keys(source, existing)) == {"b", "c"}
23
+
24
+
25
+ def test_build_subset_nested():
26
+ src = {"btn": {"save": "Save", "cancel": "Cancel"}, "hi": "Hi"}
27
+ subset = inc.build_subset(src, {"btn.save"}, flat_style=False)
28
+ assert subset == {"btn": {"save": "Save"}}
29
+
30
+
31
+ def test_build_subset_flat():
32
+ src = {"btn.save": "Save", "btn.cancel": "Cancel"}
33
+ subset = inc.build_subset(src, {"btn.cancel"}, flat_style=True)
34
+ assert subset == {"btn.cancel": "Cancel"}
35
+
36
+
37
+ def test_merge_preserves_existing_and_numbers():
38
+ existing = {"greeting": "Hola", "count": 5, "btn": {"save": "Guardar"}}
39
+ translated = {"btn": {"cancel": "Cancelar"}, "farewell": "Adiós"}
40
+ merged = inc.merge_translation(existing, translated)
41
+ assert merged["greeting"] == "Hola"
42
+ assert merged["count"] == 5
43
+ assert merged["btn"]["save"] == "Guardar"
44
+ assert merged["btn"]["cancel"] == "Cancelar"
45
+ assert merged["farewell"] == "Adiós"
46
+
47
+
48
+ def test_flat_style_detection():
49
+ assert inc.is_flat({"a.b": "x", "c": "y"}) is True
50
+ assert inc.is_flat({"a": {"b": "x"}}) is False
51
+
52
+
53
+ if __name__ == "__main__":
54
+ fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
55
+ for fn in fns:
56
+ fn()
57
+ print(f"ok {fn.__name__}")
58
+ print(f"\n{len(fns)} passed")