FTL-Extract 0.9.0__py3-none-win_amd64.whl

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.
@@ -0,0 +1,4 @@
1
+ from ftl_extract.__version__ import __version__
2
+ from ftl_extract.code_extractor import extract_fluent_keys
3
+
4
+ __all__ = ("__version__", "extract_fluent_keys")
@@ -0,0 +1,3 @@
1
+ import importlib.metadata
2
+
3
+ __version__ = importlib.metadata.version(__package__)
ftl_extract/cli.py ADDED
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from time import perf_counter_ns
5
+ from typing import Literal
6
+
7
+ import click
8
+
9
+ from ftl_extract.const import (
10
+ COMMENT_KEYS_MODE,
11
+ DEFAULT_EXCLUDE_DIRS,
12
+ DEFAULT_FTL_FILE,
13
+ DEFAULT_I18N_KEYS,
14
+ DEFAULT_IGNORE_ATTRIBUTES,
15
+ DEFAULT_IGNORE_KWARGS,
16
+ )
17
+ from ftl_extract.ftl_extractor import extract
18
+ from ftl_extract.stub.generator import generate_stubs
19
+
20
+
21
+ @click.group("ftl")
22
+ @click.version_option()
23
+ def ftl() -> None: ...
24
+
25
+
26
+ @ftl.command("extract")
27
+ @click.argument("code_path", type=click.Path(exists=True, path_type=Path))
28
+ @click.argument("output_path", type=click.Path(path_type=Path))
29
+ @click.option(
30
+ "--language",
31
+ "-l",
32
+ multiple=True,
33
+ default=("en",),
34
+ show_default=True,
35
+ help="Language of translation.",
36
+ )
37
+ @click.option(
38
+ "--i18n-keys",
39
+ "-k",
40
+ default=DEFAULT_I18N_KEYS,
41
+ multiple=True,
42
+ show_default=True,
43
+ help="Names of function that is used to get translation.",
44
+ )
45
+ @click.option(
46
+ "--i18n-keys-append",
47
+ "-K",
48
+ default=(),
49
+ multiple=True,
50
+ help="Append names of function that is used to get translation.",
51
+ )
52
+ @click.option(
53
+ "--i18n-keys-prefix",
54
+ "-p",
55
+ default=(),
56
+ multiple=True,
57
+ help="Prefix names of function that is used to get translation. `self.i18n.*()`",
58
+ )
59
+ @click.option(
60
+ "--exclude-dirs",
61
+ "-e",
62
+ multiple=True,
63
+ default=DEFAULT_EXCLUDE_DIRS,
64
+ show_default=True,
65
+ help="Exclude directories.",
66
+ )
67
+ @click.option(
68
+ "--exclude-dirs-append",
69
+ "-E",
70
+ default=(),
71
+ multiple=True,
72
+ help="Append directories to exclude.",
73
+ )
74
+ @click.option(
75
+ "--ignore-attributes",
76
+ "-i",
77
+ default=DEFAULT_IGNORE_ATTRIBUTES,
78
+ multiple=True,
79
+ show_default=True,
80
+ help="Ignore attributes, like `i18n.set_locale`.",
81
+ )
82
+ @click.option(
83
+ "--append-ignore-attributes",
84
+ "-I",
85
+ multiple=True,
86
+ help="Append attributes to ignore.",
87
+ )
88
+ @click.option(
89
+ "--ignore-kwargs",
90
+ default=DEFAULT_IGNORE_KWARGS,
91
+ multiple=True,
92
+ show_default=True,
93
+ help="Ignore kwargs, like `when` from `aiogram_dialog.I18nFormat(..., when=...)`.",
94
+ )
95
+ @click.option(
96
+ "--comment-junks",
97
+ is_flag=True,
98
+ default=False,
99
+ show_default=True,
100
+ help="Comments Junk elements.",
101
+ )
102
+ @click.option(
103
+ "--default-ftl-file",
104
+ default=DEFAULT_FTL_FILE,
105
+ show_default=True,
106
+ type=click.Path(path_type=Path),
107
+ )
108
+ @click.option(
109
+ "--comment-keys-mode",
110
+ default=COMMENT_KEYS_MODE[0],
111
+ show_default=True,
112
+ help="Comment keys mode.",
113
+ type=click.Choice(COMMENT_KEYS_MODE, case_sensitive=False),
114
+ )
115
+ @click.option(
116
+ "--dry-run",
117
+ is_flag=True,
118
+ default=False,
119
+ show_default=True,
120
+ help="Do not write to output files.",
121
+ )
122
+ @click.option(
123
+ "--verbose",
124
+ "-v",
125
+ is_flag=True,
126
+ default=False,
127
+ show_default=True,
128
+ help="Verbose output.",
129
+ )
130
+ def cli_extract(
131
+ code_path: Path,
132
+ output_path: Path,
133
+ language: tuple[str, ...],
134
+ i18n_keys: tuple[str, ...],
135
+ i18n_keys_append: tuple[str, ...],
136
+ i18n_keys_prefix: tuple[str, ...],
137
+ exclude_dirs: tuple[str, ...],
138
+ exclude_dirs_append: tuple[str, ...],
139
+ ignore_attributes: tuple[str, ...],
140
+ append_ignore_attributes: tuple[str, ...],
141
+ ignore_kwargs: tuple[str, ...],
142
+ comment_junks: bool,
143
+ default_ftl_file: Path,
144
+ comment_keys_mode: Literal["comment", "warn"],
145
+ dry_run: bool,
146
+ verbose: bool,
147
+ ) -> None:
148
+ click.echo(f"Extracting from {code_path}")
149
+ start_time = perf_counter_ns()
150
+
151
+ statistics = extract(
152
+ code_path=code_path,
153
+ output_path=output_path,
154
+ language=language,
155
+ i18n_keys=i18n_keys,
156
+ i18n_keys_append=i18n_keys_append,
157
+ i18n_keys_prefix=i18n_keys_prefix,
158
+ exclude_dirs=exclude_dirs,
159
+ exclude_dirs_append=exclude_dirs_append,
160
+ ignore_attributes=ignore_attributes,
161
+ append_ignore_attributes=append_ignore_attributes,
162
+ ignore_kwargs=ignore_kwargs,
163
+ comment_junks=comment_junks,
164
+ default_ftl_file=default_ftl_file,
165
+ comment_keys_mode=comment_keys_mode,
166
+ dry_run=dry_run,
167
+ )
168
+
169
+ if verbose:
170
+ click.echo("Extraction statistics:")
171
+ click.echo(f" - Py files count: {statistics.py_files_count}")
172
+ click.echo(f" - FTL files count: {statistics.ftl_files_count}")
173
+ click.echo(f" - FTL keys in code: {statistics.ftl_in_code_keys_count}")
174
+ click.echo(f" - FTL keys stored: {statistics.ftl_stored_keys_count}")
175
+ click.echo(f" - FTL keys updated: {statistics.ftl_keys_updated}")
176
+ click.echo(f" - FTL keys added: {statistics.ftl_keys_added}")
177
+ click.echo(f" - FTL keys commented: {statistics.ftl_keys_commented}")
178
+
179
+ click.echo(f"[Python] Done in {(perf_counter_ns() - start_time) * 1e-9:.3f}s.")
180
+
181
+
182
+ @ftl.command("stub")
183
+ @click.argument("locale_path", type=click.Path(exists=True, path_type=Path))
184
+ @click.argument("output_path", type=click.Path(path_type=Path))
185
+ @click.option(
186
+ "--export-tree",
187
+ is_flag=True,
188
+ default=False,
189
+ show_default=True,
190
+ help="Export tree structure of FTL messages.",
191
+ )
192
+ def cli_stub(locale_path: Path, output_path: Path, export_tree: bool) -> None:
193
+ click.echo(f"Generating stubs from {locale_path}")
194
+ start_time = perf_counter_ns()
195
+
196
+ generate_stubs(locale_path, output_path, export_tree)
197
+
198
+ click.echo(f"[Python] Done in {(perf_counter_ns() - start_time) * 1e-9:.3f}s.")
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from fluent.syntax import ast as fluent_ast
8
+
9
+ from ftl_extract.exceptions import (
10
+ FTLExtractorDifferentPathsError,
11
+ FTLExtractorDifferentTranslationError,
12
+ )
13
+ from ftl_extract.matcher import FluentKey, I18nMatcher
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Iterable, Iterator
17
+
18
+ from ftl_extract.matcher import FluentKey
19
+ from ftl_extract.utils import ExtractionStatistics
20
+
21
+
22
+ def find_py_files(*, search_path: Path, exclude_dirs: frozenset[Path]) -> Iterator[Path]:
23
+ """
24
+ First step: find all .py files in given path.
25
+
26
+ :param search_path: Path to directory with .py files.
27
+ :type search_path: Path
28
+ :param exclude_dirs: Exclude directories from search.
29
+ :type exclude_dirs: frozenset[Path]
30
+ :return: Iterator with Path to .py files.
31
+ :rtype: Iterator[Path]
32
+ """
33
+ if search_path.is_dir():
34
+ for path in search_path.rglob("[!{.}]*.py"):
35
+ if path.is_file() and not any(
36
+ path.is_relative_to(exclude_dir) for exclude_dir in exclude_dirs
37
+ ):
38
+ yield path
39
+ else:
40
+ # If search_path is not a directory, check if it is a file
41
+ if search_path.is_file() and search_path.suffix == ".py":
42
+ yield search_path
43
+ else:
44
+ # If search_path is not a directory or file, return an empty iterator
45
+ yield from iter([])
46
+
47
+
48
+ def parse_file(
49
+ *,
50
+ path: Path,
51
+ i18n_keys: Iterable[str],
52
+ i18n_keys_prefix: Iterable[str],
53
+ ignore_attributes: Iterable[str],
54
+ ignore_kwargs: Iterable[str],
55
+ default_ftl_file: Path,
56
+ ) -> dict[str, FluentKey]:
57
+ """
58
+ Second step: parse given .py file and find all i18n calls.
59
+
60
+ :param path: Path to .py file.
61
+ :type path: Path
62
+ :param i18n_keys: Names of function that is used to get translation.
63
+ :type i18n_keys: Iterable[str]
64
+ :param i18n_keys_prefix: Prefix names of function that is used to get translation.
65
+ :type i18n_keys_prefix: Iterable[str]
66
+ :param ignore_attributes: Ignore attributes, like `i18n.set_locale`.
67
+ :type ignore_attributes: Iterable[str]
68
+ :param ignore_kwargs: Ignore kwargs, like `when` from
69
+ `aiogram_dialog.I18nFormat(..., when=...)`.
70
+ :type ignore_kwargs: Iterable[str]
71
+ :param default_ftl_file: Default name of FTL file.
72
+ :type default_ftl_file: Path
73
+ :return: Dict with `key` and `FluentKey`.
74
+ :rtype: dict[str, FluentKey]
75
+ """
76
+ node = ast.parse(path.read_bytes())
77
+ matcher = I18nMatcher(
78
+ code_path=path,
79
+ default_ftl_file=default_ftl_file,
80
+ i18n_keys=i18n_keys,
81
+ i18n_keys_prefix=i18n_keys_prefix,
82
+ ignore_attributes=ignore_attributes,
83
+ ignore_kwargs=ignore_kwargs,
84
+ )
85
+ matcher.visit(node)
86
+ return matcher.fluent_keys
87
+
88
+
89
+ def post_process_fluent_keys(*, fluent_keys: dict[str, FluentKey], default_ftl_file: Path) -> None:
90
+ """
91
+ Third step: post-process parsed `FluentKey`.
92
+
93
+ :param fluent_keys: Dict with `key` and `FluentKey` that will be post-processed.
94
+ :type fluent_keys: dict[str, FluentKey]
95
+ :param default_ftl_file: Default name of FTL file.
96
+ :type default_ftl_file: Path
97
+ """
98
+ for fluent_key in fluent_keys.values():
99
+ if not isinstance(fluent_key.path, Path):
100
+ fluent_key.path = Path(fluent_key.path)
101
+
102
+ if not fluent_key.path.suffix: # if path looks like directory (no suffix)
103
+ fluent_key.path /= default_ftl_file
104
+
105
+
106
+ def find_conflicts(
107
+ *,
108
+ current_fluent_keys: dict[str, FluentKey],
109
+ new_fluent_keys: dict[str, FluentKey],
110
+ ) -> None:
111
+ """
112
+ Fourth step: find conflicts between current and new `FluentKey`s.
113
+
114
+ If conflict is found, raise `ValueError`.
115
+
116
+ Conflict is when `key` is the same, but `path` or `kwargs` are different.
117
+ """
118
+ # Find common keys
119
+ conflict_keys = set(current_fluent_keys.keys()) & set(new_fluent_keys.keys())
120
+
121
+ if not conflict_keys:
122
+ return
123
+
124
+ for key in conflict_keys:
125
+ if current_fluent_keys[key].path != new_fluent_keys[key].path:
126
+ raise FTLExtractorDifferentPathsError(
127
+ key,
128
+ current_fluent_keys[key].path,
129
+ new_fluent_keys[key].path,
130
+ )
131
+
132
+ if not current_fluent_keys[key].translation.equals(new_fluent_keys[key].translation):
133
+ raise FTLExtractorDifferentTranslationError(
134
+ key,
135
+ cast(fluent_ast.Message, current_fluent_keys[key].translation),
136
+ cast(fluent_ast.Message, new_fluent_keys[key].translation),
137
+ )
138
+
139
+
140
+ def extract_fluent_keys(
141
+ *,
142
+ path: Path,
143
+ i18n_keys: Iterable[str],
144
+ i18n_keys_prefix: Iterable[str],
145
+ exclude_dirs: frozenset[Path],
146
+ ignore_attributes: Iterable[str],
147
+ ignore_kwargs: Iterable[str],
148
+ default_ftl_file: Path,
149
+ statistics: ExtractionStatistics | None = None,
150
+ ) -> dict[str, FluentKey]:
151
+ """
152
+ Extract all `FluentKey`s from given path.
153
+
154
+ :param path: Path to [.py file] / [directory with .py files].
155
+ :type path: Path
156
+ :param i18n_keys: Names of function that is used to get translation.
157
+ :type i18n_keys: Iterable[str]
158
+ :param i18n_keys_prefix: Prefix names of function that is used to get translation.
159
+ :type i18n_keys_prefix: Iterable[str]
160
+ :param exclude_dirs: Exclude directories from search.
161
+ :type exclude_dirs: frozenset[Path]
162
+ :param ignore_attributes: Ignore attributes, like `i18n.set_locale`.
163
+ :type ignore_attributes: Iterable[str]
164
+ :param ignore_kwargs: Ignore kwargs, like `when` from
165
+ `aiogram_dialog.I18nFormat(..., when=...)`.
166
+ :type ignore_kwargs: Iterable[str]
167
+ :param default_ftl_file: Default name of FTL file.
168
+ :type default_ftl_file: Path
169
+ :param statistics: Statistics of extraction.
170
+ :type statistics: ExtractionStatistics
171
+ :return: Dict with `key` and `FluentKey`.
172
+ :rtype: dict[str, FluentKey]
173
+
174
+ """
175
+ fluent_keys: dict[str, FluentKey] = {}
176
+
177
+ for file in find_py_files(search_path=path, exclude_dirs=exclude_dirs):
178
+ keys = parse_file(
179
+ path=file,
180
+ i18n_keys=i18n_keys,
181
+ i18n_keys_prefix=i18n_keys_prefix,
182
+ ignore_attributes=ignore_attributes,
183
+ ignore_kwargs=ignore_kwargs,
184
+ default_ftl_file=default_ftl_file,
185
+ )
186
+ post_process_fluent_keys(fluent_keys=keys, default_ftl_file=default_ftl_file)
187
+ find_conflicts(current_fluent_keys=fluent_keys, new_fluent_keys=keys)
188
+ fluent_keys.update(keys)
189
+
190
+ if statistics and keys:
191
+ statistics.py_files_count += 1
192
+
193
+ return fluent_keys
194
+
195
+
196
+ def sort_fluent_keys_by_path(*, fluent_keys: dict[str, FluentKey]) -> dict[Path, list[FluentKey]]:
197
+ """
198
+ Sort `FluentKey`s by their paths.
199
+
200
+ :param fluent_keys: Dict with `key` and `FluentKey`.
201
+ :type fluent_keys: dict[str, FluentKey]
202
+ :return: Dict with `Path` and list of `FluentKey`.
203
+ :rtype: dict[Path, list[FluentKey]]
204
+ """
205
+ sorted_fluent_keys: dict[Path, list[FluentKey]] = {}
206
+ for fluent_key in fluent_keys.values():
207
+ sorted_fluent_keys.setdefault(fluent_key.path, []).append(fluent_key)
208
+
209
+ return sorted_fluent_keys
ftl_extract/const.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from typing import Final
8
+
9
+ I18N_LITERAL: Final[str] = "i18n"
10
+ GET_LITERAL: Final[str] = "get"
11
+ PATH_LITERAL: Final[str] = "_path"
12
+ DEFAULT_I18N_KEYS: Final[tuple[str, ...]] = (I18N_LITERAL, "L", "LazyProxy", "LazyFilter")
13
+ DEFAULT_IGNORE_ATTRIBUTES: Final[tuple[str, ...]] = (
14
+ "set_locale",
15
+ "use_locale",
16
+ "use_context",
17
+ "set_context",
18
+ )
19
+ DEFAULT_IGNORE_KWARGS: Final[tuple[str, ...]] = ()
20
+ DEFAULT_FTL_FILE: Final[Path] = Path("_default.ftl")
21
+ FTL_DEBUG_VAR_NAME: Final[str] = "FTL_DEBUG"
22
+ COMMENT_KEYS_MODE: Final[tuple[str, ...]] = ("comment", "warn")
23
+ DEFAULT_EXCLUDE_DIRS: Final[tuple[str, ...]] = (
24
+ ".venv",
25
+ "venv",
26
+ ".git",
27
+ "__pycache__",
28
+ ".pytest_cache",
29
+ )
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from os import environ
4
+ from pprint import pformat
5
+ from typing import TYPE_CHECKING
6
+
7
+ from ftl_extract.const import FTL_DEBUG_VAR_NAME
8
+ from ftl_extract.utils import to_json_no_span
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ from fluent.syntax import ast
14
+
15
+
16
+ class FTLExtractorError(Exception):
17
+ pass
18
+
19
+
20
+ class FTLExtractorDifferentPathsError(FTLExtractorError):
21
+ def __init__(self, key: str, current_path: Path, new_path: Path) -> None:
22
+ self.current_path = current_path
23
+ self.new_path = new_path
24
+ super().__init__(
25
+ f"Key {key!r} already exists with different path: "
26
+ f"{self.current_path} != {self.new_path}",
27
+ )
28
+
29
+
30
+ class FTLExtractorDifferentTranslationError(FTLExtractorError):
31
+ def __init__(
32
+ self,
33
+ key: str,
34
+ current_translation: ast.Message,
35
+ new_translation: ast.Message,
36
+ ) -> None:
37
+ self.current_translation = current_translation
38
+ self.new_translation = new_translation
39
+
40
+ if bool(environ.get(FTL_DEBUG_VAR_NAME, "")) is True:
41
+ super().__init__(
42
+ f"Translation {key!r} already exists with different elements:\n"
43
+ f"current_translation: "
44
+ f"{pformat(self.current_translation.to_json(fn=to_json_no_span))}\n!= "
45
+ f"new_translation: {pformat(self.new_translation.to_json(fn=to_json_no_span))}",
46
+ )
47
+ else:
48
+ super().__init__(
49
+ f"Translation {key!r} already exists with different elements: "
50
+ f"{self.current_translation} != {self.new_translation}",
51
+ )
52
+
53
+
54
+ class FTLExtractorCantFindReferenceError(FTLExtractorError):
55
+ def __init__(self, key: str, key_path: Path, reference_key: str) -> None:
56
+ self.key = key
57
+ self.key_path = key_path
58
+ self.reference_key = reference_key
59
+ super().__init__(f"Can't find reference {reference_key!r} for key {key!r} at {key_path}")
60
+
61
+
62
+ class FTLExtractorCantFindTermError(FTLExtractorError):
63
+ def __init__(self, key: str, locale: str, key_path: Path, term_key: str) -> None:
64
+ self.key = key
65
+ self.locale = locale
66
+ self.key_path = key_path
67
+ self.term_key = term_key
68
+ super().__init__(
69
+ f"Can't find term {term_key!r} for key {key!r} with locale {locale!r} at: {key_path}",
70
+ )