ckparser 0.1__py3-none-any.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.
ckparser.py
ADDED
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
ckparser
|
|
4
|
+
~~~~~~~~
|
|
5
|
+
|
|
6
|
+
A lightweight Python parser for Paradox Jomini data files.
|
|
7
|
+
|
|
8
|
+
This module provides tools to parse Jomini-based script files used by
|
|
9
|
+
Paradox games such as Crusader Kings III and Europa Universalis V into
|
|
10
|
+
Python data structures, and to revert Python/JSON data back to a
|
|
11
|
+
Jomini-like text format.
|
|
12
|
+
|
|
13
|
+
Main features
|
|
14
|
+
-------------
|
|
15
|
+
- Parse raw Jomini text into Python dictionaries/lists
|
|
16
|
+
- Parse individual files or entire directories recursively
|
|
17
|
+
- Preserve comments optionally
|
|
18
|
+
- Resolve variables and formulas when possible
|
|
19
|
+
- Revert Python/JSON structures back to Jomini text (experimental)
|
|
20
|
+
- Parse localization files
|
|
21
|
+
- Provide helper utilities for color conversion, date conversion,
|
|
22
|
+
encoding-aware file reading, and nested structure traversal
|
|
23
|
+
|
|
24
|
+
This project is primarily intended for modding, data inspection,
|
|
25
|
+
automation, and personal tooling around Paradox script files.
|
|
26
|
+
|
|
27
|
+
Author: Marc Debureaux (debnet)
|
|
28
|
+
Project: https://github.com/debnet/ckparser
|
|
29
|
+
License: MIT
|
|
30
|
+
"""
|
|
31
|
+
import argparse
|
|
32
|
+
import ast
|
|
33
|
+
import colorsys
|
|
34
|
+
import datetime
|
|
35
|
+
import functools
|
|
36
|
+
import json
|
|
37
|
+
import logging
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import time
|
|
41
|
+
|
|
42
|
+
# Script version
|
|
43
|
+
__version__ = "0.1"
|
|
44
|
+
|
|
45
|
+
# Logger (because logging is awesome)
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
# Try to import chardet for encoding detection
|
|
49
|
+
try:
|
|
50
|
+
from chardet import detect
|
|
51
|
+
except ImportError:
|
|
52
|
+
detect = None
|
|
53
|
+
logger.warning("chardet not installed, encoding detection is disabled!")
|
|
54
|
+
|
|
55
|
+
# Boolean transformation
|
|
56
|
+
booleans = {"yes": True, "no": False}
|
|
57
|
+
# Tags which must be aggregate as a list in JSON (don't hesitate to add more if needed)
|
|
58
|
+
forced_list_keys = [] # "if", "else_if", "else", "not", "or", "and", "nor", "nand", "root", "from", "prev"
|
|
59
|
+
# Tags or tag couples which be forced as a string in revert parser
|
|
60
|
+
forced_string_keys = [("genes", None)]
|
|
61
|
+
# Special keywords
|
|
62
|
+
keywords = ("scripted_trigger", "scripted_effect")
|
|
63
|
+
# Variables collected in files
|
|
64
|
+
global_variables = {}
|
|
65
|
+
|
|
66
|
+
# Regex to find and replace quoted string
|
|
67
|
+
regex_string = re.compile(r"\"[^\"\n]*\"")
|
|
68
|
+
regex_string_multiline = re.compile(r"\"[^\"]*\"", re.MULTILINE)
|
|
69
|
+
# Regex for quoted strings inside quoted strings
|
|
70
|
+
regex_inner_string = re.compile(r"\|(?P<index>\d+)\|")
|
|
71
|
+
# Regex to remove comments in files
|
|
72
|
+
regex_comment = re.compile(r"(?P<space>\s*)(?P<comment>#.*)$", re.MULTILINE)
|
|
73
|
+
# Regex to fix blocks with no equal sign
|
|
74
|
+
regex_block = re.compile(r"^([^\s\{\=]+)\s*\{\s*$", re.MULTILINE)
|
|
75
|
+
# Regex to remove "list" prefix
|
|
76
|
+
regex_list = re.compile(r"\s*=\s*list\s+([\{\"\|])", re.MULTILINE)
|
|
77
|
+
# Regex for color blocks (color = [rgb|hsv] { x y z })
|
|
78
|
+
regex_color = re.compile(r"=\s*(?P<type>\w+)\s*{")
|
|
79
|
+
# Regex to parse items with format key=value
|
|
80
|
+
regex_inline = re.compile(r"([^\s\"]+\s*[?!<=>]+\s*(([^@\"]\[?[^\s]+\]?)|(\"[^\"]+\")|(@\[[^\]]+\]))|(@\w+))")
|
|
81
|
+
# Regex to parse blocks with bracket below the key
|
|
82
|
+
regex_values = re.compile(r"(([?!<=>]+)\s*\n+)|(\n+\s*([?!<=>]+))")
|
|
83
|
+
# Regex to parse lines with format key=value
|
|
84
|
+
regex_line = re.compile(r"\"?(?P<key>[^\s\"]+)\"?\s*(?P<operator>[?!<=>]+)\s*(list\s*)?(?P<value>.*)")
|
|
85
|
+
# Regex to parse independent items in a list
|
|
86
|
+
regex_item = re.compile(r"(\"[^\"]+\"|[\d\.]+|[^\s]+)")
|
|
87
|
+
# Regex to remove empty lines
|
|
88
|
+
regex_empty = re.compile(r"(\n\s*\n)+", re.MULTILINE)
|
|
89
|
+
# Regex to parse locale files
|
|
90
|
+
regex_locale = re.compile(r"^\s*(?P<key>[^\:#]+)\:(\d+)?\s\"(?P<value>.+)\".*$")
|
|
91
|
+
# Regex for keywords
|
|
92
|
+
regex_keyword = re.compile(r"(" + "|".join(map(re.escape, sorted(keywords, key=len, reverse=True))) + r") ")
|
|
93
|
+
# Regex for string indexes
|
|
94
|
+
regex_index = re.compile(r"\|(\d+)\|")
|
|
95
|
+
# Regex for variables
|
|
96
|
+
regex_variables = re.compile(r"\b([\w]+)\b")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def convert_color(color):
|
|
100
|
+
if not color:
|
|
101
|
+
return ""
|
|
102
|
+
if isinstance(color, str):
|
|
103
|
+
return color
|
|
104
|
+
if len(color) > 3 and isinstance(color[0], str):
|
|
105
|
+
color_type, *color = color[:4]
|
|
106
|
+
if color_type == "hsv360":
|
|
107
|
+
color = [int(c) / 360 for c in color]
|
|
108
|
+
color_type = "hsv"
|
|
109
|
+
if color_type != "rgb":
|
|
110
|
+
try:
|
|
111
|
+
functions = {"hsv": colorsys.hsv_to_rgb, "hls": colorsys.hls_to_rgb}
|
|
112
|
+
color = functions.get(color_type)(*color)
|
|
113
|
+
except: # noqa
|
|
114
|
+
logger.warning(f"Unable to convert color {color} ({color_type}")
|
|
115
|
+
return ""
|
|
116
|
+
if any(isinstance(c, float) for c in color):
|
|
117
|
+
color = [round(c * 255) for c in color]
|
|
118
|
+
r, g, b = (hex(int(c)).split("x")[-1] for c in color[:3])
|
|
119
|
+
return f"{r:>02}{g:>02}{b:>02}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def convert_date(date, key=None):
|
|
123
|
+
if not date:
|
|
124
|
+
return None
|
|
125
|
+
try:
|
|
126
|
+
year, month, day = (int(d) for d in date.split("."))
|
|
127
|
+
return datetime.date(year, month, day)
|
|
128
|
+
except Exception as error:
|
|
129
|
+
logger.error(f'Error converting date "{date}" for "{key}": {error}')
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def read_file(path, encoding="utf_8_sig"):
|
|
134
|
+
"""
|
|
135
|
+
Try to read file with encoding
|
|
136
|
+
If chardet is installed, encoding will be automatically detected
|
|
137
|
+
:param path: Path to file
|
|
138
|
+
:param encoding: Encoding
|
|
139
|
+
:return: File content
|
|
140
|
+
"""
|
|
141
|
+
if not os.path.exists(path) or not os.path.isfile(path):
|
|
142
|
+
return
|
|
143
|
+
if detect:
|
|
144
|
+
with open(path, "rb") as file:
|
|
145
|
+
raw_data = file.read()
|
|
146
|
+
if result := detect(raw_data):
|
|
147
|
+
encoding = result["encoding"]
|
|
148
|
+
logger.debug(f"Detected encoding: {result['encoding']} ({result['confidence']:0.0%})")
|
|
149
|
+
del raw_data
|
|
150
|
+
with open(path, encoding=encoding) as file:
|
|
151
|
+
return file.read()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def parse_text(text, return_text_on_error=False, comments=False, filename=None, is_global=False):
|
|
155
|
+
"""
|
|
156
|
+
Parse raw text
|
|
157
|
+
:param text: Text to parse
|
|
158
|
+
:param return_text_on_error: (default false) Return working text document if parsing fails
|
|
159
|
+
:param comments: (default false) Include comments?
|
|
160
|
+
:param filename: (default none) Filename (only for debugging)
|
|
161
|
+
:param is_global: (default false) Are variables global?
|
|
162
|
+
:return: Parsed data as dictionary
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def replace(match):
|
|
166
|
+
nonlocal strings, index
|
|
167
|
+
index = len(strings)
|
|
168
|
+
strings[str(index)] = match.group(0).replace("\n", "\\n")
|
|
169
|
+
return f"|{index}|"
|
|
170
|
+
|
|
171
|
+
def replace_comment(match):
|
|
172
|
+
nonlocal strings, index
|
|
173
|
+
index = len(strings)
|
|
174
|
+
value, space = match.group("comment").replace('"', "'").strip(), match.group("space")
|
|
175
|
+
strings[str(index)] = f'"{value}"'
|
|
176
|
+
if not value.strip():
|
|
177
|
+
return ""
|
|
178
|
+
return f"\n{space}#{index}=|{index}|\n"
|
|
179
|
+
|
|
180
|
+
def set_variable(key, value, is_global=is_global):
|
|
181
|
+
global global_variables
|
|
182
|
+
nonlocal variables
|
|
183
|
+
lkey = key.lstrip("@")
|
|
184
|
+
variables[key] = variables[lkey] = value
|
|
185
|
+
if is_global:
|
|
186
|
+
global_variables[key] = global_variables[lkey] = value
|
|
187
|
+
|
|
188
|
+
root = {}
|
|
189
|
+
nodes = [("", root)]
|
|
190
|
+
strings, index = {}, 0
|
|
191
|
+
variables = global_variables.copy()
|
|
192
|
+
# Cleaning document
|
|
193
|
+
text = regex_string.sub(replace, text)
|
|
194
|
+
if comments:
|
|
195
|
+
text = regex_comment.sub(replace_comment, text)
|
|
196
|
+
else:
|
|
197
|
+
text = regex_comment.sub("", text)
|
|
198
|
+
text = regex_string_multiline.sub(replace, text)
|
|
199
|
+
text = regex_list.sub(r"|list=\g<1>", text)
|
|
200
|
+
text = regex_block.sub(r"\g<1>={", text)
|
|
201
|
+
text = text.replace("{", "\n{\n").replace("}", "\n}\n")
|
|
202
|
+
text = regex_color.sub(r"={\n\g<1>", text)
|
|
203
|
+
text = regex_inline.sub(r"\g<1>\n", text)
|
|
204
|
+
text = regex_values.sub(r"\g<2>\g<4>", text)
|
|
205
|
+
text = regex_empty.sub(r"\n", text)
|
|
206
|
+
text = regex_keyword.sub(r"\1|", text)
|
|
207
|
+
text = regex_index.sub(lambda match: strings[match.group(1)], text)
|
|
208
|
+
|
|
209
|
+
# Parsing document line by line
|
|
210
|
+
for line_number, line_text in enumerate(text.splitlines(), start=1):
|
|
211
|
+
try:
|
|
212
|
+
line_text = line_text.strip()
|
|
213
|
+
# Nothing to do if line is empty
|
|
214
|
+
if not line_text:
|
|
215
|
+
continue
|
|
216
|
+
# Get the current node
|
|
217
|
+
node_name, node = nodes[-1]
|
|
218
|
+
# If line is key=value
|
|
219
|
+
if match := regex_line.fullmatch(line_text):
|
|
220
|
+
key, operator, _, value = match.groups()
|
|
221
|
+
value = value.strip()
|
|
222
|
+
if subindexes := key.startswith("#") and regex_inner_string.findall(value):
|
|
223
|
+
for subindex in subindexes:
|
|
224
|
+
value = value.replace(f"|{subindex}|", strings[subindex], 1)
|
|
225
|
+
value = value.strip('"') # Removing extra quotes
|
|
226
|
+
# If value is a new block
|
|
227
|
+
if value.endswith("{"):
|
|
228
|
+
item = {}
|
|
229
|
+
# If key is duplicate with inner block
|
|
230
|
+
if key in node:
|
|
231
|
+
if not isinstance(node[key], list):
|
|
232
|
+
node[key] = [node[key]]
|
|
233
|
+
if operator != "=":
|
|
234
|
+
node[key].append({"@operator": operator, "@value": item})
|
|
235
|
+
else:
|
|
236
|
+
node[key].append(item)
|
|
237
|
+
# If this block name must be forced as list
|
|
238
|
+
elif key.lower() in forced_list_keys:
|
|
239
|
+
node[key] = [item]
|
|
240
|
+
elif isinstance(node, list): # Only for on_actions...
|
|
241
|
+
node.append(item)
|
|
242
|
+
elif operator != "=":
|
|
243
|
+
node[key] = {"@operator": operator, "@value": item}
|
|
244
|
+
else:
|
|
245
|
+
node[key] = item
|
|
246
|
+
# Change current node for next lines
|
|
247
|
+
nodes.append((key, item))
|
|
248
|
+
continue
|
|
249
|
+
elif (val := booleans.get(value.lower())) is not None:
|
|
250
|
+
# Convert to boolean
|
|
251
|
+
value = val
|
|
252
|
+
elif value:
|
|
253
|
+
# Try to convert value to Python value
|
|
254
|
+
try:
|
|
255
|
+
value = ast.literal_eval(value)
|
|
256
|
+
except: # noqa
|
|
257
|
+
pass
|
|
258
|
+
# If key is duplicate with direct value
|
|
259
|
+
if key in node:
|
|
260
|
+
if node[key] != value: # Avoid single duplicates
|
|
261
|
+
if not isinstance(node[key], list):
|
|
262
|
+
node[key] = [node[key]]
|
|
263
|
+
if isinstance(value, str) and (value.startswith("@") or value in variables):
|
|
264
|
+
if (result := variables.get(value)) is None:
|
|
265
|
+
if filename:
|
|
266
|
+
logger.warning(f"Filename: {filename}")
|
|
267
|
+
logger.warning(f'Value for "{value}" cannot be found (line: {line_number})')
|
|
268
|
+
node[key].append({"@type": "variable", "@value": value, "@result": result})
|
|
269
|
+
else:
|
|
270
|
+
node[key].append(value)
|
|
271
|
+
# If this key must be forced as list
|
|
272
|
+
elif key.lower() in forced_list_keys:
|
|
273
|
+
node[key] = [value]
|
|
274
|
+
else:
|
|
275
|
+
# If operator is not equal
|
|
276
|
+
if operator != "=":
|
|
277
|
+
node[key] = {"@operator": operator, "@value": value}
|
|
278
|
+
if isinstance(value, str):
|
|
279
|
+
if value == "":
|
|
280
|
+
node[key]["@value"] = item = {}
|
|
281
|
+
nodes.append(("@", item))
|
|
282
|
+
elif value.startswith("@[") and value.endswith("]"):
|
|
283
|
+
formula = value.lstrip("@[").rstrip("]")
|
|
284
|
+
if variables:
|
|
285
|
+
repl = lambda match: str(variables.get(match.group(1), match.group(1)))
|
|
286
|
+
formula = regex_variables.sub(repl, formula)
|
|
287
|
+
try:
|
|
288
|
+
result = eval(formula, None, variables)
|
|
289
|
+
except Exception as exception:
|
|
290
|
+
if filename:
|
|
291
|
+
logger.warning(f"Filename: {filename}")
|
|
292
|
+
logger.warning(
|
|
293
|
+
f"Formula [{formula}] (line: {line_number}) can't be evaluated: {exception}"
|
|
294
|
+
)
|
|
295
|
+
result = None
|
|
296
|
+
if isinstance(result, float):
|
|
297
|
+
result = round(result, 5)
|
|
298
|
+
node[key]["@value"] = {"@type": "formula", "@value": value, "@result": result}
|
|
299
|
+
elif value.startswith("@") or value in variables:
|
|
300
|
+
if (result := variables.get(value)) is None:
|
|
301
|
+
if filename:
|
|
302
|
+
logger.warning(f"Filename: {filename}")
|
|
303
|
+
logger.warning(f'Value for "{value}" cannot be found (line: {line_number})')
|
|
304
|
+
node[key]["@value"] = {"@type": "variable", "@value": value, "@result": result}
|
|
305
|
+
# If value is a formula
|
|
306
|
+
elif isinstance(value, str) and not key.startswith("#"):
|
|
307
|
+
if value.startswith("@[") and value.endswith("]"):
|
|
308
|
+
formula = value.lstrip("@[").rstrip("]")
|
|
309
|
+
if variables:
|
|
310
|
+
repl = lambda match: str(variables.get(match.group(1), match.group(1)))
|
|
311
|
+
formula = regex_variables.sub(repl, formula)
|
|
312
|
+
try:
|
|
313
|
+
result = eval(formula, None, variables)
|
|
314
|
+
except Exception as exception:
|
|
315
|
+
if filename:
|
|
316
|
+
logger.warning(f"Filename: {filename}")
|
|
317
|
+
logger.warning(
|
|
318
|
+
f"Formula [{formula}] (line: {line_number}) can't be evaluated: {exception}"
|
|
319
|
+
)
|
|
320
|
+
result = None
|
|
321
|
+
if isinstance(result, float):
|
|
322
|
+
result = round(result, 5)
|
|
323
|
+
node[key] = {"@type": "formula", "@value": value, "@result": result}
|
|
324
|
+
if result is not None and (key.startswith("@") or len(nodes) == 1):
|
|
325
|
+
set_variable(key, result)
|
|
326
|
+
elif value.startswith("@"):
|
|
327
|
+
if (result := variables.get(value)) is None:
|
|
328
|
+
if filename:
|
|
329
|
+
logger.warning(f"Filename: {filename}")
|
|
330
|
+
logger.warning(f'Value for "{value}" cannot be found (line: {line_number})')
|
|
331
|
+
node[key] = {"@type": "variable", "@value": value, "@result": result}
|
|
332
|
+
if result is not None and (key.startswith("@") or len(nodes) == 1):
|
|
333
|
+
set_variable(key, result)
|
|
334
|
+
elif result := variables.get(value):
|
|
335
|
+
if not isinstance(result, str):
|
|
336
|
+
node[key] = {"@type": "variable", "@value": value, "@result": result}
|
|
337
|
+
if result is not None and (key.startswith("@") or len(nodes) == 1):
|
|
338
|
+
set_variable(key, result)
|
|
339
|
+
elif key.startswith("#") and isinstance(node, list):
|
|
340
|
+
if subindexes := regex_inner_string.findall(value):
|
|
341
|
+
for subindex in subindexes:
|
|
342
|
+
value = value.replace(f"|{subindex}|", strings[subindex], 1)
|
|
343
|
+
value = value.strip('"') # Removing extra quotes
|
|
344
|
+
node.append(f"#{value}#")
|
|
345
|
+
else:
|
|
346
|
+
node[key] = value
|
|
347
|
+
if value is not None and (key.startswith("@") or len(nodes) == 1):
|
|
348
|
+
set_variable(key, value)
|
|
349
|
+
elif isinstance(node, list) and key.startswith("#"):
|
|
350
|
+
if subindexes := regex_inner_string.findall(value):
|
|
351
|
+
for subindex in subindexes:
|
|
352
|
+
value = value.replace(f"|{subindex}|", strings[subindex], 1)
|
|
353
|
+
value = value.strip('"') # Removing extra quotes
|
|
354
|
+
node.append(f"#{value}#")
|
|
355
|
+
else:
|
|
356
|
+
node[key] = value
|
|
357
|
+
if value is not None and not key.startswith("#") and (key.startswith("@") or len(nodes) == 1):
|
|
358
|
+
set_variable(key, value)
|
|
359
|
+
# If line is opening bracket inside an operator
|
|
360
|
+
elif line_text == "{" and node_name == "@":
|
|
361
|
+
continue
|
|
362
|
+
# If line is closing block
|
|
363
|
+
elif line_text == "}":
|
|
364
|
+
# Return to previous node
|
|
365
|
+
nodes.pop()
|
|
366
|
+
# If line is a list or list item
|
|
367
|
+
else:
|
|
368
|
+
# Ensure previous data are treated as list
|
|
369
|
+
if not isinstance(node, list):
|
|
370
|
+
if len(nodes) < 2:
|
|
371
|
+
raise SyntaxError("Incorrect file format or syntax!")
|
|
372
|
+
_, prev = nodes[-2]
|
|
373
|
+
if node_name:
|
|
374
|
+
if node and isinstance(node, dict):
|
|
375
|
+
(key, value), *_ = node.items()
|
|
376
|
+
if key.startswith("#"):
|
|
377
|
+
if subindexes := regex_inner_string.findall(value):
|
|
378
|
+
for subindex in subindexes:
|
|
379
|
+
value = value.replace(f"|{subindex}|", strings[subindex], 1)
|
|
380
|
+
value = value.strip('"') # Removing extra quotes
|
|
381
|
+
prev[node_name] = node = [f"#{value}#"]
|
|
382
|
+
elif node_name in ("on_actions", "events"): # Only for on_actions/events...
|
|
383
|
+
prev[node_name] = node = []
|
|
384
|
+
else:
|
|
385
|
+
if filename:
|
|
386
|
+
logger.warning(f"Filename: {filename}")
|
|
387
|
+
logger.warning(
|
|
388
|
+
f"Single value cannot be added to a dictionary "
|
|
389
|
+
f"(line {line_number}: {line_text})"
|
|
390
|
+
)
|
|
391
|
+
continue
|
|
392
|
+
elif isinstance(prev, dict):
|
|
393
|
+
prev[node_name] = node = []
|
|
394
|
+
elif isinstance(prev, list):
|
|
395
|
+
prev[-1] = node = []
|
|
396
|
+
nodes[-1] = (node_name, node)
|
|
397
|
+
# If list is composed of blocks
|
|
398
|
+
if line_text == "{":
|
|
399
|
+
item = {}
|
|
400
|
+
node.append(item)
|
|
401
|
+
nodes.append(("", item))
|
|
402
|
+
# Or if list is composed of plain values
|
|
403
|
+
else:
|
|
404
|
+
# Find every couple of key=value
|
|
405
|
+
for item in regex_item.findall(line_text):
|
|
406
|
+
if item.startswith("@[") and item.endswith("]"):
|
|
407
|
+
formula = item.lstrip("@[").rstrip("]")
|
|
408
|
+
if variables:
|
|
409
|
+
repl = lambda match: str(variables.get(match.group(1), match.group(1)))
|
|
410
|
+
formula = regex_variables.sub(repl, formula)
|
|
411
|
+
try:
|
|
412
|
+
result = eval(formula, None, variables)
|
|
413
|
+
except Exception as exception:
|
|
414
|
+
if filename:
|
|
415
|
+
logger.warning(f"Filename: {filename}")
|
|
416
|
+
logger.warning(
|
|
417
|
+
f"Formula [{formula}] (line: {line_number}) can't be evaluated: {exception}"
|
|
418
|
+
)
|
|
419
|
+
result = None
|
|
420
|
+
if isinstance(result, float):
|
|
421
|
+
result = round(result, 5)
|
|
422
|
+
node.append({"@type": "formula", "@value": value, "@result": result})
|
|
423
|
+
elif item.startswith("@"):
|
|
424
|
+
if (result := variables.get(item)) is None:
|
|
425
|
+
if filename:
|
|
426
|
+
logger.warning(f"Filename: {filename}")
|
|
427
|
+
logger.warning(f'Value for "{value}" cannot be found (line: {line_number})')
|
|
428
|
+
node.append({"@type": "variable", "@value": item, "@result": result})
|
|
429
|
+
else:
|
|
430
|
+
try:
|
|
431
|
+
node.append(ast.literal_eval(item))
|
|
432
|
+
except: # noqa
|
|
433
|
+
node.append(item)
|
|
434
|
+
except Exception as error:
|
|
435
|
+
if filename:
|
|
436
|
+
logger.error(f"Filename: {filename}")
|
|
437
|
+
logger.error(f"Line {line_number}: {line_text}")
|
|
438
|
+
logger.error(f"Parse error: {error}")
|
|
439
|
+
logger.debug("Exception:", exc_info=True)
|
|
440
|
+
return text if return_text_on_error else None
|
|
441
|
+
return root
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def parse_file(
|
|
445
|
+
path,
|
|
446
|
+
output_dir=None,
|
|
447
|
+
encoding="utf_8_sig",
|
|
448
|
+
base_dir=None,
|
|
449
|
+
save=False,
|
|
450
|
+
comments=False,
|
|
451
|
+
is_global=False,
|
|
452
|
+
patch=None,
|
|
453
|
+
):
|
|
454
|
+
"""
|
|
455
|
+
Parse file
|
|
456
|
+
:param path: Path to file to parse
|
|
457
|
+
:param output_dir: Directory where to save parsed file
|
|
458
|
+
:param encoding: Encoding used to read file
|
|
459
|
+
:param base_dir: Base directory (for debug)
|
|
460
|
+
:param save: (default false) Save parsed file in output directory
|
|
461
|
+
:param comments: Include comments?
|
|
462
|
+
:param is_global: (default false) Are file's variables global?
|
|
463
|
+
:param patch: String replacement patterns
|
|
464
|
+
:return: Parsed data as dictionary or text if parsing fails
|
|
465
|
+
"""
|
|
466
|
+
start_time = time.monotonic()
|
|
467
|
+
if base_dir:
|
|
468
|
+
base_dir = os.sep.join(str(base_dir).rstrip(os.sep).split(os.sep)[:-1]) + os.sep
|
|
469
|
+
base_dir = os.path.dirname(path.replace(base_dir.replace(os.sep, "/"), ""))
|
|
470
|
+
base_dir = base_dir or "."
|
|
471
|
+
text = read_file(path, encoding)
|
|
472
|
+
if not text or not text.strip():
|
|
473
|
+
return None
|
|
474
|
+
for pattern, replacement in patch or []:
|
|
475
|
+
text = re.sub(pattern, replacement, text)
|
|
476
|
+
filename = os.path.join(base_dir, os.path.basename(path)).replace(os.sep, "/")
|
|
477
|
+
logger.debug(f"Parsing {filename}")
|
|
478
|
+
data = parse_text(text, return_text_on_error=True, comments=comments, filename=filename, is_global=is_global)
|
|
479
|
+
if save:
|
|
480
|
+
filename, _ = os.path.splitext(os.path.basename(path))
|
|
481
|
+
directory = os.path.join(output_dir or "output", *base_dir.split("/")).replace(os.sep, "/")
|
|
482
|
+
os.makedirs(directory, exist_ok=True)
|
|
483
|
+
if not isinstance(data, dict):
|
|
484
|
+
filename = os.path.join(directory, filename + ".error")
|
|
485
|
+
try:
|
|
486
|
+
with open(filename, "w") as file:
|
|
487
|
+
file.write(data)
|
|
488
|
+
except UnicodeEncodeError as error:
|
|
489
|
+
logger.error(f"Unable to write file {filename}: {error}")
|
|
490
|
+
else:
|
|
491
|
+
filename = os.path.join(directory, filename + ".json")
|
|
492
|
+
with open(filename, "w") as file:
|
|
493
|
+
json.dump(data, file, indent=4)
|
|
494
|
+
total_time = time.monotonic() - start_time
|
|
495
|
+
logger.debug(f"Elapsed time: {total_time:0.3f}s!")
|
|
496
|
+
return data
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def parse_all_files(
|
|
500
|
+
path,
|
|
501
|
+
output_dir=None,
|
|
502
|
+
encoding="utf_8_sig",
|
|
503
|
+
keep_data=False,
|
|
504
|
+
save=False,
|
|
505
|
+
comments=False,
|
|
506
|
+
variables_first=True,
|
|
507
|
+
_variables_only=False,
|
|
508
|
+
):
|
|
509
|
+
"""
|
|
510
|
+
Parse all text files in a directory
|
|
511
|
+
:param path: Path where to find files to parse
|
|
512
|
+
:param output_dir: Directory where to save parsed files
|
|
513
|
+
:param encoding: Encoding used to read files
|
|
514
|
+
:param keep_data: (default false) Return parsed data of all files in a dictionary
|
|
515
|
+
:param save: (default false) Save every parsed data in output directory
|
|
516
|
+
:param comments: Include comments?
|
|
517
|
+
:param variables_first: Try to parse variables first
|
|
518
|
+
:return: Dictionary (key: file, value: parsed data if keep_data=True)
|
|
519
|
+
"""
|
|
520
|
+
start_time = time.monotonic()
|
|
521
|
+
success, errors = {}, []
|
|
522
|
+
if variables_first and not _variables_only:
|
|
523
|
+
success.update(
|
|
524
|
+
parse_all_files(
|
|
525
|
+
path,
|
|
526
|
+
output_dir,
|
|
527
|
+
encoding,
|
|
528
|
+
keep_data,
|
|
529
|
+
save,
|
|
530
|
+
comments,
|
|
531
|
+
variables_first=False,
|
|
532
|
+
_variables_only=True,
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
for current_path, _, all_files in os.walk(path):
|
|
536
|
+
is_script_values = current_path.endswith("script_values")
|
|
537
|
+
if (_variables_only and not is_script_values) or (not _variables_only and variables_first and is_script_values):
|
|
538
|
+
continue
|
|
539
|
+
for filename in all_files:
|
|
540
|
+
if not filename.lower().endswith(".txt"):
|
|
541
|
+
continue
|
|
542
|
+
filepath = os.path.join(current_path, filename).replace(os.sep, "/")
|
|
543
|
+
data = parse_file(
|
|
544
|
+
filepath,
|
|
545
|
+
output_dir=output_dir,
|
|
546
|
+
encoding=encoding,
|
|
547
|
+
base_dir=path,
|
|
548
|
+
save=save,
|
|
549
|
+
comments=comments,
|
|
550
|
+
is_global=_variables_only,
|
|
551
|
+
)
|
|
552
|
+
if isinstance(data, str):
|
|
553
|
+
errors.append(filepath)
|
|
554
|
+
continue
|
|
555
|
+
filepath = filepath.replace(str(path), "").lstrip("/")
|
|
556
|
+
success[filepath] = data if keep_data else True
|
|
557
|
+
total_time = time.monotonic() - start_time
|
|
558
|
+
logger.info(f"{len(success)} parsed file(s) and {len(errors)} errors in {total_time:0.3f}s!")
|
|
559
|
+
for error in errors:
|
|
560
|
+
logger.warning(f"Error detected in: {error}")
|
|
561
|
+
return success
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def parse_all_locales(path, encoding="utf_8_sig", language="english", save=False):
|
|
565
|
+
"""
|
|
566
|
+
Parse all locales strings
|
|
567
|
+
:param path: Path where to find locale files
|
|
568
|
+
:param encoding: Encoding for reading files
|
|
569
|
+
:param language: Target language
|
|
570
|
+
:param save: (default false) save locales in file
|
|
571
|
+
:return: Locales in dictionary
|
|
572
|
+
"""
|
|
573
|
+
locales = {}
|
|
574
|
+
if os.path.isfile(path):
|
|
575
|
+
with open(path, encoding=encoding) as file:
|
|
576
|
+
while line := file.readline():
|
|
577
|
+
if line.strip().lower() == f"l_{language}:":
|
|
578
|
+
break
|
|
579
|
+
for line in file:
|
|
580
|
+
if match := regex_locale.match(line):
|
|
581
|
+
key, _, value = match.groups()
|
|
582
|
+
locales[key] = value
|
|
583
|
+
else:
|
|
584
|
+
for current_path, _, all_files in os.walk(path):
|
|
585
|
+
for filename in all_files:
|
|
586
|
+
if not filename.lower().endswith(".yml"):
|
|
587
|
+
continue
|
|
588
|
+
filepath = os.path.join(current_path, filename)
|
|
589
|
+
with open(filepath, encoding=encoding) as file:
|
|
590
|
+
while line := file.readline():
|
|
591
|
+
if line.strip().lower() == f"l_{language}:":
|
|
592
|
+
break
|
|
593
|
+
for line in file:
|
|
594
|
+
if match := regex_locale.match(line):
|
|
595
|
+
key, _, value = match.groups()
|
|
596
|
+
locales[key] = value
|
|
597
|
+
if save:
|
|
598
|
+
with open("_locales.json", "w") as file:
|
|
599
|
+
json.dump(locales, file, indent=4, sort_keys=True)
|
|
600
|
+
return locales
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def walk(obj, *from_keys):
|
|
604
|
+
"""
|
|
605
|
+
Walk through a complex dictionary struct
|
|
606
|
+
:param obj: Dictionary
|
|
607
|
+
:param from_keys: (only used by recursion) Key of the parent sections
|
|
608
|
+
:return: Yield key and value during iteration
|
|
609
|
+
"""
|
|
610
|
+
if isinstance(obj, dict):
|
|
611
|
+
for key, value in obj.items():
|
|
612
|
+
yield from walk(value, key, *from_keys)
|
|
613
|
+
elif isinstance(obj, list) and any(isinstance(subitem, (list, dict)) for subitem in obj):
|
|
614
|
+
for item in obj:
|
|
615
|
+
yield from walk(item, *from_keys)
|
|
616
|
+
else:
|
|
617
|
+
yield obj, from_keys
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# Tags which are always a list when reverting
|
|
621
|
+
list_keys_rules = [
|
|
622
|
+
# Colors
|
|
623
|
+
re.compile(r"^\w+_color$", re.IGNORECASE),
|
|
624
|
+
re.compile(r"^color\w*$", re.IGNORECASE),
|
|
625
|
+
# DNA
|
|
626
|
+
re.compile(r"^gene_\w+$", re.IGNORECASE),
|
|
627
|
+
re.compile(r"^face_detail_\w+$", re.IGNORECASE),
|
|
628
|
+
re.compile(r"^expression_\w+$", re.IGNORECASE),
|
|
629
|
+
re.compile(r"^\w+_accessory$", re.IGNORECASE),
|
|
630
|
+
re.compile(r"^complexion$", re.IGNORECASE),
|
|
631
|
+
# Plural keys
|
|
632
|
+
re.compile(r"^(?!(h_|e_|k_|d_|c_|b_))[^\.\:\s]+s$", re.IGNORECASE),
|
|
633
|
+
# GFX
|
|
634
|
+
re.compile(r"^\w+_gfx$", re.IGNORECASE),
|
|
635
|
+
# Object=
|
|
636
|
+
re.compile(r"^[^=]+=$", re.IGNORECASE),
|
|
637
|
+
]
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def revert(obj, from_key=None, prev_key=None, depth=-1, sep="\t", sort=False):
|
|
641
|
+
"""
|
|
642
|
+
/!\\ Work in progress /!\\
|
|
643
|
+
Try to revert a dict-struct to Paradox format
|
|
644
|
+
:param obj: Dictionary
|
|
645
|
+
:param from_key: (only used by recursion) Key of the parent section
|
|
646
|
+
:param prev_key: (only used by recursion) Key of the great-parent section
|
|
647
|
+
:param depth: (only used by recursion) Depth of the current section
|
|
648
|
+
:param sep: Line-start separator
|
|
649
|
+
:param sort: Sort key/values (can mess with comments)
|
|
650
|
+
:return: Text
|
|
651
|
+
"""
|
|
652
|
+
lines = []
|
|
653
|
+
tabs = sep * depth
|
|
654
|
+
if isinstance(obj, dict):
|
|
655
|
+
if special := revert_special(obj, from_key, prev_key, sep=sep, sort=sort):
|
|
656
|
+
special = special if isinstance(special, list) else [special]
|
|
657
|
+
for line in sorted(special) if sort else special:
|
|
658
|
+
lines.append(f"{tabs}{line}")
|
|
659
|
+
else:
|
|
660
|
+
if from_key:
|
|
661
|
+
from_key = str(from_key).replace("|", " ")
|
|
662
|
+
lines.append(f"{tabs}{from_key} = {{")
|
|
663
|
+
elif depth > 0:
|
|
664
|
+
lines.append(f"{tabs}{{")
|
|
665
|
+
for key, value in sorted(obj.items()) if sort else obj.items():
|
|
666
|
+
lines.extend(revert(value, from_key=key, prev_key=from_key, depth=depth + 1, sep=sep, sort=sort))
|
|
667
|
+
if from_key or depth > 0:
|
|
668
|
+
lines.append(f"{tabs}}}")
|
|
669
|
+
elif isinstance(obj, list):
|
|
670
|
+
if from_key and (from_key.lower() == "this" or not any(regex.match(from_key) for regex in list_keys_rules)):
|
|
671
|
+
for value in sorted(obj) if sort else obj:
|
|
672
|
+
lines.extend(revert(value, from_key=from_key, prev_key=prev_key, depth=depth, sep=sep, sort=sort))
|
|
673
|
+
elif not any(isinstance(o, (dict, list)) for o in obj):
|
|
674
|
+
prefix = f"{tabs}{from_key} = {{"
|
|
675
|
+
# Only for color modes
|
|
676
|
+
if from_key == "color" and len(obj) == 4 and isinstance(obj[0], str):
|
|
677
|
+
prefix = f"{tabs}{from_key} {obj[0]} = {{"
|
|
678
|
+
obj = obj[1:]
|
|
679
|
+
func = functools.partial(revert_value, from_key=from_key, prev_key=prev_key, sep=sep, sort=sort)
|
|
680
|
+
values = " ".join(map(str, map(func, obj)))
|
|
681
|
+
lines.append(f"{prefix} {values} }}")
|
|
682
|
+
else:
|
|
683
|
+
if from_key:
|
|
684
|
+
key = str(from_key).replace("|", " ")
|
|
685
|
+
lines.append(f"{tabs}{key} = {{")
|
|
686
|
+
else:
|
|
687
|
+
lines.append(f"{tabs}{{")
|
|
688
|
+
for value in sorted(obj) if sort else obj:
|
|
689
|
+
lines.extend(revert(value, depth=depth + 1, sep=sep, sort=sort))
|
|
690
|
+
lines.append(f"{tabs}}}")
|
|
691
|
+
elif isinstance(obj, (int, float)) or obj:
|
|
692
|
+
if from_key:
|
|
693
|
+
if from_key.startswith("#") or (isinstance(obj, str) and obj.startswith("#") and obj.endswith("#")):
|
|
694
|
+
value = obj.strip("#")
|
|
695
|
+
lines.append(f"{tabs}#{value}")
|
|
696
|
+
else:
|
|
697
|
+
from_key = str(from_key).replace("|", " ")
|
|
698
|
+
value = revert_value(obj, from_key, prev_key, sep=sep, sort=sort)
|
|
699
|
+
lines.append(f"{tabs}{from_key} = {value}")
|
|
700
|
+
else:
|
|
701
|
+
lines.append(f"{tabs}{revert_value(obj, sep=sep, sort=sort)}")
|
|
702
|
+
if depth < 0:
|
|
703
|
+
return "\n".join(lines)
|
|
704
|
+
return lines
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def revert_value(value, from_key=None, prev_key=None, **kwargs):
|
|
708
|
+
"""
|
|
709
|
+
/!\\ Work in progress /!\\
|
|
710
|
+
Revert values utility for revert function
|
|
711
|
+
:param value: Value to revert
|
|
712
|
+
:param from_key: Key of the parent section
|
|
713
|
+
:param prev_key: Key of the great-parent section
|
|
714
|
+
:return: Reverted value
|
|
715
|
+
"""
|
|
716
|
+
if isinstance(value, bool):
|
|
717
|
+
return "yes" if value else "no"
|
|
718
|
+
elif isinstance(value, str):
|
|
719
|
+
if value.startswith("#") and value.endswith("#"):
|
|
720
|
+
return f"#{value}"
|
|
721
|
+
elif (
|
|
722
|
+
" " in value
|
|
723
|
+
or (value.startswith("$") and value.endswith("$"))
|
|
724
|
+
or from_key in forced_string_keys
|
|
725
|
+
or (prev_key and (prev_key, from_key) in forced_string_keys)
|
|
726
|
+
or (prev_key and (prev_key, None) in forced_string_keys)
|
|
727
|
+
):
|
|
728
|
+
value = value.replace('"', '\\"')
|
|
729
|
+
return f'"{value}"'
|
|
730
|
+
elif isinstance(value, dict):
|
|
731
|
+
value = revert(value, from_key=from_key, prev_key=prev_key, depth=0, **kwargs)
|
|
732
|
+
return value
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def revert_special(obj, from_key=None, prev_key=None, **kwargs):
|
|
736
|
+
"""
|
|
737
|
+
/!\\ Work in progress /!\\
|
|
738
|
+
Revert special values utility for revert function
|
|
739
|
+
:param obj: Special object to revert
|
|
740
|
+
:param from_key: Key of the parent section
|
|
741
|
+
:param prev_key: Key of the great-parent section
|
|
742
|
+
:return: Reverted object
|
|
743
|
+
"""
|
|
744
|
+
if "@operator" in obj:
|
|
745
|
+
operator, value = obj["@operator"], obj["@value"]
|
|
746
|
+
value = revert_value(value, from_key, prev_key, **kwargs)
|
|
747
|
+
if isinstance(value, list):
|
|
748
|
+
value[0] = value[0].replace("=", operator, 1)
|
|
749
|
+
return value
|
|
750
|
+
else:
|
|
751
|
+
return f"{from_key} {operator} {value}"
|
|
752
|
+
elif "@type" in obj:
|
|
753
|
+
value, result = obj["@value"], obj["@result"]
|
|
754
|
+
return f"{from_key or value} = {value}"
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def revert_file(path, output_dir=None, encoding="utf_8_sig", base_dir=None, save=False):
|
|
758
|
+
"""
|
|
759
|
+
Revert JSON file to Paradox format
|
|
760
|
+
:param path: Path to JSON file to revert
|
|
761
|
+
:param output_dir: Directory where to save reverted files
|
|
762
|
+
:param encoding: Encoding used to write files
|
|
763
|
+
:param base_dir: Base directory (for debug)
|
|
764
|
+
:param save: (default false) Save every reverted data in output directory
|
|
765
|
+
"""
|
|
766
|
+
start_time = time.monotonic()
|
|
767
|
+
if base_dir:
|
|
768
|
+
base_dir = os.sep.join(str(base_dir).rstrip(os.sep).split(os.sep)[:-1]) + os.sep
|
|
769
|
+
base_dir = os.path.dirname(path.replace(base_dir, ""))
|
|
770
|
+
base_dir = base_dir or "."
|
|
771
|
+
with open(path) as file:
|
|
772
|
+
data = json.load(file)
|
|
773
|
+
filename = os.path.join(str(base_dir), os.path.basename(path))
|
|
774
|
+
logger.debug(f"Reverting {filename}")
|
|
775
|
+
text = revert(data)
|
|
776
|
+
if save:
|
|
777
|
+
filename, _ = os.path.splitext(os.path.basename(path))
|
|
778
|
+
directory = os.path.join(output_dir or "output", *base_dir.split(os.sep))
|
|
779
|
+
os.makedirs(directory, exist_ok=True)
|
|
780
|
+
filename = os.path.join(directory, filename + ".txt")
|
|
781
|
+
with open(filename, "w", encoding=encoding) as file:
|
|
782
|
+
file.write(text)
|
|
783
|
+
total_time = time.monotonic() - start_time
|
|
784
|
+
logger.debug(f"Elapsed time: {total_time:0.3}s!")
|
|
785
|
+
return text
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def load_variables(filepath="_variables.json"):
|
|
789
|
+
"""
|
|
790
|
+
Load variables from a local file variables.json
|
|
791
|
+
"""
|
|
792
|
+
global global_variables
|
|
793
|
+
if not os.path.exists(filepath):
|
|
794
|
+
return
|
|
795
|
+
with open(filepath) as file:
|
|
796
|
+
global_variables = json.load(file)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def save_variables(filepath="_variables.json"):
|
|
800
|
+
"""
|
|
801
|
+
Save variables in local file variables.json
|
|
802
|
+
"""
|
|
803
|
+
if not global_variables:
|
|
804
|
+
return
|
|
805
|
+
with open(filepath, "w") as file:
|
|
806
|
+
json.dump(global_variables, file, indent=4, sort_keys=True)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def main():
|
|
810
|
+
"""
|
|
811
|
+
Command-line main entrypoint
|
|
812
|
+
"""
|
|
813
|
+
parser = argparse.ArgumentParser(
|
|
814
|
+
description="Parse data from Paradox files in JSON or revert JSON files to Paradox format"
|
|
815
|
+
)
|
|
816
|
+
parser.add_argument("path", type=str, help="path to a file or a directory to parse/revert")
|
|
817
|
+
parser.add_argument("--encoding", type=str, help="encoding for reading/writing files")
|
|
818
|
+
parser.add_argument("--output", type=str, help="output directory for parsing results")
|
|
819
|
+
parser.add_argument("--revert", action="store_true", help="revert JSON files?")
|
|
820
|
+
parser.add_argument("--comments", action="store_true", help="include comments?")
|
|
821
|
+
parser.add_argument("--debug", action="store_true", help="debug mode?")
|
|
822
|
+
args = parser.parse_args()
|
|
823
|
+
|
|
824
|
+
logger.setLevel(logging.DEBUG)
|
|
825
|
+
formatter = logging.Formatter("[%(asctime)s] %(levelname)s: %(message)s")
|
|
826
|
+
console_handler = logging.StreamHandler()
|
|
827
|
+
console_handler.setLevel(logging.INFO)
|
|
828
|
+
console_handler.setFormatter(formatter)
|
|
829
|
+
logger.addHandler(console_handler)
|
|
830
|
+
if args.debug:
|
|
831
|
+
console_handler.setLevel(logging.DEBUG)
|
|
832
|
+
file_handler = logging.FileHandler("ckparser.log")
|
|
833
|
+
file_handler.setLevel(logging.INFO)
|
|
834
|
+
file_handler.setFormatter(formatter)
|
|
835
|
+
logger.addHandler(file_handler)
|
|
836
|
+
|
|
837
|
+
if args.revert:
|
|
838
|
+
if os.path.isdir(args.path):
|
|
839
|
+
logger.error("Reverting many files is not implemented yet!")
|
|
840
|
+
else:
|
|
841
|
+
revert_file(args.path, encoding=args.encoding, output_dir=args.output, save=True)
|
|
842
|
+
else:
|
|
843
|
+
load_variables()
|
|
844
|
+
if os.path.isdir(args.path):
|
|
845
|
+
parse_all_files(
|
|
846
|
+
args.path,
|
|
847
|
+
encoding=args.encoding,
|
|
848
|
+
output_dir=args.output,
|
|
849
|
+
comments=args.comments,
|
|
850
|
+
save=True,
|
|
851
|
+
)
|
|
852
|
+
else:
|
|
853
|
+
parse_file(
|
|
854
|
+
args.path,
|
|
855
|
+
encoding=args.encoding,
|
|
856
|
+
output_dir=args.output,
|
|
857
|
+
comments=args.comments,
|
|
858
|
+
save=True,
|
|
859
|
+
)
|
|
860
|
+
save_variables()
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
if __name__ == "__main__":
|
|
864
|
+
main()
|