diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 32b1adf..2007e5c 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index 5b25e70..3f6bafe 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ Python 3+ compatible port of the [configobj](https://pypi.python.org/pypi/configobj/) library. The Github CI/CD Pipeline runs tests on python versions: -- 3.7 - 3.8 - 3.9 - 3.10 - 3.11 - 3.12 +- 3.13 ## Documentation @@ -23,6 +23,24 @@ You can find a full manual on how to use ConfigObj at [readthedocs](http://confi This is a mature project that is not actively maintained at this time. +## Branches + +The default branch of this repository is the [`release` branch](https://github.com/DiffSK/configobj/tree/release), +rather than the [`master` branch](https://github.com/DiffSK/configobj/tree/master). +This decision dates back to when this project moved from being actively +maintained to it's current state as a mature project. +At that time (in 2023), [changes introduced](https://github.com/DiffSK/configobj/compare/v5.0.6...master) +into the `master` branch had not been fully integrated, and the tooling had +shifted in ways that made [continuing from `master` unclear](https://github.com/DiffSK/configobj/pull/237#issuecomment-1925401612). + +Instead, [we chose to continue development](https://github.com/DiffSK/configobj/issues/213#issuecomment-1377686121) +from the `5.0.6` release branch. Any changes made to master after that release +were effectively abandoned, and the `release` branch has served as the main +line of development since. + +As a result, going forward, new pull requests should be made against the +`release` branch as the `master` branch is frozen in time. + ## Past Contributors: - [Michael Foord](https://agileabstractions.com/) diff --git a/docs/configobj.rst b/docs/configobj.rst index ef6fe28..9fd6bde 100644 --- a/docs/configobj.rst +++ b/docs/configobj.rst @@ -281,9 +281,10 @@ ConfigObj takes the following arguments (with the default values shown) : * Nothing. In which case the ``filename`` attribute of your ConfigObj will be ``None``. You can set a filename at any time. - * A filename. What happens if the file doesn't already exist is determined by - the options ``file_error`` and ``create_empty``. The filename will be - preserved as the ``filename`` attribute. This can be changed at any time. + * A filename or pathlib.Path object. What happens if the file doesn't already + exist is determined by the options ``file_error`` and ``create_empty``. The + filename will be preserved as the ``filename`` attribute. This can be + changed at any time. * A list of lines. Any trailing newlines will be removed from the lines. The ``filename`` attribute of your ConfigObj will be ``None``. @@ -417,8 +418,7 @@ ConfigObj takes the following arguments (with the default values shown) : ``default_encoding``, if specified, is the encoding used to decode byte strings in the **ConfigObj** before writing. If this is ``None``, then - the Python default encoding (``sys.defaultencoding`` - usually ASCII) is - used. + a default encoding (currently ASCII) is used. For most Western European users, a value of ``latin-1`` is sensible. @@ -869,7 +869,8 @@ members) will first be decoded to Unicode using the encoding specified by the ``default_encoding`` attribute. This ensures that the output is in the encoding specified. -If this value is ``None`` then ``sys.defaultencoding`` is used instead. +If this value is ``None`` then a default encoding (currently ASCII) is used to decode +any byte-strings in your ConfigObj instance. unrepr diff --git a/setup.py b/setup.py index dbb87e9..33a7546 100644 --- a/setup.py +++ b/setup.py @@ -81,12 +81,12 @@ 'License :: OSI Approved :: BSD License', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Operating System :: OS Independent', 'Topic :: Software Development :: Libraries', 'Topic :: Software Development :: Libraries :: Python Modules', @@ -109,7 +109,7 @@ py_modules=MODULES, package_dir={'': 'src'}, packages=PACKAGES, - python_requires='>=3.7', + python_requires='>=3.8', classifiers=CLASSIFIERS, keywords=KEYWORDS, license='BSD-3-Clause', diff --git a/src/configobj/__init__.py b/src/configobj/__init__.py index 3f91127..0ae986a 100644 --- a/src/configobj/__init__.py +++ b/src/configobj/__init__.py @@ -18,6 +18,7 @@ import sys from codecs import BOM_UTF8, BOM_UTF16, BOM_UTF16_BE, BOM_UTF16_LE +from pathlib import Path from ._version import __version__ @@ -1241,7 +1242,20 @@ def _load(self, infile, configspec): with open(infile, 'w') as h: h.write('') content = [] - + + elif isinstance(infile, Path): + self.filename = str(infile) + if infile.is_file(): + with infile.open('rb') as h: + content = h.readlines() or [] + elif self.file_error: + raise IOError('Config file not found: "%s".' % self.filename) + else: + if self.create_empty: + with infile.open('w') as h: + h.write('') + content = [] + elif isinstance(infile, (list, tuple)): content = list(infile) @@ -1275,7 +1289,7 @@ def set_section(in_section, this_section): # needs splitting into lines - but needs doing *after* decoding # in case it's not an 8 bit encoding else: - raise TypeError('infile must be a filename, file like object, or list of lines.') + raise TypeError('infile must be a filename, file like object, pathlib.Path or list of lines.') if content: # don't do it for the empty ConfigObj @@ -2000,6 +2014,8 @@ def _handle_comment(self, comment): start = self.indent_type if not comment.startswith('#'): start += self._a_to_u(' # ') + else: + start += self._a_to_u(' ') return (start + comment) diff --git a/src/tests/test_configobj.py b/src/tests/test_configobj.py index 017428a..37ddbf0 100644 --- a/src/tests/test_configobj.py +++ b/src/tests/test_configobj.py @@ -4,6 +4,7 @@ import re from codecs import BOM_UTF8 +from pathlib import Path from warnings import catch_warnings from tempfile import NamedTemporaryFile @@ -542,13 +543,13 @@ def test_validate(self, val): 'key1 = Hello', '', '# section comment', - '[section]# inline comment', + '[section] # inline comment', '# key1 comment', 'key1 = 6', '# key2 comment', 'key2 = True', '# subsection comment', - '[[sub-section]]# inline comment', + '[[sub-section]] # inline comment', '# another key1 comment', 'key1 = 3.0' ] @@ -560,9 +561,9 @@ def test_writing_empty_values(self): 'key2 =# a comment', ] cfg = ConfigObj(config_with_empty_values) - assert cfg.write() == ['', 'key1 = ""', 'key2 = ""# a comment'] + assert cfg.write() == ['', 'key1 = ""', 'key2 = "" # a comment'] cfg.write_empty_values = True - assert cfg.write() == ['', 'key1 = ', 'key2 = # a comment'] + assert cfg.write() == ['', 'key1 = ', 'key2 = # a comment'] class TestUnrepr(object): @@ -1104,6 +1105,24 @@ def test_creating_with_a_dictionary(): assert dictionary_cfg_content == cfg.dict() assert dictionary_cfg_content is not cfg.dict() +def test_reading_a_pathlib_path(cfg_contents): + cfg = cfg_contents(""" +[section] +foo = bar""") + c = ConfigObj(Path(cfg)) + assert 'foo' in c['section'] + +def test_creating_a_file_from_pathlib_path(tmp_path): + infile = tmp_path / 'config.ini' + assert not Path(tmp_path / 'config.ini').is_file() + c = ConfigObj(Path(infile), create_empty=True) + assert Path(tmp_path / 'config.ini').is_file() + +def test_creating_a_file_from_string(tmp_path): + infile = str(tmp_path / 'config.ini') + assert not Path(infile).is_file() + c = ConfigObj(infile, create_empty=True) + assert Path(infile).is_file() class TestComments(object): @pytest.fixture @@ -1164,6 +1183,12 @@ def test_inline_comments(self): c.inline_comments['foo'] = 'Nice bar' assert c.write() == ['foo = bar # Nice bar'] + def test_inline_comments_with_leading_number_sign(self): + c = ConfigObj() + c['foo'] = 'bar' + c.inline_comments['foo'] = '# Nice bar' + assert c.write() == ['foo = bar # Nice bar'] + def test_unrepr_comments(self, comment_filled_cfg): c = ConfigObj(comment_filled_cfg, unrepr=True) assert c == { 'key': 'value', 'section': { 'key': 'value'}}