Translate Apple String Catalogs and Fastlane App Store metadata.
Project description
App Localizer
Translate Xcode String Catalogs (.xcstrings) and Fastlane App Store metadata from the command line with any OpenAI-compatible chat completions endpoint.
About
App Localizer is a development and CI tool for app repositories. It edits localization inputs in place, records what it generated, and skips unchanged work on later runs.
The output is a translation draft. It still belongs in code review, especially UI, legal text, purchase flows, and App Store copy. Do not ship App Localizer in an app target, call it from app code, or put provider API keys in mobile apps.
Supported Inputs
Two input types are supported, configured independently:
- String Catalogs. Fills missing or untranslated
.xcstringsentries. Preserves placeholders, substitutions, variation trees, translator comments, and localizations outside the current run. - Fastlane metadata. Translates
fastlane/metadata/<locale>/*.txtApp Store copy, copies URL fields verbatim, and enforces App Store character limits.
Quick Start
Installation
Install the CLI from PyPI:
uv tool install app-localizer
pipx install app-localizer and python3 -m pip install app-localizer work too. Pin a version for reproducible installs:
uv tool install "app-localizer==1.0.0"
Configuration
Add app-localizer.toml to the app repository root with the sections the repository needs:
[xcstrings]
sources = ["App/Resources/Localizable.xcstrings"]
languages = ["de", "es", "fr", "ja"]
[fastlane]
metadata_path = "fastlane/metadata"
source_locale = "en-US"
locales = ["de-DE", "es-ES", "fr-FR", "ja"]
Paths resolve relative to the config file, so the same config works from CI and local shells when --config points at it.
First Run
Validate the config and file structure without a provider API key:
app-localizer --check
Preview pending translation work without writing files:
app-localizer --dry-run
Translate configured inputs:
export OPENAI_API_KEY=...
app-localizer
A run rewrites catalogs and metadata in place, then updates .app-localizer.lock files. Commit the config, generated localization files, metadata, and lockfiles. Do not commit API keys or app-localizer.secrets.toml.
Command-Line Interface
Common Commands
Run these from the app repository root unless passing --config:
# Validate config, catalogs, metadata, and existing generated files.
app-localizer --check
# Show pending work without network access or file writes.
app-localizer --dry-run
# Translate every configured domain.
app-localizer
# Process one configured domain.
app-localizer --domain fastlane
# Retranslate even values already marked translated or locked.
app-localizer --force
--check and --dry-run do not require an API key. --fail-on-untranslated makes either command exit nonzero while work is pending. It is additive: a run can enable the policy, but cannot disable fail_on_untranslated = true set in the config.
Options
--config selects a config file. Without it, the CLI reads app-localizer.toml from the working directory when that file exists.
Provider overrides are run-specific: --base-url, --model, --reasoning-effort, --temperature, and --candidates. --api-key supplies a provider key directly and wins over environment variables and the secrets file. --secrets selects a different TOML secrets file.
--verbose enables debug logging. --version prints the installed package version. app-localizer --help prints the full parser output.
Ad Hoc Catalog Runs
For one-off String Catalog translation without a config file, pass catalog paths and target languages directly:
app-localizer --source Localizable.xcstrings --languages de es fr
Ad hoc runs configure only the String Catalog domain. Use a config file for repeatable app-team workflows.
Configuration Reference
All non-secret settings live in app-localizer.toml. Unknown keys are rejected, so typos fail early. See examples/app-localizer.toml for a copyable template.
Provider Settings
Provider settings are top-level and shared by both domains:
| Key | Default | Description |
|---|---|---|
base_url |
https://api.openai.com/v1 |
OpenAI-compatible chat completions endpoint. |
api_key_env |
OPENAI_API_KEY |
Environment variable holding the API key. Set to "" for keyless local endpoints. |
model |
gpt-5.2 |
Model identifier to request from the endpoint. |
reasoning_effort |
unset | Optional reasoning effort: none, minimal, low, medium, high, or xhigh. Unsupported endpoints are retried without it for the rest of the run. |
temperature |
0.2 |
Sampling temperature for translation requests. |
candidates |
3 |
Candidate translations requested per value before the decider pass chooses one. |
fail_on_untranslated |
false |
Makes --check and --dry-run fail while anything is pending. |
String Catalogs
[xcstrings] enables .xcstrings translation:
| Key | Description |
|---|---|
sources |
Catalog paths, resolved relative to the config file. |
languages |
Target Xcode language tags, for example de, pt-BR, zh-Hans. |
Fastlane Metadata
[fastlane] enables App Store metadata translation:
| Key | Description |
|---|---|
metadata_path |
Fastlane metadata directory, resolved relative to the config file. |
source_locale |
Locale directory to translate from, for example en-US. Fastlane's default fallback directory also works. |
locales |
Target App Store Connect locales, for example de-DE, fr-FR. Typos like de fail validation. |
keywords_trim_to_fit |
Defaults to true. Generated keywords.txt translations are normalized, deduplicated case-insensitively, and trimmed from the tail until the comma-joined list fits 100 characters. Set to false to reject over-limit keyword translations instead. |
allow_unknown_locales |
Defaults to false. Set to true to accept App Store locales added after this package's release instead of failing validation. Unknown locales warn; malformed tags still fail. |
Secrets
API keys do not belong in app-localizer.toml. Resolution order is --api-key, then the environment variable named by api_key_env, then the secrets file. --secrets selects a different file.
Use an environment variable for individual developer machines:
export OPENAI_API_KEY=...
app-localizer
Teams can share app-localizer.secrets.toml beside the config file. Each entry maps an environment variable name to its value; one file can hold keys for every provider a team uses:
OPENAI_API_KEY = "sk-..."
OPENROUTER_API_KEY = "sk-or-..."
Add the secrets file to the consuming repository's .gitignore and distribute it out of band. See docs/ios-integration.md for the full key-handling policy.
Provider Examples
Any OpenAI-compatible chat completions endpoint works. The default targets OpenAI and reads OPENAI_API_KEY.
OpenRouter:
base_url = "https://openrouter.ai/api/v1"
api_key_env = "OPENROUTER_API_KEY"
model = "anthropic/claude-sonnet-4-6"
Ollama:
base_url = "http://localhost:11434/v1"
api_key_env = ""
model = "llama3.3"
LM Studio:
base_url = "http://localhost:1234/v1"
api_key_env = ""
model = "qwen2.5-32b-instruct"
Apple Foundation Models has no direct HTTP API. Point base_url at a local OpenAI-compatible bridge and set api_key_env = "" if the bridge is keyless.
Leave reasoning_effort unset unless the selected model and endpoint accept it. When an endpoint reports the field as unknown or unsupported, the run warns once, retries without it, and omits it for the rest of that run.
Outputs
String Catalog Behavior
For each configured catalog and target language, a run:
- Adds missing target-language localizations from the source language.
- Skips entries marked with
shouldTranslate: false. - Translates
stringUnitvalues, nested variation trees, and substitution variations (%#@name@/%arg). - Adds the plural categories the target language's CLDR rules require, even when the source language omits them.
- Preserves substitution metadata such as
argNumandformatSpecifier, and keeps it in sync with the source language. - Preserves localizations for languages outside the current run.
- Copies whitespace-only values verbatim instead of sending them to the provider.
Catalog writes are atomic. The writer keeps file permissions, syncs contents to disk, sorts JSON keys, and uses Xcode-style spacing before replacing the original file. Catalogs with duplicate JSON keys or unsupported catalog versions are rejected.
Fastlane Metadata Behavior
Files in the source locale directory are handled by name:
| File | Handling | Limit |
|---|---|---|
name.txt |
translated | 30 |
subtitle.txt |
translated | 30 |
description.txt |
translated | 4000 |
keywords.txt |
translated, kept comma-separated | 100 |
promotional_text.txt |
translated | 170 |
release_notes.txt |
translated | 4000 |
apple_tv_privacy_policy.txt |
translated | - |
marketing_url.txt, support_url.txt, privacy_url.txt |
copied verbatim, never sent to the provider | - |
review_information/ |
ignored | - |
Non-localized metadata at the root of metadata_path (copyright.txt, category files, and review_information/) lives outside the locale directories and is never touched.
Unrecognized files are skipped with a warning. For prose metadata files, a translation over the App Store character limit is rejected like a placeholder mismatch. If no candidate fits, the file errors and the run fails.
Generated keywords.txt translations differ when keywords_trim_to_fit = true: spaces around keywords are stripped, case-insensitive duplicates are removed, and trailing keywords are dropped until the comma-joined list fits 100 characters. Dropped terms are logged in a warning. Set keywords_trim_to_fit = false to reject over-limit keyword translations instead. --check still fails when a source file exceeds its own limit.
Metadata .txt files have no per-value translation state, so skip decisions come from the lockfile's per-file source hashes. --check validates locked target files against App Store character limits. Other hand-edited target changes with an unchanged source are not detected as pending. Use --force to rewrite everything.
Lockfiles and Skip Behavior
.app-localizer.lock is the skip cache. The String Catalog domain writes one beside each catalog. The Fastlane domain writes one at the metadata root.
Commit lockfiles. Without them, later runs have less information and translate more than they need to.
String Catalog lockfile section:
{
"catalogs" : {
"Localizable.xcstrings" : {
"de" : {
"baseUrl" : "https://api.openai.com/v1",
"config" : "sha256...",
"keys" : {
"Localizable.xcstrings::welcome" : "sha256...",
"files.count" : "sha256..."
},
"model" : "gpt-5.2",
"timestamp" : "2026-06-11T00:00:00+00:00"
}
}
},
"generatedBy" : "app-localizer",
"version" : 2
}
Fastlane lockfile section:
{
"fastlane" : {
"de-DE" : {
"marketing_url.txt" : { "config" : "verbatim", "hash" : "sha256...", "..." : "..." },
"name.txt" : {
"baseUrl" : "https://api.openai.com/v1",
"config" : "sha256...",
"hash" : "sha256...",
"model" : "gpt-5.2",
"timestamp" : "2026-06-11T00:00:00+00:00"
}
}
},
"generatedBy" : "app-localizer",
"version" : 2
}
For catalogs, keys maps each translated catalog key to a fingerprint of its source content. A key's units are translated again when that key's source changed, the configuration changed, or the target is missing or invalid. config is per language, so a configuration change re-translates every key for that language.
For metadata, hash fingerprints each source file. Skips are per file: a file is translated again when the target is missing, the source changed, or the configuration changed.
config fingerprints the endpoint, model, reasoning effort, temperature, candidate count, prompts, and domain-specific validation policy. For metadata, it also includes the character limit and keyword trim policy. baseUrl and model are informational fields that record what produced an entry.
Unreadable, unsupported, or foreign lockfiles are discarded with a warning and rebuilt on the next run. Sections a domain does not own are preserved.
Limits and Review
Translations are validated before writing:
- Catalog translations must preserve printf placeholders, Swift interpolation placeholders, and String Catalog substitution tokens.
- Fastlane metadata translations must fit App Store character limits.
- Config files must not contain unknown keys, duplicate sources or languages, malformed language tags, or invalid App Store locale codes.
Current limits:
- The catalog lockfile tracks source freshness per top-level key per language. Editing one source string re-translates only that key's units; per-unit translation state still lives in the catalog itself.
- Plural variations are expanded to the categories the target language's CLDR rules require (Russian
fewandmany, for example), translated from the source'sotherform. Languages outside the built-in CLDR table fall back to mirroring the source's categories. - Output is a draft. Require human review for high-risk UI, marketing copy, legal text, purchase flows, and short ambiguous strings.
Team Workflow
CI
Copy examples/github-actions/app-localizer.yml into the app repository. Pull requests run --check with no API key. Manual workflow dispatch runs translation from a repository secret and opens a draft PR with the generated changes.
The workflow installs App Localizer from PyPI. Pin a version in the install step for reproducible CI runs.
For release branches, set fail_on_untranslated = true or pass --fail-on-untranslated so checks fail while configured targets still have untranslated content.
App-Team Workflow
See docs/ios-integration.md for the recommended app-team workflow: API key handling, developer checks, CI translation PRs, review policy, and release-branch behavior.
Run translation before fastlane deliver so uploaded metadata is current.
Development
Local Development
Install the package editable and run the test suite from the repository root:
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install -e .
PYTHONPATH=src python3 -m unittest discover -s tests
CI runs the same suite on every push to main and every pull request against the oldest and newest supported Python versions.
Releasing
Releases are tag-driven: pushing a version tag builds the package and publishes it to PyPI.
- Update
versioninpyproject.tomland commit tomain. - Tag the same version with a leading
v. - Push
mainand the tag.
git tag vX.Y.Z
git push origin main vX.Y.Z
Pushing the tag runs the release workflow. It verifies the tag matches the package version, runs the test suite, builds the sdist and wheel, publishes to PyPI via trusted publishing, and publishes a GitHub Release with generated notes and the build files attached.
Consumers install a pinned release from PyPI:
python3 -m pip install "app-localizer==X.Y.Z"
Compatibility
Versioning follows semantic versioning as of 1.0.0. The stable surface is the CLI flag set, the app-localizer.toml config schema, and the .app-localizer.lock format. A major release is required to break any of them.
The default model (gpt-5.2) is not part of the stable surface and may change in a minor release; pin model in the config to control it. Translation output is model-defined and never guaranteed to be stable. The lockfile is a disposable cache: an unreadable or future-version lockfile is discarded and rebuilt, so its format can advance without a breaking change.
See CHANGELOG.md for release history.
Contributions
App Localizer is not accepting source contributions at this time. Bug reports will be considered.
Author
License
App Localizer is available under the MIT license.
See LICENSE for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file app_localizer-1.0.0.tar.gz.
File metadata
- Download URL: app_localizer-1.0.0.tar.gz
- Upload date:
- Size: 66.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d958f32088c6e3a28c9033dc3123e471b413bea7873379da49d39c28fba5f3ce
|
|
| MD5 |
ef71567a3c150ca36d0e793e3b2ff127
|
|
| BLAKE2b-256 |
78abf1f3fb9583dad9b7d714d96caf7a7220f0e6fdbbb59f827d70a34e978d5b
|
Provenance
The following attestation bundles were made for app_localizer-1.0.0.tar.gz:
Publisher:
release.yml on kylehughes/app-localizer
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
app_localizer-1.0.0.tar.gz -
Subject digest:
d958f32088c6e3a28c9033dc3123e471b413bea7873379da49d39c28fba5f3ce - Sigstore transparency entry: 2048559625
- Sigstore integration time:
-
Permalink:
kylehughes/app-localizer@72ad46add29ba618d04c9bc9bb2b3394f73e8caa -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/kylehughes
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@72ad46add29ba618d04c9bc9bb2b3394f73e8caa -
Trigger Event:
push
-
Statement type:
File details
Details for the file app_localizer-1.0.0-py3-none-any.whl.
File metadata
- Download URL: app_localizer-1.0.0-py3-none-any.whl
- Upload date:
- Size: 40.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c708ff12b1e2d16d9ec7f29927802babc8525f0af202ba9ab050e5e27a7503eb
|
|
| MD5 |
50a556047165da826129ec2bae395b9c
|
|
| BLAKE2b-256 |
4806c7120dd46bf7b10758bd08af5c08d67844d32084ca3f16b80da1b48dbdbf
|
Provenance
The following attestation bundles were made for app_localizer-1.0.0-py3-none-any.whl:
Publisher:
release.yml on kylehughes/app-localizer
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
app_localizer-1.0.0-py3-none-any.whl -
Subject digest:
c708ff12b1e2d16d9ec7f29927802babc8525f0af202ba9ab050e5e27a7503eb - Sigstore transparency entry: 2048559637
- Sigstore integration time:
-
Permalink:
kylehughes/app-localizer@72ad46add29ba618d04c9bc9bb2b3394f73e8caa -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/kylehughes
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@72ad46add29ba618d04c9bc9bb2b3394f73e8caa -
Trigger Event:
push
-
Statement type: