Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-aiocsv for openSUSE:Factory checked in at 2025-09-29 16:37:21 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-aiocsv (Old) and /work/SRC/openSUSE:Factory/.python-aiocsv.new.11973 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-aiocsv" Mon Sep 29 16:37:21 2025 rev:4 rq:1307719 version:1.4.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-aiocsv/python-aiocsv.changes 2025-08-11 13:54:04.277014622 +0200 +++ /work/SRC/openSUSE:Factory/.python-aiocsv.new.11973/python-aiocsv.changes 2025-09-29 16:37:36.923985882 +0200 @@ -1,0 +2,12 @@ +Mon Sep 29 10:22:39 UTC 2025 - Dirk Müller <[email protected]> + +- update to 1.4.0: + * Allow numeric fields to start with escapechar + * Fix Dialect typing (wtf cpython) + * drop 3.8 support + * backport PyModule_Add + * Fix seg faults when Parser_new fails partway-through + * Consider CPython bug #113732 when running on 3.12 + * fix type checking issues with new quote settings + +------------------------------------------------------------------- Old: ---- aiocsv-1.3.2.tar.gz New: ---- aiocsv-1.4.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-aiocsv.spec ++++++ --- /var/tmp/diff_new_pack.K1fL08/_old 2025-09-29 16:37:37.584013535 +0200 +++ /var/tmp/diff_new_pack.K1fL08/_new 2025-09-29 16:37:37.584013535 +0200 @@ -18,7 +18,7 @@ %{?sle15_python_module_pythons} Name: python-aiocsv -Version: 1.3.2 +Version: 1.4.0 Release: 0 Summary: Asynchronous CSV reading/writing in Python License: MIT ++++++ aiocsv-1.3.2.tar.gz -> aiocsv-1.4.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiocsv-1.3.2/.github/workflows/deploy.yml new/aiocsv-1.4.0/.github/workflows/deploy.yml --- old/aiocsv-1.3.2/.github/workflows/deploy.yml 2024-04-28 12:29:50.000000000 +0200 +++ new/aiocsv-1.4.0/.github/workflows/deploy.yml 2025-09-22 14:04:03.000000000 +0200 @@ -12,15 +12,15 @@ runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, windows-latest, macos-13] + os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-13, macos-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Build wheels - uses: pypa/[email protected] + uses: pypa/[email protected] env: - CIBW_ARCHS: auto64 + CIBW_ARCHS: auto CIBW_BUILD: cp3*-* - uses: actions/upload-artifact@v4 @@ -32,7 +32,7 @@ needs: [build_wheels] runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v5 with: pattern: cibw-wheels-* merge-multiple: true @@ -42,3 +42,4 @@ with: user: __token__ password: ${{ secrets.pypi_password }} + skip-existing: true diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiocsv-1.3.2/.github/workflows/test.yml new/aiocsv-1.4.0/.github/workflows/test.yml --- old/aiocsv-1.3.2/.github/workflows/test.yml 2024-04-28 12:29:50.000000000 +0200 +++ new/aiocsv-1.4.0/.github/workflows/test.yml 2025-09-22 14:04:03.000000000 +0200 @@ -6,11 +6,11 @@ runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -23,11 +23,11 @@ name: Lint code runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.8 - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - name: Set up Python 3.9 + uses: actions/setup-python@v6 with: - python-version: "3.8" + python-version: "3.9" - name: Install dependencies run: pip install -Ur requirements.dev.txt - name: Check code formatting diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiocsv-1.3.2/aiocsv/__init__.py new/aiocsv-1.4.0/aiocsv/__init__.py --- old/aiocsv-1.3.2/aiocsv/__init__.py 2024-04-28 12:29:50.000000000 +0200 +++ new/aiocsv-1.4.0/aiocsv/__init__.py 2025-09-22 14:04:03.000000000 +0200 @@ -3,7 +3,7 @@ __title__ = "aiocsv" __description__ = "Asynchronous CSV reading/writing" -__version__ = "1.3.2" +__version__ = "1.4.0" __url__ = "https://github.com/MKuranowski/aiocsv" __author__ = "Mikołaj Kuranowski" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiocsv-1.3.2/aiocsv/_parser.c new/aiocsv-1.4.0/aiocsv/_parser.c --- old/aiocsv-1.3.2/aiocsv/_parser.c 2024-04-28 12:29:50.000000000 +0200 +++ new/aiocsv-1.4.0/aiocsv/_parser.c 2025-09-22 14:04:03.000000000 +0200 @@ -1,4 +1,4 @@ -// © Copyright 2020-2024 Mikołaj Kuranowski +// © Copyright 2020-2025 Mikołaj Kuranowski // SPDX-License-Identifier: MIT #include <assert.h> @@ -23,14 +23,6 @@ // * PYTHON API BACKPORTS * // ************************ -#if PY_VERSION_HEX < 0x03090000 - -static inline PyObject* PyObject_CallMethodOneArg(PyObject* self, PyObject* name, PyObject* arg) { - return PyObject_CallMethodObjArgs(self, name, arg, NULL); -} - -#endif - #if PY_VERSION_HEX < 0x030A0000 #define Py_TPFLAGS_IMMUTABLETYPE 0 @@ -96,6 +88,18 @@ #endif +#if PY_VERSION_HEX < 0x030D0000 + +static int PyModule_Add(PyObject* module, const char* name, PyObject* value) { + if (PyModule_AddObject(module, name, value) < 0) { + Py_XDECREF(value); + return -1; + } + return 0; +} + +#endif + // *************** // * DEFINITIONS * // *************** @@ -114,19 +118,18 @@ /// The string "read" PyObject* str_read; - - /// Parser class exposed by this module - PyTypeObject* parser_type; } ModuleState; -/// Returns a ModuleState* corresponding to a provided PyObject* representing a module -#define module_get_state(m) ((ModuleState*)PyModule_GetState(m)) +/// Returns a ModuleState* from a Parser instance (Parser*) +#define parser_get_module_state(p) ((ModuleState*)PyType_GetModuleState(Py_TYPE(p))) typedef enum { QUOTE_MINIMAL = 0, QUOTE_ALL = 1, QUOTE_NON_NUMERIC = 2, QUOTE_NONE = 3, + QUOTE_STRINGS = 4, + QUOTE_NOT_NULL = 5, } Quoting; typedef enum { @@ -197,12 +200,6 @@ // clang-format off PyObject_HEAD - /// Pointer to the _parser module. Required for 3.8 compatibility. - /// - /// TODO: Drop field once support for 3.8 is dropped. - /// PyType_GetModuleState(Py_TYPE(self)) should be used instead. - PyObject* module; - /// Anything with a `async def read(self, n: int) -> str` method. PyObject* reader; @@ -240,8 +237,8 @@ /// ParserState for the parser state machine. unsigned char state; - /// True if current field should be interpreted as a float. - bool field_was_numeric; + /// True if current field was quoted. + bool field_was_quoted; /// True if last returned character was a CR, used to avoid counting CR-LF as two separate lines. bool last_char_was_cr; @@ -306,7 +303,8 @@ if (PyErr_Occurred()) { \ return 0; \ } \ - if (quoting_value < (Py_ssize_t)QUOTE_MINIMAL || quoting_value > (Py_ssize_t)QUOTE_NONE) { \ + if (quoting_value < (Py_ssize_t)QUOTE_MINIMAL || \ + quoting_value > (Py_ssize_t)QUOTE_NOT_NULL) { \ PyErr_Format(PyExc_ValueError, "dialect.quoting: unexpected value %zd", quoting_value); \ return 0; \ } \ @@ -342,31 +340,44 @@ } static int Parser_traverse(Parser* self, visitproc visit, void* arg) { - Py_VISIT(self->module); Py_VISIT(self->reader); Py_VISIT(self->current_read); Py_VISIT(self->buffer_str); Py_VISIT(self->record_so_far); -#if PY_VERSION_HEX >= 0x03090000 Py_VISIT(Py_TYPE(self)); -#endif return 0; } static int Parser_clear(Parser* self) { - Py_CLEAR(self->module); Py_CLEAR(self->reader); Py_CLEAR(self->current_read); Py_CLEAR(self->record_so_far); return 0; } -static PyObject* Parser_new(PyObject* module, PyObject* args, PyObject* kwargs) { - ModuleState* state = module_get_state(module); - - Parser* self = PyObject_GC_New(Parser, state->parser_type); +static PyObject* Parser_new(PyTypeObject* subtype, PyObject* args, PyObject* kwargs) { + ModuleState* state = PyType_GetModuleState(subtype); + Parser* self = PyObject_GC_New(Parser, subtype); if (!self) return NULL; + // Zero-initialize all custom Parser fields. In case the initialization fails, + // we need to ensure the deallocator doesn't stumble on garbage values. + self->reader = NULL; + self->current_read = NULL; + self->buffer_str = NULL; + self->buffer_idx = 0; + self->record_so_far = NULL; + self->field_so_far = NULL; + self->field_so_far_capacity = 0; + self->field_so_far_len = 0; + self->dialect = (Dialect){0}; + self->field_size_limit = 0; + self->line_num = 0; + self->state = STATE_START_RECORD; + self->field_was_quoted = false; + self->last_char_was_cr = false; + self->eof = false; + PyObject* reader; PyObject* dialect; static char* kw_list[] = {"reader", "dialect", NULL}; @@ -380,11 +391,9 @@ return NULL; } - self->module = Py_NewRef(module); self->reader = Py_NewRef(reader); - PyObject* field_size_limit_obj = - PyObject_CallObject(module_get_state(module)->csv_field_size_limit, NULL); + PyObject* field_size_limit_obj = PyObject_CallObject(state->csv_field_size_limit, NULL); if (!field_size_limit_obj) { Py_DECREF(self); return NULL; @@ -397,19 +406,6 @@ return NULL; } - self->current_read = NULL; - self->record_so_far = NULL; - self->buffer_str = NULL; - self->buffer_idx = 0; - self->field_so_far = NULL; - self->field_so_far_capacity = 0; - self->field_so_far_len = 0; - self->line_num = 0; - self->state = STATE_START_RECORD; - self->field_was_numeric = false; - self->last_char_was_cr = false; - self->eof = false; - PyObject_GC_Track(self); return (PyObject*)self; } @@ -422,7 +418,7 @@ static int Parser_add_char(Parser* self, Py_UCS4 c) { if (self->field_so_far_len == self->field_size_limit) { - PyObject* err = module_get_state(self->module)->csv_error; + PyObject* err = parser_get_module_state(self)->csv_error; PyErr_Format(err, "field larger than field limit (%ld)", self->field_size_limit); return 0; } else if (self->field_so_far_len >= self->field_so_far_capacity) { @@ -471,14 +467,32 @@ self->field_so_far_len = 0; - // Cast the field to a float, if applicable - if (self->field_was_numeric) { - self->field_was_numeric = false; - - PyObject* field_as_float = PyFloat_FromString(field); - Py_DECREF(field); - if (!field_as_float) return 0; - field = field_as_float; + // Cast the field to a float or None, if applicable + if (!self->field_was_quoted) { + // Check if this field should be converted to float or None + bool is_none = false; + bool is_float = false; + if (self->dialect.quoting == QUOTE_NON_NUMERIC) { + is_float = PyObject_IsTrue(field); + } else if (self->dialect.quoting == QUOTE_STRINGS) { + is_none = PyObject_Not(field); + is_float = !is_none; + } else if (self->dialect.quoting == QUOTE_NOT_NULL) { + is_none = PyObject_Not(field); + } + + // Convert to None or float + if (is_none) { + Py_DECREF(field); + field = Py_NewRef(Py_None); + } else if (is_float) { + PyObject* field_as_float = PyFloat_FromString(field); + Py_DECREF(field); + if (!field_as_float) return 0; + field = field_as_float; + } + } else { + self->field_was_quoted = false; } // Append the field to the record @@ -551,7 +565,7 @@ self->state = STATE_IN_FIELD; return DECISION_CONTINUE; } else { - PyObject* csv_error = module_get_state(self->module)->csv_error; + PyObject* csv_error = parser_get_module_state(self)->csv_error; PyErr_Format(csv_error, "'%c' expected after '%c'", self->dialect.delimiter, self->dialect.quotechar); return DECISION_ERROR; @@ -616,6 +630,7 @@ self->state = STATE_START_RECORD; return DECISION_DONE; } else if (c == self->dialect.quotechar && self->dialect.quoting != QUOTE_NONE) { + self->field_was_quoted = true; self->state = STATE_IN_QUOTED_FIELD; return DECISION_CONTINUE; } else if (c == self->dialect.escapechar) { @@ -626,7 +641,6 @@ self->state = STATE_START_FIELD; return DECISION_CONTINUE; } else { - self->field_was_numeric = self->dialect.quoting == QUOTE_NON_NUMERIC; if (!Parser_add_char(self, c)) return DECISION_ERROR; self->state = STATE_IN_FIELD; return DECISION_CONTINUE; @@ -688,7 +702,7 @@ if (decision != DECISION_CONTINUE || (self->eof && !state_is_end_of_record(self->state))) { if (self->dialect.strict && state_is_unexpected_at_eof(self->state)) { - PyErr_SetString(module_get_state(self->module)->csv_error, "unexpected end of data"); + PyErr_SetString(parser_get_module_state(self)->csv_error, "unexpected end of data"); return NULL; } @@ -706,7 +720,7 @@ PyObject* read_coro = NULL; int result = 1; - ModuleState* module_state = module_get_state(self->module); + ModuleState* module_state = parser_get_module_state(self); read_coro = PyObject_CallMethodOneArg(self->reader, module_state->str_read, module_state->io_default_buffer_size); if (!read_coro) FINISH_WITH(0); @@ -823,6 +837,7 @@ {Py_tp_clear, Parser_clear}, {Py_tp_dealloc, Parser_dealloc}, {Py_tp_members, ParserMembers}, + {Py_tp_new, Parser_new}, {Py_am_await, Py_NewRef}, // Return "self" unchanged {Py_am_aiter, Py_NewRef}, // Return "self" unchanged {Py_am_anext, Py_NewRef}, // Return "self" unchanged @@ -835,8 +850,7 @@ .name = "_parser._Parser", .basicsize = sizeof(Parser), .itemsize = 0, - .flags = (Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC | Py_TPFLAGS_IMMUTABLETYPE | - Py_TPFLAGS_DISALLOW_INSTANTIATION), + .flags = (Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC | Py_TPFLAGS_IMMUTABLETYPE), .slots = ParserSlots, }; @@ -845,7 +859,7 @@ // ************************* static int module_clear(PyObject* module) { - ModuleState* state = module_get_state(module); + ModuleState* state = PyModule_GetState(module); if (state) { Py_CLEAR(state->csv_error); Py_CLEAR(state->csv_field_size_limit); @@ -856,7 +870,7 @@ } static int module_traverse(PyObject* module, visitproc visit, void* arg) { - ModuleState* state = module_get_state(module); + ModuleState* state = PyModule_GetState(module); if (state) { Py_VISIT(state->csv_error); Py_VISIT(state->csv_field_size_limit); @@ -873,7 +887,7 @@ PyObject* csv_module = NULL; PyObject* io_module = NULL; - ModuleState* state = module_get_state(module); + ModuleState* state = PyModule_GetState(module); state->str_read = PyUnicode_InternFromString("read"); if (!state->str_read) FINISH_WITH(-1); @@ -902,8 +916,8 @@ FINISH_WITH(-1); } - state->parser_type = (PyTypeObject*)PyType_FromSpec(&ParserSpec); - if (!state->parser_type) FINISH_WITH(-1); + if (PyModule_Add(module, "Parser", PyType_FromModuleAndSpec(module, &ParserSpec, NULL))) + FINISH_WITH(-1); ret: Py_XDECREF(csv_module); @@ -911,12 +925,6 @@ return result; } -static PyMethodDef ModuleMethods[] = { - {"Parser", (PyCFunction)Parser_new, METH_VARARGS | METH_KEYWORDS, - "Creates a new Parser instance"}, - {NULL, NULL}, -}; - static PyModuleDef_Slot ModuleSlots[] = { {Py_mod_exec, module_exec}, #if PY_VERSION_HEX >= 0x030C0000 @@ -931,7 +939,6 @@ .m_doc = "_parser implements asynchronous CSV record parsing", .m_size = sizeof(ModuleState), .m_slots = ModuleSlots, - .m_methods = ModuleMethods, .m_traverse = module_traverse, .m_clear = module_clear, .m_free = module_free, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiocsv-1.3.2/aiocsv/_parser.pyi new/aiocsv-1.4.0/aiocsv/_parser.pyi --- old/aiocsv-1.3.2/aiocsv/_parser.pyi 2024-04-28 12:29:50.000000000 +0200 +++ new/aiocsv-1.4.0/aiocsv/_parser.pyi 2025-09-22 14:04:03.000000000 +0200 @@ -1,16 +1,17 @@ -# © Copyright 2020-2024 Mikołaj Kuranowski +# © Copyright 2020-2025 Mikołaj Kuranowski # SPDX-License-Identifier: MIT from typing import AsyncIterator, Awaitable, List +from typing_extensions import Self + from .protocols import DialectLike, WithAsyncRead -class _Parser: +class Parser: """Return type of the "Parser" function, not accessible from Python.""" + def __new__(cls, reader: WithAsyncRead, dialect: DialectLike) -> Self: ... def __aiter__(self) -> AsyncIterator[List[str]]: ... def __anext__(self) -> Awaitable[List[str]]: ... @property def line_num(self) -> int: ... - -def Parser(reader: WithAsyncRead, dialect: DialectLike) -> _Parser: ... diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiocsv-1.3.2/aiocsv/parser.py new/aiocsv-1.4.0/aiocsv/parser.py --- old/aiocsv-1.3.2/aiocsv/parser.py 2024-04-28 12:29:50.000000000 +0200 +++ new/aiocsv-1.4.0/aiocsv/parser.py 2025-09-22 14:04:03.000000000 +0200 @@ -1,9 +1,11 @@ -# © Copyright 2020-2024 Mikołaj Kuranowski +# © Copyright 2020-2025 Mikołaj Kuranowski # SPDX-License-Identifier: MIT +# pyright: reportUnnecessaryComparison=false import csv +import sys from enum import IntEnum, auto -from typing import Any, AsyncIterator, Awaitable, Generator, List, Optional, Sequence, Union +from typing import Any, AsyncIterator, Awaitable, Final, Generator, List, Optional, Sequence, Union from .protocols import DialectLike, WithAsyncRead @@ -39,6 +41,12 @@ QUOTE_ALL = csv.QUOTE_ALL QUOTE_NONNUMERIC = csv.QUOTE_NONNUMERIC QUOTE_NONE = csv.QUOTE_NONE +if sys.version_info >= (3, 12): + QUOTE_STRINGS = csv.QUOTE_STRINGS + QUOTE_NOTNULL = csv.QUOTE_NOTNULL +else: + QUOTE_STRINGS: Final = 4 + QUOTE_NOTNULL: Final = 5 class Parser: @@ -55,7 +63,7 @@ self.record_so_far: List[str] = [] self.field_so_far: List[str] = [] self.field_limit: int = csv.field_size_limit() - self.field_was_numeric: bool = False + self.field_was_quoted: bool = False self.last_char_was_cr: bool = False # AsyncIterator[List[str]] interface @@ -161,6 +169,7 @@ self.state = ParserState.START_RECORD return Decision.DONE elif c == self.dialect.quotechar and self.dialect.quoting != QUOTE_NONE: + self.field_was_quoted = True self.state = ParserState.IN_QUOTED_FIELD elif c == self.dialect.escapechar: self.state = ParserState.ESCAPE @@ -169,7 +178,6 @@ self.save_field() self.state = ParserState.START_FIELD else: - self.field_was_numeric = self.dialect.quoting == QUOTE_NONNUMERIC self.add_char(c) self.state = ParserState.IN_FIELD return Decision.CONTINUE @@ -234,7 +242,7 @@ self.state = ParserState.IN_FIELD else: raise csv.Error( - f"{self.dialect.delimiter!r} expected after {self.dialect.quotechar!r}" + f"{self.dialect.delimiter!r} expected after {self.dialect.quotechar!r}", ) return Decision.CONTINUE @@ -254,11 +262,15 @@ else: field = "".join(self.field_so_far) - # Convert to float if QUOTE_NONNUMERIC - if self.dialect.quoting == QUOTE_NONNUMERIC and field and self.field_was_numeric: - self.field_was_numeric = False - field = float(field) + # Handle unquoted fields for special quote modes + if self.dialect.quoting == QUOTE_NONNUMERIC and not self.field_was_quoted: + field = float(field) if field else "" + elif self.dialect.quoting == QUOTE_STRINGS and not self.field_was_quoted: + field = float(field) if field else None + elif self.dialect.quoting == QUOTE_NOTNULL and not self.field_was_quoted: + field = field if field else None + self.field_was_quoted = False self.record_so_far.append(field) # type: ignore self.field_so_far.clear() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiocsv-1.3.2/aiocsv/protocols.py new/aiocsv-1.4.0/aiocsv/protocols.py --- old/aiocsv-1.3.2/aiocsv/protocols.py 2024-04-28 12:29:50.000000000 +0200 +++ new/aiocsv-1.4.0/aiocsv/protocols.py 2025-09-22 14:04:03.000000000 +0200 @@ -1,13 +1,19 @@ -# © Copyright 2020-2024 Mikołaj Kuranowski +# © Copyright 2020-2025 Mikołaj Kuranowski # SPDX-License-Identifier: MIT -from typing import TYPE_CHECKING, Any, Optional, Protocol, Type, TypedDict, Union +import sys +from typing import TYPE_CHECKING, Any, Literal, Optional, Protocol, Type, TypedDict, Union from typing_extensions import NotRequired if TYPE_CHECKING: import csv +if sys.version_info < (3, 12): + _QuotingType = Literal[0, 1, 2, 3] +else: + _QuotingType = Literal[0, 1, 2, 3, 4, 5] + class WithAsyncWrite(Protocol): async def write(self, __b: str) -> Any: ... @@ -23,7 +29,7 @@ escapechar: Optional[str] doublequote: bool skipinitialspace: bool - quoting: int + quoting: _QuotingType strict: bool @@ -37,5 +43,5 @@ doublequote: NotRequired[bool] skipinitialspace: NotRequired[bool] lineterminator: NotRequired[str] - quoting: NotRequired[int] + quoting: NotRequired[_QuotingType] strict: NotRequired[bool] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiocsv-1.3.2/aiocsv/readers.py new/aiocsv-1.4.0/aiocsv/readers.py --- old/aiocsv-1.3.2/aiocsv/readers.py 2024-04-28 12:29:50.000000000 +0200 +++ new/aiocsv-1.4.0/aiocsv/readers.py 2025-09-22 14:04:03.000000000 +0200 @@ -1,4 +1,4 @@ -# © Copyright 2020-2024 Mikołaj Kuranowski +# © Copyright 2020-2025 Mikołaj Kuranowski # SPDX-License-Identifier: MIT # cSpell: words asyncfile restkey restval @@ -9,7 +9,7 @@ from typing_extensions import Unpack -from .protocols import CsvDialectArg, CsvDialectKwargs, WithAsyncRead +from .protocols import CsvDialectArg, CsvDialectKwargs, DialectLike, WithAsyncRead try: from ._parser import Parser @@ -40,7 +40,7 @@ self._parser = Parser(self._file, self._dialect) @property - def dialect(self) -> csv.Dialect: + def dialect(self) -> DialectLike: return self._dialect @property @@ -78,7 +78,7 @@ self.reader = AsyncReader(asyncfile, dialect=dialect, **csv_dialect_kwargs) @property - def dialect(self) -> csv.Dialect: + def dialect(self) -> DialectLike: return self.reader.dialect @property diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiocsv-1.3.2/aiocsv/writers.py new/aiocsv-1.4.0/aiocsv/writers.py --- old/aiocsv-1.3.2/aiocsv/writers.py 2024-04-28 12:29:50.000000000 +0200 +++ new/aiocsv-1.4.0/aiocsv/writers.py 2025-09-22 14:04:03.000000000 +0200 @@ -1,4 +1,4 @@ -# © Copyright 2020-2024 Mikołaj Kuranowski +# © Copyright 2020-2025 Mikołaj Kuranowski # SPDX-License-Identifier: MIT # cSpell: words asyncfile extrasaction fieldnames restval @@ -9,7 +9,7 @@ from typing_extensions import Unpack -from .protocols import CsvDialectArg, CsvDialectKwargs, WithAsyncWrite +from .protocols import CsvDialectArg, CsvDialectKwargs, DialectLike, WithAsyncWrite class AsyncWriter: @@ -30,7 +30,7 @@ self._csv_writer = csv.writer(self._buffer, dialect=dialect, **csv_dialect_kwargs) @property - def dialect(self) -> csv.Dialect: + def dialect(self) -> DialectLike: return self._csv_writer.dialect async def _rewrite_buffer(self) -> None: @@ -86,7 +86,7 @@ self.writer = AsyncWriter(asyncfile, dialect, **csv_dialect_kwargs) @property - def dialect(self) -> csv.Dialect: + def dialect(self) -> DialectLike: return self.writer.dialect def _dict_to_iterable(self, row_dict: Mapping[str, Any]) -> Iterable[Any]: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiocsv-1.3.2/pyproject.toml new/aiocsv-1.4.0/pyproject.toml --- old/aiocsv-1.3.2/pyproject.toml 2024-04-28 12:29:50.000000000 +0200 +++ new/aiocsv-1.4.0/pyproject.toml 2025-09-22 14:04:03.000000000 +0200 @@ -5,7 +5,7 @@ [project] name = "aiocsv" readme = "readme.md" -requires-python = ">=3.8" +requires-python = ">=3.9" dynamic = ["version"] authors = [ {name = "Mikołaj Kuranowski", email = "[email protected]"}, @@ -36,10 +36,12 @@ [tool.black] line-length = 99 +target-version = ['py39'] [tool.isort] profile = "black" line_length = 99 +py_version = 39 [tool.pyright] typeCheckingMode = "strict" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiocsv-1.3.2/readme.md new/aiocsv-1.4.0/readme.md --- old/aiocsv-1.3.2/readme.md 2024-04-28 12:29:50.000000000 +0200 +++ new/aiocsv-1.4.0/readme.md 2025-09-22 14:04:03.000000000 +0200 @@ -71,8 +71,8 @@ ## Differences with `csv` -`aiocsv` strives to be a drop-in replacement for Python's builtin -[csv module](https://docs.python.org/3/library/csv.html). However, there are 3 notable differences: +`aiocsv` strives to be a drop-in replacement for Python's builtin [csv module](https://docs.python.org/3/library/csv.html). +However, there are a few notable differences, due to technical limitations or CPython bugs: - Readers accept objects with async `read` methods, instead of an AsyncIterable over lines from a file. @@ -80,6 +80,8 @@ - Changes to `csv.field_size_limit` are not picked up by existing Reader instances. The field size limit is cached on Reader instantiation to avoid expensive function calls on each character of the input. +- `QUOTE_NOTNULL` and `QUOTE_STRINGS` work on readers even in 3.12. aiocsv does not replicate + [CPython bug #113732](https://github.com/python/cpython/issues/113732). Other, minor, differences include: - `AsyncReader.line_num`, `AsyncDictReader.line_num` and `AsyncDictReader.dialect` are not settable, @@ -111,8 +113,8 @@ - `async __anext__(self) -> List[str]` *Read-only properties*: -- `dialect`: The csv.Dialect used when parsing -- `line_num`: The number of lines read from the source file. This coincides with a 1-based index +- `dialect` (`aiocsv.protocols.DialectLike`): The dialect used when parsing +- `line_num` (`int`): The number of lines read from the source file. This coincides with a 1-based index of the line number of the last line of the recently parsed record. @@ -141,7 +143,7 @@ *Properties*: -- `fieldnames`: field names used when converting rows to dictionaries +- `fieldnames` (`List[str] | None`): field names used when converting rows to dictionaries **⚠️** Unlike csv.DictReader, this property can't read the fieldnames if they are missing - it's not possible to `await` on the header row in a property getter. **Use `await reader.get_fieldnames()`**. @@ -153,15 +155,15 @@ areader.fieldnames # ⚠️ None await areader.get_fieldnames() # ["cells", "from", "the", "header"] ``` -- `restkey`: If a row has more cells then the header, all remaining cells are stored under +- `restkey` (`str | None`): If a row has more cells then the header, all remaining cells are stored under this key in the returned dictionary. Defaults to `None`. -- `restval`: If a row has less cells then the header, then missing keys will use this +- `restval` (`str | None`): If a row has less cells then the header, then missing keys will use this value. Defaults to `None`. - `reader`: Underlying `aiofiles.AsyncReader` instance *Read-only properties*: -- `dialect`: Link to `self.reader.dialect` - the current csv.Dialect -- `line_num`: The number of lines read from the source file. This coincides with a 1-based index +- `dialect` (`aiocsv.protocols.DialectLike`): Link to `self.reader.dialect` - the current csv.Dialect +- `line_num` (`int`): The number of lines read from the source file. This coincides with a 1-based index of the line number of the last line of the recently parsed record. @@ -187,7 +189,7 @@ Writes multiple rows to the specified file. *Readonly properties*: -- `dialect`: Link to underlying's csv.writer's `dialect` attribute +- `dialect` (`aiocsv.protocols.DialectLike`): Link to underlying's csv.writer's `dialect` attribute ### aiocsv.AsyncDictWriter @@ -216,17 +218,17 @@ Writes multiple rows to the specified file. *Properties*: -- `fieldnames`: Sequence of keys to identify the order of values when writing rows +- `fieldnames` (`Sequence[str]`): Sequence of keys to identify the order of values when writing rows to the underlying file -- `restval`: Placeholder value used when a key from fieldnames is missing in a row, +- `restval` (`Any`): Placeholder value used when a key from fieldnames is missing in a row, defaults to `""` -- `extrasaction`: Action to take when there are keys in a row, which are not present in +- `extrasaction` (`Literal["raise", "ignore"]`): Action to take when there are keys in a row, which are not present in fieldnames, defaults to `"raise"` which causes ValueError to be raised on extra keys, may be also set to `"ignore"` to ignore any extra keys - `writer`: Link to the underlying `AsyncWriter` *Readonly properties*: -- `dialect`: Link to underlying's csv.reader's `dialect` attribute +- `dialect` (`aiocsv.protocols.DialectLike`): Link to underlying's csv.reader's `dialect` attribute ### aiocsv.protocols.WithAsyncRead @@ -236,6 +238,9 @@ ### aiocsv.protocols.WithAsyncWrite A `typing.Protocol` describing an asynchronous file, which can be written to. +### aiocsv.protocols.DialectLike +Type of an instantiated `dialect` property. Thank CPython for an incredible mess of +having unrelated and disjoint `csv.Dialect` and `_csv.Dialect` classes. ### aiocsv.protocols.CsvDialectArg Type of the `dialect` argument, as used in the `csv` module. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiocsv-1.3.2/tests/test_parser.py new/aiocsv-1.4.0/tests/test_parser.py --- old/aiocsv-1.3.2/tests/test_parser.py 2024-04-28 12:29:50.000000000 +0200 +++ new/aiocsv-1.4.0/tests/test_parser.py 2025-09-22 14:04:03.000000000 +0200 @@ -1,5 +1,6 @@ import csv import io +import sys from typing import AsyncIterator, Callable, List, Protocol, Type import pytest @@ -157,6 +158,10 @@ @pytest.mark.asyncio [email protected]( + sys.version_info < (3, 12, 9), + reason="CPython bug gh-113785 was fixed in 3.12.9", +) @pytest.mark.parametrize("parser", PARSERS, ids=PARSER_NAMES) async def test_parsing_weird_quotes_nonnumeric(parser: Type[Parser]): data = '3.0,\r\n"1."5,"15"\r\n$2,"-4".5\r\n-5$.2,-11' @@ -173,7 +178,7 @@ ] assert csv_result == custom_result - assert custom_result == [[3.0, ""], ["1.5", "15"], ["2", "-4.5"], [-5.2, -11.0]] + assert custom_result == [[3.0, ""], ["1.5", "15"], [2.0, "-4.5"], [-5.2, -11.0]] @pytest.mark.asyncio @@ -367,3 +372,80 @@ assert csv_result == expected_result assert custom_result == expected_result + + [email protected] [email protected]("parser", PARSERS, ids=PARSER_NAMES) +async def test_parsing_no_newline_at_the_end(parser: Type[Parser]): + data = "pi,3.1416\r\nsqrt2,1.4142\r\nphi,1.618\r\ne,2.7183" + + csv_result = list(csv.reader(io.StringIO(data, newline=""))) + custom_result = [ + r async for r in parser(AsyncStringIO(data), csv.get_dialect("excel")) # type: ignore + ] + + assert csv_result == custom_result + assert custom_result == [ + ["pi", "3.1416"], + ["sqrt2", "1.4142"], + ["phi", "1.618"], + ["e", "2.7183"], + ] + + [email protected] [email protected](sys.version_info < (3, 12), reason="csv.QUOTE_STRINGS was added in 3.12") [email protected]("parser", PARSERS, ids=PARSER_NAMES) +async def test_parsing_quote_strings(parser: Type[Parser]): + data = '3.14,,"abc",""\r\n' + + csv_parser = csv.reader(io.StringIO(data, newline=""), quoting=csv.QUOTE_STRINGS, strict=True) # type: ignore + csv_result = list(csv_parser) + custom_result = [ + r async for r in parser(AsyncStringIO(data), csv_parser.dialect) # type: ignore + ] + + if sys.version_info < (3, 13): + # https://github.com/python/cpython/issues/113732 + assert csv_result == [["3.14", "", "abc", ""]] + else: + assert csv_result == [[3.14, None, "abc", ""]] + assert custom_result == [[3.14, None, "abc", ""]] + + [email protected] [email protected](sys.version_info < (3, 12), reason="csv.QUOTE_STRINGS was added in 3.12") [email protected]("parser", PARSERS, ids=PARSER_NAMES) +async def test_parsing_quote_strings_non_float(parser: Type[Parser]): + data = "abc" + + csv_parser = csv.reader(io.StringIO(data, newline=""), quoting=csv.QUOTE_STRINGS, strict=True) # type: ignore + if sys.version_info < (3, 13): + # https://github.com/python/cpython/issues/113732 + assert list(csv_parser) == [["abc"]] + else: + with pytest.raises(ValueError): + list(csv_parser) + + with pytest.raises(ValueError): + [r async for r in parser(AsyncStringIO(data), csv_parser.dialect)] # type: ignore + + [email protected] [email protected](sys.version_info < (3, 12), reason="csv.QUOTE_NOTNULL was added in 3.12") [email protected]("parser", PARSERS, ids=PARSER_NAMES) +async def test_parsing_quote_not_null(parser: Type[Parser]): + data = '3.14,,abc,""\r\n' + + csv_parser = csv.reader(io.StringIO(data, newline=""), quoting=csv.QUOTE_NOTNULL, strict=True) # type: ignore + csv_result = list(csv_parser) + custom_result = [ + r async for r in parser(AsyncStringIO(data), csv_parser.dialect) # type: ignore + ] + + if sys.version_info < (3, 13): + # https://github.com/python/cpython/issues/113732 + assert csv_result == [["3.14", "", "abc", ""]] + else: + assert csv_result == [["3.14", None, "abc", ""]] + assert custom_result == [["3.14", None, "abc", ""]]
