From f1d822822a10caa2999d3a9ec7ff13f849cae90d Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 7 Dec 2022 13:50:43 +0100 Subject: [PATCH 01/10] inline type hints --- .pre-commit-config.yaml | 8 +- MANIFEST.in | 1 - pyproject.toml | 5 +- setup.py | 2 +- src/iniconfig/__init__.py | 189 ++++++++++++++++++++++++++++--------- src/iniconfig/__init__.pyi | 39 -------- testing/test_iniconfig.py | 107 ++++++++++----------- 7 files changed, 212 insertions(+), 139 deletions(-) delete mode 100644 src/iniconfig/__init__.pyi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b6dd5ae..3a204be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.2.0 + rev: v3.3.1 hooks: - id: pyupgrade args: [--py37-plus] @@ -14,3 +14,9 @@ repos: hooks: - id: black language_version: python3 +- repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v0.991' + hooks: + - id: mypy + additional_dependencies: + - "pytest==7.2.0" \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index 06be514..badaa0c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,4 +2,3 @@ include LICENSE include example.ini include tox.ini include src/iniconfig/py.typed -recursive-include src *.pyi diff --git a/pyproject.toml b/pyproject.toml index a806a14..fabbdd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,4 +3,7 @@ requires = ["setuptools>=41.2.0", "wheel", "setuptools_scm>3"] build-backend = "setuptools.build_meta" -[tool.setuptools_scm] \ No newline at end of file +[tool.setuptools_scm] + +[tool.mypy] +strict = true \ No newline at end of file diff --git a/setup.py b/setup.py index e0e256b..0c87edb 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ import setuptools_scm # noqa -def local_scheme(version): +def local_scheme(version: object) -> str: """Skip the local version (eg. +xyz of 0.6.1.dev4+gdf99fe2) to be able to upload to Test PyPI""" return "" diff --git a/src/iniconfig/__init__.py b/src/iniconfig/__init__.py index d74032a..403c709 100644 --- a/src/iniconfig/__init__.py +++ b/src/iniconfig/__init__.py @@ -1,63 +1,111 @@ """ brain-dead simple parser for ini-style files. (C) Ronny Pfannschmidt, Holger Krekel -- MIT licensed """ +from __future__ import annotations +from typing import ( + Callable, + Iterator, + Mapping, + Optional, + Tuple, + TypeVar, + Union, + TYPE_CHECKING, + NoReturn, + NamedTuple, + overload, + cast, +) + + +if TYPE_CHECKING: + from typing_extensions import Final + __all__ = ["IniConfig", "ParseError"] COMMENTCHARS = "#;" +_D = TypeVar("_D") +_T = TypeVar("_T") + + +_str_default = cast(Callable[[str], str], str) + + +class _ParsedLine(NamedTuple): + lineno: int + section: str | None + name: str | None + value: str | None + class ParseError(Exception): - def __init__(self, path, lineno, msg): + path: Final[str] + lineno: Final[int] + msg: Final[str] + + def __init__(self, path: str, lineno: int, msg: str): Exception.__init__(self, path, lineno, msg) self.path = path self.lineno = lineno self.msg = msg - def __str__(self): + def __str__(self) -> str: return f"{self.path}:{self.lineno + 1}: {self.msg}" class SectionWrapper: - def __init__(self, config, name): + config: Final[IniConfig] + name: Final[str] + + def __init__(self, config: IniConfig, name: str): self.config = config self.name = name - def lineof(self, name): + def lineof(self, name: str) -> int | None: return self.config.lineof(self.name, name) - def get(self, key, default=None, convert=str): + def get( + self, + key: str, + default: _D | None = None, + convert: Callable[[str], _T] | None = None, + ) -> _D | _T | str | None: return self.config.get(self.name, key, convert=convert, default=default) - def __getitem__(self, key): + def __getitem__(self, key: str) -> str: return self.config.sections[self.name][key] - def __iter__(self): - section = self.config.sections.get(self.name, []) + def __iter__(self) -> Iterator[str]: + section: Mapping[str, str] = self.config.sections.get(self.name, {}) - def lineof(key): - return self.config.lineof(self.name, key) + def lineof(key: str) -> int: + return self.config.lineof(self.name, key) # type: ignore yield from sorted(section, key=lineof) - def items(self): + def items(self) -> Iterator[tuple[str, str]]: for name in self: yield name, self[name] class IniConfig: - def __init__(self, path, data=None): + path: Final[str] + sections: Final[Mapping[str, Mapping[str, str]]] + + def __init__( + self, path: str, data: str | None = None, encoding: str = "utf-8" + ) -> None: self.path = str(path) # convenience if data is None: - f = open(self.path) - try: - tokens = self._parse(iter(f)) - finally: - f.close() - else: - tokens = self._parse(data.splitlines(True)) + with open(self.path, encoding=encoding) as fp: + data = fp.read() + + tokens = self._parse(data.splitlines(True)) self._sources = {} - self.sections = {} + sections_data: dict[str, dict[str, str]] + self.sections = sections_data = {} for lineno, section, name, value in tokens: if section is None: @@ -66,44 +114,46 @@ def __init__(self, path, data=None): if name is None: if section in self.sections: self._raise(lineno, f"duplicate section {section!r}") - self.sections[section] = {} + sections_data[section] = {} else: if name in self.sections[section]: self._raise(lineno, f"duplicate name {name!r}") - self.sections[section][name] = value + assert value is not None + sections_data[section][name] = value - def _raise(self, lineno, msg): + def _raise(self, lineno: int, msg: str) -> NoReturn: raise ParseError(self.path, lineno, msg) - def _parse(self, line_iter): - result = [] + def _parse(self, line_iter: list[str]) -> list[_ParsedLine]: + result: list[_ParsedLine] = [] section = None for lineno, line in enumerate(line_iter): name, data = self._parseline(line, lineno) # new value if name is not None and data is not None: - result.append((lineno, section, name, data)) + result.append(_ParsedLine(lineno, section, name, data)) # new section elif name is not None and data is None: if not name: self._raise(lineno, "empty section name") section = name - result.append((lineno, section, None, None)) + result.append(_ParsedLine(lineno, section, None, None)) # continuation elif name is None and data is not None: if not result: self._raise(lineno, "unexpected value continuation") last = result.pop() - last_name, last_data = last[-2:] - if last_name is None: + if last.name is None: self._raise(lineno, "unexpected value continuation") - if last_data: - data = f"{last_data}\n{data}" - result.append(last[:-1] + (data,)) + if last.value: + last = last._replace(value=f"{last.value}\n{data}") + else: + last = last._replace(value=data) + result.append(last) return result - def _parseline(self, line, lineno): + def _parseline(self, line: str, lineno: int) -> tuple[str | None, str | None]: # blank lines if iscommentline(line): line = "" @@ -135,30 +185,83 @@ def _parseline(self, line, lineno): else: return None, line.strip() - def lineof(self, section, name=None): + def lineof(self, section: str, name: str | None = None) -> int | None: lineno = self._sources.get((section, name)) - if lineno is not None: - return lineno + 1 + return None if lineno is None else lineno + 1 + + @overload + def get( + self, + section: str, + name: str, + ) -> str | None: + ... + + @overload + def get( + self, + section: str, + name: str, + convert: Callable[[str], _T], + ) -> _T | None: + ... - def get(self, section, name, default=None, convert=str): + @overload + def get( + self, + section: str, + name: str, + default: None, + convert: Callable[[str], _T], + ) -> _T | None: + ... + + @overload + def get( + self, section: str, name: str, default: _D, convert: None = None + ) -> str | _D: + ... + + @overload + def get( + self, + section: str, + name: str, + default: _D, + convert: Callable[[str], _T], + ) -> _T | _D: + ... + + def get( # type: ignore + self, + section: str, + name: str, + default: _D | None = None, + convert: Callable[[str], _T] | None = None, + ) -> _D | _T | str | None: try: - return convert(self.sections[section][name]) + value: str = self.sections[section][name] except KeyError: return default + else: + if convert is not None: + return convert(value) + else: + return value - def __getitem__(self, name): + def __getitem__(self, name: str) -> SectionWrapper: if name not in self.sections: raise KeyError(name) return SectionWrapper(self, name) - def __iter__(self): - for name in sorted(self.sections, key=self.lineof): + def __iter__(self) -> Iterator[SectionWrapper]: + for name in sorted(self.sections, key=self.lineof): # type: ignore yield SectionWrapper(self, name) - def __contains__(self, arg): + def __contains__(self, arg: str) -> bool: return arg in self.sections -def iscommentline(line): +def iscommentline(line: str) -> bool: c = line.lstrip()[:1] return c in COMMENTCHARS diff --git a/src/iniconfig/__init__.pyi b/src/iniconfig/__init__.pyi deleted file mode 100644 index fdcccd7..0000000 --- a/src/iniconfig/__init__.pyi +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Callable, Iterator, Mapping, Optional, Tuple, TypeVar, Union -from typing_extensions import Final - -_D = TypeVar("_D") -_T = TypeVar("_T") - -class ParseError(Exception): - # Private __init__. - path: Final[str] - lineno: Final[int] - msg: Final[str] - -class SectionWrapper: - # Private __init__. - config: Final[IniConfig] - name: Final[str] - def __getitem__(self, key: str) -> str: ... - def __iter__(self) -> Iterator[str]: ... - def get( - self, key: str, default: _D = ..., convert: Callable[[str], _T] = ... - ) -> Union[_T, _D]: ... - def items(self) -> Iterator[Tuple[str, str]]: ... - def lineof(self, name: str) -> Optional[int]: ... - -class IniConfig: - path: Final[str] - sections: Final[Mapping[str, Mapping[str, str]]] - def __init__(self, path: str, data: Optional[str] = None): ... - def __contains__(self, arg: str) -> bool: ... - def __getitem__(self, name: str) -> SectionWrapper: ... - def __iter__(self) -> Iterator[SectionWrapper]: ... - def get( - self, - section: str, - name: str, - default: _D = ..., - convert: Callable[[str], _T] = ..., - ) -> Union[_T, _D]: ... - def lineof(self, section: str, name: Optional[str] = ...) -> Optional[int]: ... diff --git a/testing/test_iniconfig.py b/testing/test_iniconfig.py index 87f5bfe..99558c0 100644 --- a/testing/test_iniconfig.py +++ b/testing/test_iniconfig.py @@ -1,98 +1,99 @@ +from __future__ import annotations import pytest -from iniconfig import IniConfig, ParseError, __all__ as ALL +from iniconfig import IniConfig, ParseError, __all__ as ALL, _ParsedLine as PL from iniconfig import iscommentline from textwrap import dedent +from pathlib import Path -check_tokens = { - "section": ("[section]", [(0, "section", None, None)]), - "value": ("value = 1", [(0, None, "value", "1")]), +check_tokens: dict[str, tuple[str, list[PL]]] = { + "section": ("[section]", [PL(0, "section", None, None)]), + "value": ("value = 1", [PL(0, None, "value", "1")]), "value in section": ( "[section]\nvalue=1", - [(0, "section", None, None), (1, "section", "value", "1")], + [PL(0, "section", None, None), PL(1, "section", "value", "1")], ), "value with continuation": ( "names =\n Alice\n Bob", - [(0, None, "names", "Alice\nBob")], + [PL(0, None, "names", "Alice\nBob")], ), "value with aligned continuation": ( "names = Alice\n Bob", - [(0, None, "names", "Alice\nBob")], + [PL(0, None, "names", "Alice\nBob")], ), "blank line": ( "[section]\n\nvalue=1", - [(0, "section", None, None), (2, "section", "value", "1")], + [PL(0, "section", None, None), PL(2, "section", "value", "1")], ), "comment": ("# comment", []), - "comment on value": ("value = 1", [(0, None, "value", "1")]), - "comment on section": ("[section] #comment", [(0, "section", None, None)]), + "comment on value": ("value = 1", [PL(0, None, "value", "1")]), + "comment on section": ("[section] #comment", [PL(0, "section", None, None)]), "comment2": ("; comment", []), - "comment2 on section": ("[section] ;comment", [(0, "section", None, None)]), + "comment2 on section": ("[section] ;comment", [PL(0, "section", None, None)]), "pseudo section syntax in value": ( "name = value []", - [(0, None, "name", "value []")], + [PL(0, None, "name", "value []")], ), - "assignment in value": ("value = x = 3", [(0, None, "value", "x = 3")]), - "use of colon for name-values": ("name: y", [(0, None, "name", "y")]), - "use of colon without space": ("value:y=5", [(0, None, "value", "y=5")]), - "equality gets precedence": ("value=xyz:5", [(0, None, "value", "xyz:5")]), + "assignment in value": ("value = x = 3", [PL(0, None, "value", "x = 3")]), + "use of colon for name-values": ("name: y", [PL(0, None, "name", "y")]), + "use of colon without space": ("value:y=5", [PL(0, None, "value", "y=5")]), + "equality gets precedence": ("value=xyz:5", [PL(0, None, "value", "xyz:5")]), } @pytest.fixture(params=sorted(check_tokens)) -def input_expected(request): +def input_expected(request: pytest.FixtureRequest) -> tuple[str, list[PL]]: + return check_tokens[request.param] @pytest.fixture -def input(input_expected): +def input(input_expected: tuple[str, list[PL]]) -> str: return input_expected[0] @pytest.fixture -def expected(input_expected): +def expected(input_expected: tuple[str, list[PL]]) -> list[PL]: return input_expected[1] -def parse(input): - # only for testing purposes - _parse() does not use state except path - ini = object.__new__(IniConfig) - ini.path = "sample" +def parse(input: str) -> list[PL]: + ini = IniConfig("sample", data="") return ini._parse(input.splitlines(True)) -def parse_a_error(input): +def parse_a_error(input: str) -> pytest.ExceptionInfo[ParseError]: return pytest.raises(ParseError, parse, input) -def test_tokenize(input, expected): +def test_tokenize(input: str, expected: list[PL]) -> None: parsed = parse(input) assert parsed == expected -def test_parse_empty(): +def test_parse_empty() -> None: parsed = parse("") assert not parsed ini = IniConfig("sample", "") assert not ini.sections -def test_ParseError(): +def test_ParseError() -> None: e = ParseError("filename", 0, "hello") assert str(e) == "filename:1: hello" -def test_continuation_needs_perceeding_token(): +def test_continuation_needs_perceeding_token() -> None: excinfo = parse_a_error(" Foo") assert excinfo.value.lineno == 0 -def test_continuation_cant_be_after_section(): +def test_continuation_cant_be_after_section() -> None: excinfo = parse_a_error("[section]\n Foo") assert excinfo.value.lineno == 1 -def test_section_cant_be_empty(): +def test_section_cant_be_empty() -> None: excinfo = parse_a_error("[]") assert excinfo.value.lineno == 0 @@ -103,42 +104,42 @@ def test_section_cant_be_empty(): "!!", ], ) -def test_error_on_weird_lines(line): +def test_error_on_weird_lines(line: str) -> None: parse_a_error(line) -def test_iniconfig_from_file(tmpdir): - path = tmpdir / "test.txt" - path.write("[metadata]\nname=1") +def test_iniconfig_from_file(tmp_path: Path) -> None: + path = tmp_path / "test.txt" + path.write_text("[metadata]\nname=1") - config = IniConfig(path=path) + config = IniConfig(path=str(path)) assert list(config.sections) == ["metadata"] - config = IniConfig(path, "[diff]") + config = IniConfig(str(path), "[diff]") assert list(config.sections) == ["diff"] with pytest.raises(TypeError): - IniConfig(data=path.read()) + IniConfig(data=path.read_text()) # type: ignore -def test_iniconfig_section_first(tmpdir): +def test_iniconfig_section_first() -> None: with pytest.raises(ParseError) as excinfo: IniConfig("x", data="name=1") assert excinfo.value.msg == "no section header defined" -def test_iniconig_section_duplicate_fails(): +def test_iniconig_section_duplicate_fails() -> None: with pytest.raises(ParseError) as excinfo: IniConfig("x", data="[section]\n[section]") assert "duplicate section" in str(excinfo.value) -def test_iniconfig_duplicate_key_fails(): +def test_iniconfig_duplicate_key_fails() -> None: with pytest.raises(ParseError) as excinfo: IniConfig("x", data="[section]\nname = Alice\nname = bob") assert "duplicate name" in str(excinfo.value) -def test_iniconfig_lineof(): +def test_iniconfig_lineof() -> None: config = IniConfig( "x.ini", data=("[section]\nvalue = 1\n[section2]\n# comment\nvalue =2"), @@ -154,19 +155,19 @@ def test_iniconfig_lineof(): assert config["section2"].lineof("value") == 5 -def test_iniconfig_get_convert(): +def test_iniconfig_get_convert() -> None: config = IniConfig("x", data="[section]\nint = 1\nfloat = 1.1") assert config.get("section", "int") == "1" assert config.get("section", "int", convert=int) == 1 -def test_iniconfig_get_missing(): +def test_iniconfig_get_missing() -> None: config = IniConfig("x", data="[section]\nint = 1\nfloat = 1.1") assert config.get("section", "missing", default=1) == 1 assert config.get("section", "missing") is None -def test_section_get(): +def test_section_get() -> None: config = IniConfig("x", data="[section]\nvalue=1") section = config["section"] assert section.get("value", convert=int) == 1 @@ -174,19 +175,19 @@ def test_section_get(): assert section.get("missing", 2) == 2 -def test_missing_section(): +def test_missing_section() -> None: config = IniConfig("x", data="[section]\nvalue=1") with pytest.raises(KeyError): config["other"] -def test_section_getitem(): +def test_section_getitem() -> None: config = IniConfig("x", data="[section]\nvalue=1") assert config["section"]["value"] == "1" assert config["section"]["value"] == "1" -def test_section_iter(): +def test_section_iter() -> None: config = IniConfig("x", data="[section]\nvalue=1") names = list(config["section"]) assert names == ["value"] @@ -194,7 +195,7 @@ def test_section_iter(): assert items == [("value", "1")] -def test_config_iter(): +def test_config_iter() -> None: config = IniConfig( "x.ini", data=dedent( @@ -214,7 +215,7 @@ def test_config_iter(): assert l[1]["value"] == "2" -def test_config_contains(): +def test_config_contains() -> None: config = IniConfig( "x.ini", data=dedent( @@ -231,7 +232,7 @@ def test_config_contains(): assert "section2" in config -def test_iter_file_order(): +def test_iter_file_order() -> None: config = IniConfig( "x.ini", data=""" @@ -250,7 +251,7 @@ def test_iter_file_order(): assert list(config["section"]) == ["a", "b"] -def test_example_pypirc(): +def test_example_pypirc() -> None: config = IniConfig( "pypirc", data=dedent( @@ -280,7 +281,7 @@ def test_example_pypirc(): assert ["repository", "username", "password"] == list(other) -def test_api_import(): +def test_api_import() -> None: assert ALL == ["IniConfig", "ParseError"] @@ -293,5 +294,5 @@ def test_api_import(): " ;qwe", ], ) -def test_iscommentline_true(line): +def test_iscommentline_true(line: str) -> None: assert iscommentline(line) From 95fc7f9c3c776ed8bd7e775f6a42d0b4b64a5e40 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 7 Dec 2022 13:59:52 +0100 Subject: [PATCH 02/10] move ParseError to own file --- src/iniconfig/__init__.py | 17 ++--------------- src/iniconfig/exceptions.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 15 deletions(-) create mode 100644 src/iniconfig/exceptions.py diff --git a/src/iniconfig/__init__.py b/src/iniconfig/__init__.py index 403c709..be078f8 100644 --- a/src/iniconfig/__init__.py +++ b/src/iniconfig/__init__.py @@ -23,6 +23,8 @@ __all__ = ["IniConfig", "ParseError"] +from .exceptions import ParseError + COMMENTCHARS = "#;" _D = TypeVar("_D") @@ -39,21 +41,6 @@ class _ParsedLine(NamedTuple): value: str | None -class ParseError(Exception): - path: Final[str] - lineno: Final[int] - msg: Final[str] - - def __init__(self, path: str, lineno: int, msg: str): - Exception.__init__(self, path, lineno, msg) - self.path = path - self.lineno = lineno - self.msg = msg - - def __str__(self) -> str: - return f"{self.path}:{self.lineno + 1}: {self.msg}" - - class SectionWrapper: config: Final[IniConfig] name: Final[str] diff --git a/src/iniconfig/exceptions.py b/src/iniconfig/exceptions.py new file mode 100644 index 0000000..4bfdea2 --- /dev/null +++ b/src/iniconfig/exceptions.py @@ -0,0 +1,20 @@ + +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing_extensions import Final + +class ParseError(Exception): + path: Final[str] + lineno: Final[int] + msg: Final[str] + + def __init__(self, path: str, lineno: int, msg: str): + Exception.__init__(self, path, lineno, msg) + self.path = path + self.lineno = lineno + self.msg = msg + + def __str__(self) -> str: + return f"{self.path}:{self.lineno + 1}: {self.msg}" \ No newline at end of file From 03b726258a7bd4b679c8b57ba065e3c7f0d9a64c Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 7 Dec 2022 14:19:54 +0100 Subject: [PATCH 03/10] move parsing to ownfile --- src/iniconfig/__init__.py | 93 +++---------------------------------- src/iniconfig/_parse.py | 82 ++++++++++++++++++++++++++++++++ src/iniconfig/exceptions.py | 4 +- testing/test_iniconfig.py | 31 ++++++++----- 4 files changed, 110 insertions(+), 100 deletions(-) create mode 100644 src/iniconfig/_parse.py diff --git a/src/iniconfig/__init__.py b/src/iniconfig/__init__.py index be078f8..e95a044 100644 --- a/src/iniconfig/__init__.py +++ b/src/iniconfig/__init__.py @@ -21,26 +21,16 @@ if TYPE_CHECKING: from typing_extensions import Final -__all__ = ["IniConfig", "ParseError"] +__all__ = ["IniConfig", "ParseError", "COMMENTCHARS", "iscommentline"] from .exceptions import ParseError - -COMMENTCHARS = "#;" +from . import _parse +from ._parse import COMMENTCHARS, iscommentline _D = TypeVar("_D") _T = TypeVar("_T") -_str_default = cast(Callable[[str], str], str) - - -class _ParsedLine(NamedTuple): - lineno: int - section: str | None - name: str | None - value: str | None - - class SectionWrapper: config: Final[IniConfig] name: Final[str] @@ -88,7 +78,7 @@ def __init__( with open(self.path, encoding=encoding) as fp: data = fp.read() - tokens = self._parse(data.splitlines(True)) + tokens = _parse.parse_lines(path, data.splitlines(True)) self._sources = {} sections_data: dict[str, dict[str, str]] @@ -96,82 +86,18 @@ def __init__( for lineno, section, name, value in tokens: if section is None: - self._raise(lineno, "no section header defined") + raise ParseError(path, lineno, "no section header defined") self._sources[section, name] = lineno if name is None: if section in self.sections: - self._raise(lineno, f"duplicate section {section!r}") + raise ParseError(path, lineno, f"duplicate section {section!r}") sections_data[section] = {} else: if name in self.sections[section]: - self._raise(lineno, f"duplicate name {name!r}") + raise ParseError(path, lineno, f"duplicate name {name!r}") assert value is not None sections_data[section][name] = value - def _raise(self, lineno: int, msg: str) -> NoReturn: - raise ParseError(self.path, lineno, msg) - - def _parse(self, line_iter: list[str]) -> list[_ParsedLine]: - result: list[_ParsedLine] = [] - section = None - for lineno, line in enumerate(line_iter): - name, data = self._parseline(line, lineno) - # new value - if name is not None and data is not None: - result.append(_ParsedLine(lineno, section, name, data)) - # new section - elif name is not None and data is None: - if not name: - self._raise(lineno, "empty section name") - section = name - result.append(_ParsedLine(lineno, section, None, None)) - # continuation - elif name is None and data is not None: - if not result: - self._raise(lineno, "unexpected value continuation") - last = result.pop() - if last.name is None: - self._raise(lineno, "unexpected value continuation") - - if last.value: - last = last._replace(value=f"{last.value}\n{data}") - else: - last = last._replace(value=data) - result.append(last) - return result - - def _parseline(self, line: str, lineno: int) -> tuple[str | None, str | None]: - # blank lines - if iscommentline(line): - line = "" - else: - line = line.rstrip() - if not line: - return None, None - # section - if line[0] == "[": - realline = line - for c in COMMENTCHARS: - line = line.split(c)[0].rstrip() - if line[-1] == "]": - return line[1:-1], None - return None, realline.strip() - # value - elif not line[0].isspace(): - try: - name, value = line.split("=", 1) - if ":" in name: - raise ValueError() - except ValueError: - try: - name, value = line.split(":", 1) - except ValueError: - self._raise(lineno, "unexpected line: %r" % line) - return name.strip(), value.strip() - # continuation - else: - return None, line.strip() - def lineof(self, section: str, name: str | None = None) -> int | None: lineno = self._sources.get((section, name)) return None if lineno is None else lineno + 1 @@ -247,8 +173,3 @@ def __iter__(self) -> Iterator[SectionWrapper]: def __contains__(self, arg: str) -> bool: return arg in self.sections - - -def iscommentline(line: str) -> bool: - c = line.lstrip()[:1] - return c in COMMENTCHARS diff --git a/src/iniconfig/_parse.py b/src/iniconfig/_parse.py new file mode 100644 index 0000000..2d03437 --- /dev/null +++ b/src/iniconfig/_parse.py @@ -0,0 +1,82 @@ +from __future__ import annotations +from .exceptions import ParseError + +from typing import NamedTuple + + +COMMENTCHARS = "#;" + + +class _ParsedLine(NamedTuple): + lineno: int + section: str | None + name: str | None + value: str | None + + +def parse_lines(path: str, line_iter: list[str]) -> list[_ParsedLine]: + result: list[_ParsedLine] = [] + section = None + for lineno, line in enumerate(line_iter): + name, data = _parseline(path, line, lineno) + # new value + if name is not None and data is not None: + result.append(_ParsedLine(lineno, section, name, data)) + # new section + elif name is not None and data is None: + if not name: + raise ParseError(path, lineno, "empty section name") + section = name + result.append(_ParsedLine(lineno, section, None, None)) + # continuation + elif name is None and data is not None: + if not result: + raise ParseError(path, lineno, "unexpected value continuation") + last = result.pop() + if last.name is None: + raise ParseError(path, lineno, "unexpected value continuation") + + if last.value: + last = last._replace(value=f"{last.value}\n{data}") + else: + last = last._replace(value=data) + result.append(last) + return result + + +def _parseline(path: str, line: str, lineno: int) -> tuple[str | None, str | None]: + # blank lines + if iscommentline(line): + line = "" + else: + line = line.rstrip() + if not line: + return None, None + # section + if line[0] == "[": + realline = line + for c in COMMENTCHARS: + line = line.split(c)[0].rstrip() + if line[-1] == "]": + return line[1:-1], None + return None, realline.strip() + # value + elif not line[0].isspace(): + try: + name, value = line.split("=", 1) + if ":" in name: + raise ValueError() + except ValueError: + try: + name, value = line.split(":", 1) + except ValueError: + raise ParseError(path, lineno, "unexpected line: %r" % line) + return name.strip(), value.strip() + # continuation + else: + return None, line.strip() + + +def iscommentline(line: str) -> bool: + c = line.lstrip()[:1] + return c in COMMENTCHARS diff --git a/src/iniconfig/exceptions.py b/src/iniconfig/exceptions.py index 4bfdea2..8cbfdf3 100644 --- a/src/iniconfig/exceptions.py +++ b/src/iniconfig/exceptions.py @@ -1,10 +1,10 @@ - from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from typing_extensions import Final + class ParseError(Exception): path: Final[str] lineno: Final[int] @@ -17,4 +17,4 @@ def __init__(self, path: str, lineno: int, msg: str): self.msg = msg def __str__(self) -> str: - return f"{self.path}:{self.lineno + 1}: {self.msg}" \ No newline at end of file + return f"{self.path}:{self.lineno + 1}: {self.msg}" diff --git a/testing/test_iniconfig.py b/testing/test_iniconfig.py index 99558c0..9e94b28 100644 --- a/testing/test_iniconfig.py +++ b/testing/test_iniconfig.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest -from iniconfig import IniConfig, ParseError, __all__ as ALL, _ParsedLine as PL +from iniconfig import IniConfig, ParseError, __all__ as ALL +from iniconfig._parse import _ParsedLine as PL from iniconfig import iscommentline from textwrap import dedent from pathlib import Path @@ -58,12 +59,18 @@ def expected(input_expected: tuple[str, list[PL]]) -> list[PL]: def parse(input: str) -> list[PL]: - ini = IniConfig("sample", data="") - return ini._parse(input.splitlines(True)) + from iniconfig._parse import parse_lines + return parse_lines("sample", input.splitlines(True)) -def parse_a_error(input: str) -> pytest.ExceptionInfo[ParseError]: - return pytest.raises(ParseError, parse, input) + +def parse_a_error(input: str) -> ParseError: + try: + parse(input) + except ParseError as e: + return e + else: + raise ValueError(input) def test_tokenize(input: str, expected: list[PL]) -> None: @@ -84,18 +91,18 @@ def test_ParseError() -> None: def test_continuation_needs_perceeding_token() -> None: - excinfo = parse_a_error(" Foo") - assert excinfo.value.lineno == 0 + err = parse_a_error(" Foo") + assert err.lineno == 0 def test_continuation_cant_be_after_section() -> None: - excinfo = parse_a_error("[section]\n Foo") - assert excinfo.value.lineno == 1 + err = parse_a_error("[section]\n Foo") + assert err.lineno == 1 def test_section_cant_be_empty() -> None: - excinfo = parse_a_error("[]") - assert excinfo.value.lineno == 0 + err = parse_a_error("[]") + assert err.lineno == 0 @pytest.mark.parametrize( @@ -282,7 +289,7 @@ def test_example_pypirc() -> None: def test_api_import() -> None: - assert ALL == ["IniConfig", "ParseError"] + assert ALL == ["IniConfig", "ParseError", "COMMENTCHARS", "iscommentline"] @pytest.mark.parametrize( From 10583b86c3de36764f27e24f453eb9fe91c2ab3f Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 7 Dec 2022 15:29:38 +0100 Subject: [PATCH 04/10] update changelog --- CHANGELOG | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index db591bf..61a864a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,9 +1,11 @@ -Unreleased +1.2.0 (Unreleased) ========== -* add support for Python 3.6-3.10 -* drop support for Python 2.6-2.7, 3.3-3.5 - +* add support for Python 3.7-3.11 +* drop support for Python 2.6-3.6 +* add encoding argument defaulting to utf-8 +* inline and clarify type annotations +* move parsing code from inline to extra file 1.1.1 ===== From 0253ff11d9e2ca1967df615185d0f52c983ee642 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 19 Dec 2022 12:09:45 +0100 Subject: [PATCH 05/10] implement review comments --- .pre-commit-config.yaml | 6 ++++-- pyproject.toml | 2 +- setup.cfg | 2 +- setup.py | 4 ++-- src/iniconfig/__init__.py | 22 ++++++++++++++-------- src/iniconfig/exceptions.py | 4 ++-- 6 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3a204be..e48deea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: - id: setup-cfg-fmt args: [--include-version-classifiers] - repo: https://github.com/psf/black - rev: 22.10.0 + rev: 22.12.0 hooks: - id: black language_version: python3 @@ -18,5 +18,7 @@ repos: rev: 'v0.991' hooks: - id: mypy + args: [] additional_dependencies: - - "pytest==7.2.0" \ No newline at end of file + - "pytest==7.2.0" + - "tomli" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index fabbdd3..71990b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,4 +6,4 @@ build-backend = "setuptools.build_meta" [tool.setuptools_scm] [tool.mypy] -strict = true \ No newline at end of file +strict = true diff --git a/setup.cfg b/setup.cfg index dd960fb..c503945 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,7 +7,7 @@ url = https://github.com/RonnyPfannschmidt/iniconfig author = Ronny Pfannschmidt, Holger Krekel author_email = pensource@ronnypfannschmidt.de, holger.krekel@gmail.com license = MIT -license_file = LICENSE +license_files = LICENSE platforms = unix, linux, osx, cygwin, win32 classifiers = Development Status :: 4 - Beta diff --git a/setup.py b/setup.py index 0c87edb..1396226 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ -from setuptools import setup +from setuptools import setup # type: ignore[import] # Ensures that setuptools_scm is installed, so wheels get proper versions -import setuptools_scm # noqa +import setuptools_scm # type: ignore[import] def local_scheme(version: object) -> str: diff --git a/src/iniconfig/__init__.py b/src/iniconfig/__init__.py index e95a044..23ef881 100644 --- a/src/iniconfig/__init__.py +++ b/src/iniconfig/__init__.py @@ -17,6 +17,7 @@ cast, ) +import os if TYPE_CHECKING: from typing_extensions import Final @@ -35,7 +36,7 @@ class SectionWrapper: config: Final[IniConfig] name: Final[str] - def __init__(self, config: IniConfig, name: str): + def __init__(self, config: IniConfig, name: str) -> None: self.config = config self.name = name @@ -57,7 +58,7 @@ def __iter__(self) -> Iterator[str]: section: Mapping[str, str] = self.config.sections.get(self.name, {}) def lineof(key: str) -> int: - return self.config.lineof(self.name, key) # type: ignore + return self.config.lineof(self.name, key) # type: ignore[return-value] yield from sorted(section, key=lineof) @@ -71,14 +72,17 @@ class IniConfig: sections: Final[Mapping[str, Mapping[str, str]]] def __init__( - self, path: str, data: str | None = None, encoding: str = "utf-8" + self, + path: str | os.PathLike[str], + data: str | None = None, + encoding: str = "utf-8", ) -> None: - self.path = str(path) # convenience + self.path = os.fspath(path) if data is None: with open(self.path, encoding=encoding) as fp: data = fp.read() - tokens = _parse.parse_lines(path, data.splitlines(True)) + tokens = _parse.parse_lines(self.path, data.splitlines(True)) self._sources = {} sections_data: dict[str, dict[str, str]] @@ -86,15 +90,17 @@ def __init__( for lineno, section, name, value in tokens: if section is None: - raise ParseError(path, lineno, "no section header defined") + raise ParseError(self.path, lineno, "no section header defined") self._sources[section, name] = lineno if name is None: if section in self.sections: - raise ParseError(path, lineno, f"duplicate section {section!r}") + raise ParseError( + self.path, lineno, f"duplicate section {section!r}" + ) sections_data[section] = {} else: if name in self.sections[section]: - raise ParseError(path, lineno, f"duplicate name {name!r}") + raise ParseError(self.path, lineno, f"duplicate name {name!r}") assert value is not None sections_data[section][name] = value diff --git a/src/iniconfig/exceptions.py b/src/iniconfig/exceptions.py index 8cbfdf3..bc898e6 100644 --- a/src/iniconfig/exceptions.py +++ b/src/iniconfig/exceptions.py @@ -10,8 +10,8 @@ class ParseError(Exception): lineno: Final[int] msg: Final[str] - def __init__(self, path: str, lineno: int, msg: str): - Exception.__init__(self, path, lineno, msg) + def __init__(self, path: str, lineno: int, msg: str) -> None: + super().__init__(path, lineno, msg) self.path = path self.lineno = lineno self.msg = msg From c113dd6d4c6403ae636e0d69df5b3170c2c32888 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 19 Dec 2022 13:57:23 +0100 Subject: [PATCH 06/10] migrate to hatch --- .github/workflows/main.yml | 9 ++---- .gitignore | 1 + pyproject.toml | 60 ++++++++++++++++++++++++++++++++++++-- setup.cfg | 34 --------------------- setup.py | 13 --------- tox.ini | 14 --------- 6 files changed, 62 insertions(+), 69 deletions(-) delete mode 100644 setup.cfg delete mode 100644 setup.py delete mode 100644 tox.ini diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 75f4a04..049cb1a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,12 +20,9 @@ jobs: with: python-version: ${{ matrix.python }} - name: Install tox - run: | - python -m pip install --upgrade pip setuptools setuptools_scm - pip install tox - - name: Test - run: | - tox -e py + run: python -m pip install --upgrade pip setuptools_scm hatch + - name: install package local + run: pip install --no-build-isolation . pre-commit: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 89e6234..f1a42cb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build/ dist/ __pycache__ .tox/ +src/iniconfig/_version.py \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 71990b7..b6d07f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,65 @@ [build-system] -requires = ["setuptools>=41.2.0", "wheel", "setuptools_scm>3"] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" -build-backend = "setuptools.build_meta" +[project] +name = "iniconfig" +dynamic = ["version"] +description = "brain-dead simple config-ini parsing" +readme = "README.rst" +license = "MIT" +requires-python = ">=3.7" +authors = [ + { name = "Ronny Pfannschmidt", email = "opensource@ronnypfannschmidt.de" }, + { name = "Holger Krekel", email = "holger.krekel@gmail.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "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", + "Topic :: Software Development :: Libraries", + "Topic :: Utilities", +] + +[project.urls] +Homepage = "https://github.com/pytest-dev/iniconfig" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "src/iniconfig/_version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", +] + +[tool.hatch.envs.test] +dependencies = [ + "pytest" +] +[tool.hatch.envs.test.scripts] +default = "pytest" + +[[tool.hatch.envs.test.matrix]] +python = ["3.7", "3.8", "3.9", "3.10", "3.11"] [tool.setuptools_scm] [tool.mypy] strict = true + + +[tool.pytest.ini_options] +testpaths = "testing" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index c503945..0000000 --- a/setup.cfg +++ /dev/null @@ -1,34 +0,0 @@ -[metadata] -name = iniconfig -description = brain-dead simple config-ini parsing -long_description = file: README.rst -long_description_content_type = text/x-rst -url = https://github.com/RonnyPfannschmidt/iniconfig -author = Ronny Pfannschmidt, Holger Krekel -author_email = pensource@ronnypfannschmidt.de, holger.krekel@gmail.com -license = MIT -license_files = LICENSE -platforms = unix, linux, osx, cygwin, win32 -classifiers = - Development Status :: 4 - Beta - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Operating System :: MacOS :: MacOS X - Operating System :: Microsoft :: Windows - Operating System :: POSIX - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - 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 - Topic :: Software Development :: Libraries - Topic :: Utilities - -[options] -packages = iniconfig -python_requires = >=3.7 -include_package_data = True -package_dir = =src -zip_safe = False diff --git a/setup.py b/setup.py deleted file mode 100644 index 1396226..0000000 --- a/setup.py +++ /dev/null @@ -1,13 +0,0 @@ -from setuptools import setup # type: ignore[import] - -# Ensures that setuptools_scm is installed, so wheels get proper versions -import setuptools_scm # type: ignore[import] - - -def local_scheme(version: object) -> str: - """Skip the local version (eg. +xyz of 0.6.1.dev4+gdf99fe2) - to be able to upload to Test PyPI""" - return "" - - -setup(use_scm_version={"local_scheme": local_scheme}) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 3ec479b..0000000 --- a/tox.ini +++ /dev/null @@ -1,14 +0,0 @@ -[tox] -envlist=py{37,38,39,310,311} -isolated_build=True - -[testenv] -commands= - pytest {posargs} -deps= - pytest - - -[pytest] -testpaths= - testing From df78c51bfba888c9d145fbd3d66f45b74dac0271 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 19 Dec 2022 14:00:35 +0100 Subject: [PATCH 07/10] pre-commit pyproject ftm --- .pre-commit-config.yaml | 10 +++++----- pyproject.toml | 15 ++++++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e48deea..09cb80b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,11 +4,11 @@ repos: hooks: - id: pyupgrade args: [--py37-plus] -- repo: https://github.com/asottile/setup-cfg-fmt - rev: v2.2.0 - hooks: - - id: setup-cfg-fmt - args: [--include-version-classifiers] +- repo: https://github.com/tox-dev/pyproject-fmt + rev: "0.4.1" + hooks: + - id: pyproject-fmt + - repo: https://github.com/psf/black rev: 22.12.0 hooks: diff --git a/pyproject.toml b/pyproject.toml index b6d07f4..6eee6ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,23 @@ [build-system] -requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +requires = [ + "hatch-vcs", + "hatchling", +] [project] name = "iniconfig" -dynamic = ["version"] description = "brain-dead simple config-ini parsing" readme = "README.rst" license = "MIT" -requires-python = ">=3.7" authors = [ { name = "Ronny Pfannschmidt", email = "opensource@ronnypfannschmidt.de" }, { name = "Holger Krekel", email = "holger.krekel@gmail.com" }, ] +requires-python = ">=3.7" +dynamic = [ + "version", +] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -30,10 +35,10 @@ classifiers = [ "Topic :: Software Development :: Libraries", "Topic :: Utilities", ] - [project.urls] Homepage = "https://github.com/pytest-dev/iniconfig" + [tool.hatch.version] source = "vcs" @@ -62,4 +67,4 @@ strict = true [tool.pytest.ini_options] -testpaths = "testing" \ No newline at end of file +testpaths = "testing" From 90df3d776833fcfba42c9641d73c22956f25bf37 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 19 Dec 2022 14:02:10 +0100 Subject: [PATCH 08/10] hatch-vcs --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 049cb1a..524973f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,7 +20,7 @@ jobs: with: python-version: ${{ matrix.python }} - name: Install tox - run: python -m pip install --upgrade pip setuptools_scm hatch + run: python -m pip install --upgrade pip setuptools_scm hatch hatch-vcs - name: install package local run: pip install --no-build-isolation . From c7d1d88a398cb64884c30fb03c6581dd2bcd1ae7 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 4 Jan 2023 08:04:52 +0100 Subject: [PATCH 09/10] add sectionwrapper get overload types --- src/iniconfig/__init__.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/iniconfig/__init__.py b/src/iniconfig/__init__.py index 23ef881..c18a8e4 100644 --- a/src/iniconfig/__init__.py +++ b/src/iniconfig/__init__.py @@ -43,7 +43,42 @@ def __init__(self, config: IniConfig, name: str) -> None: def lineof(self, name: str) -> int | None: return self.config.lineof(self.name, name) + @overload + def get(self, key: str) -> str | None: + ... + + @overload + def get( + self, + key: str, + convert: Callable[[str], _T], + ) -> _T | None: + ... + + @overload def get( + self, + key: str, + default: None, + convert: Callable[[str], _T], + ) -> _T | None: + ... + + @overload + def get(self, key: str, default: _D, convert: None = None) -> str | _D: + ... + + @overload + def get( + self, + key: str, + default: _D, + convert: Callable[[str], _T], + ) -> _T | _D: + ... + + # TODO: investigate possible mypy bug wrt matching the passed over data + def get( # type: ignore [misc] self, key: str, default: _D | None = None, From 180065cec29f156b1baf9cf684ebbf3f251db073 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 5 Jan 2023 14:47:22 +0100 Subject: [PATCH 10/10] changelog --- CHANGELOG | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 61a864a..5b7570a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,11 +1,20 @@ -1.2.0 (Unreleased) -========== +2.0.0 +====== * add support for Python 3.7-3.11 * drop support for Python 2.6-3.6 * add encoding argument defaulting to utf-8 * inline and clarify type annotations * move parsing code from inline to extra file +* add typing overloads for helper methods + + +.. note:: + + major release due to the major changes in python versions supported + changes in packaging + + the api is expected to be compatible + 1.1.1 =====