get-sinapi 3.1.0__tar.gz → 3.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/LICENSE +1 -1
- {get_sinapi-3.1.0/src/get_sinapi.egg-info → get_sinapi-3.1.1}/PKG-INFO +2 -2
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/pyproject.toml +2 -2
- {get_sinapi-3.1.0 → get_sinapi-3.1.1/src/get_sinapi.egg-info}/PKG-INFO +2 -2
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/get_sinapi.egg-info/SOURCES.txt +1 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/__init__.py +1 -1
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/cli.py +128 -33
- get_sinapi-3.1.1/tests/test_cli.py +36 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/README.md +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/setup.cfg +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/get_sinapi.egg-info/dependency_links.txt +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/get_sinapi.egg-info/entry_points.txt +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/get_sinapi.egg-info/requires.txt +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/get_sinapi.egg-info/top_level.txt +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/api/__init__.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/api/main.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/bootstrap.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/collectors/__init__.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/collectors/caixa.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/collectors/history.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/config.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/domain.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/exporter.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/extractors/__init__.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/extractors/compositions.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/extractors/price_tables.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/extractors/reference_workbook.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/extractors/sinapi_parser.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/extractors/workbooks.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/normalizers.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/pipeline/__init__.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/pipeline/normalize.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/services/__init__.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/services/costing.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/storage/__init__.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/storage/sqlite.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_api.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_api_connection_lifecycle.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_api_literal_search.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_bootstrap.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_caixa_collector.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_compositions.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_config.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_config_finite_backoff.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_costing.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_domain.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_exporter.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_historical_catalog.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_normalizers.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_normalizers_non_finite.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_price_tables.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_reference_workbook.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_sinapi_parser.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_storage.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_storage_manifest_validation.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_storage_nullable_price_keys.py +0 -0
- {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_workbook_extractor.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: get-sinapi
|
|
3
|
-
Version: 3.1.
|
|
3
|
+
Version: 3.1.1
|
|
4
4
|
Summary: Pipeline rastreável de dados públicos do SINAPI/CAIXA
|
|
5
|
-
Author:
|
|
5
|
+
Author: Nadison Roberto
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Requires-Python: >=3.11
|
|
8
8
|
Description-Content-Type: text/markdown
|
|
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "get-sinapi"
|
|
7
|
-
version = "3.1.
|
|
7
|
+
version = "3.1.1"
|
|
8
8
|
description = "Pipeline rastreável de dados públicos do SINAPI/CAIXA"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
license = "MIT"
|
|
12
|
-
authors = [{ name = "
|
|
12
|
+
authors = [{ name = "Nadison Roberto" }]
|
|
13
13
|
dependencies = [
|
|
14
14
|
"requests>=2.31",
|
|
15
15
|
"curl-cffi>=0.7",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: get-sinapi
|
|
3
|
-
Version: 3.1.
|
|
3
|
+
Version: 3.1.1
|
|
4
4
|
Summary: Pipeline rastreável de dados públicos do SINAPI/CAIXA
|
|
5
|
-
Author:
|
|
5
|
+
Author: Nadison Roberto
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Requires-Python: >=3.11
|
|
8
8
|
Description-Content-Type: text/markdown
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""GET-SINAPI: pipeline de dados do SINAPI/CAIXA."""
|
|
2
|
-
__version__ = "3.1.
|
|
2
|
+
__version__ = "3.1.1"
|
|
@@ -4,11 +4,91 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import os
|
|
7
|
+
import platform
|
|
8
|
+
import sys
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
|
|
9
11
|
from getsinapi import __version__
|
|
10
12
|
from getsinapi.config import Settings
|
|
11
13
|
|
|
14
|
+
CYAN = "\033[96m"
|
|
15
|
+
BLUE = "\033[94m"
|
|
16
|
+
YELLOW = "\033[93m"
|
|
17
|
+
GREEN = "\033[92m"
|
|
18
|
+
RESET = "\033[0m"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _enable_ansi() -> bool:
|
|
22
|
+
"""Habilita cores ANSI no terminal, com fallback seguro."""
|
|
23
|
+
if not sys.stdout.isatty():
|
|
24
|
+
return False
|
|
25
|
+
if platform.system() != "Windows":
|
|
26
|
+
return True
|
|
27
|
+
try:
|
|
28
|
+
import ctypes
|
|
29
|
+
import msvcrt
|
|
30
|
+
|
|
31
|
+
handle = msvcrt.get_osfhandle(sys.stdout.fileno())
|
|
32
|
+
mode = ctypes.c_uint()
|
|
33
|
+
kernel32 = ctypes.windll.kernel32
|
|
34
|
+
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
35
|
+
return False
|
|
36
|
+
return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
|
|
37
|
+
except (AttributeError, OSError, ValueError):
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ANSI_ENABLED = _enable_ansi()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _paint(value: str, color: str) -> str:
|
|
45
|
+
return f"{color}{value}{RESET}" if ANSI_ENABLED else value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _rule(char: str = "─", color: str = CYAN) -> None:
|
|
49
|
+
"""Desenha um separador padronizado para os painéis do terminal."""
|
|
50
|
+
print(_paint(" " + char * 66, color))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _section(number: str, title: str, subtitle: str | None = None) -> None:
|
|
54
|
+
"""Exibe uma seção numerada com hierarquia visual consistente."""
|
|
55
|
+
print()
|
|
56
|
+
print(_paint(f" {number} | {title.upper()}", BLUE))
|
|
57
|
+
_rule()
|
|
58
|
+
if subtitle:
|
|
59
|
+
print(f" {subtitle}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _show_header() -> None:
|
|
63
|
+
"""Exibe a identidade do produto e os avisos de titularidade no terminal."""
|
|
64
|
+
width = 68
|
|
65
|
+
print()
|
|
66
|
+
print(_paint("╔" + "═" * width + "╗", CYAN))
|
|
67
|
+
print(_paint("║" + "GET-SINAPI".center(width) + "║", CYAN))
|
|
68
|
+
print(_paint("║" + "PLATAFORMA DE DADOS SINAPI | CAIXA".center(width) + "║", BLUE))
|
|
69
|
+
print(_paint("║" + "NR TECH • TERMINAL PYTHON".center(width) + "║", CYAN))
|
|
70
|
+
print(_paint("║" + f"VERSÃO {__version__}".center(width) + "║", CYAN))
|
|
71
|
+
print(_paint("╚" + "═" * width + "╝", CYAN))
|
|
72
|
+
print(" FONTE : Publicações oficiais da CAIXA / SINAPI")
|
|
73
|
+
print(" RECURSOS : Consulta local • Excel • CSV • Exportação em lote")
|
|
74
|
+
print(_paint(" TITULAR : © 2026 Nadison Roberto | NR TECH", BLUE))
|
|
75
|
+
print(_paint(" LICENÇA : MIT — consulte o arquivo LICENSE", BLUE))
|
|
76
|
+
print()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_multi_selection(answer: str, total: int) -> list[int] | None:
|
|
80
|
+
"""Converte números separados por vírgula em índices; None representa 'todas'."""
|
|
81
|
+
value = answer.strip().upper()
|
|
82
|
+
if not value or value == "A":
|
|
83
|
+
return None
|
|
84
|
+
tokens = [item.strip() for item in value.split(",")]
|
|
85
|
+
if any(not token.isdigit() for token in tokens):
|
|
86
|
+
raise ValueError("Use números separados por vírgula ou A para todas.")
|
|
87
|
+
indexes = [int(token) for token in tokens]
|
|
88
|
+
if any(index < 1 or index > total for index in indexes):
|
|
89
|
+
raise ValueError(f"Seleção fora do intervalo. Informe valores de 1 a {total}.")
|
|
90
|
+
return list(dict.fromkeys(indexes))
|
|
91
|
+
|
|
12
92
|
|
|
13
93
|
def build_parser() -> argparse.ArgumentParser:
|
|
14
94
|
parser = argparse.ArgumentParser(
|
|
@@ -54,79 +134,90 @@ def _export_menu(settings: Settings) -> int:
|
|
|
54
134
|
desktop_data / "getsinapi.sqlite3",
|
|
55
135
|
settings.data_dir / "getsinapi.sqlite3",
|
|
56
136
|
]
|
|
57
|
-
database = next(
|
|
58
|
-
|
|
137
|
+
database = next(
|
|
138
|
+
(path for path in candidates if path and path.is_file()),
|
|
139
|
+
desktop_data / "getsinapi.sqlite3" if os.name == "nt" else settings.data_dir / "getsinapi.sqlite3",
|
|
140
|
+
)
|
|
59
141
|
if not database.is_file():
|
|
60
142
|
print(f"Banco SINAPI não encontrado: {database}")
|
|
61
143
|
print("Inicialize a base com os comandos download, extract, parse e load.")
|
|
62
144
|
return 2
|
|
63
145
|
|
|
64
|
-
print("
|
|
65
|
-
print("
|
|
66
|
-
print("
|
|
67
|
-
print("
|
|
68
|
-
|
|
146
|
+
print(_paint(" ╔" + "═" * 66 + "╗", CYAN))
|
|
147
|
+
print(_paint(" ║" + "CENTRAL DE EXPORTAÇÃO".center(66) + "║", CYAN))
|
|
148
|
+
print(_paint(" ╚" + "═" * 66 + "╝", CYAN))
|
|
149
|
+
print(" Selecione os dados, aplique os filtros e defina o formato de saída.")
|
|
150
|
+
_section("01", "Modo de exportação", "Escolha entre uma exportação completa ou personalizada.")
|
|
151
|
+
print(_paint(" [1]", CYAN) + " Exportação completa — insumos, composições e componentes")
|
|
152
|
+
print(_paint(" [2]", CYAN) + " Exportação personalizada — selecionar categorias")
|
|
69
153
|
mode = _ask_choice("Selecione", {"1": "Tudo", "2": "Personalizado"}, "1")
|
|
70
154
|
|
|
71
155
|
categories = ["INSUMO", "COMPOSICAO", "COMPONENTE"]
|
|
72
156
|
if mode == "2":
|
|
73
|
-
|
|
74
|
-
print("1
|
|
75
|
-
|
|
157
|
+
_section("02", "Categorias", "Informe uma ou mais categorias separadas por vírgula.")
|
|
158
|
+
print(_paint(" [1]", CYAN) + " Insumos")
|
|
159
|
+
print(_paint(" [2]", CYAN) + " Composições")
|
|
160
|
+
print(_paint(" [3]", CYAN) + " Componentes")
|
|
161
|
+
selected = input("Categorias (ex.: 1,3): ").strip()
|
|
76
162
|
mapping = {"1": "INSUMO", "2": "COMPOSICAO", "3": "COMPONENTE"}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
163
|
+
try:
|
|
164
|
+
indexes = _parse_multi_selection(selected, 3)
|
|
165
|
+
categories = list(mapping.values()) if indexes is None else [
|
|
166
|
+
mapping[str(index)] for index in indexes
|
|
167
|
+
]
|
|
168
|
+
except ValueError as exc:
|
|
169
|
+
print(_paint(f"Seleção inválida: {exc}", YELLOW))
|
|
80
170
|
return 2
|
|
81
|
-
categories = list(dict.fromkeys(chosen))
|
|
82
171
|
|
|
83
172
|
refs = available_references(database)
|
|
84
173
|
if not refs:
|
|
85
174
|
print("A base SQLite não possui competências carregadas.")
|
|
86
175
|
return 2
|
|
87
|
-
|
|
176
|
+
_section("03", "Competências disponíveis", "Selecione números separados por vírgula ou A para todas.")
|
|
88
177
|
for index, reference in enumerate(refs, start=1):
|
|
89
|
-
print(f"{index}
|
|
90
|
-
print("A
|
|
91
|
-
reference_choice = input("
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
print("Competência inválida.")
|
|
178
|
+
print(_paint(f" [{index:>2}] {reference}", CYAN))
|
|
179
|
+
print(_paint(" [ A]", CYAN) + " Todas as competências")
|
|
180
|
+
reference_choice = input("Competências (ex.: 1,2,5 ou A) [A]: ").strip().upper() or "A"
|
|
181
|
+
try:
|
|
182
|
+
indexes = _parse_multi_selection(reference_choice, len(refs))
|
|
183
|
+
references = None if indexes is None else [refs[index - 1] for index in indexes]
|
|
184
|
+
except ValueError as exc:
|
|
185
|
+
print(_paint(f"Competência inválida: {exc}", YELLOW))
|
|
98
186
|
return 2
|
|
99
187
|
|
|
100
|
-
|
|
188
|
+
_section("04", "Filtros complementares", "Pressione ENTER para manter o filtro sem restrição.")
|
|
189
|
+
uf = input(" UF (duas letras) ou ENTER para todas: ").strip().upper() or None
|
|
101
190
|
if uf and (len(uf) != 2 or not uf.isalpha()):
|
|
102
191
|
print("UF inválida.")
|
|
103
192
|
return 2
|
|
104
193
|
|
|
105
194
|
regime_choice = _ask_choice(
|
|
106
|
-
"Regime: 1
|
|
195
|
+
" Regime: [1] Todos | [2] Desonerado | [3] Não desonerado",
|
|
107
196
|
{"1": "all", "2": "desonerado", "3": "nao_desonerado"},
|
|
108
197
|
"1",
|
|
109
198
|
)
|
|
110
199
|
regime = None if regime_choice == "1" else regime_choice
|
|
111
200
|
|
|
112
201
|
encargo_choice = _ask_choice(
|
|
113
|
-
"Encargos: 1
|
|
202
|
+
" Encargos: [1] Todos | [2] Com encargos | [3] Sem encargos",
|
|
114
203
|
{"1": "all", "2": "com_encargos", "3": "sem_encargos"},
|
|
115
204
|
"1",
|
|
116
205
|
)
|
|
117
206
|
encargo_mode = None if encargo_choice == "1" else encargo_choice
|
|
118
207
|
|
|
119
|
-
search = input("
|
|
208
|
+
search = input(" Código ou descrição (ENTER para sem filtro): ").strip() or None
|
|
209
|
+
_section("05", "Formato e destino", "Os arquivos serão gravados na pasta informada.")
|
|
120
210
|
file_format = _ask_choice(
|
|
121
|
-
"Formato: 1 Excel (.xlsx) | 2 CSV | 3 Excel + CSV",
|
|
211
|
+
" Formato: [1] Excel (.xlsx) | [2] CSV | [3] Excel + CSV",
|
|
122
212
|
{"1": "xlsx", "2": "csv", "3": "both"},
|
|
123
213
|
"1",
|
|
124
214
|
)
|
|
125
215
|
default_output = database.parent / "exports"
|
|
126
|
-
output_text = input(f"Pasta de destino [{default_output}]: ").strip()
|
|
216
|
+
output_text = input(f" Pasta de destino [{default_output}]: ").strip()
|
|
127
217
|
output_dir = Path(output_text).expanduser() if output_text else default_output
|
|
128
218
|
|
|
129
|
-
|
|
219
|
+
_section("06", "Processamento", "Preparando a exportação a partir da base SQLite local.")
|
|
220
|
+
print(_paint(" PROCESSANDO...", YELLOW))
|
|
130
221
|
generated = export_catalog(
|
|
131
222
|
database,
|
|
132
223
|
output_dir,
|
|
@@ -138,15 +229,19 @@ def _export_menu(settings: Settings) -> int:
|
|
|
138
229
|
encargo_mode=encargo_mode,
|
|
139
230
|
search=search,
|
|
140
231
|
)
|
|
141
|
-
print(
|
|
232
|
+
print(_paint("\n STATUS : EXPORTAÇÃO CONCLUÍDA", GREEN))
|
|
233
|
+
print(_paint(f" ARQUIVOS: {len(generated)}", GREEN))
|
|
142
234
|
for path in generated:
|
|
143
|
-
print(
|
|
235
|
+
print(_paint(" [OK]", GREEN) + f" {path}")
|
|
236
|
+
_rule("═")
|
|
237
|
+
print(_paint(" © 2026 Nadison Roberto | NR TECH", BLUE))
|
|
144
238
|
return 0
|
|
145
239
|
|
|
146
240
|
|
|
147
241
|
def main() -> int:
|
|
148
242
|
parser = build_parser()
|
|
149
243
|
args = parser.parse_args()
|
|
244
|
+
_show_header()
|
|
150
245
|
settings = Settings.from_env()
|
|
151
246
|
|
|
152
247
|
if args.command in (None, "export"):
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from getsinapi.cli import _parse_multi_selection, _show_header
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_header_displays_product_identity_and_copyright(capsys, monkeypatch):
|
|
7
|
+
monkeypatch.setattr("getsinapi.cli.ANSI_ENABLED", False)
|
|
8
|
+
_show_header()
|
|
9
|
+
output = capsys.readouterr().out
|
|
10
|
+
assert "GET-SINAPI" in output
|
|
11
|
+
assert "VERSÃO 3.1.1" in output
|
|
12
|
+
assert "© 2026 Nadison Roberto | NR TECH" in output
|
|
13
|
+
assert "LICENÇA : MIT" in output
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.parametrize(
|
|
18
|
+
("answer", "expected"),
|
|
19
|
+
[
|
|
20
|
+
("1,2", [1, 2]),
|
|
21
|
+
(" 1, 3 ", [1, 3]),
|
|
22
|
+
("2,2,1", [2, 1]),
|
|
23
|
+
("A", None),
|
|
24
|
+
("a", None),
|
|
25
|
+
("", None),
|
|
26
|
+
(" ", None),
|
|
27
|
+
],
|
|
28
|
+
)
|
|
29
|
+
def test_parse_multi_selection_accepts_multiple_indexes_and_all(answer, expected):
|
|
30
|
+
assert _parse_multi_selection(answer, total=3) == expected
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize("answer", ["0", "4", "1,,2", "1,x", "-1", "1.5"])
|
|
34
|
+
def test_parse_multi_selection_rejects_invalid_values(answer):
|
|
35
|
+
with pytest.raises(ValueError):
|
|
36
|
+
_parse_multi_selection(answer, total=3)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|