├── .github └── workflows │ ├── deploy.yml │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── README.rst ├── example.ini ├── pyproject.toml ├── src └── iniconfig │ ├── __init__.py │ ├── _parse.py │ ├── exceptions.py │ └── py.typed └── testing ├── conftest.py └── test_iniconfig.py /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - "*deploy*" 8 | release: 9 | types: 10 | - published 11 | 12 | jobs: 13 | build: 14 | if: github.repository == 'pytest-dev/iniconfig' 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - name: Cache 22 | uses: actions/cache@v4 23 | with: 24 | path: ~/.cache/pip 25 | key: deploy-${{ hashFiles('**/pyproject.toml') }} 26 | restore-keys: | 27 | deploy- 28 | 29 | - name: Set up Python 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: "3.x" 33 | 34 | - name: Install build + twine 35 | run: python -m pip install build twine setuptools_scm 36 | 37 | - name: git describe output 38 | run: git describe --tags 39 | 40 | - id: scm_version 41 | run: | 42 | VERSION=$(python -m setuptools_scm --strip-dev) 43 | echo SETUPTOOLS_SCM_PRETEND_VERSION=$VERSION >> $GITHUB_ENV 44 | 45 | - name: Build package 46 | run: python -m build 47 | 48 | - name: twine check 49 | run: twine check dist/* 50 | 51 | - name: Publish package to PyPI 52 | if: github.event.action == 'published' 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | with: 55 | user: __token__ 56 | password: ${{ secrets.pypi_password }} 57 | 58 | - name: Publish package to TestPyPI 59 | uses: pypa/gh-action-pypi-publish@release/v1 60 | with: 61 | user: __token__ 62 | password: ${{ secrets.test_pypi_password }} 63 | repository_url: https://test.pypi.org/legacy/ 64 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ${{ matrix.os }} 9 | 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 14 | os: [ubuntu-latest, windows-latest] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python }} 22 | allow-prereleases: true 23 | - name: Install hatch 24 | run: python -m pip install --upgrade pip hatch hatch-vcs 25 | - name: Run tests 26 | run: hatch run +py=${{ matrix.python }} test:default --color=yes 27 | 28 | pre-commit: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: actions/setup-python@v5 33 | with: 34 | python-version: 3.x 35 | - uses: pre-commit/action@v3.0.1 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .cache/ 4 | .eggs/ 5 | build/ 6 | dist/ 7 | __pycache__ 8 | .tox/ 9 | src/iniconfig/_version.py -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v3.3.1 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py38-plus] 7 | - repo: https://github.com/tox-dev/pyproject-fmt 8 | rev: "0.4.1" 9 | hooks: 10 | - id: pyproject-fmt 11 | 12 | - repo: https://github.com/psf/black 13 | rev: 22.12.0 14 | hooks: 15 | - id: black 16 | language_version: python3 17 | - repo: https://github.com/pre-commit/mirrors-mypy 18 | rev: 'v0.991' 19 | hooks: 20 | - id: mypy 21 | args: [] 22 | additional_dependencies: 23 | - "pytest==7.2.0" 24 | - "tomli" -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 2.1.0 2 | ===== 3 | 4 | * fix artifact building - pin minimal version of hatch 5 | * drop eol python 3.8 6 | * add python 3.12 and 3.13 7 | 8 | 2.0.0 9 | ====== 10 | 11 | * add support for Python 3.7-3.11 12 | * drop support for Python 2.6-3.6 13 | * add encoding argument defaulting to utf-8 14 | * inline and clarify type annotations 15 | * move parsing code from inline to extra file 16 | * add typing overloads for helper methods 17 | 18 | 19 | .. note:: 20 | 21 | major release due to the major changes in python versions supported + changes in packaging 22 | 23 | the api is expected to be compatible 24 | 25 | 26 | 1.1.1 27 | ===== 28 | 29 | * fix version determination (thanks @florimondmanca) 30 | 31 | 1.1.0 32 | ===== 33 | 34 | - typing stubs (thanks @bluetech) 35 | - ci fixes 36 | 37 | 1.0.1 38 | ===== 39 | 40 | pytest 5+ support 41 | 42 | 1.0 43 | === 44 | 45 | - re-sync with pylib codebase 46 | - add support for Python 3.4-3.5 47 | - drop support for Python 2.4-2.5, 3.2 48 | 49 | 0.2 50 | === 51 | 52 | - added ability to ask "name in iniconfig", i.e. to check 53 | if a section is contained. 54 | 55 | - fix bug in "name=value" parsing where value was "x=3" 56 | 57 | - allow for ': ' to delimit name=value pairs, so that e.g. .pypirc files 58 | like http://docs.python.org/distutils/packageindex.html 59 | can be successfully parsed 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2010 - 2023 Holger Krekel and others 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include example.ini 3 | include tox.ini 4 | include src/iniconfig/py.typed 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | iniconfig: brain-dead simple parsing of ini files 2 | ======================================================= 3 | 4 | iniconfig is a small and simple INI-file parser module 5 | having a unique set of features: 6 | 7 | * maintains order of sections and entries 8 | * supports multi-line values with or without line-continuations 9 | * supports "#" comments everywhere 10 | * raises errors with proper line-numbers 11 | * no bells and whistles like automatic substitutions 12 | * iniconfig raises an Error if two sections have the same name. 13 | 14 | If you encounter issues or have feature wishes please report them to: 15 | 16 | https://github.com/RonnyPfannschmidt/iniconfig/issues 17 | 18 | Basic Example 19 | =================================== 20 | 21 | If you have an ini file like this: 22 | 23 | .. code-block:: ini 24 | 25 | # content of example.ini 26 | [section1] # comment 27 | name1=value1 # comment 28 | name1b=value1,value2 # comment 29 | 30 | [section2] 31 | name2= 32 | line1 33 | line2 34 | 35 | then you can do: 36 | 37 | .. code-block:: pycon 38 | 39 | >>> import iniconfig 40 | >>> ini = iniconfig.IniConfig("example.ini") 41 | >>> ini['section1']['name1'] # raises KeyError if not exists 42 | 'value1' 43 | >>> ini.get('section1', 'name1b', [], lambda x: x.split(",")) 44 | ['value1', 'value2'] 45 | >>> ini.get('section1', 'notexist', [], lambda x: x.split(",")) 46 | [] 47 | >>> [x.name for x in list(ini)] 48 | ['section1', 'section2'] 49 | >>> list(list(ini)[0].items()) 50 | [('name1', 'value1'), ('name1b', 'value1,value2')] 51 | >>> 'section1' in ini 52 | True 53 | >>> 'inexistendsection' in ini 54 | False 55 | -------------------------------------------------------------------------------- /example.ini: -------------------------------------------------------------------------------- 1 | 2 | # content of example.ini 3 | [section1] # comment 4 | name1=value1 # comment 5 | name1b=value1,value2 # comment 6 | 7 | [section2] 8 | name2= 9 | line1 10 | line2 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs", 5 | "hatchling>=1.26", 6 | ] 7 | 8 | [project] 9 | name = "iniconfig" 10 | description = "brain-dead simple config-ini parsing" 11 | readme = "README.rst" 12 | license = "MIT" 13 | authors = [ 14 | { name = "Ronny Pfannschmidt", email = "opensource@ronnypfannschmidt.de" }, 15 | { name = "Holger Krekel", email = "holger.krekel@gmail.com" }, 16 | ] 17 | requires-python = ">=3.8" 18 | dynamic = [ 19 | "version", 20 | ] 21 | classifiers = [ 22 | "Development Status :: 4 - Beta", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: MacOS :: MacOS X", 26 | "Operating System :: Microsoft :: Windows", 27 | "Operating System :: POSIX", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3 :: Only", 30 | "Programming Language :: Python :: 3.8", 31 | "Programming Language :: Python :: 3.9", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "Programming Language :: Python :: 3.13", 36 | "Topic :: Software Development :: Libraries", 37 | "Topic :: Utilities", 38 | ] 39 | [project.urls] 40 | Homepage = "https://github.com/pytest-dev/iniconfig" 41 | 42 | 43 | [tool.hatch.version] 44 | source = "vcs" 45 | 46 | [tool.hatch.build.hooks.vcs] 47 | version-file = "src/iniconfig/_version.py" 48 | 49 | [tool.hatch.build.targets.sdist] 50 | include = [ 51 | "/src", 52 | "/testing", 53 | ] 54 | 55 | [tool.hatch.envs.test] 56 | dependencies = [ 57 | "pytest" 58 | ] 59 | [tool.hatch.envs.test.scripts] 60 | default = "pytest {args}" 61 | 62 | [[tool.hatch.envs.test.matrix]] 63 | python = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 64 | 65 | [tool.setuptools_scm] 66 | 67 | [tool.mypy] 68 | strict = true 69 | 70 | 71 | [tool.pytest.ini_options] 72 | testpaths = "testing" 73 | -------------------------------------------------------------------------------- /src/iniconfig/__init__.py: -------------------------------------------------------------------------------- 1 | """ brain-dead simple parser for ini-style files. 2 | (C) Ronny Pfannschmidt, Holger Krekel -- MIT licensed 3 | """ 4 | from __future__ import annotations 5 | from typing import ( 6 | Callable, 7 | Iterator, 8 | Mapping, 9 | Optional, 10 | Tuple, 11 | TypeVar, 12 | Union, 13 | TYPE_CHECKING, 14 | NoReturn, 15 | NamedTuple, 16 | overload, 17 | cast, 18 | ) 19 | 20 | import os 21 | 22 | if TYPE_CHECKING: 23 | from typing import Final 24 | 25 | __all__ = ["IniConfig", "ParseError", "COMMENTCHARS", "iscommentline"] 26 | 27 | from .exceptions import ParseError 28 | from . import _parse 29 | from ._parse import COMMENTCHARS, iscommentline 30 | 31 | _D = TypeVar("_D") 32 | _T = TypeVar("_T") 33 | 34 | 35 | class SectionWrapper: 36 | config: Final[IniConfig] 37 | name: Final[str] 38 | 39 | def __init__(self, config: IniConfig, name: str) -> None: 40 | self.config = config 41 | self.name = name 42 | 43 | def lineof(self, name: str) -> int | None: 44 | return self.config.lineof(self.name, name) 45 | 46 | @overload 47 | def get(self, key: str) -> str | None: 48 | ... 49 | 50 | @overload 51 | def get( 52 | self, 53 | key: str, 54 | convert: Callable[[str], _T], 55 | ) -> _T | None: 56 | ... 57 | 58 | @overload 59 | def get( 60 | self, 61 | key: str, 62 | default: None, 63 | convert: Callable[[str], _T], 64 | ) -> _T | None: 65 | ... 66 | 67 | @overload 68 | def get(self, key: str, default: _D, convert: None = None) -> str | _D: 69 | ... 70 | 71 | @overload 72 | def get( 73 | self, 74 | key: str, 75 | default: _D, 76 | convert: Callable[[str], _T], 77 | ) -> _T | _D: 78 | ... 79 | 80 | # TODO: investigate possible mypy bug wrt matching the passed over data 81 | def get( # type: ignore [misc] 82 | self, 83 | key: str, 84 | default: _D | None = None, 85 | convert: Callable[[str], _T] | None = None, 86 | ) -> _D | _T | str | None: 87 | return self.config.get(self.name, key, convert=convert, default=default) 88 | 89 | def __getitem__(self, key: str) -> str: 90 | return self.config.sections[self.name][key] 91 | 92 | def __iter__(self) -> Iterator[str]: 93 | section: Mapping[str, str] = self.config.sections.get(self.name, {}) 94 | 95 | def lineof(key: str) -> int: 96 | return self.config.lineof(self.name, key) # type: ignore[return-value] 97 | 98 | yield from sorted(section, key=lineof) 99 | 100 | def items(self) -> Iterator[tuple[str, str]]: 101 | for name in self: 102 | yield name, self[name] 103 | 104 | 105 | class IniConfig: 106 | path: Final[str] 107 | sections: Final[Mapping[str, Mapping[str, str]]] 108 | 109 | def __init__( 110 | self, 111 | path: str | os.PathLike[str], 112 | data: str | None = None, 113 | encoding: str = "utf-8", 114 | ) -> None: 115 | self.path = os.fspath(path) 116 | if data is None: 117 | with open(self.path, encoding=encoding) as fp: 118 | data = fp.read() 119 | 120 | tokens = _parse.parse_lines(self.path, data.splitlines(True)) 121 | 122 | self._sources = {} 123 | sections_data: dict[str, dict[str, str]] 124 | self.sections = sections_data = {} 125 | 126 | for lineno, section, name, value in tokens: 127 | if section is None: 128 | raise ParseError(self.path, lineno, "no section header defined") 129 | self._sources[section, name] = lineno 130 | if name is None: 131 | if section in self.sections: 132 | raise ParseError( 133 | self.path, lineno, f"duplicate section {section!r}" 134 | ) 135 | sections_data[section] = {} 136 | else: 137 | if name in self.sections[section]: 138 | raise ParseError(self.path, lineno, f"duplicate name {name!r}") 139 | assert value is not None 140 | sections_data[section][name] = value 141 | 142 | def lineof(self, section: str, name: str | None = None) -> int | None: 143 | lineno = self._sources.get((section, name)) 144 | return None if lineno is None else lineno + 1 145 | 146 | @overload 147 | def get( 148 | self, 149 | section: str, 150 | name: str, 151 | ) -> str | None: 152 | ... 153 | 154 | @overload 155 | def get( 156 | self, 157 | section: str, 158 | name: str, 159 | convert: Callable[[str], _T], 160 | ) -> _T | None: 161 | ... 162 | 163 | @overload 164 | def get( 165 | self, 166 | section: str, 167 | name: str, 168 | default: None, 169 | convert: Callable[[str], _T], 170 | ) -> _T | None: 171 | ... 172 | 173 | @overload 174 | def get( 175 | self, section: str, name: str, default: _D, convert: None = None 176 | ) -> str | _D: 177 | ... 178 | 179 | @overload 180 | def get( 181 | self, 182 | section: str, 183 | name: str, 184 | default: _D, 185 | convert: Callable[[str], _T], 186 | ) -> _T | _D: 187 | ... 188 | 189 | def get( # type: ignore 190 | self, 191 | section: str, 192 | name: str, 193 | default: _D | None = None, 194 | convert: Callable[[str], _T] | None = None, 195 | ) -> _D | _T | str | None: 196 | try: 197 | value: str = self.sections[section][name] 198 | except KeyError: 199 | return default 200 | else: 201 | if convert is not None: 202 | return convert(value) 203 | else: 204 | return value 205 | 206 | def __getitem__(self, name: str) -> SectionWrapper: 207 | if name not in self.sections: 208 | raise KeyError(name) 209 | return SectionWrapper(self, name) 210 | 211 | def __iter__(self) -> Iterator[SectionWrapper]: 212 | for name in sorted(self.sections, key=self.lineof): # type: ignore 213 | yield SectionWrapper(self, name) 214 | 215 | def __contains__(self, arg: str) -> bool: 216 | return arg in self.sections 217 | -------------------------------------------------------------------------------- /src/iniconfig/_parse.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from .exceptions import ParseError 3 | 4 | from typing import NamedTuple 5 | 6 | 7 | COMMENTCHARS = "#;" 8 | 9 | 10 | class _ParsedLine(NamedTuple): 11 | lineno: int 12 | section: str | None 13 | name: str | None 14 | value: str | None 15 | 16 | 17 | def parse_lines(path: str, line_iter: list[str]) -> list[_ParsedLine]: 18 | result: list[_ParsedLine] = [] 19 | section = None 20 | for lineno, line in enumerate(line_iter): 21 | name, data = _parseline(path, line, lineno) 22 | # new value 23 | if name is not None and data is not None: 24 | result.append(_ParsedLine(lineno, section, name, data)) 25 | # new section 26 | elif name is not None and data is None: 27 | if not name: 28 | raise ParseError(path, lineno, "empty section name") 29 | section = name 30 | result.append(_ParsedLine(lineno, section, None, None)) 31 | # continuation 32 | elif name is None and data is not None: 33 | if not result: 34 | raise ParseError(path, lineno, "unexpected value continuation") 35 | last = result.pop() 36 | if last.name is None: 37 | raise ParseError(path, lineno, "unexpected value continuation") 38 | 39 | if last.value: 40 | last = last._replace(value=f"{last.value}\n{data}") 41 | else: 42 | last = last._replace(value=data) 43 | result.append(last) 44 | return result 45 | 46 | 47 | def _parseline(path: str, line: str, lineno: int) -> tuple[str | None, str | None]: 48 | # blank lines 49 | if iscommentline(line): 50 | line = "" 51 | else: 52 | line = line.rstrip() 53 | if not line: 54 | return None, None 55 | # section 56 | if line[0] == "[": 57 | realline = line 58 | for c in COMMENTCHARS: 59 | line = line.split(c)[0].rstrip() 60 | if line[-1] == "]": 61 | return line[1:-1], None 62 | return None, realline.strip() 63 | # value 64 | elif not line[0].isspace(): 65 | try: 66 | name, value = line.split("=", 1) 67 | if ":" in name: 68 | raise ValueError() 69 | except ValueError: 70 | try: 71 | name, value = line.split(":", 1) 72 | except ValueError: 73 | raise ParseError(path, lineno, "unexpected line: %r" % line) 74 | return name.strip(), value.strip() 75 | # continuation 76 | else: 77 | return None, line.strip() 78 | 79 | 80 | def iscommentline(line: str) -> bool: 81 | c = line.lstrip()[:1] 82 | return c in COMMENTCHARS 83 | -------------------------------------------------------------------------------- /src/iniconfig/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: 5 | from typing import Final 6 | 7 | 8 | class ParseError(Exception): 9 | path: Final[str] 10 | lineno: Final[int] 11 | msg: Final[str] 12 | 13 | def __init__(self, path: str, lineno: int, msg: str) -> None: 14 | super().__init__(path, lineno, msg) 15 | self.path = path 16 | self.lineno = lineno 17 | self.msg = msg 18 | 19 | def __str__(self) -> str: 20 | return f"{self.path}:{self.lineno + 1}: {self.msg}" 21 | -------------------------------------------------------------------------------- /src/iniconfig/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pytest-dev/iniconfig/71901257f6cbf660e77b1077e00f11a0f440be1e/src/iniconfig/py.typed -------------------------------------------------------------------------------- /testing/conftest.py: -------------------------------------------------------------------------------- 1 | option_doctestglob = "README.txt" 2 | -------------------------------------------------------------------------------- /testing/test_iniconfig.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import pytest 3 | from iniconfig import IniConfig, ParseError, __all__ as ALL 4 | from iniconfig._parse import _ParsedLine as PL 5 | from iniconfig import iscommentline 6 | from textwrap import dedent 7 | from pathlib import Path 8 | 9 | 10 | check_tokens: dict[str, tuple[str, list[PL]]] = { 11 | "section": ("[section]", [PL(0, "section", None, None)]), 12 | "value": ("value = 1", [PL(0, None, "value", "1")]), 13 | "value in section": ( 14 | "[section]\nvalue=1", 15 | [PL(0, "section", None, None), PL(1, "section", "value", "1")], 16 | ), 17 | "value with continuation": ( 18 | "names =\n Alice\n Bob", 19 | [PL(0, None, "names", "Alice\nBob")], 20 | ), 21 | "value with aligned continuation": ( 22 | "names = Alice\n Bob", 23 | [PL(0, None, "names", "Alice\nBob")], 24 | ), 25 | "blank line": ( 26 | "[section]\n\nvalue=1", 27 | [PL(0, "section", None, None), PL(2, "section", "value", "1")], 28 | ), 29 | "comment": ("# comment", []), 30 | "comment on value": ("value = 1", [PL(0, None, "value", "1")]), 31 | "comment on section": ("[section] #comment", [PL(0, "section", None, None)]), 32 | "comment2": ("; comment", []), 33 | "comment2 on section": ("[section] ;comment", [PL(0, "section", None, None)]), 34 | "pseudo section syntax in value": ( 35 | "name = value []", 36 | [PL(0, None, "name", "value []")], 37 | ), 38 | "assignment in value": ("value = x = 3", [PL(0, None, "value", "x = 3")]), 39 | "use of colon for name-values": ("name: y", [PL(0, None, "name", "y")]), 40 | "use of colon without space": ("value:y=5", [PL(0, None, "value", "y=5")]), 41 | "equality gets precedence": ("value=xyz:5", [PL(0, None, "value", "xyz:5")]), 42 | } 43 | 44 | 45 | @pytest.fixture(params=sorted(check_tokens)) 46 | def input_expected(request: pytest.FixtureRequest) -> tuple[str, list[PL]]: 47 | 48 | return check_tokens[request.param] 49 | 50 | 51 | @pytest.fixture 52 | def input(input_expected: tuple[str, list[PL]]) -> str: 53 | return input_expected[0] 54 | 55 | 56 | @pytest.fixture 57 | def expected(input_expected: tuple[str, list[PL]]) -> list[PL]: 58 | return input_expected[1] 59 | 60 | 61 | def parse(input: str) -> list[PL]: 62 | from iniconfig._parse import parse_lines 63 | 64 | return parse_lines("sample", input.splitlines(True)) 65 | 66 | 67 | def parse_a_error(input: str) -> ParseError: 68 | try: 69 | parse(input) 70 | except ParseError as e: 71 | return e 72 | else: 73 | raise ValueError(input) 74 | 75 | 76 | def test_tokenize(input: str, expected: list[PL]) -> None: 77 | parsed = parse(input) 78 | assert parsed == expected 79 | 80 | 81 | def test_parse_empty() -> None: 82 | parsed = parse("") 83 | assert not parsed 84 | ini = IniConfig("sample", "") 85 | assert not ini.sections 86 | 87 | 88 | def test_ParseError() -> None: 89 | e = ParseError("filename", 0, "hello") 90 | assert str(e) == "filename:1: hello" 91 | 92 | 93 | def test_continuation_needs_perceeding_token() -> None: 94 | err = parse_a_error(" Foo") 95 | assert err.lineno == 0 96 | 97 | 98 | def test_continuation_cant_be_after_section() -> None: 99 | err = parse_a_error("[section]\n Foo") 100 | assert err.lineno == 1 101 | 102 | 103 | def test_section_cant_be_empty() -> None: 104 | err = parse_a_error("[]") 105 | assert err.lineno == 0 106 | 107 | 108 | @pytest.mark.parametrize( 109 | "line", 110 | [ 111 | "!!", 112 | ], 113 | ) 114 | def test_error_on_weird_lines(line: str) -> None: 115 | parse_a_error(line) 116 | 117 | 118 | def test_iniconfig_from_file(tmp_path: Path) -> None: 119 | path = tmp_path / "test.txt" 120 | path.write_text("[metadata]\nname=1") 121 | 122 | config = IniConfig(path=str(path)) 123 | assert list(config.sections) == ["metadata"] 124 | config = IniConfig(str(path), "[diff]") 125 | assert list(config.sections) == ["diff"] 126 | with pytest.raises(TypeError): 127 | IniConfig(data=path.read_text()) # type: ignore 128 | 129 | 130 | def test_iniconfig_section_first() -> None: 131 | with pytest.raises(ParseError) as excinfo: 132 | IniConfig("x", data="name=1") 133 | assert excinfo.value.msg == "no section header defined" 134 | 135 | 136 | def test_iniconig_section_duplicate_fails() -> None: 137 | with pytest.raises(ParseError) as excinfo: 138 | IniConfig("x", data="[section]\n[section]") 139 | assert "duplicate section" in str(excinfo.value) 140 | 141 | 142 | def test_iniconfig_duplicate_key_fails() -> None: 143 | with pytest.raises(ParseError) as excinfo: 144 | IniConfig("x", data="[section]\nname = Alice\nname = bob") 145 | 146 | assert "duplicate name" in str(excinfo.value) 147 | 148 | 149 | def test_iniconfig_lineof() -> None: 150 | config = IniConfig( 151 | "x.ini", 152 | data=("[section]\nvalue = 1\n[section2]\n# comment\nvalue =2"), 153 | ) 154 | 155 | assert config.lineof("missing") is None 156 | assert config.lineof("section") == 1 157 | assert config.lineof("section2") == 3 158 | assert config.lineof("section", "value") == 2 159 | assert config.lineof("section2", "value") == 5 160 | 161 | assert config["section"].lineof("value") == 2 162 | assert config["section2"].lineof("value") == 5 163 | 164 | 165 | def test_iniconfig_get_convert() -> None: 166 | config = IniConfig("x", data="[section]\nint = 1\nfloat = 1.1") 167 | assert config.get("section", "int") == "1" 168 | assert config.get("section", "int", convert=int) == 1 169 | 170 | 171 | def test_iniconfig_get_missing() -> None: 172 | config = IniConfig("x", data="[section]\nint = 1\nfloat = 1.1") 173 | assert config.get("section", "missing", default=1) == 1 174 | assert config.get("section", "missing") is None 175 | 176 | 177 | def test_section_get() -> None: 178 | config = IniConfig("x", data="[section]\nvalue=1") 179 | section = config["section"] 180 | assert section.get("value", convert=int) == 1 181 | assert section.get("value", 1) == "1" 182 | assert section.get("missing", 2) == 2 183 | 184 | 185 | def test_missing_section() -> None: 186 | config = IniConfig("x", data="[section]\nvalue=1") 187 | with pytest.raises(KeyError): 188 | config["other"] 189 | 190 | 191 | def test_section_getitem() -> None: 192 | config = IniConfig("x", data="[section]\nvalue=1") 193 | assert config["section"]["value"] == "1" 194 | assert config["section"]["value"] == "1" 195 | 196 | 197 | def test_section_iter() -> None: 198 | config = IniConfig("x", data="[section]\nvalue=1") 199 | names = list(config["section"]) 200 | assert names == ["value"] 201 | items = list(config["section"].items()) 202 | assert items == [("value", "1")] 203 | 204 | 205 | def test_config_iter() -> None: 206 | config = IniConfig( 207 | "x.ini", 208 | data=dedent( 209 | """ 210 | [section1] 211 | value=1 212 | [section2] 213 | value=2 214 | """ 215 | ), 216 | ) 217 | l = list(config) 218 | assert len(l) == 2 219 | assert l[0].name == "section1" 220 | assert l[0]["value"] == "1" 221 | assert l[1].name == "section2" 222 | assert l[1]["value"] == "2" 223 | 224 | 225 | def test_config_contains() -> None: 226 | config = IniConfig( 227 | "x.ini", 228 | data=dedent( 229 | """ 230 | [section1] 231 | value=1 232 | [section2] 233 | value=2 234 | """ 235 | ), 236 | ) 237 | assert "xyz" not in config 238 | assert "section1" in config 239 | assert "section2" in config 240 | 241 | 242 | def test_iter_file_order() -> None: 243 | config = IniConfig( 244 | "x.ini", 245 | data=""" 246 | [section2] #cpython dict ordered before section 247 | value = 1 248 | value2 = 2 # dict ordered before value 249 | [section] 250 | a = 1 251 | b = 2 252 | """, 253 | ) 254 | l = list(config) 255 | secnames = [x.name for x in l] 256 | assert secnames == ["section2", "section"] 257 | assert list(config["section2"]) == ["value", "value2"] 258 | assert list(config["section"]) == ["a", "b"] 259 | 260 | 261 | def test_example_pypirc() -> None: 262 | config = IniConfig( 263 | "pypirc", 264 | data=dedent( 265 | """ 266 | [distutils] 267 | index-servers = 268 | pypi 269 | other 270 | 271 | [pypi] 272 | repository: 273 | username: 274 | password: 275 | 276 | [other] 277 | repository: http://example.com/pypi 278 | username: 279 | password: 280 | """ 281 | ), 282 | ) 283 | distutils, pypi, other = list(config) 284 | assert distutils["index-servers"] == "pypi\nother" 285 | assert pypi["repository"] == "" 286 | assert pypi["username"] == "" 287 | assert pypi["password"] == "" 288 | assert ["repository", "username", "password"] == list(other) 289 | 290 | 291 | def test_api_import() -> None: 292 | assert ALL == ["IniConfig", "ParseError", "COMMENTCHARS", "iscommentline"] 293 | 294 | 295 | @pytest.mark.parametrize( 296 | "line", 297 | [ 298 | "#qwe", 299 | " #qwe", 300 | ";qwe", 301 | " ;qwe", 302 | ], 303 | ) 304 | def test_iscommentline_true(line: str) -> None: 305 | assert iscommentline(line) 306 | --------------------------------------------------------------------------------