Skip to content

Commit d171e34

Browse files
authored
Merge pull request #2238 from gitpython-developers/read-implicit-bool
Preserve implicit boolean config keys
2 parents a9913c7 + 2229668 commit d171e34

2 files changed

Lines changed: 91 additions & 14 deletions

File tree

git/config.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
from git.repo.base import Repo
4646

4747
T_ConfigParser = TypeVar("T_ConfigParser", bound="GitConfigParser")
48-
T_OMD_value = TypeVar("T_OMD_value", str, bytes, int, float, bool)
48+
T_OMD_value = TypeVar("T_OMD_value", str, bytes, int, float, bool, None)
4949

5050
if sys.version_info[:3] < (3, 7, 2):
5151
# typing.Ordereddict not added until Python 3.7.2.
@@ -291,6 +291,12 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder):
291291
292292
:note:
293293
If used as a context manager, this will release the locked file.
294+
295+
:note:
296+
Options without a value are stored as ``None`` and written without ``=``.
297+
:meth:`get_value` and :meth:`get_values` return an empty string for them,
298+
while :meth:`getboolean` returns ``True``. An explicit empty value is
299+
stored as an empty string and reads as ``False`` with :meth:`getboolean`.
294300
"""
295301

296302
# { Configuration
@@ -348,7 +354,7 @@ def __init__(
348354
Reference to repository to use if ``[includeIf]`` sections are found in
349355
configuration files.
350356
"""
351-
cp.RawConfigParser.__init__(self, dict_type=_OMD)
357+
cp.RawConfigParser.__init__(self, dict_type=_OMD, allow_no_value=True)
352358
self._dict: Callable[..., _OMD]
353359
self._defaults: _OMD
354360
self._sections: _OMD
@@ -587,8 +593,12 @@ def parse_value(value: str) -> str:
587593
# Preserves multiple values for duplicate optnames.
588594
cursect.add(optname, optval)
589595
else:
590-
# Check if it's an option with no value - it's just ignored by git.
591-
if not self.OPTVALUEONLY.match(line):
596+
# A valueless option is an implicit boolean true, not an empty value.
597+
mo = self.OPTVALUEONLY.match(line)
598+
if mo:
599+
optname = self.optionxform(mo.group("option").rstrip())
600+
cursect.add(optname, None)
601+
else:
592602
if not e:
593603
e = cp.ParsingError(fpname)
594604
e.append(lineno, repr(line))
@@ -625,6 +635,7 @@ def _all_items(section: str) -> List[Tuple[str, str]]:
625635
for key, values in self._sections[section].items_all()
626636
if key != "__name__"
627637
for value in values
638+
if value is not None
628639
]
629640

630641
paths = []
@@ -760,13 +771,16 @@ def _write(self, fp: IO) -> None:
760771
def write_section(name: str, section_dict: _OMD) -> None:
761772
fp.write(("[%s]\n" % name).encode(defenc))
762773

763-
values: Sequence[str] # Runtime only gets str in tests, but should be whatever _OMD stores.
764-
v: str
774+
values: List[Any]
775+
v: Any
765776
for key, values in section_dict.items_all():
766777
if key == "__name__":
767778
continue
768779

769780
for v in values:
781+
if v is None:
782+
fp.write(("\t%s\n" % key).encode(defenc))
783+
continue
770784
value = self._value_to_string(v)
771785
if any(char in value for char in '\n\t\b\\"#;') or value[:1].isspace() or value[-1:].isspace():
772786
value = value.replace("\\", "\\\\").replace('"', '\\"')
@@ -783,11 +797,11 @@ def write_section(name: str, section_dict: _OMD) -> None:
783797
for name, value in self._sections.items():
784798
write_section(name, value)
785799

786-
def items(self, section_name: str) -> List[Tuple[str, str]]: # type: ignore[override]
800+
def items(self, section_name: str) -> List[Tuple[str, Union[str, None]]]: # type: ignore[override]
787801
""":return: list((option, value), ...) pairs of all items in the given section"""
788802
return [(k, v) for k, v in super().items(section_name) if k != "__name__"]
789803

790-
def items_all(self, section_name: str) -> List[Tuple[str, List[str]]]:
804+
def items_all(self, section_name: str) -> List[Tuple[str, List[Union[str, None]]]]:
791805
""":return: list((option, [values...]), ...) pairs of all items in the given section"""
792806
rv = _OMD(self._defaults)
793807

@@ -841,6 +855,8 @@ def write(self) -> None:
841855
for key, values in section.items_all():
842856
if key != "__name__":
843857
for raw_value in values:
858+
if raw_value is None:
859+
continue
844860
if "\r" in self._value_to_string(raw_value) or "\x00" in self._value_to_string(raw_value):
845861
raise ValueError("Git config values must not contain CR or NUL")
846862

@@ -877,7 +893,6 @@ def read_only(self) -> bool:
877893
""":return: ``True`` if this instance may change the configuration file"""
878894
return self._read_only
879895

880-
# FIXME: Figure out if default or return type can really include bool.
881896
def get_value(
882897
self,
883898
section: str,
@@ -894,7 +909,7 @@ def get_value(
894909
did not exist.
895910
896911
:return:
897-
A properly typed value, either int, float or string
912+
A properly typed value, either int, float, string or bool
898913
899914
:raise TypeError:
900915
In case the value could not be understood.
@@ -925,7 +940,7 @@ def get_values(
925940
in case the option did not exist.
926941
927942
:return:
928-
A list of properly typed values, either int, float or string
943+
A list of properly typed values, either int, float, string or bool
929944
930945
:raise TypeError:
931946
In case the value could not be understood.
@@ -941,7 +956,19 @@ def get_values(
941956

942957
return [self._string_to_value(valuestr) for valuestr in lst]
943958

944-
def _string_to_value(self, valuestr: str) -> Union[int, float, str, bool]:
959+
def _convert_to_boolean(self, value: Union[str, None]) -> bool:
960+
if value is None:
961+
return True
962+
if value == "":
963+
return False
964+
try:
965+
return self.BOOLEAN_STATES[value.lower()]
966+
except KeyError:
967+
raise ValueError("Not a boolean: %s" % value) from None
968+
969+
def _string_to_value(self, valuestr: Union[str, None]) -> Union[int, float, str, bool]:
970+
if valuestr is None:
971+
return ""
945972
types = (int, float)
946973
for numtype in types:
947974
try:

test/test_config.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -864,8 +864,58 @@ def test_empty_config_value(self):
864864

865865
assert cr.get_value("core", "filemode"), "Should read keys with values"
866866

867-
with self.assertRaises(cp.NoOptionError):
868-
cr.get_value("color", "ui")
867+
self.assertTrue(cr.has_option("color", "ui"))
868+
self.assertIsNone(cr.get("color", "ui"))
869+
self.assertEqual(cr.get_value("color", "ui"), "")
870+
self.assertIs(cr.getboolean("color", "ui"), True)
871+
872+
@with_rw_directory
873+
def test_implicit_boolean_round_trip(self, rw_dir):
874+
config_path = osp.join(rw_dir, "config")
875+
with open(config_path, "wb") as config_file:
876+
config_file.write(
877+
b"[include]\n"
878+
b"\toptional\n"
879+
b"[flag]\n"
880+
b"\timplicit\n"
881+
b"\ttrailing-space \n"
882+
b"\ttrailing-tab\t\n"
883+
b"\tempty =\n"
884+
b'\tquoted = ""\n'
885+
b"\tmultiple = false\n"
886+
b"\tmultiple\n"
887+
b"\tmultiple =\n"
888+
b"\tmultiple"
889+
)
890+
git_config = ["git", "config", "--file", config_path]
891+
original = subprocess.check_output(git_config + ["--null", "--list"])
892+
893+
with GitConfigParser(config_path, read_only=False) as config:
894+
for option in ("implicit", "trailing-space", "trailing-tab"):
895+
self.assertIsNone(config.get("flag", option))
896+
self.assertEqual(config.get_value("flag", option), "")
897+
self.assertIs(config.getboolean("flag", option), True)
898+
for option in ("empty", "quoted"):
899+
self.assertEqual(config.get("flag", option), "")
900+
self.assertEqual(config.get_value("flag", option), "")
901+
self.assertIs(config.getboolean("flag", option), False)
902+
self.assertEqual(config.get_values("flag", "multiple"), [False, "", "", ""])
903+
self.assertIsNone(dict(config.items("flag"))["multiple"])
904+
self.assertEqual(dict(config.items_all("flag"))["multiple"], ["false", None, "", None])
905+
config.set_value("other", "value", "updated")
906+
907+
self.assertEqual(
908+
subprocess.check_output(git_config + ["--null", "--list"]),
909+
original + b"other.value\nupdated\0",
910+
)
911+
self.assertEqual(
912+
subprocess.check_output(git_config + ["--type=bool", "--get-all", "flag.multiple"]),
913+
b"false\ntrue\nfalse\ntrue\n",
914+
)
915+
with GitConfigParser(config_path) as config:
916+
self.assertIs(config.getboolean("flag", "implicit"), True)
917+
self.assertIs(config.getboolean("flag", "empty"), False)
918+
self.assertEqual(dict(config.items_all("flag"))["multiple"], ["false", None, "", None])
869919

870920
def test_config_with_quotes(self):
871921
cr = GitConfigParser(fixture_path("git_config_with_quotes"), read_only=True)

0 commit comments

Comments
 (0)