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()