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.
Files changed (57) hide show
  1. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/LICENSE +1 -1
  2. {get_sinapi-3.1.0/src/get_sinapi.egg-info → get_sinapi-3.1.1}/PKG-INFO +2 -2
  3. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/pyproject.toml +2 -2
  4. {get_sinapi-3.1.0 → get_sinapi-3.1.1/src/get_sinapi.egg-info}/PKG-INFO +2 -2
  5. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/get_sinapi.egg-info/SOURCES.txt +1 -0
  6. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/__init__.py +1 -1
  7. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/cli.py +128 -33
  8. get_sinapi-3.1.1/tests/test_cli.py +36 -0
  9. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/README.md +0 -0
  10. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/setup.cfg +0 -0
  11. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/get_sinapi.egg-info/dependency_links.txt +0 -0
  12. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/get_sinapi.egg-info/entry_points.txt +0 -0
  13. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/get_sinapi.egg-info/requires.txt +0 -0
  14. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/get_sinapi.egg-info/top_level.txt +0 -0
  15. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/api/__init__.py +0 -0
  16. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/api/main.py +0 -0
  17. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/bootstrap.py +0 -0
  18. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/collectors/__init__.py +0 -0
  19. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/collectors/caixa.py +0 -0
  20. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/collectors/history.py +0 -0
  21. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/config.py +0 -0
  22. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/domain.py +0 -0
  23. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/exporter.py +0 -0
  24. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/extractors/__init__.py +0 -0
  25. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/extractors/compositions.py +0 -0
  26. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/extractors/price_tables.py +0 -0
  27. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/extractors/reference_workbook.py +0 -0
  28. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/extractors/sinapi_parser.py +0 -0
  29. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/extractors/workbooks.py +0 -0
  30. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/normalizers.py +0 -0
  31. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/pipeline/__init__.py +0 -0
  32. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/pipeline/normalize.py +0 -0
  33. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/services/__init__.py +0 -0
  34. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/services/costing.py +0 -0
  35. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/storage/__init__.py +0 -0
  36. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/src/getsinapi/storage/sqlite.py +0 -0
  37. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_api.py +0 -0
  38. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_api_connection_lifecycle.py +0 -0
  39. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_api_literal_search.py +0 -0
  40. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_bootstrap.py +0 -0
  41. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_caixa_collector.py +0 -0
  42. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_compositions.py +0 -0
  43. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_config.py +0 -0
  44. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_config_finite_backoff.py +0 -0
  45. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_costing.py +0 -0
  46. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_domain.py +0 -0
  47. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_exporter.py +0 -0
  48. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_historical_catalog.py +0 -0
  49. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_normalizers.py +0 -0
  50. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_normalizers_non_finite.py +0 -0
  51. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_price_tables.py +0 -0
  52. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_reference_workbook.py +0 -0
  53. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_sinapi_parser.py +0 -0
  54. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_storage.py +0 -0
  55. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_storage_manifest_validation.py +0 -0
  56. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_storage_nullable_price_keys.py +0 -0
  57. {get_sinapi-3.1.0 → get_sinapi-3.1.1}/tests/test_workbook_extractor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 NR Data Analysis
3
+ Copyright (c) 2026 Nadison Roberto | NR TECH
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: get-sinapi
3
- Version: 3.1.0
3
+ Version: 3.1.1
4
4
  Summary: Pipeline rastreável de dados públicos do SINAPI/CAIXA
5
- Author: NR TECH
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.0"
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 = "NR TECH" }]
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.0
3
+ Version: 3.1.1
4
4
  Summary: Pipeline rastreável de dados públicos do SINAPI/CAIXA
5
- Author: NR TECH
5
+ Author: Nadison Roberto
6
6
  License-Expression: MIT
7
7
  Requires-Python: >=3.11
8
8
  Description-Content-Type: text/markdown
@@ -36,6 +36,7 @@ tests/test_api_connection_lifecycle.py
36
36
  tests/test_api_literal_search.py
37
37
  tests/test_bootstrap.py
38
38
  tests/test_caixa_collector.py
39
+ tests/test_cli.py
39
40
  tests/test_compositions.py
40
41
  tests/test_config.py
41
42
  tests/test_config_finite_backoff.py
@@ -1,2 +1,2 @@
1
1
  """GET-SINAPI: pipeline de dados do SINAPI/CAIXA."""
2
- __version__ = "3.1.0"
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((path for path in candidates if path and path.is_file()),
58
- desktop_data / "getsinapi.sqlite3" if os.name == "nt" else settings.data_dir / "getsinapi.sqlite3")
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("\n" + "=" * 62)
65
- print("GET-SINAPI | CENTRAL DE EXPORTAÇÃO EM LOTE")
66
- print("=" * 62)
67
- print("1. Exportar tudo (insumos, composições e componentes)")
68
- print("2. Escolher categorias")
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
- print("\nCategorias disponíveis:")
74
- print("1. Insumos 2. Composições 3. Componentes")
75
- selected = input("Informe uma ou mais opções separadas por vírgula (ex.: 1,3): ").strip()
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
- chosen = [mapping[item.strip()] for item in selected.split(",") if item.strip() in mapping]
78
- if not chosen:
79
- print("Nenhuma categoria válida foi selecionada.")
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
- print("\nCompetências disponíveis:")
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}. {reference}")
90
- print("A. Todas as competências")
91
- reference_choice = input("Competência [A]: ").strip().upper() or "A"
92
- if reference_choice == "A":
93
- references = None
94
- elif reference_choice.isdigit() and 1 <= int(reference_choice) <= len(refs):
95
- references = [refs[int(reference_choice) - 1]]
96
- else:
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
- uf = input("\nUF (duas letras) ou ENTER para todas: ").strip().upper() or None
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 todos | 2 desonerado | 3 não desonerado",
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 todos | 2 com encargos | 3 sem encargos",
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("Pesquisar por código/descrição (ENTER para sem filtro): ").strip() or None
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
- print("\nIniciando extração da base local...")
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(f"\nExportação concluída. Arquivos gerados: {len(generated)}")
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(f" - {path}")
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