ckparser 0.3.1__tar.gz → 0.3.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ckparser
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Parse Paradox Jomini data files to Python/JSON and revert them back experimentally.
5
5
  Author: Marc Debureaux (debnet)
6
6
  License-Expression: MIT
@@ -284,6 +284,29 @@ from ckparser import walk
284
284
  for value, path in walk(data):
285
285
  print(path, value)
286
286
  ```
287
+
288
+ ### `objectify`
289
+
290
+ Transform a dictionary datastructure into an object-like dictionary which allows a better inspection.
291
+
292
+ ```python
293
+ from ckparser import objectify
294
+
295
+ data = {
296
+ "name": "Louis",
297
+ "traits": [
298
+ "brave",
299
+ "diligent"
300
+ ],
301
+ "father": {
302
+ "name": "Charles"
303
+ }
304
+ }
305
+
306
+ obj = objectify(data)
307
+ print(obj.traits[1])
308
+ print(obj.father.name)
309
+ ```
287
310
 
288
311
  ### `parse_all_locales`
289
312
 
@@ -518,9 +541,9 @@ Paradox scripting languages include many practical exceptions, formatting varian
518
541
 
519
542
  ### Parsing
520
543
 
521
- * `parse_text(text, return_text_on_error=False, comments=False, dates=False, filename=None, is_global=False)`
522
- * `parse_file(path, output_dir=None, encoding="utf_8_sig", base_dir=None, save=False, comments=False, dates=False, is_global=False, patch=None)`
523
- * `parse_all_files(path, output_dir=None, encoding="utf_8_sig", keep_data=False, save=False, comments=False, dates=False, variables_first=True)`
544
+ * `parse_text(text, return_text_on_error=False, comments=False, dates=False, as_object=False, filename=None, is_global=False)`
545
+ * `parse_file(path, output_dir=None, encoding="utf_8_sig", base_dir=None, save=False, comments=False, dates=False, as_object=False, is_global=False, patch=None)`
546
+ * `parse_all_files(path, output_dir=None, encoding="utf_8_sig", keep_data=False, save=False, comments=False, dates=False, as_object=False, variables_first=True)`
524
547
 
525
548
  ### Reversion
526
549
 
@@ -533,6 +556,7 @@ Paradox scripting languages include many practical exceptions, formatting varian
533
556
  * `convert_date(date, key=None)`
534
557
  * `read_file(path, encoding="utf_8_sig")`
535
558
  * `walk(obj, *from_keys)`
559
+ * `objectify(item, name="Object", default=None)`
536
560
  * `parse_all_locales(path, encoding="utf_8_sig", language="english", save=False)`
537
561
  * `load_variables(filepath="_variables.json")`
538
562
  * `save_variables(filepath="_variables.json")`
@@ -253,6 +253,29 @@ from ckparser import walk
253
253
  for value, path in walk(data):
254
254
  print(path, value)
255
255
  ```
256
+
257
+ ### `objectify`
258
+
259
+ Transform a dictionary datastructure into an object-like dictionary which allows a better inspection.
260
+
261
+ ```python
262
+ from ckparser import objectify
263
+
264
+ data = {
265
+ "name": "Louis",
266
+ "traits": [
267
+ "brave",
268
+ "diligent"
269
+ ],
270
+ "father": {
271
+ "name": "Charles"
272
+ }
273
+ }
274
+
275
+ obj = objectify(data)
276
+ print(obj.traits[1])
277
+ print(obj.father.name)
278
+ ```
256
279
 
257
280
  ### `parse_all_locales`
258
281
 
@@ -487,9 +510,9 @@ Paradox scripting languages include many practical exceptions, formatting varian
487
510
 
488
511
  ### Parsing
489
512
 
490
- * `parse_text(text, return_text_on_error=False, comments=False, dates=False, filename=None, is_global=False)`
491
- * `parse_file(path, output_dir=None, encoding="utf_8_sig", base_dir=None, save=False, comments=False, dates=False, is_global=False, patch=None)`
492
- * `parse_all_files(path, output_dir=None, encoding="utf_8_sig", keep_data=False, save=False, comments=False, dates=False, variables_first=True)`
513
+ * `parse_text(text, return_text_on_error=False, comments=False, dates=False, as_object=False, filename=None, is_global=False)`
514
+ * `parse_file(path, output_dir=None, encoding="utf_8_sig", base_dir=None, save=False, comments=False, dates=False, as_object=False, is_global=False, patch=None)`
515
+ * `parse_all_files(path, output_dir=None, encoding="utf_8_sig", keep_data=False, save=False, comments=False, dates=False, as_object=False, variables_first=True)`
493
516
 
494
517
  ### Reversion
495
518
 
@@ -502,6 +525,7 @@ Paradox scripting languages include many practical exceptions, formatting varian
502
525
  * `convert_date(date, key=None)`
503
526
  * `read_file(path, encoding="utf_8_sig")`
504
527
  * `walk(obj, *from_keys)`
528
+ * `objectify(item, name="Object", default=None)`
505
529
  * `parse_all_locales(path, encoding="utf_8_sig", language="english", save=False)`
506
530
  * `load_variables(filepath="_variables.json")`
507
531
  * `save_variables(filepath="_variables.json")`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ckparser
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Parse Paradox Jomini data files to Python/JSON and revert them back experimentally.
5
5
  Author: Marc Debureaux (debnet)
6
6
  License-Expression: MIT
@@ -284,6 +284,29 @@ from ckparser import walk
284
284
  for value, path in walk(data):
285
285
  print(path, value)
286
286
  ```
287
+
288
+ ### `objectify`
289
+
290
+ Transform a dictionary datastructure into an object-like dictionary which allows a better inspection.
291
+
292
+ ```python
293
+ from ckparser import objectify
294
+
295
+ data = {
296
+ "name": "Louis",
297
+ "traits": [
298
+ "brave",
299
+ "diligent"
300
+ ],
301
+ "father": {
302
+ "name": "Charles"
303
+ }
304
+ }
305
+
306
+ obj = objectify(data)
307
+ print(obj.traits[1])
308
+ print(obj.father.name)
309
+ ```
287
310
 
288
311
  ### `parse_all_locales`
289
312
 
@@ -518,9 +541,9 @@ Paradox scripting languages include many practical exceptions, formatting varian
518
541
 
519
542
  ### Parsing
520
543
 
521
- * `parse_text(text, return_text_on_error=False, comments=False, dates=False, filename=None, is_global=False)`
522
- * `parse_file(path, output_dir=None, encoding="utf_8_sig", base_dir=None, save=False, comments=False, dates=False, is_global=False, patch=None)`
523
- * `parse_all_files(path, output_dir=None, encoding="utf_8_sig", keep_data=False, save=False, comments=False, dates=False, variables_first=True)`
544
+ * `parse_text(text, return_text_on_error=False, comments=False, dates=False, as_object=False, filename=None, is_global=False)`
545
+ * `parse_file(path, output_dir=None, encoding="utf_8_sig", base_dir=None, save=False, comments=False, dates=False, as_object=False, is_global=False, patch=None)`
546
+ * `parse_all_files(path, output_dir=None, encoding="utf_8_sig", keep_data=False, save=False, comments=False, dates=False, as_object=False, variables_first=True)`
524
547
 
525
548
  ### Reversion
526
549
 
@@ -533,6 +556,7 @@ Paradox scripting languages include many practical exceptions, formatting varian
533
556
  * `convert_date(date, key=None)`
534
557
  * `read_file(path, encoding="utf_8_sig")`
535
558
  * `walk(obj, *from_keys)`
559
+ * `objectify(item, name="Object", default=None)`
536
560
  * `parse_all_locales(path, encoding="utf_8_sig", language="english", save=False)`
537
561
  * `load_variables(filepath="_variables.json")`
538
562
  * `save_variables(filepath="_variables.json")`
@@ -41,7 +41,7 @@ import re
41
41
  import time
42
42
 
43
43
  # Script version
44
- __version__ = "0.3.1"
44
+ __version__ = "0.3.3"
45
45
 
46
46
  # Logger (because logging is awesome)
47
47
  logger = logging.getLogger(__name__)
@@ -104,9 +104,11 @@ regex_inner_string = re.compile(r"\|(?P<index>\d+)\|")
104
104
  # Regex to remove comments in files
105
105
  regex_comment = re.compile(r"(?P<space>\s*)(?P<comment>#.*)", re.MULTILINE)
106
106
  # Regex to fix blocks with no equal sign
107
- regex_missing = re.compile(r"^\s*([\w\.]+)\s+([{])", re.MULTILINE)
107
+ regex_missing = re.compile(r"^(\s*)([\w\.]+)\s+([{])", re.MULTILINE)
108
108
  # Regex to remove "list" prefix
109
109
  regex_list = re.compile(r"\s*=\s*list\s+([\{\"\|])", re.MULTILINE)
110
+ # Regex to split brackets on new lines
111
+ regex_jump = re.compile(r"(\s*)([{}])")
110
112
  # Regex for color blocks (color = [rgb|hsv] { x y z })
111
113
  regex_color = re.compile(r"=\s*(?P<type>\w+)\s*{", re.MULTILINE)
112
114
  # Regex to parse items with format key=value
@@ -118,7 +120,7 @@ regex_line = re.compile(r"\"?(?P<key>[^\s\"]+)\"?\s*(?P<operator>[?!<=>]+)\s*(li
118
120
  # Regex to parse independent items in a list
119
121
  regex_item = re.compile(r"(\"[^\"]+\"|[\d\.]+|[^\s]+)")
120
122
  # Regex to parse dates
121
- regex_date = re.compile(r"^(?:\d{1,4})\.(?:[1-9]|1[0-2])\.(?:[1-9]|[12]\d|3[01])$")
123
+ regex_date = re.compile(r"^(?P<year>\d{1,4})\.(?P<month>[1-9]|1[0-2])\.(?P<day>[1-9]|[12]\d|3[01])$")
122
124
  # Regex to remove empty lines
123
125
  regex_empty = re.compile(r"(\n\s*\n)+", re.MULTILINE)
124
126
  # Regex to parse locale files
@@ -129,6 +131,8 @@ regex_keyword = re.compile(r"(" + "|".join(map(re.escape, sorted(keywords, key=l
129
131
  regex_count = re.compile(r"([?!<=>]+)\s*([§\n]+)\s*\{")
130
132
  # Regex for string indexes
131
133
  regex_index = re.compile(r"\|(\d+)\|")
134
+ # Regex to clean line numbers
135
+ regex_clean = re.compile(r"^§+\n", re.MULTILINE)
132
136
  # Regex for variables
133
137
  regex_variables = re.compile(r"\b([\w]+)\b")
134
138
 
@@ -188,14 +192,23 @@ def read_file(path, encoding="utf_8_sig"):
188
192
  return file.read()
189
193
 
190
194
 
191
- def parse_text(text, return_text_on_error=False, comments=False, dates=False, filename=None, is_global=False):
195
+ def parse_text(
196
+ text,
197
+ return_text_on_error=False,
198
+ comments=False,
199
+ dates=False,
200
+ as_object=False,
201
+ filename=None,
202
+ is_global=False,
203
+ ):
192
204
  """
193
205
  Parse raw text
194
206
  :param text: Text to parse
195
207
  :param return_text_on_error: (default false) Return working text document if parsing fails
196
208
  :param comments: (default false) Include comments?
197
209
  :param dates: (default false) Include dates?
198
- :param filename: (default none) Filename (only for debugging)
210
+ :param as_object: (default false) Return data as an object-like instead of dict
211
+ :param filename: (default none) file which is parsed (only for debugging)
199
212
  :param is_global: (default false) Are variables global?
200
213
  :return: Parsed data as dictionary
201
214
  """
@@ -238,12 +251,12 @@ def parse_text(text, return_text_on_error=False, comments=False, dates=False, fi
238
251
  if missings := regex_missing.findall(text):
239
252
  if filename:
240
253
  logger.warning(f"Filename: {filename}")
241
- for key, val in missings:
254
+ for _, key, val in missings:
242
255
  logger.warning(f"Potential missing `=` operator between `{key}` and `{val}` needs to be fixed.")
243
- text = regex_missing.sub(r"\g<1>=\g<2>", text)
256
+ text = regex_missing.sub(r"\g<1>\g<2>=\g<3>", text)
244
257
  text = regex_color.sub(r"={\n\g<1>", text)
245
258
  text = regex_list.sub(r"|list=\g<1>", text)
246
- text = text.replace("{", "\n{\n").replace("}", "\n}\n")
259
+ text = regex_jump.sub(r"\n\g<1>\g<2>\n", text)
247
260
  text = regex_inline.sub(r"\g<1>\n", text)
248
261
  text = regex_block.sub(r"\g<2>\g<4>{", text)
249
262
  text = regex_empty.sub(r"\n", text)
@@ -418,10 +431,14 @@ def parse_text(text, return_text_on_error=False, comments=False, dates=False, fi
418
431
  nodes.pop()
419
432
  # If line is a list or list item
420
433
  else:
434
+ # Assuming the parsed file is just an array
435
+ if len(nodes) == 1 and not node:
436
+ root = node = []
437
+ nodes = [("", root)]
421
438
  # Ensure previous data are treated as list
422
439
  if not isinstance(node, list):
423
440
  if len(nodes) < 2:
424
- raise SyntaxError("Incorrect file format or syntax!")
441
+ raise SyntaxError("Incorrect file format or unknown syntax error!")
425
442
  _, prev = nodes[-2]
426
443
  if node_name:
427
444
  if node and isinstance(node, dict):
@@ -472,12 +489,12 @@ def parse_text(text, return_text_on_error=False, comments=False, dates=False, fi
472
489
  result = None
473
490
  if isinstance(result, float):
474
491
  result = round(result, 5)
475
- node.append({"@type": "formula", "@value": value, "@result": result})
492
+ node.append({"@type": "formula", "@value": item, "@result": result})
476
493
  elif item.startswith("@"):
477
494
  if (result := variables.get(item)) is None:
478
495
  if filename:
479
496
  logger.warning(f"Filename: {filename}")
480
- logger.warning(f'Value for "{value}" cannot be found (line: {line_number})')
497
+ logger.warning(f'Value for "{item}" cannot be found (line: {line_number})')
481
498
  node.append({"@type": "variable", "@value": item, "@result": result})
482
499
  elif dates and regex_date.fullmatch(item):
483
500
  item = convert_date(item) or item
@@ -494,8 +511,8 @@ def parse_text(text, return_text_on_error=False, comments=False, dates=False, fi
494
511
  logger.error(f"Line {line_number}: {line_text}")
495
512
  logger.error(f"Parse error: {error}")
496
513
  logger.debug("Exception:", exc_info=True)
497
- return text if return_text_on_error else None
498
- return root
514
+ return regex_clean.sub("", text) if return_text_on_error else None
515
+ return objectify(root) if as_object else root
499
516
 
500
517
 
501
518
  def parse_file(
@@ -506,6 +523,7 @@ def parse_file(
506
523
  save=False,
507
524
  comments=False,
508
525
  dates=False,
526
+ as_object=False,
509
527
  is_global=False,
510
528
  patch=None,
511
529
  ):
@@ -516,12 +534,16 @@ def parse_file(
516
534
  :param encoding: Encoding used to read file
517
535
  :param base_dir: Base directory (for debug)
518
536
  :param save: (default false) Save parsed file in output directory
519
- :param comments: Include comments?
520
- :param dates: Include dates?
537
+ :param comments: (default false) Include comments?
538
+ :param dates: (default false) Include dates?
539
+ :param as_object: (default false) Return data as an object-like instead of dict
521
540
  :param is_global: (default false) Are file's variables global?
522
541
  :param patch: String replacement patterns
523
542
  :return: Parsed data as dictionary or text if parsing fails
524
543
  """
544
+ if save and as_object:
545
+ logger.warning("`as_object` disabled bacause only dicts can be converted to JSON.")
546
+ as_object = False
525
547
  start_time = time.monotonic()
526
548
  if base_dir:
527
549
  base_dir = os.sep.join(str(base_dir).rstrip(os.sep).split(os.sep)[:-1]) + os.sep
@@ -535,7 +557,13 @@ def parse_file(
535
557
  filename = os.path.join(base_dir, os.path.basename(path))
536
558
  logger.debug(f"Parsing {filename}")
537
559
  data = parse_text(
538
- text, return_text_on_error=True, comments=comments, dates=dates, filename=filename, is_global=is_global
560
+ text,
561
+ return_text_on_error=True,
562
+ comments=comments,
563
+ dates=dates,
564
+ as_object=as_object,
565
+ filename=filename,
566
+ is_global=is_global,
539
567
  )
540
568
  if save:
541
569
  filename, _ = os.path.splitext(os.path.basename(path))
@@ -565,6 +593,7 @@ def parse_all_files(
565
593
  save=False,
566
594
  comments=False,
567
595
  dates=False,
596
+ as_object=False,
568
597
  variables_first=True,
569
598
  _variables_only=False,
570
599
  ):
@@ -575,8 +604,9 @@ def parse_all_files(
575
604
  :param encoding: Encoding used to read files
576
605
  :param keep_data: (default false) Return parsed data of all files in a dictionary
577
606
  :param save: (default false) Save every parsed data in output directory
578
- :param comments: Include comments?
579
- :param dates: Include dates?
607
+ :param comments: (default false) Include comments?
608
+ :param dates: (default false) Include dates?
609
+ :param as_object: (default false) Return data as an object-like instead of dict
580
610
  :param variables_first: Try to parse variables first
581
611
  :return: Dictionary (key: file, value: parsed data if keep_data=True)
582
612
  """
@@ -592,6 +622,7 @@ def parse_all_files(
592
622
  save=save,
593
623
  comments=comments,
594
624
  dates=dates,
625
+ as_object=as_object,
595
626
  variables_first=False,
596
627
  _variables_only=True,
597
628
  )
@@ -612,6 +643,7 @@ def parse_all_files(
612
643
  save=save,
613
644
  comments=comments,
614
645
  dates=dates,
646
+ as_object=as_object,
615
647
  is_global=_variables_only,
616
648
  )
617
649
  if isinstance(data, str):
@@ -683,6 +715,48 @@ def walk(obj, *from_keys):
683
715
  yield obj, from_keys
684
716
 
685
717
 
718
+ def objectify(data, name="Object", default=None):
719
+ """
720
+ Convert a dictionary to an object-like dictionary
721
+ :param data: dictionary
722
+ :param name: name of the object
723
+ :param default: default value for unknown attributes
724
+ :return: object-like dictionary
725
+ """
726
+
727
+ def _getattr(s, k):
728
+ try:
729
+ object.__getattribute__(s, k)
730
+ except AttributeError:
731
+ return s.get(k, default)
732
+
733
+ if isinstance(data, list):
734
+ return [
735
+ objectify(item)
736
+ for item in data
737
+ if not (isinstance(item, str) and item.startswith("@") and item.endswith("@"))
738
+ ]
739
+ elif isinstance(data, dict):
740
+ name = (data.get("@type") or name).title()
741
+ slots = set()
742
+ attrs = dict(__getattr__=lambda s, k: _getattr(s, k), __slots__=slots)
743
+ subdata = {}
744
+ for key, value in data.items():
745
+ if key.startswith("#"):
746
+ continue
747
+ skey = key.replace("@", "_").replace(":", "_")
748
+ if match := regex_date.match(skey):
749
+ skey = "_".join(match.groups())
750
+ skey = f"_{skey}" if key != skey or key[0].isdigit() else skey
751
+ slots.add(skey)
752
+ if isinstance(value, (list, dict)):
753
+ subdata[key] = subdata[skey] = objectify(value)
754
+ continue
755
+ subdata[key] = subdata[skey] = value
756
+ return type(name, (dict,), attrs)(subdata)
757
+ return data
758
+
759
+
686
760
  # Tags which are always a list when reverting
687
761
  list_keys_rules = [
688
762
  # Colors
@@ -729,6 +803,8 @@ def revert(obj, from_key=None, prev_key=None, depth=-1, sep="\t", sort=False):
729
803
  elif depth > 0:
730
804
  lines.append(f"{tabs}{{")
731
805
  for key, value in sorted(obj.items()) if sort else obj.items():
806
+ if key.startswith("_"):
807
+ continue
732
808
  lines.extend(revert(value, from_key=key, prev_key=from_key, depth=depth + 1, sep=sep, sort=sort))
733
809
  if from_key or depth > 0:
734
810
  lines.append(f"{tabs}}}")
@@ -736,11 +812,13 @@ def revert(obj, from_key=None, prev_key=None, depth=-1, sep="\t", sort=False):
736
812
  if from_key and (from_key.lower() == "this" or not any(regex.match(from_key) for regex in list_keys_rules)):
737
813
  for value in sorted(obj) if sort else obj:
738
814
  lines.extend(revert(value, from_key=from_key, prev_key=prev_key, depth=depth, sep=sep, sort=sort))
739
- elif not any(isinstance(o, (dict, list)) for o in obj):
815
+ elif not any(isinstance(o, (dict, list)) for o in obj) and not any(
816
+ o.startswith("#") and o.endswith("#") for o in obj if isinstance(o, str)
817
+ ):
740
818
  prefix = f"{tabs}{from_key} = {{"
741
819
  # Only for color modes
742
820
  if from_key == "color" and len(obj) == 4 and isinstance(obj[0], str):
743
- prefix = f"{tabs}{from_key} {obj[0]} = {{"
821
+ prefix = f"{tabs}{from_key} = {obj[0]} {{"
744
822
  obj = obj[1:]
745
823
  func = functools.partial(revert_value, from_key=from_key, prev_key=prev_key, sep=sep, sort=sort)
746
824
  values = " ".join(map(str, map(func, obj)))
@@ -783,7 +861,7 @@ def revert_value(value, from_key=None, prev_key=None, **kwargs):
783
861
  return "yes" if value else "no"
784
862
  elif isinstance(value, str):
785
863
  if value.startswith("#") and value.endswith("#"):
786
- return f"#{value}"
864
+ return f"#{value.strip("#")}"
787
865
  elif (
788
866
  " " in value
789
867
  or (value.startswith("$") and value.endswith("$"))
@@ -874,6 +952,22 @@ def save_variables(filepath="_variables.json"):
874
952
  json_dump(global_variables, file, sort_keys=True)
875
953
 
876
954
 
955
+ def enable_logging(debug=False, file=False):
956
+ logger.setLevel(logging.DEBUG)
957
+ formatter = logging.Formatter("[%(asctime)s] %(levelname)s: %(message)s")
958
+ console_handler = logging.StreamHandler()
959
+ console_handler.setLevel(logging.INFO)
960
+ console_handler.setFormatter(formatter)
961
+ logger.addHandler(console_handler)
962
+ if debug:
963
+ console_handler.setLevel(logging.DEBUG)
964
+ if file:
965
+ file_handler = logging.FileHandler("ckparser.log")
966
+ file_handler.setLevel(logging.INFO)
967
+ file_handler.setFormatter(formatter)
968
+ logger.addHandler(file_handler)
969
+
970
+
877
971
  def main():
878
972
  """
879
973
  Command-line main entrypoint
@@ -890,18 +984,7 @@ def main():
890
984
  parser.add_argument("--debug", action="store_true", help="debug mode?")
891
985
  args = parser.parse_args()
892
986
 
893
- logger.setLevel(logging.DEBUG)
894
- formatter = logging.Formatter("[%(asctime)s] %(levelname)s: %(message)s")
895
- console_handler = logging.StreamHandler()
896
- console_handler.setLevel(logging.INFO)
897
- console_handler.setFormatter(formatter)
898
- logger.addHandler(console_handler)
899
- if args.debug:
900
- console_handler.setLevel(logging.DEBUG)
901
- file_handler = logging.FileHandler("ckparser.log")
902
- file_handler.setLevel(logging.INFO)
903
- file_handler.setFormatter(formatter)
904
- logger.addHandler(file_handler)
987
+ enable_logging(debug=args.debug, file=True)
905
988
 
906
989
  if args.revert:
907
990
  if os.path.isdir(args.path):
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ckparser"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  description = "Parse Paradox Jomini data files to Python/JSON and revert them back experimentally."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes