quick-ini 4.0__tar.gz → 4.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: quick_ini
3
- Version: 4.0
3
+ Version: 4.3
4
4
  Summary: A library for reading from and writing to .ini formatted files
5
5
  Author-email: OllieZ-Mods <socksinthewash@gmail.com>
6
6
  License-Expression: MIT
@@ -38,7 +38,7 @@ pip install quick_ini
38
38
  ```python
39
39
  from quick_ini import QuickIni
40
40
 
41
- # Load from file
41
+ # Load from file, before any get_value() or get_all() (those raise RuntimeError otherwise)
42
42
  QuickIni.load_file("config.ini")
43
43
 
44
44
  # Load from URL
@@ -82,7 +82,19 @@ if report and report["added"]:
82
82
  print("New settings added to config.ini:", ", ".join(report["added"]))
83
83
  ```
84
84
 
85
- The report also has `created`, `changed`, `orphaned` (settings kept that aren't in the template) and `backup`. Use `QuickIni.sync_template("config.ini")` to sync without loading.
85
+ The report also has `created`, `changed`, `orphaned` (settings kept that aren't in the template), `backup` and `text` (the synced file). Use `QuickIni.sync_template("config.ini")` to sync without loading, and `dry_run=True` to work out the changes without writing anything.
86
+
87
+ ### From the command line
88
+
89
+ Installing QuickINI adds a `quick-ini` command (or run `python -m quick_ini`), so you can sync without starting your program:
90
+
91
+ ```bash
92
+ quick-ini sync # config.ini with config.ini.template
93
+ quick-ini sync --dry-run # show what would change as a diff, write nothing (--diff works too)
94
+ quick-ini sync --config app.ini --template defaults.ini
95
+ ```
96
+
97
+ `--config` defaults to `config.ini` and `--template` to `<config>.template`. Unlike `template=True`, a missing template is an error here. It prints the settings added and kept, and exits with 1 on any error.
86
98
 
87
99
  ## File Format
88
100
 
@@ -13,7 +13,7 @@ pip install quick_ini
13
13
  ```python
14
14
  from quick_ini import QuickIni
15
15
 
16
- # Load from file
16
+ # Load from file, before any get_value() or get_all() (those raise RuntimeError otherwise)
17
17
  QuickIni.load_file("config.ini")
18
18
 
19
19
  # Load from URL
@@ -57,7 +57,19 @@ if report and report["added"]:
57
57
  print("New settings added to config.ini:", ", ".join(report["added"]))
58
58
  ```
59
59
 
60
- The report also has `created`, `changed`, `orphaned` (settings kept that aren't in the template) and `backup`. Use `QuickIni.sync_template("config.ini")` to sync without loading.
60
+ The report also has `created`, `changed`, `orphaned` (settings kept that aren't in the template), `backup` and `text` (the synced file). Use `QuickIni.sync_template("config.ini")` to sync without loading, and `dry_run=True` to work out the changes without writing anything.
61
+
62
+ ### From the command line
63
+
64
+ Installing QuickINI adds a `quick-ini` command (or run `python -m quick_ini`), so you can sync without starting your program:
65
+
66
+ ```bash
67
+ quick-ini sync # config.ini with config.ini.template
68
+ quick-ini sync --dry-run # show what would change as a diff, write nothing (--diff works too)
69
+ quick-ini sync --config app.ini --template defaults.ini
70
+ ```
71
+
72
+ `--config` defaults to `config.ini` and `--template` to `<config>.template`. Unlike `template=True`, a missing template is an error here. It prints the settings added and kept, and exits with 1 on any error.
61
73
 
62
74
  ## File Format
63
75
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "quick_ini"
7
- version = "4.0"
7
+ version = "4.3"
8
8
  description = "A library for reading from and writing to .ini formatted files"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -25,6 +25,9 @@ classifiers = [
25
25
  "Typing :: Typed",
26
26
  ]
27
27
 
28
+ [project.scripts]
29
+ quick-ini = "quick_ini.__main__:main"
30
+
28
31
  [project.urls]
29
32
  Homepage = "https://github.com/olliez-mods/Quick-Ini"
30
33
  "Bug Reports" = "https://github.com/olliez-mods/Quick-Ini/issues"
@@ -0,0 +1,67 @@
1
+ """
2
+ Command line: quick-ini sync [--config config.ini] [--template <config>.template] [--dry-run | --diff]
3
+ \nAlso runs as "python -m quick_ini". Brings a config file up to date with its template, see QuickIni.sync_template().
4
+ """
5
+ from .quick_ini import QuickIni
6
+ import argparse
7
+ import difflib
8
+ import sys
9
+
10
+ def sync(config:str, template:str | None, dry_run:bool) -> int:
11
+ # Unlike load_file(template=True), a missing template is an error here: on the command line it's a typo
12
+ template = template or f"{config}.template"
13
+ try:
14
+ with open(config, 'r', encoding='utf-8') as file:
15
+ old_text = file.read()
16
+ except FileNotFoundError:
17
+ old_text = ""
18
+ except IOError as e:
19
+ print(f"Error reading file: '{e}'", file=sys.stderr)
20
+ return 1
21
+
22
+ if(not QuickIni.sync_template(config, template, dry_run=dry_run)):
23
+ print(QuickIni.get_last_error(), file=sys.stderr)
24
+ return 1
25
+ report = QuickIni.get_sync_report()
26
+
27
+ if(dry_run and report["changed"]):
28
+ diff = difflib.unified_diff(old_text.splitlines(keepends=True), report["text"].splitlines(keepends=True),
29
+ config, f"{config} (synced)")
30
+ sys.stdout.writelines(diff)
31
+ print()
32
+
33
+ if(report["added"]):
34
+ print("Added from template:", ", ".join(report["added"]))
35
+ if(report["orphaned"]):
36
+ print("Kept at the end (not in template):", ", ".join(report["orphaned"]))
37
+
38
+ if(not report["changed"]):
39
+ print("Already in sync")
40
+ elif(dry_run):
41
+ print(f"Would create {config} from {template}" if report["created"] else "Dry run: nothing written")
42
+ elif(report["created"]):
43
+ print(f"Created {config} from {template}")
44
+ else:
45
+ print(f"Synced {config} (previous version: {report['backup']})")
46
+ return 0
47
+
48
+ def main(argv:list[str] | None = None) -> int:
49
+ parser = argparse.ArgumentParser(prog="quick-ini", description="Tools for .ini config files.")
50
+ commands = parser.add_subparsers(dest="command")
51
+ sync_parser = commands.add_parser("sync", help="bring a config file up to date with its template",
52
+ description="Bring a config file up to date with its template. Your values are "
53
+ "kept, new keys get the template's value, keys the template doesn't have are kept "
54
+ "at the end of their section. The previous version is saved as <config>.bak.")
55
+ sync_parser.add_argument("--config", default="config.ini", help="the config file (default: config.ini)")
56
+ sync_parser.add_argument("--template", help="the template (default: <config>.template)")
57
+ sync_parser.add_argument("--dry-run", "--diff", dest="dry_run", action="store_true",
58
+ help="show the changes as a diff, write nothing")
59
+
60
+ args = parser.parse_args(argv)
61
+ if(args.command == "sync"):
62
+ return sync(args.config, args.template, args.dry_run)
63
+ parser.print_help()
64
+ return 1
65
+
66
+ if __name__ == "__main__":
67
+ sys.exit(main())
@@ -17,6 +17,7 @@ class QuickIni:
17
17
  error_message: str = ""
18
18
  web_file: str | None = None
19
19
  sync_report: dict[str, Any] | None = None
20
+ load_called: bool = False
20
21
 
21
22
  @staticmethod
22
23
  def grab_from_url(url:str, timeout:float = 10) -> bool:
@@ -159,7 +160,7 @@ class QuickIni:
159
160
  raise
160
161
 
161
162
  @staticmethod
162
- def sync_template(file_location:str, template:bool | str = True) -> bool:
163
+ def sync_template(file_location:str, template:bool | str = True, dry_run:bool = False) -> bool:
163
164
  """
164
165
  Brings a config file up to date with its template, keeping the values already set in the config.
165
166
  \nThe template decides which keys exist, their order, sections and comments. Keys the config doesn't have yet get
@@ -170,6 +171,7 @@ class QuickIni:
170
171
  file_location (str): The path of the config file.
171
172
  template (bool | str): True to use "<config>.template", skipped quietly if it doesn't exist.
172
173
  Or the path of the template file, which must exist.
174
+ dry_run (bool): Work out the synced file without writing it (or a backup). The report has it as "text".
173
175
  Returns:
174
176
  bool: True if successfull, False otherwise
175
177
  Example:
@@ -265,7 +267,7 @@ class QuickIni:
265
267
  changed = new_text != old_text
266
268
  backup = None
267
269
 
268
- if(changed):
270
+ if(changed and not dry_run):
269
271
  try:
270
272
  if(old_text is not None):
271
273
  backup = f"{path}.bak"
@@ -281,6 +283,7 @@ class QuickIni:
281
283
  "added": added if old_text is not None else [],
282
284
  "orphaned": orphaned,
283
285
  "backup": backup,
286
+ "text": new_text,
284
287
  }
285
288
  return True
286
289
 
@@ -291,12 +294,12 @@ class QuickIni:
291
294
  Return:
292
295
  dict[str, Any]: "created" (bool) the config was made from the template, "changed" (bool) the config was rewritten,
293
296
  "added" (list) keys taken from the template, "orphaned" (list) keys kept that aren't in the template,
294
- "backup" (str | None) the path of the backup of the previous config
297
+ "backup" (str | None) the path of the backup of the previous config, "text" (str) the synced file
295
298
  Example:
296
299
  >>> load_file("config.ini", template=True)
297
300
  True
298
301
  >>> get_sync_report()
299
- {'created': False, 'changed': True, 'added': ['database.timeout'], 'orphaned': [], 'backup': '/app/config.ini.bak'}
302
+ {'created': False, 'changed': True, 'added': ['database.timeout'], 'orphaned': [], 'backup': '/app/config.ini.bak', 'text': '...'}
300
303
  """
301
304
  if(QuickIni.sync_report is None):
302
305
  return None
@@ -323,6 +326,8 @@ class QuickIni:
323
326
  """
324
327
  QuickIni.parsed_ini = {}
325
328
  QuickIni.auto_type_convert_g = auto_type_convert
329
+ # Set even if loading fails, the caller gets False and reading falls back to defaults
330
+ QuickIni.load_called = True
326
331
 
327
332
  if(not QuickIni.sync_template(file_location, template)):
328
333
  # sync_template sets the error message
@@ -411,6 +416,15 @@ class QuickIni:
411
416
  QuickIni.loaded_file_path=file_location
412
417
  return True
413
418
 
419
+ @staticmethod
420
+ def check_loaded(call:str) -> None:
421
+ """
422
+ Raises a RuntimeError if load_file() was never called, so settings aren't silently read as defaults.
423
+ """
424
+ if(not QuickIni.load_called):
425
+ QuickIni.error_message = f"QuickIni.{call} was called before QuickIni.load_file()"
426
+ raise(RuntimeError(QuickIni.error_message))
427
+
414
428
  @staticmethod
415
429
  def get_value(name:str, default:Any = None, expected_type:type | None = None, section:str | None = None) -> Any:
416
430
  """
@@ -423,6 +437,8 @@ class QuickIni:
423
437
  section (str): The [section] the setting is in, None for keys above any section
424
438
  Return:
425
439
  Any: The value associated with the name
440
+ Raises:
441
+ RuntimeError: If load_file() was never called
426
442
  Example:
427
443
  >>> get_value("do_debug_logs", False)
428
444
  True
@@ -431,6 +447,7 @@ class QuickIni:
431
447
  """
432
448
  if(section):
433
449
  name = f"{section}.{name}"
450
+ QuickIni.check_loaded(f"get_value({name!r})")
434
451
  value:Any = QuickIni.parsed_ini.get(name, default)
435
452
  if(expected_type != None):
436
453
  if(not isinstance(value, expected_type)):
@@ -446,10 +463,13 @@ class QuickIni:
446
463
  section (str): Only return the settings in this [section], with the section prefix removed
447
464
  Return:
448
465
  dict[str, Any]: The settings, keys in a section are given as "section.key" when no section is passed
466
+ Raises:
467
+ RuntimeError: If load_file() was never called
449
468
  Example:
450
469
  >>> get_all("database")
451
470
  {'host': 'localhost', 'port': 5432}
452
471
  """
472
+ QuickIni.check_loaded(f"get_all({section!r})" if section else "get_all()")
453
473
  if(not section):
454
474
  return dict(QuickIni.parsed_ini)
455
475
  prefix = f"{section}."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quick_ini
3
- Version: 4.0
3
+ Version: 4.3
4
4
  Summary: A library for reading from and writing to .ini formatted files
5
5
  Author-email: OllieZ-Mods <socksinthewash@gmail.com>
6
6
  License-Expression: MIT
@@ -38,7 +38,7 @@ pip install quick_ini
38
38
  ```python
39
39
  from quick_ini import QuickIni
40
40
 
41
- # Load from file
41
+ # Load from file, before any get_value() or get_all() (those raise RuntimeError otherwise)
42
42
  QuickIni.load_file("config.ini")
43
43
 
44
44
  # Load from URL
@@ -82,7 +82,19 @@ if report and report["added"]:
82
82
  print("New settings added to config.ini:", ", ".join(report["added"]))
83
83
  ```
84
84
 
85
- The report also has `created`, `changed`, `orphaned` (settings kept that aren't in the template) and `backup`. Use `QuickIni.sync_template("config.ini")` to sync without loading.
85
+ The report also has `created`, `changed`, `orphaned` (settings kept that aren't in the template), `backup` and `text` (the synced file). Use `QuickIni.sync_template("config.ini")` to sync without loading, and `dry_run=True` to work out the changes without writing anything.
86
+
87
+ ### From the command line
88
+
89
+ Installing QuickINI adds a `quick-ini` command (or run `python -m quick_ini`), so you can sync without starting your program:
90
+
91
+ ```bash
92
+ quick-ini sync # config.ini with config.ini.template
93
+ quick-ini sync --dry-run # show what would change as a diff, write nothing (--diff works too)
94
+ quick-ini sync --config app.ini --template defaults.ini
95
+ ```
96
+
97
+ `--config` defaults to `config.ini` and `--template` to `<config>.template`. Unlike `template=True`, a missing template is an error here. It prints the settings added and kept, and exits with 1 on any error.
86
98
 
87
99
  ## File Format
88
100
 
@@ -2,10 +2,12 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  quick_ini/__init__.py
5
+ quick_ini/__main__.py
5
6
  quick_ini/py.typed
6
7
  quick_ini/quick_ini.py
7
8
  quick_ini.egg-info/PKG-INFO
8
9
  quick_ini.egg-info/SOURCES.txt
9
10
  quick_ini.egg-info/dependency_links.txt
11
+ quick_ini.egg-info/entry_points.txt
10
12
  quick_ini.egg-info/top_level.txt
11
13
  tests/test_quickini.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ quick-ini = quick_ini.__main__:main
@@ -8,6 +8,9 @@ import tempfile
8
8
  import os
9
9
  from unittest.mock import patch, mock_open
10
10
  from quick_ini import QuickIni
11
+ from quick_ini import __main__ as cli
12
+ from contextlib import redirect_stdout, redirect_stderr
13
+ import io
11
14
 
12
15
 
13
16
  class TestQuickIni(unittest.TestCase):
@@ -16,6 +19,7 @@ class TestQuickIni(unittest.TestCase):
16
19
  """Set up test fixtures before each test method."""
17
20
  # Clear any previous state
18
21
  QuickIni.parsed_ini = {}
22
+ QuickIni.load_called = False
19
23
  QuickIni.loaded_file_path = None
20
24
  QuickIni.auto_type_convert_g = True
21
25
  QuickIni.error_message = ""
@@ -473,6 +477,7 @@ class TestQuickIniFormats(unittest.TestCase):
473
477
 
474
478
  def setUp(self):
475
479
  QuickIni.parsed_ini = {}
480
+ QuickIni.load_called = False
476
481
  QuickIni.loaded_file_path = None
477
482
  QuickIni.auto_type_convert_g = True
478
483
  QuickIni.error_message = ""
@@ -496,6 +501,30 @@ class TestQuickIniFormats(unittest.TestCase):
496
501
  with open(path, encoding='utf-8') as f:
497
502
  return f.read()
498
503
 
504
+ # ===== LOADING FIRST =====
505
+
506
+ def test_get_value_before_load_raises(self):
507
+ with self.assertRaises(RuntimeError):
508
+ QuickIni.get_value("port", 8080)
509
+ self.assertEqual(QuickIni.get_last_error(), "QuickIni.get_value('port') was called before QuickIni.load_file()")
510
+
511
+ def test_get_value_section_before_load_names_key(self):
512
+ with self.assertRaises(RuntimeError):
513
+ QuickIni.get_value("port", section="database")
514
+ self.assertIn("'database.port'", QuickIni.get_last_error())
515
+
516
+ def test_get_all_before_load_raises(self):
517
+ with self.assertRaises(RuntimeError):
518
+ QuickIni.get_all()
519
+ with self.assertRaises(RuntimeError):
520
+ QuickIni.get_all("database")
521
+ self.assertIn("get_all('database')", QuickIni.get_last_error())
522
+
523
+ def test_failed_load_falls_back_to_defaults(self):
524
+ self.assertFalse(QuickIni.load_file("/no/such/config.ini"))
525
+ self.assertEqual(QuickIni.get_value("port", 8080), 8080)
526
+ self.assertEqual(QuickIni.get_all(), {})
527
+
499
528
  # ===== PARSING =====
500
529
 
501
530
  def test_spaces_around_equals(self):
@@ -667,6 +696,7 @@ port = 5432
667
696
 
668
697
  def setUp(self):
669
698
  QuickIni.parsed_ini = {}
699
+ QuickIni.load_called = False
670
700
  QuickIni.loaded_file_path = None
671
701
  QuickIni.auto_type_convert_g = True
672
702
  QuickIni.error_message = ""
@@ -826,5 +856,102 @@ port = 6000
826
856
  self.assertFalse(any(name.endswith(".tmp") for name in os.listdir(self.dir.name)))
827
857
 
828
858
 
859
+
860
+ class TestQuickIniCommandLine(unittest.TestCase):
861
+ """Tests for "quick-ini sync" (quick_ini/__main__.py), run from inside a temp folder for the default paths."""
862
+
863
+ # The template tests' setup and helpers, without running those tests again
864
+ TEMPLATE = TestQuickIniTemplate.TEMPLATE
865
+ write = TestQuickIniTemplate.write
866
+ read = TestQuickIniTemplate.read
867
+
868
+ def setUp(self):
869
+ TestQuickIniTemplate.setUp(self)
870
+ self.cwd = os.getcwd()
871
+ os.chdir(self.dir.name)
872
+
873
+ def tearDown(self):
874
+ os.chdir(self.cwd)
875
+ TestQuickIniTemplate.tearDown(self)
876
+
877
+ def run_cli(self, *args):
878
+ out, err = io.StringIO(), io.StringIO()
879
+ with redirect_stdout(out), redirect_stderr(err):
880
+ code = cli.main(list(args))
881
+ return code, out.getvalue(), err.getvalue()
882
+
883
+ def test_default_config_and_template(self):
884
+ self.write("config.ini.template", self.TEMPLATE)
885
+ self.write("config.ini", "debug = true\nold = 1\n")
886
+ code, out, _ = self.run_cli("sync")
887
+ self.assertEqual(code, 0)
888
+ self.assertIn("debug = true", self.read(self.config))
889
+ self.assertIn("name = MyApp", self.read(self.config))
890
+ self.assertIn("Added from template: name, parts, database.host, database.port", out)
891
+ self.assertIn("Kept at the end (not in template): old", out)
892
+ self.assertIn("previous version:", out)
893
+ self.assertEqual(self.read(self.config + ".bak"), "debug = true\nold = 1\n")
894
+
895
+ def test_config_and_template_given(self):
896
+ self.write("defaults.ini", self.TEMPLATE)
897
+ self.write("app.ini", "debug = true\n")
898
+ code, _, _ = self.run_cli("sync", "--config", "app.ini", "--template", "defaults.ini")
899
+ self.assertEqual(code, 0)
900
+ self.assertIn("name = MyApp", self.read(os.path.join(self.dir.name, "app.ini")))
901
+
902
+ def test_template_defaults_to_given_config(self):
903
+ self.write("app.ini.template", self.TEMPLATE)
904
+ self.write("app.ini", "debug = true\n")
905
+ code, _, _ = self.run_cli("sync", "--config", "app.ini")
906
+ self.assertEqual(code, 0)
907
+ self.assertIn("name = MyApp", self.read(os.path.join(self.dir.name, "app.ini")))
908
+
909
+ def test_dry_run_shows_diff_and_writes_nothing(self):
910
+ for flag in ("--dry-run", "--diff"):
911
+ self.write("config.ini.template", self.TEMPLATE)
912
+ self.write("config.ini", "debug = true\n")
913
+ code, out, _ = self.run_cli("sync", flag)
914
+ self.assertEqual(code, 0)
915
+ self.assertEqual(self.read(self.config), "debug = true\n")
916
+ self.assertFalse(os.path.exists(self.config + ".bak"))
917
+ self.assertIn("+++ config.ini (synced)", out)
918
+ self.assertIn("+name = MyApp", out)
919
+ self.assertIn("Dry run: nothing written", out)
920
+
921
+ def test_dry_run_does_not_create_config(self):
922
+ self.write("config.ini.template", self.TEMPLATE)
923
+ code, out, _ = self.run_cli("sync", "--dry-run")
924
+ self.assertEqual(code, 0)
925
+ self.assertFalse(os.path.exists(self.config))
926
+ self.assertIn("Would create config.ini from config.ini.template", out)
927
+
928
+ def test_missing_config_is_created(self):
929
+ self.write("config.ini.template", self.TEMPLATE)
930
+ code, out, _ = self.run_cli("sync")
931
+ self.assertEqual(code, 0)
932
+ self.assertEqual(self.read(self.config), self.TEMPLATE)
933
+ self.assertIn("Created config.ini from config.ini.template", out)
934
+
935
+ def test_already_in_sync(self):
936
+ self.write("config.ini.template", self.TEMPLATE)
937
+ self.write("config.ini", self.TEMPLATE)
938
+ code, out, _ = self.run_cli("sync", "--dry-run")
939
+ self.assertEqual(code, 0)
940
+ self.assertEqual(out.strip(), "Already in sync")
941
+
942
+ def test_missing_template_is_an_error(self):
943
+ self.write("config.ini", "debug = true\n")
944
+ code, out, err = self.run_cli("sync")
945
+ self.assertEqual(code, 1)
946
+ self.assertIn("Could not find template", err)
947
+ self.assertEqual(out, "")
948
+ self.assertEqual(self.read(self.config), "debug = true\n")
949
+
950
+ def test_no_command_prints_help(self):
951
+ code, out, _ = self.run_cli()
952
+ self.assertEqual(code, 1)
953
+ self.assertIn("sync", out)
954
+
955
+
829
956
  if __name__ == '__main__':
830
957
  unittest.main()
File without changes
File without changes
File without changes
File without changes