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 +97 -0
- kaeris-0.1.0/README.md +81 -0
- kaeris-0.1.0/kaeris/__init__.py +7 -0
- kaeris-0.1.0/kaeris/__main__.py +6 -0
- kaeris-0.1.0/kaeris/cli.py +189 -0
- kaeris-0.1.0/kaeris/client.py +125 -0
- kaeris-0.1.0/kaeris/incremental.py +89 -0
- kaeris-0.1.0/kaeris.egg-info/PKG-INFO +97 -0
- kaeris-0.1.0/kaeris.egg-info/SOURCES.txt +13 -0
- kaeris-0.1.0/kaeris.egg-info/dependency_links.txt +1 -0
- kaeris-0.1.0/kaeris.egg-info/entry_points.txt +2 -0
- kaeris-0.1.0/kaeris.egg-info/top_level.txt +1 -0
- kaeris-0.1.0/pyproject.toml +31 -0
- kaeris-0.1.0/setup.cfg +4 -0
- kaeris-0.1.0/tests/test_incremental.py +58 -0
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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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")
|