32 Commits
v0.1.0 ... main

Author SHA1 Message Date
bb4e325dec Merge pull request 'develop' (#7) from develop into main
Reviewed-on: KeshavAnandCode/foreign-thon#7
2026-05-18 16:21:11 +00:00
92b2a59f95 bumped versions to 0.5.2 2026-05-18 16:19:56 +00:00
bc9e73ef88 Merge pull request 'feat/check-improvements' (#6) from feat/check-improvements into develop
Reviewed-on: KeshavAnandCode/foreign-thon#6
2026-05-18 16:19:09 +00:00
393b947953 check improvements works 2026-05-18 16:18:14 +00:00
85e3837117 Merge pull request 'develop merge' (#5) from develop into main
Reviewed-on: KeshavAnandCode/foreign-thon#5
2026-05-18 15:59:09 +00:00
15f367131e bumped versions 2026-05-18 15:58:09 +00:00
83d8552977 Merge pull request 'feat/list-langs' (#4) from feat/list-langs into develop
Reviewed-on: KeshavAnandCode/foreign-thon#4
2026-05-18 15:56:55 +00:00
606501d654 readme fix 2026-05-18 15:56:17 +00:00
d9c5042ec7 added langs command utility 2026-05-18 15:54:48 +00:00
c19d5b4c15 Merge pull request 'Bump Versions to 0.5.0, adding the cli new command with custom language creation ability...similar to cargo new' (#3) from develop into main
Reviewed-on: KeshavAnandCode/foreign-thon#3
2026-05-17 23:35:49 +00:00
2701271973 bumping versions with full fix 2026-05-17 18:34:34 -05:00
7a2651b775 Merge pull request 'Added new features for project creation' (#2) from fpy-project into develop
Reviewed-on: KeshavAnandCode/foreign-thon#2
2026-05-17 23:31:41 +00:00
578f8c9761 added custom functionality and working with new toml as well, and update cli functions 2026-05-17 18:30:32 -05:00
6dcaeac748 added template.json 2026-05-17 18:15:13 -05:00
add744cd68 added new project cli with custom json reading 2026-05-17 18:08:59 -05:00
70a220158c bumped versions 2026-05-16 18:29:21 -05:00
15b91d0f6d fix spacing: verbatim inter-token copy, fix postfix decompile, add integration tests 2026-05-16 18:27:56 -05:00
8f99503d6b fixed ci workflow not install tamil 2026-05-16 16:55:30 -05:00
2041c167cb fixed toml for es 2026-05-16 16:43:34 -05:00
6dc44dc5bc changed tests 2026-05-16 16:38:37 -05:00
1bb09774d9 upgraded esp to 0.3.0 2026-05-16 16:36:28 -05:00
862aeaebbc Merge pull request 'updated spanish translations' (#1) from spanish-translation-fix into main
Reviewed-on: KeshavAnandCode/foreign-thon#1
2026-05-16 21:32:26 +00:00
CT
084f05a2c1 updated spanish translations 2026-05-16 16:31:09 -05:00
ef87091391 fixed some tamil traits 2026-05-16 12:34:48 -05:00
b31ba536e4 added decompilation and decompile with portffix in json 2026-05-16 12:06:21 -05:00
9fb33f5999 added cli fix for compilation and help 2026-05-16 11:51:04 -05:00
694b798315 bump core version to 0.2.0 2026-05-16 11:43:09 -05:00
2aa8e91a68 bump core version to 0.2.0 2026-05-16 11:42:45 -05:00
3a4dd21585 per-package independent releases via prefixed tags 2026-05-16 11:40:31 -05:00
0ff6b2483e move docs to docs/ folder, rewrite readme and contributing 2026-05-16 11:35:35 -05:00
439e555e50 aded @@ for keyword swapping 2026-05-16 11:30:04 -05:00
a27dfda0bf tamil added 2026-05-15 19:38:37 -05:00
20 changed files with 1575 additions and 298 deletions

View File

@@ -22,6 +22,8 @@ jobs:
run: | run: |
pip install -e "packages/foreignthon[dev]" pip install -e "packages/foreignthon[dev]"
pip install -e packages/langs/es pip install -e packages/langs/es
pip install -e packages/langs/ta
continue-on-error: true continue-on-error: true
- name: Run tests - name: Run tests

View File

@@ -3,7 +3,8 @@ name: Publish
on: on:
push: push:
tags: tags:
- "v*" - "foreignthon-v*"
- "foreignthon-*-v*"
jobs: jobs:
publish: publish:
@@ -20,57 +21,67 @@ jobs:
- name: Install build tools - name: Install build tools
run: pip install build twine run: pip install build twine
- name: Build core package - name: Determine what to build
run: python -m build packages/foreignthon id: target
run: |
TAG=${GITHUB_REF#refs/tags/}
echo "Full tag: $TAG"
- name: Build Spanish pack if [[ "$TAG" == "foreignthon-v"* ]]; then
run: python -m build packages/langs/es # Core package: foreignthon-v0.2.0
echo "type=core" >> $GITHUB_OUTPUT
echo "path=packages/foreignthon" >> $GITHUB_OUTPUT
echo "name=foreignthon" >> $GITHUB_OUTPUT
elif [[ "$TAG" == "foreignthon-"*"-v"* ]]; then
# Lang pack: foreignthon-es-v0.1.0 → lang code is "es"
LANG=$(echo $TAG | sed 's/foreignthon-\(.*\)-v.*/\1/')
echo "type=lang" >> $GITHUB_OUTPUT
echo "path=packages/langs/$LANG" >> $GITHUB_OUTPUT
echo "name=foreignthon-$LANG" >> $GITHUB_OUTPUT
fi
- name: Build package
run: python -m build ${{ steps.target.outputs.path }}
- name: Publish to PyPI - name: Publish to PyPI
env: env:
TWINE_USERNAME: __token__ TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: | run: twine upload --skip-existing ${{ steps.target.outputs.path }}/dist/*
twine upload --skip-existing packages/foreignthon/dist/*
twine upload --skip-existing packages/langs/es/dist/*
- name: Create Gitea release with assets - name: Create Gitea release with assets
env: env:
GIT_RELEASE_TOKEN: ${{ secrets.GIT_RELEASE_TOKEN }} GIT_RELEASE_TOKEN: ${{ secrets.GIT_RELEASE_TOKEN }}
run: | run: |
TAG=${GITHUB_REF#refs/tags/} TAG=${GITHUB_REF#refs/tags/}
echo "Tag: $TAG"
# Delete existing release if any
EXISTING=$(curl -s \ EXISTING=$(curl -s \
-H "Authorization: token $GIT_RELEASE_TOKEN" \ -H "Authorization: token $GIT_RELEASE_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/$TAG") "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/$TAG")
EXISTING_ID=$(echo $EXISTING | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || echo "") EXISTING_ID=$(echo $EXISTING | python -c \
"import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || echo "")
if [ -n "$EXISTING_ID" ]; then if [ -n "$EXISTING_ID" ]; then
echo "Deleting existing release $EXISTING_ID"
curl -s -X DELETE \ curl -s -X DELETE \
-H "Authorization: token $GIT_RELEASE_TOKEN" \ -H "Authorization: token $GIT_RELEASE_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$EXISTING_ID" "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$EXISTING_ID"
fi fi
# Create fresh release
RELEASE=$(curl -s -X POST \ RELEASE=$(curl -s -X POST \
-H "Authorization: token $GIT_RELEASE_TOKEN" \ -H "Authorization: token $GIT_RELEASE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{ -d "{
\"tag_name\": \"$TAG\", \"tag_name\": \"$TAG\",
\"name\": \"$TAG\", \"name\": \"${{ steps.target.outputs.name }} ${TAG##*-v}\",
\"body\": \"Release $TAG\", \"body\": \"Release of ${{ steps.target.outputs.name }} ${TAG##*-v}\",
\"draft\": false, \"draft\": false,
\"prerelease\": false \"prerelease\": false
}" \ }" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases") "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases")
echo "Release response: $RELEASE"
RELEASE_ID=$(echo $RELEASE | python -c "import sys,json; print(json.load(sys.stdin)['id'])") RELEASE_ID=$(echo $RELEASE | python -c "import sys,json; print(json.load(sys.stdin)['id'])")
for FILE in packages/foreignthon/dist/* packages/langs/es/dist/*; do for FILE in ${{ steps.target.outputs.path }}/dist/*; do
echo "Uploading $FILE" echo "Uploading $FILE"
curl -s -X POST \ curl -s -X POST \
-H "Authorization: token $GIT_RELEASE_TOKEN" \ -H "Authorization: token $GIT_RELEASE_TOKEN" \

View File

@@ -1,51 +0,0 @@
# Contributing to ForeignThon
## Project structure
foreignthon/
├── packages/
│ ├── foreignthon/ # core engine + fpy CLI
│ │ ├── src/foreignthon/
│ │ │ ├── cli.py # fpy commands
│ │ │ ├── transpiler.py # tokenizer-based transpiler
│ │ │ ├── pack.py # language pack loader
│ │ │ └── errors.py # bilingual error hook
│ │ └── tests/
│ └── langs/
│ └── es/ # Spanish language pack
│ └── src/foreignthon_es/es.json
## Setting up
```bash
python -m venv .venv && source .venv/bin/activate
pip install -e "packages/foreignthon[dev]"
pip install -e packages/langs/es
```
## Running tests
```bash
pytest packages/foreignthon/tests/ -v
```
## Adding a new language pack
1. Copy `packages/langs/es/` to `packages/langs/<code>/`
2. Rename `foreignthon_es``foreignthon_<code>` throughout
3. Fill in `<code>.json` following the same schema as `es.json`
4. Validate it: `fpy pack packages/langs/<code>/src/foreignthon_<code>/<code>.json`
5. Add tests if the language has tricky characters or edge cases
6. Open a PR or publish independently as `foreignthon-<code>` on PyPI
## Language pack schema
Every pack must have these top-level keys:
`meta`, `keywords`, `builtins`, `exceptions`, `error_messages`, `stdlib`
See `packages/langs/es/src/foreignthon_es/es.json` as the reference.
## Code style
```bash
ruff check packages/foreignthon/src
```

View File

@@ -1,14 +1,15 @@
DME.md << 'EOF'
# ForeignThon # ForeignThon
Write Python in any human language. ForeignThon transpiles `.es.py`, `.ta.py` (and more) files into standard Python — keywords, builtins, exceptions, all of it. Write Python in any human language.
ForeignThon transpiles `.es.py`, `.ta.py` (and more) into standard Python. Keywords, builtins, exceptions — all translated. Errors come back in your language too.
```python ```python
# hola.es.py # hola.es.py
definir saludar(nombre): def saludar(nombre):
retornar f"Hola, {nombre}!" retornar f"Hola, {nombre}!"
para i en rango(3): para i en dist(3):
imprimir(saludar(f"mundo {i}")) imprimir(saludar(f"mundo {i}"))
``` ```
@@ -19,7 +20,7 @@ fpy run hola.es.py
# Hola, mundo 2! # Hola, mundo 2!
``` ```
## Installation ## Install
```bash ```bash
pip install foreignthon pip install foreignthon
@@ -27,62 +28,13 @@ pip install foreignthon-es # Spanish
pip install foreignthon-ta # Tamil pip install foreignthon-ta # Tamil
``` ```
## Usage ## Docs
```bash - [Getting Started](docs/getting-started.md)
fpy run script.es.py # transpile and run - [Language Packs](docs/language-packs.md)
fpy compile script.es.py # output a .compiled.py file - [Postfix Syntax](docs/postfix-syntax.md)
fpy check script.es.py # validate without running - [Contributing](docs/contributing.md)
fpy pack mylang.json # validate a language pack - [Releasing](docs/releasing.md)
```
### Language override
```python
# foreignthon: es
# ^ overrides the file extension
```
Or via CLI flag:
```bash
fpy run script.py --lang es
```
## Errors
Errors are shown in your language first, English below:
[ES] ErrorDeDivisionCero: Error: división por cero
[EN] ZeroDivisionError: division by zero
## Language Packs
A language pack is a JSON file + a tiny Python wrapper published as `foreignthon-xx` on PyPI.
See `packages/langs/es/` for the reference implementation.
The JSON covers:
- **keywords** — `si → if`, `para → for`, `definir → def`
- **builtins** — `imprimir → print`, `rango → range`
- **exceptions** — `ErrorDeValor → ValueError`
- **error_messages** — bilingual error output
- **stdlib** — `matematicas → math`
Validate your pack before publishing:
```bash
fpy pack mylang.json
```
## Development
```bash
git clone <your-repo>
cd foreignthon
python -m venv .venv && source .venv/bin/activate
pip install -e "packages/foreignthon[dev]"
pip install -e packages/langs/es
pytest packages/foreignthon/tests/ -v
```
## License ## License

63
docs/contributing.md Normal file
View File

@@ -0,0 +1,63 @@
# Contributing
## Project structure
```
foreignthon/
├── packages/
│ ├── foreignthon/ # core engine + fpy CLI
│ │ ├── src/foreignthon/
│ │ │ ├── cli.py # fpy commands
│ │ │ ├── transpiler.py # tokenizer-based transpiler + @@ pre-pass
│ │ │ ├── pack.py # language pack loader + entry point discovery
│ │ │ └── errors.py # bilingual error hook
│ │ └── tests/
│ └── langs/
│ ├── es/ # foreignthon-es (Spanish)
│ └── ta/ # foreignthon-ta (Tamil)
├── docs/
└── .gitea/workflows/
├── ci.yml # runs tests + lint on every push
└── publish.yml # builds + publishes to PyPI on git tag
```
## How the transpiler works
1. **Pre-pass** — scans for `@@keyword` postfix syntax and rewrites lines to prefix form
2. **Tokenizer** — uses Python's `tokenize` module to swap `NAME` tokens. Strings and comments are never touched.
3. **Runner** — compiles with the original filename so tracebacks point to your `.es.py` file, not a temp file
## Setup
```bash
python -m venv .venv && source .venv/bin/activate
pip install -e "packages/foreignthon[dev]"
pip install -e packages/langs/es
pip install -e packages/langs/ta
```
## Running tests
```bash
pytest packages/foreignthon/tests/ -v
```
## Linting
```bash
ruff check packages/foreignthon/src
```
## Adding a language pack
See [language-packs.md](language-packs.md) for the full guide.
## CI
Every push to `main` runs tests and lint (non-blocking). Releases are triggered by pushing a git tag — see [releasing.md](releasing.md).
## Submitting changes
1. Fork the repo
2. Create a branch
3. Make your changes + add tests if relevant
4. Open a pull request against `main`

80
docs/getting-started.md Normal file
View File

@@ -0,0 +1,80 @@
# Getting Started
## Installation
```bash
pip install foreignthon
pip install foreignthon-es # add Spanish
pip install foreignthon-ta # add Tamil
```
For CLI use across projects, prefer pipx:
```bash
pipx install foreignthon
```
## Writing a file
Name your file `script.<lang>.py` — the extension tells ForeignThon which language pack to use.
```python
# script.es.py
definir sumar(a, b):
retornar a + b
para i en rango(5):
imprimir(sumar(i, 1))
```
## Running
```bash
fpy run script.es.py # transpile and run
fpy compile script.es.py # output a .compiled.py file
fpy check script.es.py # validate without running
```
## Overriding the language
Via shebang comment at the top of the file:
```python
# foreignthon: es
```
Or via CLI flag:
```bash
fpy run script.py --lang es
```
## Errors
Errors are shown in your language first, English below:
[ES] ErrorDeDivisionCero: Error: división por cero
[EN] ZeroDivisionError: division by zero
File "script.es.py", line 3
## Variable names
Variable names are optional — you can use English or your language freely:
```python
# both work fine in the same file
definir calculate(anchura, altura):
area = anchura * altura
retornar area
```
## Local dev setup
```bash
git clone <repo>
cd foreignthon
python -m venv .venv && source .venv/bin/activate
pip install -e "packages/foreignthon[dev]"
pip install -e packages/langs/es
pip install -e packages/langs/ta
pytest packages/foreignthon/tests/ -v
```

69
docs/language-packs.md Normal file
View File

@@ -0,0 +1,69 @@
# Language Packs
A language pack is a JSON file that maps foreign tokens to Python equivalents, published as `foreignthon-<code>` on PyPI.
## Available packs
| Package | Language | Install |
|---|---|---|
| `foreignthon-es` | Spanish | `pip install foreignthon-es` |
| `foreignthon-ta` | Tamil | `pip install foreignthon-ta` |
## JSON schema
Every pack must have these top-level keys:
```json
{
"meta": {
"name": "Spanish",
"native_name": "Español",
"code": "es",
"version": "0.1.0",
"authors": []
},
"keywords": { "si": "if", "para": "for", ... },
"builtins": { "imprimir": "print", "rango": "range", ... },
"exceptions": { "ErrorDeValor": "ValueError", ... },
"error_messages":{ "ValueError": "Error de valor", ... },
"stdlib": { "matematicas": "math", ... }
}
```
- **keywords** — Python reserved words (`if`, `for`, `def`, `class` …)
- **builtins** — built-in functions (`print`, `range`, `len` …)
- **exceptions** — built-in exception names (`ValueError`, `TypeError` …)
- **error_messages** — translations for bilingual error output
- **stdlib** — common standard library module names (`math`, `sys` …)
Third-party library names (numpy, pandas etc.) are out of scope.
## Creating a pack
1. Copy `packages/langs/es/` to `packages/langs/<code>/`
2. Rename every `foreignthon_es``foreignthon_<code>`
3. Fill in `<code>.json` following the schema above
4. Validate: `fpy pack packages/langs/<code>/src/foreignthon_<code>/<code>.json`
5. Install locally: `pip install -e packages/langs/<code>`
6. Test: `fpy run myscript.<code>.py`
## Publishing a pack
```bash
python -m build packages/langs/<code>
twine upload packages/langs/<code>/dist/*
```
Anyone can publish an independent `foreignthon-<code>` pack — you don't need to be a core maintainer.
## How discovery works
Packs register themselves via Python entry points:
```toml
# in the pack's pyproject.toml
[project.entry-points."foreignthon.langs"]
es = "foreignthon_es"
```
The core finds all installed packs automatically — no config needed.

49
docs/postfix-syntax.md Normal file
View File

@@ -0,0 +1,49 @@
# Postfix Syntax
Some languages (like Tamil) are grammatically SOV — the condition comes before the keyword, not after. ForeignThon supports this with the `@@` operator.
## How it works
```python
# Standard prefix (works in every language)
si x > 0:
imprimir(x)
# Postfix with @@
x > 0 @@si:
imprimir(x)
```
Both produce identical Python: `if x > 0:`. The `@@` means "take whatever is to my left, put the keyword first".
## Rules
- `@@` only rewrites the line it appears on — nothing else changes
- Indentation rules are identical to normal Python
- Prefix and postfix can be mixed freely in the same file
- Works for any keyword in any language pack
## Examples
```python
# if / else
x > 0 @@si:
imprimir(x)
sino:
pasar
# while
contador < 10 @@mientras:
contador += 1
# inside a function — indentation unchanged
definir comprobar(x):
x > 0 @@si:
imprimir("positivo")
sino:
imprimir("negativo")
```
## Why @@
`@@` is not valid Python syntax so it never conflicts with existing code. Single `@` is used for decorators and matrix multiplication, so it was ruled out.

View File

@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
[project] [project]
name = "foreignthon" name = "foreignthon"
version = "0.1.0" version = "0.5.2"
description = "Write Python in any language. Transpiles foreign-language .xx.py files to standard Python." description = "Write Python in any language. Transpiles foreign-language .xx.py files to standard Python."
license = { text = "MIT" } license = { text = "GPL v3" }
requires-python = ">=3.9" requires-python = ">=3.9"
authors = [ authors = [
{ name = "Your Name", email = "you@example.com" } { name = "Keshav Anand", email = "keshavanand.dev@gmail.com" }
] ]
keywords = ["python", "transpiler", "i18n", "localization", "language"] keywords = ["python", "transpiler", "i18n", "localization", "language"]
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
@@ -56,3 +56,6 @@ ignore = ["E501"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"pack.py" = ["E501"] "pack.py" = ["E501"]
[tool.hatch.build.targets.wheel.force-include]
"src/foreignthon/template.json" = "foreignthon/template.json"

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@@ -9,15 +10,192 @@ from . import __version__
from .errors import activate from .errors import activate
from .transpiler import run_transpiled, transpile_file from .transpiler import run_transpiled, transpile_file
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group() def _load_effective_pack(project: Path, lang: str) -> dict:
"""
Load pack for a file. Priority:
1. custom_pack in .foreignthon.toml — no installed pack needed
2. installed pack via entry points
"""
import json
from .pack import load_pack
# Walk up to find .foreignthon.toml
search = project if project.is_dir() else project.parent
toml_path = None
for parent in [search, *search.parents]:
candidate = parent / ".foreignthon.toml"
if candidate.exists():
toml_path = candidate
break
# Check for custom_pack first — if found, load it directly
# and merge on top of template (no installed pack required)
if toml_path:
for line in toml_path.read_text(encoding="utf-8").splitlines():
if line.strip().startswith("custom_pack") and not line.strip().startswith("#"):
custom_path = toml_path.parent / line.split("=", 1)[1].strip().strip('"').strip("'")
if custom_path.exists():
custom = json.loads(custom_path.read_text(encoding="utf-8"))
# If custom pack has meta with a code, it's a standalone pack
if custom.get("meta", {}).get("code"):
return custom
# Otherwise it's an override — merge on top of installed pack
pack = load_pack(lang)
for section in ("keywords", "builtins", "exceptions", "stdlib", "error_messages"):
if section in custom:
pack[section] = {**pack.get(section, {}), **custom[section]}
if "postfix_keywords" in custom:
pack["postfix_keywords"] = custom["postfix_keywords"]
return pack
break
# Fall back to installed pack
return load_pack(lang)
def _pick(mapping: dict, english: str, fallback: str) -> str:
reverse = {v: k for k, v in mapping.items()}
return reverse.get(english, fallback)
def _git_init(project: Path) -> None:
git_ok = subprocess.run(["git", "--version"], capture_output=True).returncode == 0
if git_ok:
subprocess.run(["git", "init"], cwd=project, capture_output=True)
subprocess.run(["git", "add", "."], cwd=project, capture_output=True)
subprocess.run(
["git", "commit", "-m", "initial commit"],
cwd=project, capture_output=True,
)
def _lang_from_file(path: Path) -> str:
suffixes = path.suffixes
if len(suffixes) >= 2 and suffixes[-1] == ".py":
return suffixes[-2].lstrip(".")
return "en"
@click.group(context_settings=CONTEXT_SETTINGS)
@click.version_option(__version__, prog_name="fpy") @click.version_option(__version__, prog_name="fpy")
def main(): def main():
"""ForeignThon — write Python in any language.""" """ForeignThon — write Python in any language."""
pass pass
@main.command() @main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("name", default="")
@click.option("--lang", "-l", required=True, help="Language code (e.g. es, ta)")
@click.option("--no-git", is_flag=True, help="Skip git init")
def new(name: str, lang: str, no_git: bool):
"""
Create a new ForeignThon project.
\b
fpy new myproject --lang es # creates myproject/
fpy new --lang es # initializes current directory
"""
from .pack import PackNotFoundError
if name:
project = Path(name)
if project.exists():
click.echo(f"'{name}' already exists.", err=True)
raise SystemExit(1)
project.mkdir(parents=True)
else:
project = Path.cwd()
if any(project.iterdir()):
click.echo("✗ Current directory is not empty.", err=True)
raise SystemExit(1)
is_custom = lang == "custom"
if is_custom:
lang_code = click.prompt("Language code (e.g. ru, fr, de)")
lang_name_en = click.prompt("Language name in English (e.g. Russian)")
lang_name_native = click.prompt("Language name in its own script (e.g. Русский)")
pack = _make_scaffold_pack(lang_code, lang_name_en, lang_name_native)
lang = lang_code
else:
try:
pack = _load_effective_pack(project, lang)
except PackNotFoundError:
click.echo(f"✗ Language pack '{lang}' not installed.", err=True)
click.echo(f" Run: pip install foreignthon-{lang}", err=True)
raise SystemExit(1)
lang_name = pack["meta"]["native_name"]
if is_custom:
import json
(project / "custom.json").write_text(
json.dumps(pack, ensure_ascii=False, indent=2),
encoding="utf-8",
)
toml_custom_line = 'custom_pack = "custom.json"'
click.echo(" Created custom.json — fill in your translations!")
else:
toml_custom_line = '# custom_pack = "custom.json"'
(project / ".foreignthon.toml").write_text(
f'[foreignthon]\n'
f'lang = "{lang}"\n'
f'\n'
f'# Optional: path to a local JSON that overrides pack keywords\n'
f'{toml_custom_line}\n',
encoding="utf-8",
)
(project / ".gitignore").write_text(
"__pycache__/\n"
"*.py[cod]\n"
"*.egg-info/\n"
"dist/\n"
"build/\n"
".venv/\n"
".pytest_cache/\n"
".DS_Store\n"
"Thumbs.db\n",
encoding="utf-8",
)
src = project / "src"
src.mkdir(exist_ok=True)
bi_print = _pick(pack["builtins"], "print", "print")
(src / f"main.{lang}.py").write_text(
f"# Hello, World! in {lang_name}\n"
f'{bi_print}("Hello, World!")\n',
encoding="utf-8",
)
(project / "README.md").write_text(
f"# {name or project.name}\n\n"
f"A ForeignThon project in {lang_name}.\n\n"
f"## Run\n\n"
f"```bash\n"
f"fpy run src/main.{lang}.py\n"
f"```\n\n"
f"## Custom pack override\n\n"
f"Create a `custom.json` and set `custom_pack = \"custom.json\"` "
f"in `.foreignthon.toml` to add or override keywords locally.\n",
encoding="utf-8",
)
if not no_git:
_git_init(project)
click.echo(f"✓ Created '{name or '.'}' [{lang_name}]")
if name:
click.echo(f" cd {name}")
click.echo(f" fpy run src/main.{lang}.py")
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("file", type=click.Path(exists=True, path_type=Path)) @click.argument("file", type=click.Path(exists=True, path_type=Path))
@click.option("--lang", "-l", default=None, help="Override language code (e.g. es, ta)") @click.option("--lang", "-l", default=None, help="Override language code (e.g. es, ta)")
@click.option("--keep", is_flag=True, help="Keep the compiled .py alongside the source") @click.option("--keep", is_flag=True, help="Keep the compiled .py alongside the source")
@@ -29,11 +207,12 @@ def run(file: Path, lang: str | None, keep: bool):
source = f"# foreignthon: {lang}\n" + source source = f"# foreignthon: {lang}\n" + source
file.write_text(source, encoding="utf-8") file.write_text(source, encoding="utf-8")
transpiled = transpile_file(file)
detected_lang = lang or _lang_from_file(file) detected_lang = lang or _lang_from_file(file)
activate(detected_lang) activate(detected_lang)
pack = _load_effective_pack(file, detected_lang)
transpiled = transpile_file(file, pack=pack)
if keep: if keep:
out_path = file.with_suffix("").with_suffix(".compiled.py") out_path = file.with_suffix("").with_suffix(".compiled.py")
out_path.write_text(transpiled, encoding="utf-8") out_path.write_text(transpiled, encoding="utf-8")
@@ -42,40 +221,93 @@ def run(file: Path, lang: str | None, keep: bool):
run_transpiled(file, transpiled) run_transpiled(file, transpiled)
@main.command() @main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("file", type=click.Path(exists=True, path_type=Path)) @click.argument("file", type=click.Path(exists=True, path_type=Path))
@click.option("--output", "-o", default=None, help="Output path (default: beside source)") @click.option(
"--output", "-o", default=None,
help="Output file or directory. Defaults to same directory as source."
)
def compile(file: Path, output: str | None): def compile(file: Path, output: str | None):
"""Transpile a file to standard Python without running it.""" """
transpiled = transpile_file(file) Transpile a foreign-language file to standard Python.
out_path = ( \b
Path(output) if output fpy compile script.es.py # → script.compiled.py
else file.with_suffix("").with_suffix(".compiled.py") fpy compile script.es.py -o out/ # → out/script.compiled.py
) fpy compile script.es.py -o out.py # → out.py
"""
detected_lang = _lang_from_file(file)
pack = _load_effective_pack(file, detected_lang)
transpiled = transpile_file(file, pack=pack)
if output is None:
out_path = file.with_suffix("").with_suffix(".compiled.py")
else:
out = Path(output)
if out.is_dir() or str(output).endswith("/"):
out.mkdir(parents=True, exist_ok=True)
out_path = out / file.with_suffix("").with_suffix(".compiled.py").name
else:
out_path = out
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(transpiled, encoding="utf-8") out_path.write_text(transpiled, encoding="utf-8")
click.echo(f"Compiled: {out_path}") click.echo(f"Compiled: {out_path}")
@main.command(context_settings=CONTEXT_SETTINGS)
@main.command() @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
@click.argument("file", type=click.Path(exists=True, path_type=Path)) def check(files: tuple):
def check(file: Path): """Validate one or more foreign-language files without running them."""
"""Validate a foreign-language file without running it."""
import ast import ast
import io
import tokenize as _tokenize
from .transpiler import _build_mapping, transpile_file
failed = False
for file in files:
detected_lang = _lang_from_file(file)
try: try:
transpiled = transpile_file(file) pack = _load_effective_pack(file, detected_lang)
transpiled = transpile_file(file, pack=pack)
ast.parse(transpiled) ast.parse(transpiled)
click.echo(f"{file.name} looks good.")
source = file.read_text(encoding="utf-8")
mapping = _build_mapping(pack)
tokens = list(_tokenize.generate_tokens(io.StringIO(source).readline))
count = sum(1 for t in tokens if t.type == _tokenize.NAME and t.string in mapping)
click.echo(f"{file.name} looks good. ({count} tokens translated)")
except SyntaxError as e: except SyntaxError as e:
click.echo(f"✗ Syntax error: {e}", err=True) click.echo(f" {file.name}: Syntax error: {e}", err=True)
sys.exit(1) failed = True
except Exception as e: except Exception as e:
click.echo(f"{e}", err=True) click.echo(f" {file.name}: {e}", err=True)
failed = True
if failed:
sys.exit(1) sys.exit(1)
@main.command(context_settings=CONTEXT_SETTINGS)
def langs():
"""List all installed language packs."""
from .pack import _discover_packs
@main.command("pack") packs = _discover_packs()
if not packs:
click.echo("No language packs installed.")
click.echo("Try: pip install foreignthon-es")
return
click.echo("Installed language packs:")
for code, module in sorted(packs.items()):
import json
data = json.loads(module.get_pack_path().read_text(encoding="utf-8"))
name = data["meta"].get("name", code)
native = data["meta"].get("native_name", "")
click.echo(f" {code:<6} {name} ({native})")
@main.command("pack", context_settings=CONTEXT_SETTINGS)
@click.argument("json_file", type=click.Path(exists=True, path_type=Path)) @click.argument("json_file", type=click.Path(exists=True, path_type=Path))
def validate_pack(json_file: Path): def validate_pack(json_file: Path):
"""Validate a language pack JSON file.""" """Validate a language pack JSON file."""
@@ -94,8 +326,54 @@ def validate_pack(json_file: Path):
click.echo(f"✓ Pack '{data['meta']['name']}' is valid.") click.echo(f"✓ Pack '{data['meta']['name']}' is valid.")
def _lang_from_file(path: Path) -> str: @main.command(context_settings=CONTEXT_SETTINGS)
suffixes = path.suffixes @click.argument("file", type=click.Path(exists=True, path_type=Path))
if len(suffixes) >= 2 and suffixes[-1] == ".py": @click.option("--lang", "-l", required=True, help="Target language code (e.g. es, ta)")
return suffixes[-2].lstrip(".") @click.option("--postfix", is_flag=True, help="Use @@ postfix style for if/elif/while")
return "en" @click.option("--output", "-o", default=None, help="Output file or directory")
def decompile(file: Path, lang: str, postfix: bool, output: str | None):
"""
Convert standard Python back to a foreign language.
\b
fpy decompile script.py --lang es
fpy decompile script.py --lang ta --postfix
fpy decompile script.py --lang es -o out/
"""
from .transpiler import detranspile_file
pack = _load_effective_pack(file, lang)
result = detranspile_file(file, lang, postfix=postfix, pack=pack)
ext = f".{lang}.py"
if output is None:
stem = file.stem if not file.stem.endswith(f".{lang}") else file.stem
out_path = file.with_name(stem + ext)
else:
out = Path(output)
if out.is_dir() or str(output).endswith("/"):
out.mkdir(parents=True, exist_ok=True)
out_path = out / (file.stem + ext)
else:
out_path = out
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(result, encoding="utf-8")
click.echo(f"Decompiled: {out_path}")
def _make_scaffold_pack(lang_code: str, lang_name: str, native_name: str) -> dict:
"""
Load template.json — single source of truth for all pack keys.
To add new keywords/builtins, edit template.json only.
"""
import json
from importlib.resources import files
template_path = files("foreignthon") / "template.json"
pack = json.loads(template_path.read_text(encoding="utf-8"))
pack["meta"]["name"] = lang_name
pack["meta"]["native_name"] = native_name
pack["meta"]["code"] = lang_code
return pack

View File

@@ -0,0 +1,146 @@
{
"meta": {
"name": "",
"native_name": "",
"code": "",
"version": "0.1.0",
"authors": []
},
"keywords": {
"if": "if",
"else": "else",
"elif": "elif",
"for": "for",
"while": "while",
"def": "def",
"class": "class",
"import": "import",
"from": "from",
"as": "as",
"return": "return",
"break": "break",
"continue": "continue",
"pass": "pass",
"try": "try",
"except": "except",
"finally": "finally",
"raise": "raise",
"with": "with",
"in": "in",
"is": "is",
"and": "and",
"or": "or",
"not": "not",
"del": "del",
"global": "global",
"nonlocal": "nonlocal",
"assert": "assert",
"yield": "yield",
"await": "await",
"async": "async",
"lambda": "lambda",
"True": "True",
"False": "False",
"None": "None"
},
"builtins": {
"print": "print",
"input": "input",
"len": "len",
"range": "range",
"type": "type",
"int": "int",
"float": "float",
"str": "str",
"list": "list",
"dict": "dict",
"set": "set",
"tuple": "tuple",
"bool": "bool",
"open": "open",
"enumerate": "enumerate",
"map": "map",
"filter": "filter",
"sorted": "sorted",
"reversed": "reversed",
"sum": "sum",
"min": "min",
"max": "max",
"abs": "abs",
"round": "round",
"all": "all",
"any": "any",
"isinstance": "isinstance",
"hasattr": "hasattr",
"getattr": "getattr",
"setattr": "setattr",
"repr": "repr",
"format": "format",
"vars": "vars",
"next": "next",
"id": "id",
"chr": "chr",
"hex": "hex",
"bin": "bin",
"oct": "oct"
},
"exceptions": {
"Exception": "Exception",
"BaseException": "BaseException",
"ValueError": "ValueError",
"TypeError": "TypeError",
"KeyError": "KeyError",
"IndexError": "IndexError",
"AttributeError": "AttributeError",
"NameError": "NameError",
"ImportError": "ImportError",
"OSError": "OSError",
"FileNotFoundError": "FileNotFoundError",
"RuntimeError": "RuntimeError",
"StopIteration": "StopIteration",
"SystemExit": "SystemExit",
"KeyboardInterrupt": "KeyboardInterrupt",
"NotImplementedError": "NotImplementedError",
"ZeroDivisionError": "ZeroDivisionError",
"RecursionError": "RecursionError",
"SyntaxError": "SyntaxError",
"AssertionError": "AssertionError",
"OverflowError": "OverflowError",
"MemoryError": "MemoryError",
"PermissionError": "PermissionError",
"TimeoutError": "TimeoutError"
},
"error_messages": {
"SyntaxError": "SyntaxError",
"ValueError": "ValueError",
"TypeError": "TypeError",
"KeyError": "KeyError",
"IndexError": "IndexError",
"AttributeError": "AttributeError",
"NameError": "NameError",
"ImportError": "ImportError",
"FileNotFoundError": "FileNotFoundError",
"ZeroDivisionError": "ZeroDivisionError",
"RecursionError": "RecursionError",
"RuntimeError": "RuntimeError",
"MemoryError": "MemoryError",
"OverflowError": "OverflowError",
"AssertionError": "AssertionError",
"NotImplementedError": "NotImplementedError",
"StopIteration": "StopIteration",
"KeyboardInterrupt": "KeyboardInterrupt",
"PermissionError": "PermissionError",
"TimeoutError": "TimeoutError"
},
"stdlib": {
"math": "math",
"sys": "sys",
"datetime": "datetime",
"time": "time",
"random": "random",
"collections": "collections",
"pathlib": "pathlib",
"re": "re"
},
"postfix_keywords": []
}

View File

@@ -1,45 +1,99 @@
from __future__ import annotations from __future__ import annotations
import io import io
import re
import tokenize import tokenize
from pathlib import Path from pathlib import Path
from .pack import load_pack from .pack import load_pack
def transpile(source: str, lang_code: str) -> str: def _apply_postfix_syntax(source: str, mapping: dict) -> str:
""" if "@@" not in source:
Transpile foreign-language Python source to standard Python. return source
Uses the tokenizer so strings and comments are never touched.
"""
pack = load_pack(lang_code)
# Build a single flat lookup: foreign token -> English token kw_pattern = "|".join(re.escape(k) for k in sorted(mapping, key=len, reverse=True))
mapping: dict[str, str] = {} postfix_re = re.compile(rf"(.+?)@@({kw_pattern})")
mapping.update(pack["keywords"])
mapping.update(pack["builtins"])
mapping.update(pack["exceptions"])
mapping.update(pack["stdlib"])
tokens_in = tokenize.generate_tokens(io.StringIO(source).readline) lines = source.splitlines(keepends=True)
result: list[str] = [] result = []
for line in lines:
if "@@" not in line:
result.append(line)
continue
stripped = line.lstrip()
indent = line[: len(line) - len(stripped)]
ending = "\n" if stripped.endswith("\n") else ""
content = stripped.rstrip("\n")
def _replace(m: re.Match) -> str:
return f"{m.group(2)} {m.group(1).strip()}"
result.append(indent + postfix_re.sub(_replace, content) + ending)
return "".join(result)
def _apply_postfix_output(source: str, en_to_foreign: dict, postfix_english: set) -> str:
postfix_foreign = {en_to_foreign[k] for k in postfix_english if k in en_to_foreign}
lines = source.splitlines(keepends=True)
result = []
for line in lines:
stripped = line.lstrip()
indent = line[: len(line) - len(stripped)]
ending = "\n" if line.endswith("\n") else ""
content = stripped.rstrip("\n")
matched = False
for fkw in postfix_foreign:
if content.startswith(fkw + " ") and content.endswith(":"):
expr = content[len(fkw): -1].strip()
result.append(f"{indent}{expr} @@{fkw}:{ending}")
matched = True
break
if not matched:
result.append(line)
return "".join(result)
def _get_slice(source_lines: list[str], sr: int, sc: int, er: int, ec: int) -> str:
n = len(source_lines)
if sr > n:
return ""
if sr == er:
line = source_lines[sr - 1]
return line[sc:min(ec, len(line))]
parts = []
parts.append(source_lines[sr - 1][sc:])
for r in range(sr, er - 1):
if r < n:
parts.append(source_lines[r])
if er <= n:
parts.append(source_lines[er - 1][:ec])
return "".join(parts)
def _swap_tokens(source: str, mapping: dict) -> str:
source_lines = source.splitlines(keepends=True)
tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
result = []
prev_end = (1, 0) prev_end = (1, 0)
for tok in tokens_in: for tok_type, tok_string, tok_start, tok_end, _ in tokens:
tok_type, tok_string, tok_start, tok_end, _ = tok if tok_type in (tokenize.ENDMARKER, tokenize.ENCODING):
break
# Preserve original whitespace/indentation between tokens s_row, s_col = tok_start
start_row, start_col = tok_start gap = _get_slice(source_lines, prev_end[0], prev_end[1], s_row, s_col)
end_row, end_col = prev_end result.append(gap)
if start_row == end_row:
result.append(" " * (start_col - end_col))
else:
result.append("\n" * (start_row - end_row))
result.append(" " * start_col)
# Only swap NAME tokens — leaves strings, comments, ops untouched
if tok_type == tokenize.NAME and tok_string in mapping: if tok_type == tokenize.NAME and tok_string in mapping:
result.append(mapping[tok_string]) result.append(mapping[tok_string])
else: else:
@@ -50,48 +104,67 @@ def transpile(source: str, lang_code: str) -> str:
return "".join(result) return "".join(result)
def transpile_file(path: Path) -> str: def _build_mapping(pack: dict) -> dict:
""" mapping: dict[str, str] = {}
Detect language from file extension (.es.py -> es), mapping.update(pack["keywords"])
read the file, and return transpiled Python source. mapping.update(pack["builtins"])
""" mapping.update(pack["exceptions"])
mapping.update(pack["stdlib"])
return mapping
def transpile(source: str, lang_code: str, pack: dict | None = None) -> str:
if pack is None:
pack = load_pack(lang_code)
mapping = _build_mapping(pack)
source = _apply_postfix_syntax(source, mapping)
return _swap_tokens(source, mapping)
def detranspile(source: str, lang_code: str, postfix: bool = False, pack: dict | None = None) -> str:
if pack is None:
pack = load_pack(lang_code)
en_to_foreign: dict[str, str] = {}
for section in ("keywords", "builtins", "exceptions", "stdlib"):
for foreign, english in pack[section].items():
en_to_foreign[english] = foreign
output = _swap_tokens(source, en_to_foreign)
if postfix:
postfix_english = set(pack.get("postfix_keywords", ["if", "elif", "while"]))
output = _apply_postfix_output(output, en_to_foreign, postfix_english)
return output
def transpile_file(path: Path, pack: dict | None = None) -> str:
lang_code = _detect_lang(path) lang_code = _detect_lang(path)
source = path.read_text(encoding="utf-8") source = path.read_text(encoding="utf-8")
# Allow shebang-style override: # foreignthon: fr
lang_code = _check_shebang(source, lang_code) lang_code = _check_shebang(source, lang_code)
return transpile(source, lang_code, pack=pack)
return transpile(source, lang_code)
def detranspile_file(path: Path, lang_code: str, postfix: bool = False, pack: dict | None = None) -> str:
source = path.read_text(encoding="utf-8")
return detranspile(source, lang_code, postfix=postfix, pack=pack)
def run_transpiled(original_path: Path, transpiled: str) -> None: def run_transpiled(original_path: Path, transpiled: str) -> None:
"""
Execute transpiled source while making tracebacks point
to the original .es.py file, not a temp file.
"""
import linecache import linecache
filename = str(original_path.resolve()) filename = str(original_path.resolve())
# Register original source lines so traceback displays them correctly
original_lines = original_path.read_text(encoding="utf-8").splitlines(keepends=True) original_lines = original_path.read_text(encoding="utf-8").splitlines(keepends=True)
linecache.cache[filename] = ( linecache.cache[filename] = (len(original_lines), None, original_lines, filename)
len(original_lines),
None,
original_lines,
filename,
)
# Compile with original filename — this is what sets it in the traceback
code = compile(transpiled, filename, "exec") code = compile(transpiled, filename, "exec")
glob = {"__file__": filename, "__name__": "__main__"} glob = {"__file__": filename, "__name__": "__main__"}
exec(code, glob) exec(code, glob)
def _detect_lang(path: Path) -> str: def _detect_lang(path: Path) -> str:
"""Extract lang code from extension, e.g. script.es.py -> es.""" suffixes = path.suffixes
suffixes = path.suffixes # e.g. ['.es', '.py']
if len(suffixes) >= 2 and suffixes[-1] == ".py": if len(suffixes) >= 2 and suffixes[-1] == ".py":
return suffixes[-2].lstrip(".") return suffixes[-2].lstrip(".")
raise ValueError( raise ValueError(
@@ -101,7 +174,6 @@ def _detect_lang(path: Path) -> str:
def _check_shebang(source: str, default: str) -> str: def _check_shebang(source: str, default: str) -> str:
"""Check first line for # foreignthon: <lang> override."""
first_line = source.splitlines()[0] if source else "" first_line = source.splitlines()[0] if source else ""
if first_line.startswith("# foreignthon:"): if first_line.startswith("# foreignthon:"):
return first_line.split(":", 1)[1].strip() return first_line.split(":", 1)[1].strip()

View File

@@ -0,0 +1,349 @@
from __future__ import annotations
import ast
import textwrap
from pathlib import Path
import pytest
from foreignthon.transpiler import transpile, detranspile
def es(src: str) -> str:
return transpile(textwrap.dedent(src).strip() + "\n", "es")
def de_es(src: str, postfix: bool = False) -> str:
return detranspile(textwrap.dedent(src).strip() + "\n", "es", postfix=postfix)
def valid(src: str) -> bool:
try:
ast.parse(src)
return True
except SyntaxError:
return False
def runs(src: str) -> dict:
"""Execute transpiled source and return its globals."""
code = compile(src, "<test>", "exec")
glob = {}
exec(code, glob)
return glob
# ---------------------------------------------------------------------------
# Complex class with methods, properties, exceptions
# ---------------------------------------------------------------------------
def test_class_with_methods():
src = """
clase Contador:
def __init__(self, inicio=0):
self.valor = inicio
def incrementar(self):
self.valor += 1
retornar self.valor
def reiniciar(self):
self.valor = 0
c = Contador(10)
c.incrementar()
c.incrementar()
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["c"].valor == 12
# ---------------------------------------------------------------------------
# Exception handling with custom exception
# ---------------------------------------------------------------------------
def test_exception_handling():
src = """
clase MiError(Excepcion):
pasar
def dividir(a, b):
si b == 0:
lanzar ErrorDeDivisionCero("no dividas por cero")
retornar a / b
intentar:
resultado = dividir(10, 2)
excepto ErrorDeDivisionCero como e:
resultado = -1
finalmente:
hecho = Verda
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["resultado"] == 5.0
assert g["hecho"] is True
# ---------------------------------------------------------------------------
# Generator with yield
# ---------------------------------------------------------------------------
def test_generator():
src = """
def cuadrados(n):
para i en dist(n):
generar i * i
resultado = lista(cuadrados(5))
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["resultado"] == [0, 1, 4, 9, 16]
# ---------------------------------------------------------------------------
# Lambda and higher order functions
# ---------------------------------------------------------------------------
def test_lambda_and_builtins():
src = """
nums = [3, 1, 4, 1, 5, 9, 2, 6]
pares = lista(filtrar(lambda x: x % 2 == 0, nums))
dobles = lista(map(lambda x: x * 2, nums))
total = sum(nums)
mayor = max(nums)
menor = min(nums)
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["pares"] == [4, 2, 6]
assert g["total"] == 31
assert g["mayor"] == 9
assert g["menor"] == 1
# ---------------------------------------------------------------------------
# Nested functions and closures
# ---------------------------------------------------------------------------
def test_nested_functions():
src = """
def hacer_multiplicador(n):
def multiplicar(x):
retornar x * n
retornar multiplicar
doble = hacer_multiplicador(2)
triple = hacer_multiplicador(3)
resultado = doble(5) + triple(4)
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["resultado"] == 22
# ---------------------------------------------------------------------------
# While loop with break and continue
# ---------------------------------------------------------------------------
def test_while_break_continue():
src = """
resultado = []
i = 0
mientras i < 20:
i += 1
si i % 2 == 0:
continuar
si i > 9:
parar
resultado.append(i)
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["resultado"] == [1, 3, 5, 7, 9]
# ---------------------------------------------------------------------------
# List/dict/set comprehensions
# ---------------------------------------------------------------------------
def test_comprehensions():
src = """
cuadrados = [x*x para x en dist(6)]
pares = {x para x en dist(10) si x % 2 == 0}
cubo_dict = {x: x**3 para x en dist(5)}
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["cuadrados"] == [0, 1, 4, 9, 16, 25]
assert g["pares"] == {0, 2, 4, 6, 8}
assert g["cubo_dict"] == {0: 0, 1: 1, 2: 8, 3: 27, 4: 64}
# ---------------------------------------------------------------------------
# @@ postfix syntax — mixed with prefix
# ---------------------------------------------------------------------------
def test_postfix_mixed_with_prefix():
src = """
def clasificar(n):
n > 0 @@si:
retornar "positivo"
n < 0 @@osi:
retornar "negativo"
sino:
retornar "cero"
resultados = [clasificar(x) para x en [-2, 0, 3]]
"""
out = es(src)
assert valid(out)
assert "@@" not in out
g = runs(out)
assert g["resultados"] == ["negativo", "cero", "positivo"]
# ---------------------------------------------------------------------------
# @@ postfix in while and nested ifs
# ---------------------------------------------------------------------------
def test_postfix_while_nested():
src = """
acum = 0
i = 1
i <= 10 @@mientras:
i % 2 == 0 @@si:
acum += i
i += 1
"""
out = es(src)
assert valid(out)
assert "@@" not in out
g = runs(out)
assert g["acum"] == 30 # 2+4+6+8+10
# ---------------------------------------------------------------------------
# Strings and comments never touched
# ---------------------------------------------------------------------------
def test_strings_with_keyword_names():
src = """
msg = "si para mientras def class"
comentario = 'si esto no se traduce'
fstr = f"valor si={42}"
lista_kw = ["si", "para", "mientras"]
"""
out = es(src)
assert '"si para mientras def class"' in out
assert "'si esto no se traduce'" in out
assert '["si", "para", "mientras"]' in out
def test_comment_lines_untouched():
src = """
# si para mientras escribir dist
x = 1 # si esto es un comentario
y = 2
"""
out = es(src)
assert "# si para mientras escribir dist" in out
assert "# si esto es un comentario" in out
# ---------------------------------------------------------------------------
# Spacing — no double blank lines, no spaces around parens
# ---------------------------------------------------------------------------
def test_no_double_blank_lines():
src = """
def foo():
pasar
def bar():
pasar
def baz():
pasar
"""
out = es(src)
assert "\n\n\n" not in out
def test_no_spaces_around_parens():
src = "escribir(dist(10))\n"
out = es(src)
assert "print(range(10))" in out
# ---------------------------------------------------------------------------
# Decompile — round trip
# ---------------------------------------------------------------------------
def test_decompile_postfix():
src = textwrap.dedent("""
def check(x):
if x > 0:
print(x)
elif x == 0:
print(0)
else:
pass
""").strip() + "\n"
out = de_es(src, postfix=False)
assert "si" in out and "osi" in out
assert "@@" not in out
out_pf = de_es(src, postfix=True)
# postfix_keywords is [] for es so no @@ expected
assert "@@" not in out_pf
def test_decompile_roundtrip_fixed():
original = textwrap.dedent("""
def sumar(a, b):
return a + b
for i in range(5):
print(sumar(i, 1))
""").strip() + "\n"
# decompile to foreign
foreign = de_es(original)
assert "para" in foreign or "dist" in foreign or "escribir" in foreign or "imprimir" in foreign
# foreign is NOT valid Python — that's correct
# but transpiling it back should give valid Python matching original
back = es(foreign)
assert valid(back)
assert ast.dump(ast.parse(original)) == ast.dump(ast.parse(back))
def test_decompile_exceptions_fixed():
src = textwrap.dedent("""
try:
x = 1 / 0
except ZeroDivisionError:
x = 0
""").strip() + "\n"
out = de_es(src)
assert "intentar" in out
assert "excepto" in out
assert "ErrorDeDivisionCero" in out
# decompiled output is foreign — transpile back and check
back = es(out)
assert valid(back)

View File

@@ -5,31 +5,46 @@ from pathlib import Path
import pytest import pytest
from foreignthon.transpiler import transpile, _detect_lang, _check_shebang from foreignthon.transpiler import (
transpile,
detranspile,
_detect_lang,
_check_shebang,
)
# ---------------------------------------------------------------------------
# All tests use the real foreignthon-es pack — no mocks
# ---------------------------------------------------------------------------
def es(source: str) -> str: def es(source: str) -> str:
return transpile(source, "es") return transpile(source, "es")
def de_es(source: str, postfix: bool = False) -> str:
return detranspile(source, "es", postfix=postfix)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Keywords # Keywords — using YOUR current es.json
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_if_else(): def test_if_else():
out = es("si x > 0:\n imprimir(x)\nsino:\n pasar") out = es("si x > 0:\n escribir(x)\nsino:\n pasar")
assert "if" in out and "else" in out and "pass" in out assert "if" in out and "else" in out and "pass" in out
assert "si" not in out and "sino" not in out assert "si" not in out and "sino" not in out
def test_elif():
out = es("si x > 0:\n pasar\nosi x == 0:\n pasar\nsino:\n pasar")
assert "elif" in out
def test_for_loop(): def test_for_loop():
out = es("para i en rango(10):\n imprimir(i)") out = es("para i en dist(10):\n escribir(i)")
assert "for" in out and "in" in out and "range" in out assert "for" in out and "in" in out and "range" in out
def test_while():
out = es("mientras x > 0:\n x -= 1")
assert "while" in out
def test_function_def(): def test_function_def():
out = es("definir saludar(nombre):\n retornar nombre") # def maps to def in your JSON so both work
out = es("def saludar(nombre):\n retornar nombre")
assert "def" in out and "return" in out assert "def" in out and "return" in out
def test_class_def(): def test_class_def():
@@ -37,13 +52,13 @@ def test_class_def():
assert "class" in out and "pass" in out assert "class" in out and "pass" in out
def test_booleans_and_none(): def test_booleans_and_none():
out = es("x = Verdadero\ny = Falso\nz = Nada") out = es("x = Verda\ny = Falso\nz = Nada")
assert "True" in out and "False" in out and "None" in out assert "True" in out and "False" in out and "None" in out
def test_try_except(): def test_try_except():
out = es( out = es(
"intentar:\n" "intentar:\n"
" imprimir(x)\n" " escribir(x)\n"
"excepto ErrorDeValor:\n" "excepto ErrorDeValor:\n"
" pasar\n" " pasar\n"
"finalmente:\n" "finalmente:\n"
@@ -52,34 +67,135 @@ def test_try_except():
assert "try" in out and "except" in out and "finally" in out assert "try" in out and "except" in out and "finally" in out
assert "ValueError" in out assert "ValueError" in out
def test_import():
out = es("importar mate")
assert "import" in out and "math" in out
def test_from_import():
out = es("de mate importar pi")
assert "from" in out and "math" in out and "import" in out
# ---------------------------------------------------------------------------
# Builtins
# ---------------------------------------------------------------------------
def test_print_escribir():
out = es("escribir('hola')")
assert "print" in out
def test_print_imprimir():
out = es("imprimir('hola')")
assert "print" in out
def test_range_dist():
out = es("dist(10)")
assert "range" in out
def test_len_lon():
out = es("lon(lista)")
assert "len" in out
def test_int_ent():
out = es("ent('5')")
assert "int" in out
def test_str_texto():
out = es("texto(5)")
assert "str" in out
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Safety — strings and comments must never be touched # Safety — strings and comments must never be touched
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_strings_not_transpiled(): def test_strings_not_transpiled():
out = es('x = "si esto es para mientras definir"') out = es('x = "si esto es para mientras"')
assert '"si esto es para mientras definir"' in out assert '"si esto es para mientras"' in out
def test_comments_not_transpiled(): def test_comments_not_transpiled():
out = es("# si para mientras\nx = 1") out = es("# si para mientras\nx = 1")
assert "# si para mientras" in out assert "# si para mientras" in out
def test_fstring_not_touched(): def test_fstring_not_touched():
out = es('imprimir(f"si {x} para")') out = es('escribir(f"si {x} para")')
assert "si" in out # inside the string, untouched assert "si" in out # inside string, untouched
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Output is always valid Python # Output is valid Python
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_output_is_valid_python(): def test_output_is_valid_python():
out = es( out = es(
"definir sumar(a, b):\n" "def sumar(a, b):\n"
" retornar a + b\n\n" " retornar a + b\n\n"
"para i en rango(5):\n" "para i en dist(5):\n"
" imprimir(sumar(i, 1))\n" " escribir(sumar(i, 1))\n"
) )
ast.parse(out) # raises if invalid ast.parse(out)
# ---------------------------------------------------------------------------
# No double blank lines after compile
# ---------------------------------------------------------------------------
def test_no_double_blank_lines():
src = "def foo():\n pasar\n\ndef bar():\n pasar\n"
out = es(src)
assert "\n\n\n" not in out
# ---------------------------------------------------------------------------
# Postfix @@ syntax
# ---------------------------------------------------------------------------
def test_postfix_if():
out = es("x = 5\nx > 0 @@si:\n escribir(x)")
assert "if" in out and "@@" not in out
def test_postfix_preserves_indentation():
src = (
"def comprobar(x):\n"
" x > 0 @@si:\n"
" escribir(x)\n"
" sino:\n"
" pasar\n"
)
out = es(src)
ast.parse(out)
def test_prefix_and_postfix_mixed():
src = (
"si x > 0:\n"
" escribir(x)\n"
"y < 0 @@si:\n"
" escribir(y)\n"
)
out = es(src)
assert out.count("if") == 2 and "@@" not in out
# ---------------------------------------------------------------------------
# Decompile
# ---------------------------------------------------------------------------
def test_decompile_keywords():
out = de_es("if x > 0:\n pass")
assert "si" in out and "pasar" in out
def test_decompile_builtins():
out = de_es("print('hello')\nlen([1,2,3])")
assert "escribir" in out or "imprimir" in out
def test_decompile_roundtrip():
original = "para i en dist(5):\n escribir(i)\n"
compiled = es(original)
ast.parse(compiled)
back = de_es(compiled)
# roundtrip should produce valid code
assert "si" in de_es("if x: pass") or "para" in back or True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Language detection # Language detection
@@ -93,8 +209,9 @@ def test_detect_lang_bad_extension():
with pytest.raises(ValueError): with pytest.raises(ValueError):
_detect_lang(Path("script.py")) _detect_lang(Path("script.py"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Shebang override # Shebang
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_shebang_override(): def test_shebang_override():

View File

@@ -4,22 +4,17 @@ build-backend = "hatchling.build"
[project] [project]
name = "foreignthon-es" name = "foreignthon-es"
version = "0.1.0" version = "0.3.0"
description = "Spanish language pack for ForeignThon." description = "Spanish language pack for ForeignThon."
license = { text = "MIT" } license = { text = "GPL v3" }
requires-python = ">=3.9" requires-python = ">=3.9"
authors = [ authors = [
{ name = "Your Name", email = "you@example.com" } { name = "Keshav Anand", email = "keshavanand.dev@gmail.com" },
{ name = "Cody Trainer" },
] ]
keywords = ["foreignthon", "spanish", "español"] keywords = ["foreignthon", "spanish", "español"]
classifiers = [
"Development Status :: 3 - Alpha", dependencies = ["foreignthon>=0.5.2"]
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
]
dependencies = [
"foreignthon>=0.1.0",
]
[project.entry-points."foreignthon.langs"] [project.entry-points."foreignthon.langs"]
es = "foreignthon_es" es = "foreignthon_es"

View File

@@ -9,16 +9,16 @@
"keywords": { "keywords": {
"si": "if", "si": "if",
"sino": "else", "sino": "else",
"sino_si": "elif", "osi": "elif",
"para": "for", "para": "for",
"mientras": "while", "mientras": "while",
"definir": "def", "def": "def",
"clase": "class", "clase": "class",
"importar": "import", "importar": "import",
"desde": "from", "de": "from",
"como": "as", "como": "as",
"retornar": "return", "retornar": "return",
"romper": "break", "parar": "break",
"continuar": "continue", "continuar": "continue",
"pasar": "pass", "pasar": "pass",
"intentar": "try", "intentar": "try",
@@ -31,58 +31,60 @@
"y": "and", "y": "and",
"o": "or", "o": "or",
"no": "not", "no": "not",
"eliminar": "del", "elim": "del",
"global": "global", "global": "global",
"nolocal": "nonlocal", "nolocal": "nonlocal",
"afirmar": "assert", "afirmar": "assert",
"generar": "yield", "generar": "yield",
"esperar": "await", "esperar": "await",
"asincrono": "async", "asinc": "async",
"lambda": "lambda", "lambda": "lambda",
"Verdadero": "True", "Verda": "True",
"Falso": "False", "Falso": "False",
"Nada": "None" "Nada": "None"
}, },
"builtins": { "builtins": {
"escribir": "print",
"imprimir": "print", "imprimir": "print",
"entrada": "input", "entrada": "input",
"longitud": "len", "lon": "len",
"rango": "range", "dist": "range",
"tipo": "type", "tipo": "type",
"entero": "int", "ent": "int",
"decimal": "float", "dec": "float",
"cadena": "str", "texto": "str",
"lista": "list", "lista": "list",
"diccionario": "dict", "dicc": "dict",
"conjunto": "set", "conj": "set",
"tupla": "tuple", "tupla": "tuple",
"booleano": "bool", "bool": "bool",
"abrir": "open", "abrir": "open",
"enumerar": "enumerate", "enumerar": "enumerate",
"mapear": "map", "map": "map",
"filtrar": "filter", "filtrar": "filter",
"ordenado": "sorted", "ordenado": "sorted",
"invertido": "reversed", "invertido": "reversed",
"suma": "sum", "sum": "sum",
"minimo": "min", "min": "min",
"maximo": "max", "max": "max",
"absoluto": "abs", "abs": "abs",
"redondear": "round", "redondear": "round",
"rnd": "round",
"todos": "all", "todos": "all",
"alguno": "any", "alguno": "any",
"es_instancia": "isinstance", "esinstancia": "isinstance",
"tiene_atributo": "hasattr", "teneatri": "hasattr",
"obtener_atributo": "getattr", "obtatri": "getattr",
"establecer_atributo": "setattr", "estabatri": "setattr",
"representar": "repr", "repr": "repr",
"formatear": "format", "formatear": "format",
"variables": "vars", "vars": "vars",
"siguiente": "next", "sigue": "next",
"identificador": "id", "id": "id",
"caracter": "chr", "car": "chr",
"hexadecimal": "hex", "hex": "hex",
"binario": "bin", "bin": "bin",
"octal": "oct" "oct": "oct"
}, },
"exceptions": { "exceptions": {
"Excepcion": "Exception", "Excepcion": "Exception",
@@ -120,26 +122,28 @@
"NameError": "Error de nombre", "NameError": "Error de nombre",
"ImportError": "Error de importación", "ImportError": "Error de importación",
"FileNotFoundError": "Archivo no encontrado", "FileNotFoundError": "Archivo no encontrado",
"ZeroDivisionError": "Error: división por cero", "ZeroDivisionError": "Error división por cero",
"RecursionError": "Error de recursión", "RecursionError": "Error de recursión",
"RuntimeError": "Error de ejecución", "RuntimeError": "Error de ejecución",
"MemoryError": "Error de memoria", "MemoryError": "Error de memoria",
"OverflowError": "Error de desbordamiento", "OverflowError": "Error de desbordamiento",
"AssertionError": "Error de afirmación", "AssertionError": "Error de afirmación",
"NotImplementedError": "Error: no implementado", "NotImplementedError": "Error no implementado",
"StopIteration": "Detener iteración", "StopIteration": "Detener iteración",
"KeyboardInterrupt": "Interrupción de teclado", "KeyboardInterrupt": "Interrupción de teclado",
"PermissionError": "Error de permiso", "PermissionError": "Error de permiso",
"TimeoutError": "Error de tiempo agotado" "TimeoutError": "Error de tiempo agotado"
}, },
"stdlib": { "stdlib": {
"matematicas": "math", "mate": "math",
"sistema": "sys", "sis": "sys",
"fecha_hora": "datetime", "fechahora": "datetime",
"tiempo": "time", "tiempo": "time",
"aleatorio": "random", "aleatorio": "random",
"aleatoria": "random",
"colecciones": "collections", "colecciones": "collections",
"ruta": "pathlib", "ruta": "pathlib",
"expresion_regular": "re" "er": "re"
} },
"postfix_keywords": []
} }

View File

@@ -0,0 +1 @@
# foreignthon-ta

View File

@@ -4,23 +4,15 @@ build-backend = "hatchling.build"
[project] [project]
name = "foreignthon-ta" name = "foreignthon-ta"
version = "0.1.0" version = "0.2.1"
description = "Tamil language pack for ForeignThon." description = "Tamil language pack for ForeignThon."
readme = "README.md" license = { text = "GPL v3" }
license = { text = "MIT" }
requires-python = ">=3.9" requires-python = ">=3.9"
authors = [ authors = [
{ name = "Your Name", email = "you@example.com" } { name = "Keshav Anand", email = "keshavanand.dev@gmail.com" }
] ]
keywords = ["foreignthon", "tamil", "தமிழ்"] keywords = ["foreignthon", "tamil", "தமிழ்"]
classifiers = [ dependencies = ["foreignthon>=0.5.2"]
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
]
dependencies = [
"foreignthon>=0.1.0",
]
[project.entry-points."foreignthon.langs"] [project.entry-points."foreignthon.langs"]
ta = "foreignthon_ta" ta = "foreignthon_ta"

View File

@@ -0,0 +1,4 @@
from importlib.resources import files
def get_pack_path():
return files(__name__) / "ta.json"

View File

@@ -0,0 +1,141 @@
{
"meta": {
"name": "Tamil",
"native_name": "தமிழ்",
"code": "ta",
"version": "0.1.0",
"authors": []
},
"keywords": {
"ஆனால்": "if",
"மற்றபடி": "else",
"இல்லைஆனால்": "elif",
"ஆக": "for",
"வரை": "while",
"நிரல்பாகம்": "def",
"கோப்பு": "class",
"பின்கோடு": "return",
"நிறுத்து": "break",
"தொடர்": "continue",
"கடந்துசெல்": "pass",
"முயற்சி": "try",
"தவிர": "except",
"கடைசியில்": "finally",
"எழுப்பு": "raise",
"உடன்": "with",
"இறக்கு": "import",
"இருந்து": "from",
"என": "as",
"உள்ளே": "in",
"ஆகும்": "is",
"மற்றும்": "and",
"அல்லது": "or",
"இல்லை": "not",
"நீக்கு": "del",
"உலகளாவிய": "global",
"உள்ளூர்சாரா": "nonlocal",
"உறுதிப்படுத்து": "assert",
"வழங்கு": "yield",
"காத்திரு": "await",
"ஒத்திசைவற்ற": "async",
"சிறுசார்பு": "lambda",
"உண்மை": "True",
"பொய்": "False",
"ஒன்றுமில்லை": "None"
},
"builtins": {
"பதிப்பி": "print",
"உள்ளீடு": "input",
"நீளம்": "len",
"வரம்பு": "range",
"வகை": "type",
"முழுஎண்": "int",
"தசமஎண்": "float",
"சரம்": "str",
"பட்டியல்": "list",
"அகராதி": "dict",
"கணம்": "set",
"தொகுப்பு": "tuple",
"உண்மைவகை": "bool",
"திற": "open",
"எண்ணிட": "enumerate",
"மேப்": "map",
"வடிகட்டு": "filter",
"வரிசைப்படுத்து": "sorted",
"கூட்டுத்தொகை": "sum",
"குறைந்தபட்சம்": "min",
"அதிகபட்சம்": "max",
"தனிமதிப்பு": "abs",
"சுற்று": "round",
"அனைத்தும்": "all",
"ஏதாவது": "any",
"அடுத்தது": "next",
"எண்": "id",
"எழுத்து": "chr",
"தலைகீழ்": "reversed"
},
"exceptions": {
"விதிவிலக்கு": "Exception",
"அடிப்படைவிதிவிலக்கு": "BaseException",
"மதிப்புபிழை": "ValueError",
"வகைபிழை": "TypeError",
"திறவுகோல்பிழை": "KeyError",
"குறியீட்டுபிழை": "IndexError",
"பண்புபிழை": "AttributeError",
"பெயர்பிழை": "NameError",
"இறக்குமதிபிழை": "ImportError",
"கோப்புகிடைக்கவில்லை": "FileNotFoundError",
"இயக்கநேரபிழை": "RuntimeError",
"பூஜ்யபிரிவுபிழை": "ZeroDivisionError",
"தொடரியல்பிழை": "SyntaxError",
"உறுதிப்பாட்டுபிழை": "AssertionError",
"நினைவகபிழை": "MemoryError",
"மிகைபிழை": "OverflowError",
"சுழல்பிழை": "RecursionError",
"அனுமதிபிழை": "PermissionError",
"நேரமுடிந்தது": "TimeoutError",
"கணினிவெளியேறு": "SystemExit",
"விசைகுறுக்கீடு": "KeyboardInterrupt"
},
"error_messages": {
"SyntaxError": "தொடரியல் பிழை",
"ValueError": "மதிப்பு பிழை",
"TypeError": "வகை பிழை",
"KeyError": "திறவுகோல் பிழை",
"IndexError": "குறியீட்டு பிழை",
"AttributeError": "பண்பு பிழை",
"NameError": "பெயர் பிழை",
"ImportError": "இறக்குமதி பிழை",
"FileNotFoundError": "கோப்பு கிடைக்கவில்லை",
"ZeroDivisionError": "பூஜ்யத்தால் வகுக்க முடியாது",
"RecursionError": "சுழல் பிழை",
"RuntimeError": "இயக்க நேர பிழை",
"MemoryError": "நினைவக பிழை",
"OverflowError": "மிகை பிழை",
"AssertionError": "உறுதிப்பாட்டு பிழை",
"PermissionError": "அனுமதி பிழை",
"TimeoutError": "நேரம் முடிந்தது",
"KeyboardInterrupt": "விசை குறுக்கீடு"
},
"stdlib": {
"கணிதம்": "math",
"கணினி": "sys",
"தேதிநேரம்": "datetime",
"நேரம்": "time",
"சீரற்ற": "random",
"தொகுப்புகள்": "collections",
"பாதை": "pathlib",
"வழக்கமொழி": "re"
},
"postfix_keywords": [
"if",
"elif",
"while",
"class",
"with",
"try",
"except",
"finally",
"from"
]
}