FTL-Extract 0.0.1__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.
- ftl_extract-0.0.1/LICENSE +21 -0
- ftl_extract-0.0.1/PKG-INFO +47 -0
- ftl_extract-0.0.1/README.md +28 -0
- ftl_extract-0.0.1/pyproject.toml +129 -0
- ftl_extract-0.0.1/src/ftl_extract/__init__.py +4 -0
- ftl_extract-0.0.1/src/ftl_extract/__version__.py +3 -0
- ftl_extract-0.0.1/src/ftl_extract/cli.py +51 -0
- ftl_extract-0.0.1/src/ftl_extract/code_extractor.py +133 -0
- ftl_extract-0.0.1/src/ftl_extract/exceptions.py +37 -0
- ftl_extract-0.0.1/src/ftl_extract/ftl_extractor.py +79 -0
- ftl_extract-0.0.1/src/ftl_extract/ftl_importer.py +39 -0
- ftl_extract-0.0.1/src/ftl_extract/matcher.py +177 -0
- ftl_extract-0.0.1/src/ftl_extract/process/__init__.py +0 -0
- ftl_extract-0.0.1/src/ftl_extract/process/commentator.py +13 -0
- ftl_extract-0.0.1/src/ftl_extract/process/serializer.py +66 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [year] [fullname]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: FTL-Extract
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Extracts FTL files from a directory and outputs them to a directory
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: ftl,ftl-extract,ftl-extractor
|
|
7
|
+
Author: andrew000
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Dist: fluent-syntax (>=0.19,<0.20)
|
|
16
|
+
Requires-Dist: libcst (>=1.4,<2.0)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# FTL-Extract
|
|
20
|
+
|
|
21
|
+
## Description
|
|
22
|
+
|
|
23
|
+
FTL-Extract is a Python package that extracts Fluent keys from .py files
|
|
24
|
+
and generates a .ftl file with extracted keys.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Use the package manager [pip](https://pip.pypa.io/en/stable) to install
|
|
29
|
+
FTL-Extract.
|
|
30
|
+
|
|
31
|
+
``` bash
|
|
32
|
+
pip install FTL-Extract
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
``` bash
|
|
38
|
+
ftl_extract code_path output_path
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Contributing
|
|
42
|
+
|
|
43
|
+
Pull requests are welcome. For major changes, please open an issue first
|
|
44
|
+
to discuss what you would like to change.
|
|
45
|
+
|
|
46
|
+
Please make sure to update tests as appropriate.
|
|
47
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# FTL-Extract
|
|
2
|
+
|
|
3
|
+
## Description
|
|
4
|
+
|
|
5
|
+
FTL-Extract is a Python package that extracts Fluent keys from .py files
|
|
6
|
+
and generates a .ftl file with extracted keys.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Use the package manager [pip](https://pip.pypa.io/en/stable) to install
|
|
11
|
+
FTL-Extract.
|
|
12
|
+
|
|
13
|
+
``` bash
|
|
14
|
+
pip install FTL-Extract
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
``` bash
|
|
20
|
+
ftl_extract code_path output_path
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Contributing
|
|
24
|
+
|
|
25
|
+
Pull requests are welcome. For major changes, please open an issue first
|
|
26
|
+
to discuss what you would like to change.
|
|
27
|
+
|
|
28
|
+
Please make sure to update tests as appropriate.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "FTL-Extract"
|
|
3
|
+
description = "Extracts FTL files from a directory and outputs them to a directory"
|
|
4
|
+
version = "0.0.1"
|
|
5
|
+
authors = ["andrew000"]
|
|
6
|
+
keywords = ["ftl", "ftl-extract", "ftl-extractor"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
license = "MIT"
|
|
9
|
+
packages = [
|
|
10
|
+
{ include = "ftl_extract", from = "src" },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.poetry.dependencies]
|
|
14
|
+
python = ">=3.9"
|
|
15
|
+
fluent-syntax = "^0.19"
|
|
16
|
+
libcst = "^1.4"
|
|
17
|
+
|
|
18
|
+
[tool.poetry.group.test.dependencies]
|
|
19
|
+
pytest = "8.2.2"
|
|
20
|
+
pytest-cov = "5.0.0"
|
|
21
|
+
pytest-html = "4.1.1"
|
|
22
|
+
pytest-mock = "3.14.0"
|
|
23
|
+
coverage = "7.5.4"
|
|
24
|
+
|
|
25
|
+
[tool.poetry.group.docs.dependencies]
|
|
26
|
+
sphinx = "7.3.7"
|
|
27
|
+
sphinx-rtd-theme = "2.0.0"
|
|
28
|
+
sphinx-autobuild = "2024.4.16"
|
|
29
|
+
furo = "2024.5.6"
|
|
30
|
+
pytz = "2024.1"
|
|
31
|
+
|
|
32
|
+
[tool.poetry.group.dev.dependencies]
|
|
33
|
+
black = "24.4.2"
|
|
34
|
+
isort = "5.13.2"
|
|
35
|
+
pre-commit = "3.7.1"
|
|
36
|
+
ruff = "0.5.1"
|
|
37
|
+
mypy = "1.10.1"
|
|
38
|
+
typing-extensions = "4.12.2"
|
|
39
|
+
|
|
40
|
+
[tool.poetry.scripts]
|
|
41
|
+
ftl_extract = "ftl_extract.cli:cli_extract"
|
|
42
|
+
ftl_export = "ftl_export.__main__:main"
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["poetry-core>=1.0.0"]
|
|
46
|
+
build-backend = "poetry.core.masonry.api"
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = "tests"
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
packages = ["src/ftl_extract"]
|
|
53
|
+
exclude = [
|
|
54
|
+
"\\.?venv",
|
|
55
|
+
"\\.idea",
|
|
56
|
+
"\\.tests?",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.coverage.report]
|
|
60
|
+
exclude_lines = [
|
|
61
|
+
"pragma: no cover",
|
|
62
|
+
"def __repr__",
|
|
63
|
+
"def __str__",
|
|
64
|
+
"if TYPE_CHECKING:",
|
|
65
|
+
"importlib.metadata",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[tool.isort]
|
|
69
|
+
py_version = 39
|
|
70
|
+
src_paths = ["src", "tests"]
|
|
71
|
+
line_length = 100
|
|
72
|
+
multi_line_output = 3
|
|
73
|
+
force_grid_wrap = 0
|
|
74
|
+
include_trailing_comma = true
|
|
75
|
+
split_on_trailing_comma = false
|
|
76
|
+
single_line_exclusions = ["."]
|
|
77
|
+
sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"]
|
|
78
|
+
known_first_party = ["ftl_extract"]
|
|
79
|
+
|
|
80
|
+
[tool.ruff]
|
|
81
|
+
src = ["src", "tests"]
|
|
82
|
+
target-version = "py39"
|
|
83
|
+
line-length = 100
|
|
84
|
+
exclude = [
|
|
85
|
+
".bzr",
|
|
86
|
+
".direnv",
|
|
87
|
+
".eggs",
|
|
88
|
+
".git",
|
|
89
|
+
".hg",
|
|
90
|
+
".mypy_cache",
|
|
91
|
+
".nox",
|
|
92
|
+
".pants.d",
|
|
93
|
+
".pytype",
|
|
94
|
+
".ruff_cache",
|
|
95
|
+
".svn",
|
|
96
|
+
".tox",
|
|
97
|
+
".venv",
|
|
98
|
+
"__pypackages__",
|
|
99
|
+
"_build",
|
|
100
|
+
"buck-out",
|
|
101
|
+
"build",
|
|
102
|
+
"dist",
|
|
103
|
+
"node_modules",
|
|
104
|
+
"venv",
|
|
105
|
+
".venv",
|
|
106
|
+
"tests/.data_for_testing",
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
[tool.ruff.lint]
|
|
110
|
+
select = ["ALL"]
|
|
111
|
+
ignore = [
|
|
112
|
+
"A003",
|
|
113
|
+
"ANN002", "ANN003", "ANN101", "ANN102", "ANN401",
|
|
114
|
+
"COM812",
|
|
115
|
+
"C901",
|
|
116
|
+
"D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "D203", "D205", "D212",
|
|
117
|
+
"DTZ003",
|
|
118
|
+
"ERA001",
|
|
119
|
+
"F841",
|
|
120
|
+
"FA100", "FA102",
|
|
121
|
+
"FBT001", "FBT002",
|
|
122
|
+
"FIX002",
|
|
123
|
+
"INP001", "ISC001",
|
|
124
|
+
"PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLR5501",
|
|
125
|
+
"PLW0120",
|
|
126
|
+
"RUF",
|
|
127
|
+
"S101", "S311",
|
|
128
|
+
"TD002", "TD003"
|
|
129
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ftl_extract.ftl_extractor import extract
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.argument("code_path", type=click.Path(exists=True, path_type=Path))
|
|
12
|
+
@click.argument("output_path", type=click.Path(path_type=Path))
|
|
13
|
+
@click.option(
|
|
14
|
+
"--language",
|
|
15
|
+
"-l",
|
|
16
|
+
multiple=True,
|
|
17
|
+
default=("en",),
|
|
18
|
+
show_default=True,
|
|
19
|
+
help="Language of translation.",
|
|
20
|
+
)
|
|
21
|
+
@click.option(
|
|
22
|
+
"--i18n_keys",
|
|
23
|
+
"-k",
|
|
24
|
+
default=("i18n", "L", "LazyProxy", "LazyFilter"),
|
|
25
|
+
multiple=True,
|
|
26
|
+
show_default=True,
|
|
27
|
+
help="Names of function that is used to get translation.",
|
|
28
|
+
)
|
|
29
|
+
@click.option(
|
|
30
|
+
"--beauty",
|
|
31
|
+
is_flag=True,
|
|
32
|
+
default=False,
|
|
33
|
+
show_default=True,
|
|
34
|
+
help="Beautify output FTL files.",
|
|
35
|
+
)
|
|
36
|
+
def cli_extract(
|
|
37
|
+
code_path: Path,
|
|
38
|
+
output_path: Path,
|
|
39
|
+
language: tuple[str, ...],
|
|
40
|
+
i18n_keys: tuple[str, ...],
|
|
41
|
+
beauty: bool = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
click.echo(f"Extracting from {code_path}...")
|
|
44
|
+
|
|
45
|
+
extract(
|
|
46
|
+
code_path=code_path,
|
|
47
|
+
output_path=output_path,
|
|
48
|
+
language=language,
|
|
49
|
+
i18n_keys=i18n_keys,
|
|
50
|
+
beauty=beauty,
|
|
51
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import libcst as cst
|
|
7
|
+
|
|
8
|
+
from ftl_extract.exceptions import (
|
|
9
|
+
FTLExtractorDifferentPathsError,
|
|
10
|
+
FTLExtractorDifferentTranslationError,
|
|
11
|
+
)
|
|
12
|
+
from ftl_extract.matcher import I18nMatcher
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Iterator, Sequence
|
|
16
|
+
|
|
17
|
+
from ftl_extract.matcher import FluentKey
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_py_files(path: Path) -> Iterator[Path]:
|
|
21
|
+
"""
|
|
22
|
+
First step: find all .py files in given path.
|
|
23
|
+
|
|
24
|
+
:param path: Path to directory with .py files.
|
|
25
|
+
:type path: Path
|
|
26
|
+
:return: Iterator with Path to .py files.
|
|
27
|
+
:rtype: Iterator[Path]
|
|
28
|
+
"""
|
|
29
|
+
yield from path.rglob("[!{.}]*.py") if path.is_dir() else [path]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_file(path: Path, i18n_keys: str | Sequence[str]) -> dict[str, FluentKey]:
|
|
33
|
+
"""
|
|
34
|
+
Second step: parse given .py file and find all i18n calls.
|
|
35
|
+
|
|
36
|
+
:param path: Path to .py file.
|
|
37
|
+
:type path: Path
|
|
38
|
+
:param i18n_keys: Names of function that is used to get translation.
|
|
39
|
+
:type i18n_keys: str | Sequence[str]
|
|
40
|
+
:return: Dict with `key` and `FluentKey`.
|
|
41
|
+
:rtype: dict[str, FluentKey]
|
|
42
|
+
"""
|
|
43
|
+
module = cst.parse_module(path.read_bytes())
|
|
44
|
+
matcher = I18nMatcher(code_path=path, func_names=i18n_keys)
|
|
45
|
+
matcher.extract_matches(module)
|
|
46
|
+
return matcher.fluent_keys
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def post_process_fluent_keys(fluent_keys: dict[str, FluentKey]) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Third step: post-process parsed `FluentKey`.
|
|
52
|
+
|
|
53
|
+
:param fluent_keys: Dict with `key` and `FluentKey` that will be post-processed.
|
|
54
|
+
:type fluent_keys: dict[str, FluentKey]
|
|
55
|
+
"""
|
|
56
|
+
for fluent_key in fluent_keys.values():
|
|
57
|
+
if not isinstance(fluent_key.path, Path):
|
|
58
|
+
fluent_key.path = Path(fluent_key.path)
|
|
59
|
+
|
|
60
|
+
if not fluent_key.path.suffix: # if path looks like directory (no suffix)
|
|
61
|
+
fluent_key.path /= "_default.ftl"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def find_conflicts(
|
|
65
|
+
current_fluent_keys: dict[str, FluentKey],
|
|
66
|
+
new_fluent_keys: dict[str, FluentKey],
|
|
67
|
+
) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Fourth step: find conflicts between current and new `FluentKey`s.
|
|
70
|
+
|
|
71
|
+
If conflict is found, raise `ValueError`.
|
|
72
|
+
|
|
73
|
+
Conflict is when `key` is the same, but `path` or `kwargs` are different.
|
|
74
|
+
"""
|
|
75
|
+
# Find common keys
|
|
76
|
+
conflict_keys = set(current_fluent_keys.keys()) & set(new_fluent_keys.keys())
|
|
77
|
+
|
|
78
|
+
if not conflict_keys:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
for key in conflict_keys:
|
|
82
|
+
if current_fluent_keys[key].path != new_fluent_keys[key].path:
|
|
83
|
+
raise FTLExtractorDifferentPathsError(
|
|
84
|
+
key,
|
|
85
|
+
current_fluent_keys[key].path,
|
|
86
|
+
new_fluent_keys[key].path,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if not current_fluent_keys[key].translation.equals(new_fluent_keys[key].translation):
|
|
90
|
+
raise FTLExtractorDifferentTranslationError(
|
|
91
|
+
key,
|
|
92
|
+
current_fluent_keys[key].translation,
|
|
93
|
+
new_fluent_keys[key].translation,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extract_fluent_keys(path: Path, i18n_keys: str | Sequence[str]) -> dict[str, FluentKey]:
|
|
98
|
+
"""
|
|
99
|
+
Extract all `FluentKey`s from given path.
|
|
100
|
+
|
|
101
|
+
:param path: Path to [.py file] / [directory with .py files].
|
|
102
|
+
:type path: Path
|
|
103
|
+
:param i18n_keys: Names of function that is used to get translation.
|
|
104
|
+
:type i18n_keys: str | Sequence[str]
|
|
105
|
+
:return: Dict with `key` and `FluentKey`.
|
|
106
|
+
:rtype: dict[str, FluentKey]
|
|
107
|
+
|
|
108
|
+
"""
|
|
109
|
+
fluent_keys: dict[str, FluentKey] = {}
|
|
110
|
+
|
|
111
|
+
for file in find_py_files(path):
|
|
112
|
+
keys = parse_file(file, i18n_keys)
|
|
113
|
+
post_process_fluent_keys(keys)
|
|
114
|
+
find_conflicts(fluent_keys, keys)
|
|
115
|
+
fluent_keys.update(keys)
|
|
116
|
+
|
|
117
|
+
return fluent_keys
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def sort_fluent_keys_by_path(fluent_keys: dict[str, FluentKey]) -> dict[Path, list[FluentKey]]:
|
|
121
|
+
"""
|
|
122
|
+
Sort `FluentKey`s by their paths.
|
|
123
|
+
|
|
124
|
+
:param fluent_keys: Dict with `key` and `FluentKey`.
|
|
125
|
+
:type fluent_keys: dict[str, FluentKey]
|
|
126
|
+
:return: Dict with `Path` and list of `FluentKey`.
|
|
127
|
+
:rtype: dict[Path, list[FluentKey]]
|
|
128
|
+
"""
|
|
129
|
+
sorted_fluent_keys: dict[Path, list[FluentKey]] = {}
|
|
130
|
+
for fluent_key in fluent_keys.values():
|
|
131
|
+
sorted_fluent_keys.setdefault(fluent_key.path, []).append(fluent_key)
|
|
132
|
+
|
|
133
|
+
return sorted_fluent_keys
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fluent.syntax import ast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FTLExtractorError(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FTLExtractorDifferentPathsError(FTLExtractorError):
|
|
16
|
+
def __init__(self, key: str, current_path: Path, new_path: Path) -> None:
|
|
17
|
+
self.current_path = current_path
|
|
18
|
+
self.new_path = new_path
|
|
19
|
+
super().__init__(
|
|
20
|
+
f"Key {key!r} already exists with different path: "
|
|
21
|
+
f"{self.current_path} != {self.new_path}"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FTLExtractorDifferentTranslationError(FTLExtractorError):
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
key: str,
|
|
29
|
+
current_translation: ast.Message | ast.Comment,
|
|
30
|
+
new_translation: ast.Message | ast.Comment,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.current_translation = current_translation
|
|
33
|
+
self.new_translation = new_translation
|
|
34
|
+
super().__init__(
|
|
35
|
+
f"Translation {key!r} already exists with different elements: "
|
|
36
|
+
f"{self.current_translation} != {self.new_translation}"
|
|
37
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from click import echo
|
|
6
|
+
from fluent.syntax import FluentSerializer
|
|
7
|
+
|
|
8
|
+
from ftl_extract import extract_fluent_keys
|
|
9
|
+
from ftl_extract.code_extractor import sort_fluent_keys_by_path
|
|
10
|
+
from ftl_extract.ftl_importer import import_ftl_from_dir
|
|
11
|
+
from ftl_extract.process.commentator import comment_ftl_key
|
|
12
|
+
from ftl_extract.process.serializer import BeautyFluentSerializer, generate_ftl
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ftl_extract.matcher import FluentKey
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract(
|
|
21
|
+
code_path: Path,
|
|
22
|
+
output_path: Path,
|
|
23
|
+
language: tuple[str, ...],
|
|
24
|
+
i18n_keys: tuple[str, ...],
|
|
25
|
+
beauty: bool = False,
|
|
26
|
+
) -> None:
|
|
27
|
+
serializer: FluentSerializer | BeautyFluentSerializer
|
|
28
|
+
|
|
29
|
+
if beauty is True:
|
|
30
|
+
serializer = BeautyFluentSerializer(with_junk=True)
|
|
31
|
+
else:
|
|
32
|
+
serializer = FluentSerializer(with_junk=True)
|
|
33
|
+
|
|
34
|
+
# Extract fluent keys from code
|
|
35
|
+
in_code_fluent_keys = extract_fluent_keys(code_path, i18n_keys)
|
|
36
|
+
|
|
37
|
+
for lang in language:
|
|
38
|
+
# Import fluent keys from existing FTL files
|
|
39
|
+
stored_fluent_keys = import_ftl_from_dir(output_path, lang)
|
|
40
|
+
for fluent_key in stored_fluent_keys.values():
|
|
41
|
+
fluent_key.path = fluent_key.path.relative_to(output_path / lang)
|
|
42
|
+
|
|
43
|
+
keys_to_comment: dict[str, FluentKey] = {}
|
|
44
|
+
keys_to_add: dict[str, FluentKey] = {}
|
|
45
|
+
|
|
46
|
+
# Find keys should be commented
|
|
47
|
+
# Keys, that are not in code or not in their `path_` file
|
|
48
|
+
# First step: find keys that have different paths
|
|
49
|
+
for key, fluent_key in in_code_fluent_keys.items():
|
|
50
|
+
if key in stored_fluent_keys and fluent_key.path != stored_fluent_keys[key].path:
|
|
51
|
+
keys_to_comment[key] = stored_fluent_keys.pop(key)
|
|
52
|
+
keys_to_add[key] = fluent_key
|
|
53
|
+
|
|
54
|
+
elif key not in stored_fluent_keys:
|
|
55
|
+
keys_to_add[key] = fluent_key
|
|
56
|
+
|
|
57
|
+
else:
|
|
58
|
+
stored_fluent_keys[key].code_path = fluent_key.code_path
|
|
59
|
+
|
|
60
|
+
# Second step: find keys that are not in code
|
|
61
|
+
for key in stored_fluent_keys.keys() - in_code_fluent_keys.keys():
|
|
62
|
+
keys_to_comment[key] = stored_fluent_keys.pop(key)
|
|
63
|
+
|
|
64
|
+
for fluent_key in keys_to_comment.values():
|
|
65
|
+
comment_ftl_key(fluent_key, serializer)
|
|
66
|
+
|
|
67
|
+
sorted_fluent_keys = sort_fluent_keys_by_path(stored_fluent_keys)
|
|
68
|
+
|
|
69
|
+
for path, keys in sort_fluent_keys_by_path(keys_to_add).items():
|
|
70
|
+
sorted_fluent_keys.setdefault(path, []).extend(keys)
|
|
71
|
+
|
|
72
|
+
for path, keys in sort_fluent_keys_by_path(keys_to_comment).items():
|
|
73
|
+
sorted_fluent_keys.setdefault(path, []).extend(keys)
|
|
74
|
+
|
|
75
|
+
for path, keys in sorted_fluent_keys.items():
|
|
76
|
+
ftl, _ = generate_ftl(keys, serializer=serializer)
|
|
77
|
+
(output_path / lang / path).parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
(output_path / lang / path).write_text(ftl, encoding="utf-8")
|
|
79
|
+
echo(f"File {output_path / lang / path} has been saved. {len(keys)} keys updated.")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fluent.syntax import ast, parse
|
|
6
|
+
|
|
7
|
+
from ftl_extract.matcher import FluentKey
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def import_from_ftl(path: Path, locale: str) -> tuple[dict[str, FluentKey], ast.Resource]:
|
|
11
|
+
"""Import `FluentKey`s from FTL."""
|
|
12
|
+
ftl_keys = {}
|
|
13
|
+
|
|
14
|
+
resource = parse(path.read_text(encoding="utf-8"), with_spans=True)
|
|
15
|
+
|
|
16
|
+
for entry in resource.body:
|
|
17
|
+
if isinstance(entry, ast.Message):
|
|
18
|
+
ftl_keys[entry.id.name] = FluentKey(
|
|
19
|
+
code_path=Path(),
|
|
20
|
+
key=entry.id.name,
|
|
21
|
+
translation=entry,
|
|
22
|
+
# Cut off the locale from the path
|
|
23
|
+
path=path,
|
|
24
|
+
locale=locale,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return ftl_keys, resource
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def import_ftl_from_dir(path: Path, locale: str) -> dict[str, FluentKey]:
|
|
31
|
+
"""Import `FluentKey`s from directory of FTL files."""
|
|
32
|
+
ftl_files = (path / locale).rglob("*.ftl") if path.is_dir() else [path]
|
|
33
|
+
ftl_keys = {}
|
|
34
|
+
|
|
35
|
+
for ftl_file in ftl_files:
|
|
36
|
+
keys, _ = import_from_ftl(ftl_file, locale)
|
|
37
|
+
ftl_keys.update(keys)
|
|
38
|
+
|
|
39
|
+
return ftl_keys
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Callable, cast
|
|
7
|
+
|
|
8
|
+
import libcst as cst
|
|
9
|
+
from fluent.syntax import ast
|
|
10
|
+
from libcst import matchers as m
|
|
11
|
+
|
|
12
|
+
from ftl_extract.exceptions import (
|
|
13
|
+
FTLExtractorDifferentPathsError,
|
|
14
|
+
FTLExtractorDifferentTranslationError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
I18N_LITERAL: Literal["i18n"] = "i18n"
|
|
21
|
+
GET_LITERAL: Literal["get"] = "get"
|
|
22
|
+
PATH_LITERAL: Literal["_path"] = "_path"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FluentKey:
|
|
27
|
+
"""
|
|
28
|
+
Dataclass for storing information about key and its translation.
|
|
29
|
+
|
|
30
|
+
:param code_path: Path to .py file where key was found.
|
|
31
|
+
:type code_path: Path
|
|
32
|
+
:param key: Key that will be used to get translation.
|
|
33
|
+
:type key: str | None
|
|
34
|
+
:param translation: Translation of key.
|
|
35
|
+
:type translation: str | None
|
|
36
|
+
:param path: Path to .ftl file where key will be stored.
|
|
37
|
+
:type path: Path
|
|
38
|
+
:param locale: Locale of translation. When extracting from .py file, it will not be needed.
|
|
39
|
+
:type locale: str | None
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
code_path: Path
|
|
43
|
+
key: str
|
|
44
|
+
translation: ast.Message | ast.Comment
|
|
45
|
+
path: Path = field(default=Path("_default.ftl"))
|
|
46
|
+
locale: str | None = field(default=None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class I18nMatcher:
|
|
50
|
+
def __init__(self, code_path: Path, func_names: str | Sequence[str] = I18N_LITERAL) -> None:
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
:param code_path: Path to .py file where visitor will be used.
|
|
54
|
+
:type code_path: Path
|
|
55
|
+
:param func_names: Name of function that is used to get translation. Default is "i18n".
|
|
56
|
+
:type func_names: str | Sequence[str]
|
|
57
|
+
"""
|
|
58
|
+
self.code_path = code_path
|
|
59
|
+
self._func_names = {func_names} if isinstance(func_names, str) else set(func_names)
|
|
60
|
+
self.fluent_keys: dict[str, FluentKey] = {}
|
|
61
|
+
|
|
62
|
+
self._matcher = m.OneOf(
|
|
63
|
+
m.Call(
|
|
64
|
+
func=m.Attribute(
|
|
65
|
+
value=m.OneOf(*map(cast(Callable, m.Name), self._func_names)),
|
|
66
|
+
attr=m.SaveMatchedNode(matcher=~m.Name(GET_LITERAL) & m.Name(), name="key"),
|
|
67
|
+
),
|
|
68
|
+
args=[
|
|
69
|
+
m.SaveMatchedNode(
|
|
70
|
+
matcher=m.ZeroOrMore(
|
|
71
|
+
m.Arg(
|
|
72
|
+
value=m.DoNotCare(),
|
|
73
|
+
keyword=m.Name(),
|
|
74
|
+
)
|
|
75
|
+
),
|
|
76
|
+
name="kwargs",
|
|
77
|
+
)
|
|
78
|
+
],
|
|
79
|
+
),
|
|
80
|
+
m.Call(
|
|
81
|
+
func=m.Attribute(
|
|
82
|
+
value=m.OneOf(*map(cast(Callable, m.Name), self._func_names)),
|
|
83
|
+
attr=m.Name(value=GET_LITERAL),
|
|
84
|
+
),
|
|
85
|
+
args=[
|
|
86
|
+
m.Arg(
|
|
87
|
+
value=m.SaveMatchedNode(matcher=m.SimpleString(), name="key"), keyword=None
|
|
88
|
+
),
|
|
89
|
+
m.SaveMatchedNode(
|
|
90
|
+
matcher=m.ZeroOrMore(
|
|
91
|
+
m.Arg(
|
|
92
|
+
value=m.DoNotCare(),
|
|
93
|
+
keyword=m.Name(),
|
|
94
|
+
)
|
|
95
|
+
),
|
|
96
|
+
name="kwargs",
|
|
97
|
+
),
|
|
98
|
+
],
|
|
99
|
+
),
|
|
100
|
+
m.Call(
|
|
101
|
+
func=m.OneOf(*map(cast(Callable, m.Name), self._func_names)),
|
|
102
|
+
args=[
|
|
103
|
+
m.Arg(
|
|
104
|
+
value=m.SaveMatchedNode(matcher=m.SimpleString(), name="key"), keyword=None
|
|
105
|
+
),
|
|
106
|
+
m.SaveMatchedNode(
|
|
107
|
+
matcher=m.ZeroOrMore(
|
|
108
|
+
m.Arg(
|
|
109
|
+
value=m.DoNotCare(),
|
|
110
|
+
keyword=m.Name(),
|
|
111
|
+
)
|
|
112
|
+
),
|
|
113
|
+
name="kwargs",
|
|
114
|
+
),
|
|
115
|
+
],
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def extract_matches(self, module: cst.Module) -> None:
|
|
120
|
+
for match in m.extractall(module, self._matcher):
|
|
121
|
+
# Key
|
|
122
|
+
if isinstance(match["key"], cst.Name):
|
|
123
|
+
key = cast(cst.Name, match["key"]).value
|
|
124
|
+
translation = ast.Message(
|
|
125
|
+
id=ast.Identifier(name=key),
|
|
126
|
+
value=ast.Pattern(
|
|
127
|
+
elements=[ast.TextElement(value=cast(cst.Name, match["key"]).value)]
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
fluent_key = FluentKey(code_path=self.code_path, key=key, translation=translation)
|
|
131
|
+
elif isinstance(match["key"], cst.SimpleString):
|
|
132
|
+
key = cast(cst.SimpleString, match["key"]).raw_value
|
|
133
|
+
translation = ast.Message(
|
|
134
|
+
id=ast.Identifier(name=key),
|
|
135
|
+
value=ast.Pattern(elements=[ast.TextElement(value=key)]),
|
|
136
|
+
)
|
|
137
|
+
fluent_key = FluentKey(code_path=self.code_path, key=key, translation=translation)
|
|
138
|
+
else:
|
|
139
|
+
msg = f"Unknown type of key: {type(match['key'])} | {match['key']}"
|
|
140
|
+
raise TypeError(msg)
|
|
141
|
+
|
|
142
|
+
# Kwargs
|
|
143
|
+
for kwarg in cast(Sequence[m.Arg], match["kwargs"]):
|
|
144
|
+
keyword = cast(cst.Name, kwarg.keyword)
|
|
145
|
+
if keyword.value == PATH_LITERAL:
|
|
146
|
+
fluent_key.path = Path(cast(cst.SimpleString, kwarg.value).raw_value)
|
|
147
|
+
|
|
148
|
+
else:
|
|
149
|
+
if (
|
|
150
|
+
isinstance(fluent_key.translation, ast.Message)
|
|
151
|
+
and fluent_key.translation.value is not None
|
|
152
|
+
):
|
|
153
|
+
fluent_key.translation.value.elements.append(
|
|
154
|
+
ast.Placeable(
|
|
155
|
+
expression=ast.VariableReference(
|
|
156
|
+
id=ast.Identifier(name=keyword.value)
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if fluent_key.key in self.fluent_keys:
|
|
162
|
+
if self.fluent_keys[fluent_key.key].path != fluent_key.path:
|
|
163
|
+
raise FTLExtractorDifferentPathsError(
|
|
164
|
+
fluent_key.key,
|
|
165
|
+
fluent_key.path,
|
|
166
|
+
self.fluent_keys[fluent_key.key].path,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if not self.fluent_keys[fluent_key.key].translation.equals(fluent_key.translation):
|
|
170
|
+
raise FTLExtractorDifferentTranslationError(
|
|
171
|
+
fluent_key.key,
|
|
172
|
+
fluent_key.translation,
|
|
173
|
+
self.fluent_keys[fluent_key.key].translation,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
else:
|
|
177
|
+
self.fluent_keys[fluent_key.key] = fluent_key
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from fluent.syntax import FluentSerializer, ast
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ftl_extract.matcher import FluentKey
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def comment_ftl_key(key: FluentKey, serializer: FluentSerializer) -> None:
|
|
12
|
+
raw_entry = serializer.serialize_entry(key.translation)
|
|
13
|
+
key.translation = ast.Comment(content=raw_entry.strip())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from fluent.syntax import FluentSerializer, ast
|
|
6
|
+
from fluent.syntax.serializer import serialize_junk, serialize_message, serialize_term
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from fluent.syntax.ast import Resource
|
|
10
|
+
|
|
11
|
+
from ftl_extract.matcher import FluentKey
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BeautyFluentSerializer(FluentSerializer):
|
|
15
|
+
"""A serializer that formats the output FTL for better readability."""
|
|
16
|
+
|
|
17
|
+
def serialize_entry(self, entry: ast.EntryType, state: int = 0) -> str: # pragma: no cover
|
|
18
|
+
"""Serialize an :class:`.ast.Entry` to a string."""
|
|
19
|
+
if isinstance(entry, ast.Message):
|
|
20
|
+
return serialize_message(entry)
|
|
21
|
+
if isinstance(entry, ast.Term):
|
|
22
|
+
return serialize_term(entry)
|
|
23
|
+
if isinstance(entry, ast.Comment):
|
|
24
|
+
if state & self.HAS_ENTRIES:
|
|
25
|
+
return "\n{}\n".format(serialize_comment(entry, "#"))
|
|
26
|
+
return "{}\n".format(serialize_comment(entry, "#"))
|
|
27
|
+
if isinstance(entry, ast.GroupComment):
|
|
28
|
+
if state & self.HAS_ENTRIES:
|
|
29
|
+
return "\n{}\n".format(serialize_comment(entry, "##"))
|
|
30
|
+
return "{}\n".format(serialize_comment(entry, "##"))
|
|
31
|
+
if isinstance(entry, ast.ResourceComment):
|
|
32
|
+
if state & self.HAS_ENTRIES:
|
|
33
|
+
return "\n{}\n".format(serialize_comment(entry, "###"))
|
|
34
|
+
return "{}\n".format(serialize_comment(entry, "###"))
|
|
35
|
+
if isinstance(entry, ast.Junk):
|
|
36
|
+
return serialize_junk(entry)
|
|
37
|
+
raise Exception(f"Unknown entry type: {type(entry)}") # noqa: TRY002, TRY003, EM102
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def serialize_comment(
|
|
41
|
+
comment: ast.Comment | ast.GroupComment | ast.ResourceComment,
|
|
42
|
+
prefix: str = "#",
|
|
43
|
+
) -> str: # pragma: no cover
|
|
44
|
+
if not comment.content:
|
|
45
|
+
return f"{prefix}"
|
|
46
|
+
|
|
47
|
+
return "\n".join(
|
|
48
|
+
[prefix if len(line) == 0 else f"{prefix} {line}" for line in comment.content.split("\n")]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def generate_ftl(
|
|
53
|
+
fluent_keys: dict[str, FluentKey] | list[FluentKey],
|
|
54
|
+
serializer: FluentSerializer,
|
|
55
|
+
) -> tuple[str, Resource]:
|
|
56
|
+
"""Generate FTL translations from `fluent_keys`."""
|
|
57
|
+
resource = ast.Resource(body=None)
|
|
58
|
+
|
|
59
|
+
if isinstance(fluent_keys, list):
|
|
60
|
+
for fluent_key in fluent_keys:
|
|
61
|
+
resource.body.append(fluent_key.translation)
|
|
62
|
+
else:
|
|
63
|
+
for fluent_key in fluent_keys.values():
|
|
64
|
+
resource.body.append(fluent_key.translation)
|
|
65
|
+
|
|
66
|
+
return serializer.serialize(resource), resource
|