New Upstream Release - python-iniconfig

Ready changes

Summary

Merged new upstream version: 2.0.0 (was: 1.1.1).

Diff

diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..a4451cd
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,63 @@
+name: Deploy
+
+on:
+  push:
+    branches:
+      - master
+      - "*deploy*"
+  release:
+    types:
+      - published
+
+jobs:
+  build:
+    if: github.repository == 'pytest-dev/iniconfig'
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+      - name: Cache
+        uses: actions/cache@v3
+        with:
+          path: ~/.cache/pip
+          key: deploy-${{ hashFiles('**/pyproject.toml') }}
+          restore-keys: |
+            deploy-
+
+      - name: Set up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: "3.x"
+
+      - name: Install build + twine
+        run: python -m pip install build twine setuptools_scm
+
+      - name: git describe output
+        run: git describe --tags
+
+      - id: scm_version
+        run: |
+          VERSION=$(python -m setuptools_scm --strip-dev)
+          echo SETUPTOOLS_SCM_PRETEND_VERSION=$VERSION >> $GITHUB_ENV
+
+      - name: Build package
+        run: python -m build
+      
+      - name: twine check
+        run: twine check dist/*
+
+      - name: Publish package to PyPI
+        if: github.event.action == 'published'
+        uses: pypa/gh-action-pypi-publish@master
+        with:
+          user: __token__
+          password: ${{ secrets.pypi_password }}
+
+      - name: Publish package to TestPyPI
+        uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          user: __token__
+          password: ${{ secrets.test_pypi_password }}
+          repository_url: https://test.pypi.org/legacy/
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..524973f
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,32 @@
+name: build
+
+on: [push, pull_request]
+
+jobs:
+  build:
+
+    runs-on: ${{ matrix.os }}
+
+    strategy:
+      fail-fast: false
+      matrix:
+        python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
+        os: [ubuntu-latest, windows-latest]
+
+    steps:
+    - uses: actions/checkout@v3
+    - name: Set up Python
+      uses: actions/setup-python@v4
+      with:
+        python-version: ${{ matrix.python }}
+    - name: Install tox
+      run: python -m pip install --upgrade pip setuptools_scm hatch hatch-vcs
+    - name: install package local
+      run: pip install --no-build-isolation .
+
+  pre-commit:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v3
+    - uses: actions/setup-python@v4
+    - uses: pre-commit/action@v3.0.0
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/.hgignore b/.hgignore
deleted file mode 100644
index e8e91ab..0000000
--- a/.hgignore
+++ /dev/null
@@ -1,16 +0,0 @@
-
-# These lines are suggested according to the svn:ignore property
-# Feel free to enable them by uncommenting them
-syntax:glob
-*.pyc
-*.pyo
-*.swp
-*.html
-*.class
-
-.tox
-
-build
-dist
-*.egg-info
-
diff --git a/.landscape.yml b/.landscape.yml
deleted file mode 100644
index 5212dde..0000000
--- a/.landscape.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-pep8:
-  full: true
-python-targets:
-  - 2
-  - 3
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..09cb80b
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,24 @@
+repos:
+-   repo: https://github.com/asottile/pyupgrade
+    rev: v3.3.1
+    hooks:
+    -   id: pyupgrade
+        args: [--py37-plus]
+- 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:
+      - id: black
+        language_version: python3
+-   repo: https://github.com/pre-commit/mirrors-mypy
+    rev: 'v0.991' 
+    hooks:
+    -   id: mypy
+        args: []
+        additional_dependencies:
+          - "pytest==7.2.0"
+          - "tomli"
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index e3fee06..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-language: python
-python:
-- '2.7'
-- '3.4'
-- '3.5'
-- nightly
-- pypy
-install: pip install setuptools_scm tox
-script: tox -e py
-deploy:
-  provider: pypi
-  user: ronny
-  password:
-    secure: DsRVX99HA6+3JoXOVP/nPXeabJy2P73ws7Ager/e4rx3p3jS74bId09XsBU46bAT9ANmRWPR8y5DRi5Zlq0WQ2uXoR55wmsdu2KUegk6bDIS4Iop8DFxY8Kjou9s8RZbDTP27LfuYXKMO1rDW/xa6EhiotYRodekeZUz3P3MYjIi6rBV2Rz3vwmInpkKOti7AFwAsCGmCCK13irmPJEp5nwl3RgeKu2AGaolw9eypJXeNLUcNDVQ88ZUUXQCkwgq7a1BkK6NMeQLMrWAE1bD3amCbVXHCR9TaVx1ZH1dnha5Jcfj3gEFucTmInWWes5u9rypvsCkSxKtSqdiUA7BMJq7XykV7nGNplGLm2sq4+KSYlf3gZXg4XNXQkNOi4EBtRvathfFziD2SZgdtjiQX2neh0dMjf9czc/uCYkKYCFLeozdw2oQQ+BsxhQfsmU2ILGCFHyFikmDbBqZOWfQE5TN3itQqV3TFK8sOHQ8iy3MDShs+lBk9AUwbCA5YbRh8hJKhgXyEsDpisC417Pj22+TbutTj7v3Rmpe/st4hoL740grWc3PSVUBaypG0RsoafSDZWnYnTC+0aakd6QEb5S9wnMkP94kijYjjF6yUInuT05wdbQv5XcSXqAdGzBqB5jNNdfwgWVCOlwGfjnvzKllhF3PmWPW/nfmQpGOQh4=
-  on:
-    tags: true
-    distributions: sdist bdist_wheel
-    repo: RonnyPfannschmidt/iniconfig
diff --git a/CHANGELOG b/CHANGELOG
index 679919f..5b7570a 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,23 @@
+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
-=========
+=====
 
 * fix version determination (thanks @florimondmanca)
 
@@ -10,17 +28,19 @@
 - ci fixes
 
 1.0.1
-======
+=====
 
 pytest 5+ support
 
 1.0
-====
+===
 
 - re-sync with pylib codebase
+- add support for Python 3.4-3.5
+- drop support for Python 2.4-2.5, 3.2
 
 0.2
-==================
+===
 
 - added ability to ask "name in iniconfig", i.e. to check
   if a section is contained.
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/README.txt b/README.rst
similarity index 88%
rename from README.txt
rename to README.rst
index 6bbad9a..948e883 100644
--- a/README.txt
+++ b/README.rst
@@ -4,7 +4,6 @@ iniconfig: brain-dead simple parsing of ini files
 iniconfig is a small and simple INI-file parser module
 having a unique set of features:
 
-* tested against Python2.4 across to Python3.2, Jython, PyPy
 * maintains order of sections and entries
 * supports multi-line values with or without line-continuations
 * supports "#" comments everywhere
@@ -14,12 +13,14 @@ having a unique set of features:
 
 If you encounter issues or have feature wishes please report them to:
 
-    http://github.com/RonnyPfannschmidt/iniconfig/issues
+    https://github.com/RonnyPfannschmidt/iniconfig/issues
 
 Basic Example
 ===================================
 
-If you have an ini file like this::
+If you have an ini file like this:
+
+.. code-block:: ini
 
     # content of example.ini
     [section1] # comment
@@ -31,7 +32,9 @@ If you have an ini file like this::
         line1
         line2
 
-then you can do::
+then you can do:
+
+.. code-block:: pycon
 
     >>> import iniconfig
     >>> ini = iniconfig.IniConfig("example.ini")
diff --git a/debian/changelog b/debian/changelog
index aac0a85..963ec09 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+python-iniconfig (2.0.0-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Mon, 07 Aug 2023 16:13:39 -0000
+
 python-iniconfig (1.1.1-2) unstable; urgency=medium
 
   [ Stefano Rivera ]
diff --git a/pyproject.toml b/pyproject.toml
index b2725d8..6eee6ef 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,70 @@
 [build-system]
-requires = ["setuptools>=41.2.0", "wheel", "setuptools_scm>3"]
+build-backend = "hatchling.build"
+requires = [
+  "hatch-vcs",
+  "hatchling",
+]
 
+[project]
+name = "iniconfig"
+description = "brain-dead simple config-ini parsing"
+readme = "README.rst"
+license = "MIT"
+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",
+    "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.setuptools_scm]
\ No newline at end of file
+
+[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"
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 3c6e79c..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[bdist_wheel]
-universal=1
diff --git a/setup.py b/setup.py
deleted file mode 100644
index f46f321..0000000
--- a/setup.py
+++ /dev/null
@@ -1,46 +0,0 @@
-"""
-iniconfig: brain-dead simple config-ini parsing.
-
-compatible CPython 2.3 through to CPython 3.2, Jython, PyPy
-
-(c) 2010 Ronny Pfannschmidt, Holger Krekel
-"""
-
-from setuptools import setup
-
-
-def main():
-    with open('README.txt') as fp:
-        readme = fp.read()
-    setup(
-        name='iniconfig',
-        packages=['iniconfig'],
-        package_dir={'': 'src'},
-        description='iniconfig: brain-dead simple config-ini parsing',
-        long_description=readme,
-        use_scm_version=True,
-        url='http://github.com/RonnyPfannschmidt/iniconfig',
-        license='MIT License',
-        platforms=['unix', 'linux', 'osx', 'cygwin', 'win32'],
-        author='Ronny Pfannschmidt, Holger Krekel',
-        author_email=(
-            'opensource@ronnypfannschmidt.de, holger.krekel@gmail.com'),
-        classifiers=[
-            'Development Status :: 4 - Beta',
-            'Intended Audience :: Developers',
-            'License :: OSI Approved :: MIT License',
-            'Operating System :: POSIX',
-            'Operating System :: Microsoft :: Windows',
-            'Operating System :: MacOS :: MacOS X',
-            'Topic :: Software Development :: Libraries',
-            'Topic :: Utilities',
-            'Programming Language :: Python',
-            'Programming Language :: Python :: 2',
-            'Programming Language :: Python :: 3',
-        ],
-        include_package_data=True,
-        zip_safe=False,
-    )
-
-if __name__ == '__main__':
-    main()
diff --git a/src/iniconfig/__init__.py b/src/iniconfig/__init__.py
index 6ad9eaf..c18a8e4 100644
--- a/src/iniconfig/__init__.py
+++ b/src/iniconfig/__init__.py
@@ -1,165 +1,216 @@
 """ brain-dead simple parser for ini-style files.
 (C) Ronny Pfannschmidt, Holger Krekel -- MIT licensed
 """
-__all__ = ['IniConfig', 'ParseError']
-
-COMMENTCHARS = "#;"
-
-
-class ParseError(Exception):
-    def __init__(self, path, lineno, msg):
-        Exception.__init__(self, path, lineno, msg)
-        self.path = path
-        self.lineno = lineno
-        self.msg = msg
-
-    def __str__(self):
-        return "%s:%s: %s" % (self.path, self.lineno+1, self.msg)
-
-
-class SectionWrapper(object):
-    def __init__(self, config, name):
+from __future__ import annotations
+from typing import (
+    Callable,
+    Iterator,
+    Mapping,
+    Optional,
+    Tuple,
+    TypeVar,
+    Union,
+    TYPE_CHECKING,
+    NoReturn,
+    NamedTuple,
+    overload,
+    cast,
+)
+
+import os
+
+if TYPE_CHECKING:
+    from typing_extensions import Final
+
+__all__ = ["IniConfig", "ParseError", "COMMENTCHARS", "iscommentline"]
+
+from .exceptions import ParseError
+from . import _parse
+from ._parse import COMMENTCHARS, iscommentline
+
+_D = TypeVar("_D")
+_T = TypeVar("_T")
+
+
+class SectionWrapper:
+    config: Final[IniConfig]
+    name: Final[str]
+
+    def __init__(self, config: IniConfig, name: str) -> None:
         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):
-        return self.config.get(self.name, key,
-                               convert=convert, default=default)
-
-    def __getitem__(self, key):
+    @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,
+        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: 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)
-        for name in sorted(section, key=lineof):
-            yield name
+        def lineof(key: str) -> int:
+            return self.config.lineof(self.name, key)  # type: ignore[return-value]
 
-    def items(self):
+        yield from sorted(section, key=lineof)
+
+    def items(self) -> Iterator[tuple[str, str]]:
         for name in self:
             yield name, self[name]
 
 
-class IniConfig(object):
-    def __init__(self, path, data=None):
-        self.path = str(path)  # convenience
+class IniConfig:
+    path: Final[str]
+    sections: Final[Mapping[str, Mapping[str, str]]]
+
+    def __init__(
+        self,
+        path: str | os.PathLike[str],
+        data: str | None = None,
+        encoding: str = "utf-8",
+    ) -> None:
+        self.path = os.fspath(path)
         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 = _parse.parse_lines(self.path, 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:
-                self._raise(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:
-                    self._raise(lineno, 'duplicate section %r' % (section, ))
-                self.sections[section] = {}
+                    raise ParseError(
+                        self.path, lineno, f"duplicate section {section!r}"
+                    )
+                sections_data[section] = {}
             else:
                 if name in self.sections[section]:
-                    self._raise(lineno, 'duplicate name %r' % (name, ))
-                self.sections[section][name] = value
-
-    def _raise(self, lineno, msg):
-        raise ParseError(self.path, lineno, msg)
-
-    def _parse(self, line_iter):
-        result = []
-        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))
-            # 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))
-            # 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:
-                    self._raise(lineno, 'unexpected value continuation')
-
-                if last_data:
-                    data = '%s\n%s' % (last_data, data)
-                result.append(last[:-1] + (data,))
-        return result
-
-    def _parseline(self, line, lineno):
-        # 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()
+                    raise ParseError(self.path, lineno, f"duplicate name {name!r}")
+                assert value is not None
+                sections_data[section][name] = value
 
-    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
-
-    def get(self, section, name, default=None, convert=str):
+        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:
+        ...
+
+    @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):
-    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 b6284be..0000000
--- a/src/iniconfig/__init__.pyi
+++ /dev/null
@@ -1,31 +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/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
new file mode 100644
index 0000000..bc898e6
--- /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) -> None:
+        super().__init__(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}"
diff --git a/testing/conftest.py b/testing/conftest.py
index d265a29..f122ddd 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -1,2 +1 @@
-
 option_doctestglob = "README.txt"
diff --git a/testing/test_iniconfig.py b/testing/test_iniconfig.py
index fe12421..9e94b28 100644
--- a/testing/test_iniconfig.py
+++ b/testing/test_iniconfig.py
@@ -1,282 +1,268 @@
-import py
+from __future__ import annotations
 import pytest
 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
 
 
-check_tokens = {
-    'section': (
-        '[section]',
-        [(0, 'section', None, None)]
+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",
+        [PL(0, "section", None, None), PL(1, "section", "value", "1")],
     ),
-    'value': (
-        'value = 1',
-        [(0, None, 'value', '1')]
+    "value with continuation": (
+        "names =\n Alice\n Bob",
+        [PL(0, None, "names", "Alice\nBob")],
     ),
-    'value in section': (
-        '[section]\nvalue=1',
-        [(0, 'section', None, None), (1, 'section', 'value', '1')]
+    "value with aligned continuation": (
+        "names = Alice\n        Bob",
+        [PL(0, None, "names", "Alice\nBob")],
     ),
-    'value with continuation': (
-        'names =\n Alice\n Bob',
-        [(0, None, 'names', 'Alice\nBob')]
+    "blank line": (
+        "[section]\n\nvalue=1",
+        [PL(0, "section", None, None), PL(2, "section", "value", "1")],
     ),
-    'value with aligned continuation': (
-        'names = Alice\n'
-        '        Bob',
-        [(0, None, 'names', 'Alice\nBob')]
+    "comment": ("# comment", []),
+    "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", [PL(0, "section", None, None)]),
+    "pseudo section syntax in value": (
+        "name = value []",
+        [PL(0, None, "name", "value []")],
     ),
-    'blank line': (
-        '[section]\n\nvalue=1',
-        [(0, 'section', None, None), (2, 'section', 'value', '1')]
-    ),
-    'comment': (
-        '# comment',
-        []
-    ),
-    'comment on value': (
-        'value = 1',
-        [(0, None, 'value', '1')]
-    ),
-
-    'comment on section': (
-        '[section] #comment',
-        [(0, 'section', None, None)]
-    ),
-    'comment2': (
-        '; comment',
-        []
-    ),
-
-    'comment2 on section': (
-        '[section] ;comment',
-        [(0, 'section', None, None)]
-    ),
-    'pseudo section syntax in value': (
-        'name = value []',
-        [(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"
-    return ini._parse(input.splitlines(True))
+def parse(input: str) -> list[PL]:
+    from iniconfig._parse import parse_lines
+
+    return parse_lines("sample", input.splitlines(True))
 
 
-def parse_a_error(input):
-    return py.test.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, 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():
-    excinfo = parse_a_error(' Foo')
-    assert excinfo.value.lineno == 0
+def test_continuation_needs_perceeding_token() -> None:
+    err = parse_a_error(" Foo")
+    assert err.lineno == 0
 
 
-def test_continuation_cant_be_after_section():
-    excinfo = parse_a_error('[section]\n Foo')
-    assert excinfo.value.lineno == 1
+def test_continuation_cant_be_after_section() -> None:
+    err = parse_a_error("[section]\n Foo")
+    assert err.lineno == 1
 
 
-def test_section_cant_be_empty():
-    excinfo = parse_a_error('[]')
-    assert excinfo.value.lineno == 0
+def test_section_cant_be_empty() -> None:
+    err = parse_a_error("[]")
+    assert err.lineno == 0
 
 
-@py.test.mark.parametrize('line', [
-    '!!',
-    ])
-def test_error_on_weird_lines(line):
+@pytest.mark.parametrize(
+    "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)
-    assert list(config.sections) == ['metadata']
-    config = IniConfig(path, "[diff]")
-    assert list(config.sections) == ['diff']
+    config = IniConfig(path=str(path))
+    assert list(config.sections) == ["metadata"]
+    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')
+        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)
+        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')
+        IniConfig("x", data="[section]\nname = Alice\nname = bob")
 
-    assert 'duplicate name' in str(excinfo.value)
+    assert "duplicate name" in str(excinfo.value)
 
 
-def test_iniconfig_lineof():
-    config = IniConfig("x.ini", data=(
-        '[section]\n'
-        'value = 1\n'
-        '[section2]\n'
-        '# comment\n'
-        'value =2'
-    ))
+def test_iniconfig_lineof() -> None:
+    config = IniConfig(
+        "x.ini",
+        data=("[section]\nvalue = 1\n[section2]\n# comment\nvalue =2"),
+    )
 
-    assert config.lineof('missing') is None
-    assert config.lineof('section') == 1
-    assert config.lineof('section2') == 3
-    assert config.lineof('section', 'value') == 2
-    assert config.lineof('section2', 'value') == 5
+    assert config.lineof("missing") is None
+    assert config.lineof("section") == 1
+    assert config.lineof("section2") == 3
+    assert config.lineof("section", "value") == 2
+    assert config.lineof("section2", "value") == 5
 
-    assert config['section'].lineof('value') == 2
-    assert config['section2'].lineof('value') == 5
+    assert config["section"].lineof("value") == 2
+    assert config["section2"].lineof("value") == 5
 
 
-def test_iniconfig_get_convert():
-    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_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():
-    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_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():
-    config = IniConfig("x", data='[section]\nvalue=1')
-    section = config['section']
-    assert section.get('value', convert=int) == 1
-    assert section.get('value', 1) == "1"
-    assert section.get('missing', 2) == 2
+def test_section_get() -> None:
+    config = IniConfig("x", data="[section]\nvalue=1")
+    section = config["section"]
+    assert section.get("value", convert=int) == 1
+    assert section.get("value", 1) == "1"
+    assert section.get("missing", 2) == 2
 
 
-def test_missing_section():
-    config = IniConfig("x", data='[section]\nvalue=1')
+def test_missing_section() -> None:
+    config = IniConfig("x", data="[section]\nvalue=1")
     with pytest.raises(KeyError):
-            config["other"]
+        config["other"]
 
 
-def test_section_getitem():
-    config = IniConfig("x", data='[section]\nvalue=1')
-    assert config['section']['value'] == '1'
-    assert config['section']['value'] == '1'
+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():
-    config = IniConfig("x", data='[section]\nvalue=1')
-    names = list(config['section'])
-    assert names == ['value']
-    items = list(config['section'].items())
-    assert items == [('value', '1')]
+def test_section_iter() -> None:
+    config = IniConfig("x", data="[section]\nvalue=1")
+    names = list(config["section"])
+    assert names == ["value"]
+    items = list(config["section"].items())
+    assert items == [("value", "1")]
 
 
-def test_config_iter():
-    config = IniConfig("x.ini", data=dedent('''
+def test_config_iter() -> None:
+    config = IniConfig(
+        "x.ini",
+        data=dedent(
+            """
           [section1]
           value=1
           [section2]
           value=2
-    '''))
+    """
+        ),
+    )
     l = list(config)
     assert len(l) == 2
-    assert l[0].name == 'section1'
-    assert l[0]['value'] == '1'
-    assert l[1].name == 'section2'
-    assert l[1]['value'] == '2'
+    assert l[0].name == "section1"
+    assert l[0]["value"] == "1"
+    assert l[1].name == "section2"
+    assert l[1]["value"] == "2"
 
 
-def test_config_contains():
-    config = IniConfig("x.ini", data=dedent('''
+def test_config_contains() -> None:
+    config = IniConfig(
+        "x.ini",
+        data=dedent(
+            """
           [section1]
           value=1
           [section2]
           value=2
-    '''))
-    assert 'xyz' not in config
-    assert 'section1' in config
-    assert 'section2' in config
-
-
-def test_iter_file_order():
-    config = IniConfig("x.ini", data="""
+    """
+        ),
+    )
+    assert "xyz" not in config
+    assert "section1" in config
+    assert "section2" in config
+
+
+def test_iter_file_order() -> None:
+    config = IniConfig(
+        "x.ini",
+        data="""
 [section2] #cpython dict ordered before section
 value = 1
 value2 = 2 # dict ordered before value
 [section]
 a = 1
 b = 2
-""")
+""",
+    )
     l = list(config)
     secnames = [x.name for x in l]
-    assert secnames == ['section2', 'section']
-    assert list(config['section2']) == ['value', 'value2']
-    assert list(config['section']) == ['a', 'b']
+    assert secnames == ["section2", "section"]
+    assert list(config["section2"]) == ["value", "value2"]
+    assert list(config["section"]) == ["a", "b"]
 
 
-def test_example_pypirc():
-    config = IniConfig("pypirc", data=dedent('''
+def test_example_pypirc() -> None:
+    config = IniConfig(
+        "pypirc",
+        data=dedent(
+            """
         [distutils]
         index-servers =
             pypi
@@ -291,24 +277,29 @@ def test_example_pypirc():
         repository: http://example.com/pypi
         username: <username>
         password: <password>
-    '''))
+    """
+        ),
+    )
     distutils, pypi, other = list(config)
     assert distutils["index-servers"] == "pypi\nother"
-    assert pypi['repository'] == '<repository-url>'
-    assert pypi['username'] == '<username>'
-    assert pypi['password'] == '<password>'
-    assert ['repository', 'username', 'password'] == list(other)
-
-
-def test_api_import():
-    assert ALL == ['IniConfig', 'ParseError']
-
-
-@pytest.mark.parametrize("line", [
-    "#qwe",
-    "  #qwe",
-    ";qwe",
-    " ;qwe",
-])
-def test_iscommentline_true(line):
+    assert pypi["repository"] == "<repository-url>"
+    assert pypi["username"] == "<username>"
+    assert pypi["password"] == "<password>"
+    assert ["repository", "username", "password"] == list(other)
+
+
+def test_api_import() -> None:
+    assert ALL == ["IniConfig", "ParseError", "COMMENTCHARS", "iscommentline"]
+
+
+@pytest.mark.parametrize(
+    "line",
+    [
+        "#qwe",
+        "  #qwe",
+        ";qwe",
+        " ;qwe",
+    ],
+)
+def test_iscommentline_true(line: str) -> None:
     assert iscommentline(line)
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 298838b..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,14 +0,0 @@
-[tox]
-envlist=py27,py26,py33,py34,py35
-
-
-[testenv]
-commands=
-  pytest {posargs}
-deps=
-  pytest
-
-
-[pytest]
-testpaths=
-  testing

More details

Full run details

Historical runs