unidecompiler-plugin-jvm-class 0.1.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.
- unidecompiler_plugin_jvm_class-0.1.1/PKG-INFO +26 -0
- unidecompiler_plugin_jvm_class-0.1.1/README.md +12 -0
- unidecompiler_plugin_jvm_class-0.1.1/pyproject.toml +24 -0
- unidecompiler_plugin_jvm_class-0.1.1/setup.cfg +4 -0
- unidecompiler_plugin_jvm_class-0.1.1/src/unidecompiler_plugin_jvm_class/__init__.py +2 -0
- unidecompiler_plugin_jvm_class-0.1.1/src/unidecompiler_plugin_jvm_class/classfile.py +352 -0
- unidecompiler_plugin_jvm_class-0.1.1/src/unidecompiler_plugin_jvm_class/lifter.py +1079 -0
- unidecompiler_plugin_jvm_class-0.1.1/src/unidecompiler_plugin_jvm_class/plugin.py +55 -0
- unidecompiler_plugin_jvm_class-0.1.1/src/unidecompiler_plugin_jvm_class/simulation.py +53 -0
- unidecompiler_plugin_jvm_class-0.1.1/src/unidecompiler_plugin_jvm_class/support.py +15 -0
- unidecompiler_plugin_jvm_class-0.1.1/src/unidecompiler_plugin_jvm_class.egg-info/PKG-INFO +26 -0
- unidecompiler_plugin_jvm_class-0.1.1/src/unidecompiler_plugin_jvm_class.egg-info/SOURCES.txt +14 -0
- unidecompiler_plugin_jvm_class-0.1.1/src/unidecompiler_plugin_jvm_class.egg-info/dependency_links.txt +1 -0
- unidecompiler_plugin_jvm_class-0.1.1/src/unidecompiler_plugin_jvm_class.egg-info/entry_points.txt +2 -0
- unidecompiler_plugin_jvm_class-0.1.1/src/unidecompiler_plugin_jvm_class.egg-info/requires.txt +2 -0
- unidecompiler_plugin_jvm_class-0.1.1/src/unidecompiler_plugin_jvm_class.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unidecompiler-plugin-jvm-class
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: JVM class-file frontend plugin for unidecompiler
|
|
5
|
+
Author-email: Wker <1670133844@qq.com>
|
|
6
|
+
License-Expression: AGPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/Wker666/unidecompiler
|
|
8
|
+
Project-URL: Repository, https://github.com/Wker666/unidecompiler
|
|
9
|
+
Project-URL: Issues, https://github.com/Wker666/unidecompiler/issues
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: unidecompiler<0.2.0,>=0.1.1
|
|
13
|
+
Requires-Dist: jawa<3,>=2.2.0
|
|
14
|
+
|
|
15
|
+
# unidecompiler-plugin-jvm-class
|
|
16
|
+
|
|
17
|
+
Frontend plugin for JVM `.class` files. It uses `jawa` to read class files and
|
|
18
|
+
submits neutral thin IR to `unidecompiler` for recovery and rendering.
|
|
19
|
+
|
|
20
|
+
Install with:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
python -m pip install unidecompiler-plugin-jvm-class
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The plugin is discovered automatically by compatible CLI and GUI hosts.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# unidecompiler-plugin-jvm-class
|
|
2
|
+
|
|
3
|
+
Frontend plugin for JVM `.class` files. It uses `jawa` to read class files and
|
|
4
|
+
submits neutral thin IR to `unidecompiler` for recovery and rendering.
|
|
5
|
+
|
|
6
|
+
Install with:
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
python -m pip install unidecompiler-plugin-jvm-class
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The plugin is discovered automatically by compatible CLI and GUI hosts.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "unidecompiler-plugin-jvm-class"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "JVM class-file frontend plugin for unidecompiler"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "AGPL-3.0-or-later"
|
|
11
|
+
authors = [{ name = "Wker", email = "1670133844@qq.com" }]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = ["unidecompiler>=0.1.1,<0.2.0", "jawa>=2.2.0,<3"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/Wker666/unidecompiler"
|
|
17
|
+
Repository = "https://github.com/Wker666/unidecompiler"
|
|
18
|
+
Issues = "https://github.com/Wker666/unidecompiler/issues"
|
|
19
|
+
|
|
20
|
+
[project.entry-points."unidecompiler.frontends"]
|
|
21
|
+
jvm-class = "unidecompiler_plugin_jvm_class.plugin:JavaClassFrontendPlugin"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from unidecompiler.plugins import FrontendDecodeError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CLASS_MAGIC = b"\xca\xfe\xba\xbe"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClassDecodeError(FrontendDecodeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class JavaInstruction:
|
|
19
|
+
offset: int
|
|
20
|
+
opcode: str
|
|
21
|
+
operands: str = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class JavaExceptionRegion:
|
|
26
|
+
start: int
|
|
27
|
+
end: int
|
|
28
|
+
target: int
|
|
29
|
+
exception_type: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class JavaMethodListing:
|
|
34
|
+
name: str
|
|
35
|
+
descriptor: str | None = None
|
|
36
|
+
is_static: bool = False
|
|
37
|
+
is_annotation_member: bool = False
|
|
38
|
+
annotation_default: object | None = None
|
|
39
|
+
instructions: tuple[JavaInstruction, ...] = ()
|
|
40
|
+
exception_regions: tuple[JavaExceptionRegion, ...] = ()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class JavaClassFile:
|
|
45
|
+
major_version: int
|
|
46
|
+
minor_version: int
|
|
47
|
+
class_name: str | None = None
|
|
48
|
+
is_annotation: bool = False
|
|
49
|
+
methods: tuple[JavaMethodListing, ...] = ()
|
|
50
|
+
filename: str | None = None
|
|
51
|
+
decoder_id: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ClassFileDecoder(Protocol):
|
|
55
|
+
id: str
|
|
56
|
+
|
|
57
|
+
def can_decode(self, data: bytes, filename: str | None = None) -> bool:
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def decode(self, data: bytes, filename: str | None = None) -> JavaClassFile:
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def looks_like_class(data: bytes) -> bool:
|
|
65
|
+
return data.startswith(CLASS_MAGIC)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def decode_class_header(data: bytes, filename: str | None = None) -> JavaClassFile:
|
|
69
|
+
if len(data) < 8:
|
|
70
|
+
raise ClassDecodeError("truncated class file")
|
|
71
|
+
if not looks_like_class(data):
|
|
72
|
+
raise ClassDecodeError("missing class magic")
|
|
73
|
+
return JavaClassFile(
|
|
74
|
+
minor_version=int.from_bytes(data[4:6], "big"),
|
|
75
|
+
major_version=int.from_bytes(data[6:8], "big"),
|
|
76
|
+
filename=filename,
|
|
77
|
+
decoder_id="class-header-only",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class HeaderOnlyClassFileDecoder:
|
|
82
|
+
id = "class-header-only"
|
|
83
|
+
|
|
84
|
+
def can_decode(self, data: bytes, filename: str | None = None) -> bool:
|
|
85
|
+
return looks_like_class(data)
|
|
86
|
+
|
|
87
|
+
def decode(self, data: bytes, filename: str | None = None) -> JavaClassFile:
|
|
88
|
+
return decode_class_header(data, filename)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class JawaClassFileDecoder:
|
|
92
|
+
"""JVM classfile decoder backed by the third-party ``jawa`` library."""
|
|
93
|
+
|
|
94
|
+
id = "jawa"
|
|
95
|
+
|
|
96
|
+
def can_decode(self, data: bytes, filename: str | None = None) -> bool:
|
|
97
|
+
return looks_like_class(data) and _jawa_class_file_type() is not None
|
|
98
|
+
|
|
99
|
+
def decode(self, data: bytes, filename: str | None = None) -> JavaClassFile:
|
|
100
|
+
class_file_type = _jawa_class_file_type()
|
|
101
|
+
if class_file_type is None:
|
|
102
|
+
raise ClassDecodeError("jawa is not installed")
|
|
103
|
+
if not looks_like_class(data):
|
|
104
|
+
raise ClassDecodeError("missing class magic")
|
|
105
|
+
try:
|
|
106
|
+
parsed = class_file_type(BytesIO(data))
|
|
107
|
+
except Exception as error: # pragma: no cover - jawa owns parse details.
|
|
108
|
+
raise ClassDecodeError(f"jawa failed to decode class: {error}") from error
|
|
109
|
+
class_name = _jawa_utf8(parsed.this.name)
|
|
110
|
+
return JavaClassFile(
|
|
111
|
+
minor_version=parsed.version.minor,
|
|
112
|
+
major_version=parsed.version.major,
|
|
113
|
+
class_name=class_name,
|
|
114
|
+
is_annotation=bool(parsed.access_flags.acc_annotation),
|
|
115
|
+
methods=tuple(_jawa_method_listing(parsed, method) for method in parsed.methods),
|
|
116
|
+
filename=filename,
|
|
117
|
+
decoder_id=self.id,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class PreferredClassFileDecoder:
|
|
122
|
+
id = "class-preferred"
|
|
123
|
+
|
|
124
|
+
def __init__(self, decoders: tuple[ClassFileDecoder, ...] | None = None) -> None:
|
|
125
|
+
self.decoders = decoders or (
|
|
126
|
+
JawaClassFileDecoder(),
|
|
127
|
+
HeaderOnlyClassFileDecoder(),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def can_decode(self, data: bytes, filename: str | None = None) -> bool:
|
|
131
|
+
return any(decoder.can_decode(data, filename) for decoder in self.decoders)
|
|
132
|
+
|
|
133
|
+
def decode(self, data: bytes, filename: str | None = None) -> JavaClassFile:
|
|
134
|
+
errors: list[str] = []
|
|
135
|
+
for decoder in self.decoders:
|
|
136
|
+
if not decoder.can_decode(data, filename):
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
return decoder.decode(data, filename)
|
|
140
|
+
except ClassDecodeError as error:
|
|
141
|
+
errors.append(f"{decoder.id}: {error}")
|
|
142
|
+
if errors:
|
|
143
|
+
raise ClassDecodeError("; ".join(errors))
|
|
144
|
+
raise ClassDecodeError("no JVM class decoder can decode this input")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _jawa_class_file_type():
|
|
148
|
+
try:
|
|
149
|
+
from jawa.cf import ClassFile
|
|
150
|
+
except ImportError:
|
|
151
|
+
return None
|
|
152
|
+
return ClassFile
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _jawa_method_listing(class_file, method) -> JavaMethodListing:
|
|
156
|
+
raw_name = _jawa_utf8(method.name) or "<method>"
|
|
157
|
+
class_name = _jawa_utf8(class_file.this.name)
|
|
158
|
+
name = class_name.rsplit("/", 1)[-1] if raw_name == "<init>" and class_name else raw_name
|
|
159
|
+
code = method.code
|
|
160
|
+
instructions: tuple[JavaInstruction, ...] = ()
|
|
161
|
+
if code is not None:
|
|
162
|
+
instructions = tuple(
|
|
163
|
+
_jawa_instruction(class_file, instruction) for instruction in code.disassemble()
|
|
164
|
+
)
|
|
165
|
+
return JavaMethodListing(
|
|
166
|
+
name=name,
|
|
167
|
+
descriptor=_jawa_utf8(method.descriptor),
|
|
168
|
+
is_static=bool(method.access_flags.acc_static),
|
|
169
|
+
is_annotation_member=bool(class_file.access_flags.acc_annotation) and code is None,
|
|
170
|
+
annotation_default=_jawa_annotation_default(class_file, method),
|
|
171
|
+
instructions=instructions,
|
|
172
|
+
exception_regions=tuple(
|
|
173
|
+
JavaExceptionRegion(
|
|
174
|
+
start=entry.start_pc,
|
|
175
|
+
end=entry.end_pc,
|
|
176
|
+
target=entry.handler_pc,
|
|
177
|
+
exception_type=_jawa_exception_type(class_file, entry.catch_type),
|
|
178
|
+
)
|
|
179
|
+
for entry in (getattr(code, "exception_table", ()) if code is not None else ())
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _jawa_exception_type(class_file, index: int) -> str | None:
|
|
185
|
+
if not index:
|
|
186
|
+
return None
|
|
187
|
+
try:
|
|
188
|
+
constant = class_file.constants.get(index)
|
|
189
|
+
name = _jawa_utf8(constant.name)
|
|
190
|
+
except Exception:
|
|
191
|
+
return None
|
|
192
|
+
return name.replace("/", ".") if name else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _jawa_annotation_default(class_file, method) -> object | None:
|
|
196
|
+
for attribute in method.attributes:
|
|
197
|
+
if _jawa_utf8(attribute.name) != "AnnotationDefault":
|
|
198
|
+
continue
|
|
199
|
+
info = getattr(attribute, "info", None)
|
|
200
|
+
if not isinstance(info, (bytes, bytearray)):
|
|
201
|
+
return "<annotation-default>"
|
|
202
|
+
value, _offset = _parse_jvm_element_value(class_file, bytes(info), 0)
|
|
203
|
+
return value
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _parse_jvm_element_value(class_file, data: bytes, offset: int) -> tuple[object, int]:
|
|
208
|
+
if offset >= len(data):
|
|
209
|
+
return ("<truncated-annotation-default>", offset)
|
|
210
|
+
tag = chr(data[offset])
|
|
211
|
+
offset += 1
|
|
212
|
+
if tag in "BCDFIJSZs":
|
|
213
|
+
if offset + 2 > len(data):
|
|
214
|
+
return ("<truncated-annotation-constant>", offset)
|
|
215
|
+
index = int.from_bytes(data[offset : offset + 2], "big")
|
|
216
|
+
return (_jawa_constant_value(class_file, index), offset + 2)
|
|
217
|
+
if tag == "e":
|
|
218
|
+
if offset + 4 > len(data):
|
|
219
|
+
return ("<truncated-annotation-enum>", offset)
|
|
220
|
+
type_index = int.from_bytes(data[offset : offset + 2], "big")
|
|
221
|
+
const_index = int.from_bytes(data[offset + 2 : offset + 4], "big")
|
|
222
|
+
return (
|
|
223
|
+
f"{_jawa_constant_value(class_file, type_index)}.{_jawa_constant_value(class_file, const_index)}",
|
|
224
|
+
offset + 4,
|
|
225
|
+
)
|
|
226
|
+
if tag == "c":
|
|
227
|
+
if offset + 2 > len(data):
|
|
228
|
+
return ("<truncated-annotation-class>", offset)
|
|
229
|
+
index = int.from_bytes(data[offset : offset + 2], "big")
|
|
230
|
+
return (f"{_jawa_constant_value(class_file, index)}.class", offset + 2)
|
|
231
|
+
return (f"<annotation-default:{tag}>", len(data))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _jawa_constant_value(class_file, index: int) -> object:
|
|
235
|
+
try:
|
|
236
|
+
constant = class_file.constants.get(index)
|
|
237
|
+
except Exception:
|
|
238
|
+
return f"#{index}"
|
|
239
|
+
if constant is None:
|
|
240
|
+
return f"#{index}"
|
|
241
|
+
value = getattr(constant, "value", None)
|
|
242
|
+
if value is not None:
|
|
243
|
+
return value
|
|
244
|
+
for attr in ("string", "name"):
|
|
245
|
+
nested = getattr(constant, attr, None)
|
|
246
|
+
text = _jawa_utf8(nested)
|
|
247
|
+
if text is not None:
|
|
248
|
+
return text
|
|
249
|
+
return f"#{index}"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _jawa_instruction(class_file, instruction) -> JavaInstruction:
|
|
253
|
+
if instruction.mnemonic == "lookupswitch":
|
|
254
|
+
operands = _jawa_lookupswitch_operands(instruction)
|
|
255
|
+
if operands is not None:
|
|
256
|
+
return JavaInstruction(
|
|
257
|
+
offset=instruction.pos,
|
|
258
|
+
opcode=instruction.mnemonic,
|
|
259
|
+
operands=operands,
|
|
260
|
+
)
|
|
261
|
+
operands = tuple(
|
|
262
|
+
rendered
|
|
263
|
+
for rendered in (
|
|
264
|
+
_jawa_operand(class_file, instruction, operand) for operand in instruction.operands
|
|
265
|
+
)
|
|
266
|
+
if rendered
|
|
267
|
+
)
|
|
268
|
+
return JavaInstruction(
|
|
269
|
+
offset=instruction.pos,
|
|
270
|
+
opcode=instruction.mnemonic,
|
|
271
|
+
operands=", ".join(operands),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _jawa_lookupswitch_operands(instruction) -> str | None:
|
|
276
|
+
operands = instruction.operands
|
|
277
|
+
if len(operands) != 2 or not isinstance(operands[0], dict):
|
|
278
|
+
return None
|
|
279
|
+
default_offset = getattr(operands[1], "value", None)
|
|
280
|
+
if not isinstance(default_offset, int):
|
|
281
|
+
return None
|
|
282
|
+
pairs: list[str] = [str(instruction.pos + default_offset)]
|
|
283
|
+
try:
|
|
284
|
+
cases = sorted(operands[0].items())
|
|
285
|
+
except TypeError:
|
|
286
|
+
return None
|
|
287
|
+
for value, relative_target in cases:
|
|
288
|
+
if not isinstance(value, int) or not isinstance(relative_target, int):
|
|
289
|
+
return None
|
|
290
|
+
pairs.extend((str(value), str(instruction.pos + relative_target)))
|
|
291
|
+
return ", ".join(pairs)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _jawa_operand(class_file, instruction, operand) -> str:
|
|
295
|
+
op_type = getattr(getattr(operand, "op_type", None), "name", "")
|
|
296
|
+
value = getattr(operand, "value", None)
|
|
297
|
+
if op_type == "BRANCH" and isinstance(value, int):
|
|
298
|
+
return str(instruction.pos + value)
|
|
299
|
+
if op_type == "CONSTANT_INDEX" and isinstance(value, int):
|
|
300
|
+
return _jawa_constant_operand(class_file, value)
|
|
301
|
+
if op_type == "PADDING":
|
|
302
|
+
return ""
|
|
303
|
+
return str(value)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _jawa_constant_operand(class_file, index: int) -> str:
|
|
307
|
+
try:
|
|
308
|
+
constant = class_file.constants.get(index)
|
|
309
|
+
except Exception:
|
|
310
|
+
return f"#{index}"
|
|
311
|
+
if constant is None:
|
|
312
|
+
return f"#{index}"
|
|
313
|
+
|
|
314
|
+
type_name = type(constant).__name__
|
|
315
|
+
if type_name == "String":
|
|
316
|
+
return f"#{index} // String {_jawa_utf8(constant.string)}"
|
|
317
|
+
if type_name in {"Integer", "Float", "Long", "Double"}:
|
|
318
|
+
return f"#{index} // {type_name.lower()} {constant.value}"
|
|
319
|
+
if type_name == "ConstantClass":
|
|
320
|
+
return f"#{index} // class {_jawa_utf8(constant.name)}"
|
|
321
|
+
if type_name in {
|
|
322
|
+
"FieldReference",
|
|
323
|
+
"MethodReference",
|
|
324
|
+
"InterfaceMethodReference",
|
|
325
|
+
"InterfaceMethodRef",
|
|
326
|
+
}:
|
|
327
|
+
owner = _jawa_utf8(constant.class_.name) or "<owner>"
|
|
328
|
+
name_and_type = constant.name_and_type
|
|
329
|
+
name = _jawa_utf8(name_and_type.name) or "<name>"
|
|
330
|
+
descriptor = _jawa_utf8(name_and_type.descriptor) or "()V"
|
|
331
|
+
label = {
|
|
332
|
+
"FieldReference": "Field",
|
|
333
|
+
"MethodReference": "Method",
|
|
334
|
+
"InterfaceMethodReference": "InterfaceMethod",
|
|
335
|
+
"InterfaceMethodRef": "InterfaceMethod",
|
|
336
|
+
}[type_name]
|
|
337
|
+
return f"#{index} // {label} {owner}.{name}:{descriptor}"
|
|
338
|
+
if type_name in {"InvokeDynamic", "Dynamic"} and hasattr(constant, "name_and_type"):
|
|
339
|
+
name_and_type = constant.name_and_type
|
|
340
|
+
name = _jawa_utf8(name_and_type.name) or "<name>"
|
|
341
|
+
descriptor = _jawa_utf8(name_and_type.descriptor) or "()V"
|
|
342
|
+
return f"#{index} // {type_name} {name}:{descriptor}"
|
|
343
|
+
return f"#{index}"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _jawa_utf8(value) -> str | None:
|
|
347
|
+
if value is None:
|
|
348
|
+
return None
|
|
349
|
+
if isinstance(value, str):
|
|
350
|
+
return value
|
|
351
|
+
text = getattr(value, "value", None)
|
|
352
|
+
return text if isinstance(text, str) else None
|