kotsu-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,54 @@
1
+ -- 0003: identidade para mesclar bancos de aparelhos diferentes (D39).
2
+ -- `uid` identifica o registro em qualquer aparelho; `id` continua local. `alterado_em` é o
3
+ -- carimbo da última alteração (relógio híbrido: milissegundos-contador-réplica), comparável
4
+ -- como texto. Exclusão de lançamento deixa uma lápide, para ele não voltar na mesclagem.
5
+
6
+ CREATE TABLE meta (
7
+ chave TEXT PRIMARY KEY,
8
+ valor TEXT NOT NULL
9
+ );
10
+
11
+ INSERT INTO meta (chave, valor) VALUES ('replica', lower(hex(randomblob(4))));
12
+ INSERT INTO meta (chave, valor) VALUES ('relogio', '0000000000000-0000-00000000');
13
+
14
+ ALTER TABLE conta ADD COLUMN uid TEXT;
15
+ ALTER TABLE conta ADD COLUMN alterado_em TEXT NOT NULL DEFAULT '0000000000000-0000-00000000';
16
+ UPDATE conta SET uid = lower(hex(randomblob(16)));
17
+ CREATE UNIQUE INDEX conta_uid ON conta (uid);
18
+
19
+ ALTER TABLE categoria ADD COLUMN uid TEXT;
20
+ ALTER TABLE categoria ADD COLUMN alterado_em TEXT NOT NULL DEFAULT '0000000000000-0000-00000000';
21
+ UPDATE categoria SET uid = lower(hex(randomblob(16)));
22
+ CREATE UNIQUE INDEX categoria_uid ON categoria (uid);
23
+
24
+ -- A série já tem identidade universal (`grupo`); falta só o carimbo do modelo.
25
+ ALTER TABLE recorrencia ADD COLUMN alterado_em TEXT NOT NULL DEFAULT '0000000000000-0000-00000000';
26
+
27
+ -- `gerado` = mês criado pela série e nunca mexido: fica fora do código de conferência,
28
+ -- porque cada aparelho gera meses futuros até onde a pessoa navegou.
29
+ ALTER TABLE lancamento ADD COLUMN uid TEXT;
30
+ ALTER TABLE lancamento ADD COLUMN alterado_em TEXT NOT NULL DEFAULT '0000000000000-0000-00000000';
31
+ ALTER TABLE lancamento ADD COLUMN gerado INTEGER NOT NULL DEFAULT 0 CHECK (gerado IN (0, 1));
32
+ UPDATE lancamento SET uid = lower(hex(randomblob(16)));
33
+ CREATE UNIQUE INDEX lancamento_uid ON lancamento (uid);
34
+
35
+ CREATE TABLE lapide (
36
+ uid TEXT PRIMARY KEY,
37
+ excluido_em TEXT NOT NULL
38
+ );
39
+
40
+ -- Quem insere sem `uid` ganha um: nenhum registro fica sem identidade.
41
+ CREATE TRIGGER conta_uid_novo AFTER INSERT ON conta WHEN NEW.uid IS NULL
42
+ BEGIN
43
+ UPDATE conta SET uid = lower(hex(randomblob(16))) WHERE id = NEW.id;
44
+ END;
45
+
46
+ CREATE TRIGGER categoria_uid_novo AFTER INSERT ON categoria WHEN NEW.uid IS NULL
47
+ BEGIN
48
+ UPDATE categoria SET uid = lower(hex(randomblob(16))) WHERE id = NEW.id;
49
+ END;
50
+
51
+ CREATE TRIGGER lancamento_uid_novo AFTER INSERT ON lancamento WHEN NEW.uid IS NULL
52
+ BEGIN
53
+ UPDATE lancamento SET uid = lower(hex(randomblob(16))) WHERE id = NEW.id;
54
+ END;
@@ -0,0 +1,32 @@
1
+ """Migrações numeradas: arquivos NNNN_nome.sql nesta pasta, aplicados em ordem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from importlib import resources
8
+
9
+ _PADRAO = re.compile(r"^(\d{4})_[a-z0-9_]+\.sql$")
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Migracao:
14
+ versao: int
15
+ nome: str
16
+ sql: str
17
+
18
+
19
+ def carregar() -> list[Migracao]:
20
+ """Lê as migrações do pacote e confere que a numeração é 1, 2, 3... sem buracos."""
21
+ encontradas = []
22
+ for item in resources.files(__package__).iterdir():
23
+ casamento = _PADRAO.match(item.name)
24
+ if casamento:
25
+ encontradas.append(
26
+ Migracao(int(casamento.group(1)), item.name, item.read_text(encoding="utf-8"))
27
+ )
28
+ encontradas.sort(key=lambda m: m.versao)
29
+ for esperado, migracao in enumerate(encontradas, start=1):
30
+ if migracao.versao != esperado:
31
+ raise RuntimeError(f"migração {esperado:04d} faltando antes de {migracao.nome}")
32
+ return encontradas
@@ -0,0 +1,31 @@
1
+ """Preferências do usuário em `tema.toml`, na pasta de dados. Erro nunca impede o app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ NOME_ARQUIVO = "tema.toml"
9
+
10
+
11
+ def ler_tema(pasta: Path) -> str | None:
12
+ """Nome do tema salvo, ou None se o arquivo não existe, está ilegível ou sem `tema`."""
13
+ try:
14
+ with (pasta / NOME_ARQUIVO).open("rb") as arquivo:
15
+ nome = tomllib.load(arquivo).get("tema")
16
+ except (OSError, tomllib.TOMLDecodeError):
17
+ return None
18
+ return nome if isinstance(nome, str) else None
19
+
20
+
21
+ def gravar_tema(pasta: Path, nome: str) -> bool:
22
+ """Grava o tema por arquivo temporário e troca atômica. False se não foi possível."""
23
+ destino = pasta / NOME_ARQUIVO
24
+ temporario = destino.with_suffix(".toml.tmp")
25
+ try:
26
+ pasta.mkdir(parents=True, exist_ok=True)
27
+ temporario.write_text(f'tema = "{nome}"\n', encoding="utf-8")
28
+ temporario.replace(destino)
29
+ except OSError:
30
+ return False
31
+ return True
@@ -0,0 +1,123 @@
1
+ """Trava de uso (D38): evita abrir o mesmo banco em dois computadores ao mesmo tempo.
2
+
3
+ Com a pasta de dados num serviço de sincronização (Google Drive), dois computadores abertos
4
+ juntos geram "cópias em conflito" e perdem lançamentos. Enquanto o Kotsu está aberto existe
5
+ `kotsu.trava` ao lado do banco, dizendo quem está usando. A trava é um aviso entre
6
+ computadores, não uma garantia: o Drive leva alguns segundos para propagá-la.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import socket
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ NOME_TRAVA = "kotsu.trava"
20
+
21
+
22
+ class BancoEmUso(Exception):
23
+ """Outro Kotsu (ou outro computador) está com o banco aberto."""
24
+
25
+ def __init__(self, maquina: str, desde: str, mesma_maquina: bool) -> None:
26
+ self.maquina, self.desde, self.mesma_maquina = maquina, desde, mesma_maquina
27
+ onde = "neste computador" if mesma_maquina else f"no computador {maquina}"
28
+ super().__init__(f"O Kotsu já está aberto {onde} (desde {desde}).")
29
+
30
+
31
+ @dataclass
32
+ class Trava:
33
+ caminho: Path
34
+
35
+ def liberar(self) -> None:
36
+ """Remove a trava, só se ainda for deste processo."""
37
+ if (
38
+ _ler(self.caminho).get("pid") == os.getpid()
39
+ and _ler(self.caminho).get("maquina") == _maquina()
40
+ ):
41
+ self.caminho.unlink(missing_ok=True)
42
+
43
+
44
+ def tomar(pasta: Path, desde: str, forcar: bool = False) -> Trava:
45
+ """Cria a trava ou levanta `BancoEmUso`. `forcar` assume uma trava de outro dono."""
46
+ caminho = pasta / NOME_TRAVA
47
+ pasta.mkdir(parents=True, exist_ok=True)
48
+ dono = _ler(caminho)
49
+ if dono and not forcar:
50
+ mesma = dono.get("maquina") == _maquina()
51
+ # Na mesma máquina, trava de processo que já morreu (queda de energia) é descartada.
52
+ if not (mesma and not _vivo(int(dono.get("pid", 0)))):
53
+ raise BancoEmUso(str(dono.get("maquina", "?")), str(dono.get("desde", "?")), mesma)
54
+ conteudo = json.dumps({"maquina": _maquina(), "pid": os.getpid(), "desde": desde})
55
+ caminho.unlink(missing_ok=True)
56
+ try:
57
+ descritor = os.open(caminho, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
58
+ except FileExistsError: # dois Kotsu na mesma hora: perde quem chegou depois
59
+ dono = _ler(caminho)
60
+ raise BancoEmUso(
61
+ str(dono.get("maquina", "?")),
62
+ str(dono.get("desde", "?")),
63
+ dono.get("maquina") == _maquina(),
64
+ ) from None
65
+ with os.fdopen(descritor, "w", encoding="utf-8") as arquivo:
66
+ arquivo.write(conteudo)
67
+ return Trava(caminho)
68
+
69
+
70
+ def conflitos(pasta: Path, nome_banco: str) -> list[Path]:
71
+ """Cópias do banco que o Drive cria ao ver duas edições (ex.: `money (1).sqlite`).
72
+
73
+ Ignora o banco, backups e as cópias de pré-migração (`money.antes-v2.sqlite`).
74
+ """
75
+ stem = Path(nome_banco).stem
76
+ padrao = re.compile(re.escape(stem) + r".+\.sqlite", re.IGNORECASE)
77
+ antes = re.compile(re.escape(stem) + r"\.antes-v\d+\.sqlite", re.IGNORECASE)
78
+ try:
79
+ itens = sorted(pasta.iterdir())
80
+ except OSError:
81
+ return []
82
+ return [
83
+ p for p in itens if p.is_file() and padrao.fullmatch(p.name) and not antes.fullmatch(p.name)
84
+ ]
85
+
86
+
87
+ def _maquina() -> str:
88
+ return socket.gethostname()
89
+
90
+
91
+ def _ler(caminho: Path) -> dict:
92
+ try:
93
+ dados = json.loads(caminho.read_text(encoding="utf-8"))
94
+ except (OSError, ValueError):
95
+ # Trava existe mas está ilegível (ainda sincronizando): trata como de dono desconhecido.
96
+ return {"maquina": "?", "pid": 0, "desde": "?"} if caminho.exists() else {}
97
+ return dados if isinstance(dados, dict) else {}
98
+
99
+
100
+ def _vivo(pid: int) -> bool:
101
+ if pid <= 0:
102
+ return False
103
+ if sys.platform == "win32": # os.kill(pid, 0) MATA o processo no Windows
104
+ import ctypes
105
+
106
+ kernel = ctypes.windll.kernel32
107
+ processo = kernel.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
108
+ if not processo:
109
+ return False
110
+ try:
111
+ codigo = ctypes.c_ulong()
112
+ return bool(kernel.GetExitCodeProcess(processo, ctypes.byref(codigo))) and (
113
+ codigo.value == 259 # STILL_ACTIVE
114
+ )
115
+ finally:
116
+ kernel.CloseHandle(processo)
117
+ try:
118
+ os.kill(pid, 0)
119
+ except ProcessLookupError:
120
+ return False
121
+ except PermissionError:
122
+ return True
123
+ return True
@@ -0,0 +1,285 @@
1
+ """Arquivo de troca entre aparelhos (D39): exportar, importar (mesclar) e conferir.
2
+
3
+ Cada aparelho tem o próprio banco. Para juntar os dados, um exporta um arquivo JSON com
4
+ tudo o que tem e o outro importa: a mesclagem (`dominio.mescla`) decide registro a registro.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime as dt
10
+ import json
11
+ import os
12
+ import sqlite3
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ from money_cli.armazenamento.banco import Banco, copiar_banco
17
+ from money_cli.dominio import mescla
18
+ from money_cli.dominio.mescla import Comparacao, ErroDeTroca, Resumo
19
+
20
+ NOME_ANTES = "antes-da-troca"
21
+
22
+ _CAMPOS_SERIE = (
23
+ "grupo",
24
+ "dia",
25
+ "descricao",
26
+ "valor",
27
+ "tipo",
28
+ "conta",
29
+ "categoria",
30
+ "gerada_ate",
31
+ "encerrada",
32
+ "alterado_em",
33
+ )
34
+ _CAMPOS_LANCAMENTO = (
35
+ "uid",
36
+ "data",
37
+ "descricao",
38
+ "valor",
39
+ "tipo",
40
+ "conta",
41
+ "categoria",
42
+ "recorrencia",
43
+ "gerado",
44
+ "alterado_em",
45
+ )
46
+
47
+
48
+ def ler_estado(banco: Banco) -> dict:
49
+ """O conteúdo inteiro do banco, com `uid` no lugar dos ids locais."""
50
+ c = banco.conexao
51
+ contas = [
52
+ {"uid": uid, "nome": nome, "cor": cor, "alterado_em": em}
53
+ for uid, nome, cor, em in c.execute(
54
+ "SELECT uid, nome, cor, alterado_em FROM conta ORDER BY uid"
55
+ )
56
+ ]
57
+ categorias = [
58
+ {"uid": uid, "nome": nome, "recorrente": bool(rec), "alterado_em": em}
59
+ for uid, nome, rec, em in c.execute(
60
+ "SELECT uid, nome, recorrente, alterado_em FROM categoria ORDER BY uid"
61
+ )
62
+ ]
63
+ recorrencias = []
64
+ for linha in c.execute(
65
+ "SELECT r.grupo, r.dia, r.descricao, r.valor, r.tipo, conta.uid, categoria.uid,"
66
+ " r.gerada_ate, r.encerrada, r.alterado_em FROM recorrencia r"
67
+ " JOIN conta ON conta.id = r.conta_id"
68
+ " LEFT JOIN categoria ON categoria.id = r.categoria_id ORDER BY r.grupo"
69
+ ):
70
+ serie = dict(zip(_CAMPOS_SERIE, linha, strict=True))
71
+ serie["encerrada"] = bool(serie["encerrada"])
72
+ recorrencias.append(serie)
73
+ lancamentos = []
74
+ for *comuns, grupo, numero, total in c.execute(
75
+ "SELECT l.uid, l.data, l.descricao, l.valor, l.tipo, conta.uid, categoria.uid,"
76
+ " l.grupo_recorrencia, l.gerado, l.alterado_em,"
77
+ " l.grupo_parcela, l.parcela_numero, l.parcela_total FROM lancamento l"
78
+ " JOIN conta ON conta.id = l.conta_id"
79
+ " LEFT JOIN categoria ON categoria.id = l.categoria_id ORDER BY l.uid"
80
+ ):
81
+ lanc = dict(zip(_CAMPOS_LANCAMENTO, comuns, strict=True))
82
+ lanc["gerado"] = bool(lanc["gerado"])
83
+ lanc["parcela"] = (
84
+ None if grupo is None else {"grupo": grupo, "numero": numero, "total": total}
85
+ )
86
+ lancamentos.append(lanc)
87
+ lapides = [
88
+ {"uid": uid, "excluido_em": em}
89
+ for uid, em in c.execute("SELECT uid, excluido_em FROM lapide ORDER BY uid")
90
+ ]
91
+ return {
92
+ "contas": contas,
93
+ "categorias": categorias,
94
+ "recorrencias": recorrencias,
95
+ "lancamentos": lancamentos,
96
+ "lapides": lapides,
97
+ }
98
+
99
+
100
+ def codigo(banco: Banco) -> str:
101
+ """Código de conferência dos dados deste banco."""
102
+ return mescla.codigo(ler_estado(banco))
103
+
104
+
105
+ def exportar(banco: Banco, destino: Path, agora: dt.datetime | None = None) -> str:
106
+ """Escreve o arquivo de troca em `destino` (troca atômica). Devolve o código."""
107
+ estado = ler_estado(banco)
108
+ conferencia = mescla.codigo(estado)
109
+ arquivo = {
110
+ "app": "kotsu",
111
+ "formato": mescla.FORMATO,
112
+ "replica": banco.replica,
113
+ "exportado_em": (agora or dt.datetime.now()).isoformat(timespec="seconds"),
114
+ "conferencia": conferencia,
115
+ **estado,
116
+ }
117
+ destino.parent.mkdir(parents=True, exist_ok=True)
118
+ descritor, nome = tempfile.mkstemp(prefix=".troca-", suffix=".tmp", dir=destino.parent)
119
+ temporario = Path(nome)
120
+ try:
121
+ with os.fdopen(descritor, "w", encoding="utf-8") as saida:
122
+ json.dump(arquivo, saida, ensure_ascii=False, separators=(",", ":"))
123
+ saida.flush()
124
+ os.fsync(saida.fileno())
125
+ temporario.replace(destino)
126
+ finally:
127
+ temporario.unlink(missing_ok=True)
128
+ return conferencia
129
+
130
+
131
+ def ler_arquivo(origem: Path) -> dict:
132
+ """Lê e valida um arquivo de troca. Levanta `ErroDeTroca` com mensagem para a pessoa."""
133
+ try:
134
+ texto = origem.read_text(encoding="utf-8")
135
+ except (OSError, UnicodeDecodeError) as erro:
136
+ raise ErroDeTroca(f"não foi possível ler {origem}: {erro}") from None
137
+ try:
138
+ dados = json.loads(texto)
139
+ except ValueError:
140
+ raise ErroDeTroca(f"{origem} não é uma exportação do Kotsu") from None
141
+ return mescla.validar(dados)
142
+
143
+
144
+ def comparar(banco: Banco, origem: Path) -> Comparacao:
145
+ """Diferenças entre este banco e o arquivo de outro aparelho. Não muda nada."""
146
+ return mescla.comparar(ler_estado(banco), ler_arquivo(origem))
147
+
148
+
149
+ def importar(banco: Banco, origem: Path) -> Resumo:
150
+ """Mescla o arquivo de outro aparelho neste banco, numa transação só.
151
+
152
+ Antes de mudar qualquer coisa, guarda uma cópia do banco em `backups/` (uma só,
153
+ substituída a cada importação que muda algo).
154
+ """
155
+ remoto = ler_arquivo(origem)
156
+ local = ler_estado(banco)
157
+ novo = mescla.mesclar(local, remoto)
158
+ visto = mescla.maior_carimbo(remoto)
159
+ if novo == local:
160
+ with banco.transacao() as c:
161
+ _avancar_relogio(c, visto)
162
+ return Resumo()
163
+ if banco.caminho is not None:
164
+ pasta = banco.pasta_backups or banco.caminho.parent / "backups"
165
+ try:
166
+ pasta.mkdir(parents=True, exist_ok=True)
167
+ copiar_banco(banco.conexao, pasta / f"{banco.caminho.stem}.{NOME_ANTES}.sqlite")
168
+ except (OSError, sqlite3.Error) as erro:
169
+ raise ErroDeTroca(
170
+ f"não foi possível guardar a cópia de segurança em {pasta}: {erro}"
171
+ ) from None
172
+ with banco.transacao() as c:
173
+ _aplicar(c, novo)
174
+ _avancar_relogio(c, visto)
175
+ return mescla.resumir(local, novo)
176
+
177
+
178
+ def _avancar_relogio(c: sqlite3.Connection, visto: str) -> None:
179
+ """O próximo carimbo daqui passa de tudo o que o arquivo trouxe."""
180
+ c.execute(
181
+ "UPDATE meta SET valor = max(valor, ?) WHERE chave = 'relogio'",
182
+ (visto,),
183
+ )
184
+
185
+
186
+ def _aplicar(c: sqlite3.Connection, novo: dict) -> None:
187
+ """Deixa o banco igual ao estado `novo`, preservando os ids locais."""
188
+ contas = _aplicar_nomeados(c, "conta", novo["contas"], ("cor",))
189
+ categorias = _aplicar_nomeados(c, "categoria", novo["categorias"], ("recorrente",))
190
+
191
+ for r in novo["recorrencias"]:
192
+ c.execute(
193
+ "INSERT INTO recorrencia (grupo, dia, descricao, valor, tipo, conta_id, categoria_id,"
194
+ " gerada_ate, encerrada, alterado_em) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
195
+ " ON CONFLICT (grupo) DO UPDATE SET dia = excluded.dia,"
196
+ " descricao = excluded.descricao, valor = excluded.valor, tipo = excluded.tipo,"
197
+ " conta_id = excluded.conta_id, categoria_id = excluded.categoria_id,"
198
+ " gerada_ate = excluded.gerada_ate, encerrada = excluded.encerrada,"
199
+ " alterado_em = excluded.alterado_em",
200
+ (
201
+ r["grupo"],
202
+ r["dia"],
203
+ r["descricao"],
204
+ r["valor"],
205
+ r["tipo"],
206
+ contas[r["conta"]],
207
+ categorias.get(r["categoria"]),
208
+ r["gerada_ate"],
209
+ int(r["encerrada"]),
210
+ r["alterado_em"],
211
+ ),
212
+ )
213
+
214
+ locais = {uid for (uid,) in c.execute("SELECT uid FROM lancamento")}
215
+ vivos = {x["uid"] for x in novo["lancamentos"]}
216
+ c.executemany("DELETE FROM lancamento WHERE uid = ?", [(uid,) for uid in locais - vivos])
217
+ for x in novo["lancamentos"]:
218
+ p = x["parcela"] or {}
219
+ valores = (
220
+ x["data"],
221
+ x["descricao"],
222
+ x["valor"],
223
+ x["tipo"],
224
+ contas[x["conta"]],
225
+ categorias.get(x["categoria"]),
226
+ p.get("grupo"),
227
+ p.get("numero"),
228
+ p.get("total"),
229
+ x["recorrencia"],
230
+ int(x["gerado"]),
231
+ x["alterado_em"],
232
+ x["uid"],
233
+ )
234
+ if x["uid"] in locais:
235
+ c.execute(
236
+ "UPDATE lancamento SET data = ?, descricao = ?, valor = ?, tipo = ?, conta_id = ?,"
237
+ " categoria_id = ?, grupo_parcela = ?, parcela_numero = ?, parcela_total = ?,"
238
+ " grupo_recorrencia = ?, gerado = ?, alterado_em = ? WHERE uid = ?",
239
+ valores,
240
+ )
241
+ else:
242
+ c.execute(
243
+ "INSERT INTO lancamento (data, descricao, valor, tipo, conta_id, categoria_id,"
244
+ " grupo_parcela, parcela_numero, parcela_total, grupo_recorrencia, gerado,"
245
+ " alterado_em, uid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
246
+ valores,
247
+ )
248
+
249
+ c.executemany(
250
+ "INSERT OR REPLACE INTO lapide (uid, excluido_em) VALUES (?, ?)",
251
+ [(x["uid"], x["excluido_em"]) for x in novo["lapides"]],
252
+ )
253
+
254
+
255
+ def _aplicar_nomeados(
256
+ c: sqlite3.Connection, tabela: str, itens: list[dict], campos: tuple[str, ...]
257
+ ) -> dict[str, int]:
258
+ """Grava contas ou categorias mescladas. Devolve `uid -> id local`.
259
+
260
+ A que existe aqui com o mesmo nome e outro `uid` é a mesma: passa a usar o `uid` que a
261
+ mesclagem escolheu, e os lançamentos daqui continuam apontando para ela.
262
+ """
263
+ por_uid, por_nome = {}, {}
264
+ for id_, uid, nome in c.execute(f"SELECT id, uid, nome FROM {tabela}").fetchall():
265
+ por_uid[uid] = id_
266
+ por_nome[mescla.chave_do_nome(nome)] = id_
267
+ ids = {}
268
+ atribuicoes = ", ".join(f"{campo} = ?" for campo in campos)
269
+ for item in itens:
270
+ valores = [int(v) if isinstance(v, bool) else v for v in (item[k] for k in campos)]
271
+ id_ = por_uid.get(item["uid"]) or por_nome.get(mescla.chave_do_nome(item["nome"]))
272
+ if id_ is None:
273
+ id_ = c.execute(
274
+ f"INSERT INTO {tabela} (uid, nome, alterado_em, {', '.join(campos)})"
275
+ f" VALUES (?, ?, ?{', ?' * len(campos)})",
276
+ (item["uid"], item["nome"], item["alterado_em"], *valores),
277
+ ).lastrowid
278
+ else:
279
+ c.execute(
280
+ f"UPDATE {tabela} SET uid = ?, nome = ?, alterado_em = ?, {atribuicoes}"
281
+ " WHERE id = ?",
282
+ (item["uid"], item["nome"], item["alterado_em"], *valores, id_),
283
+ )
284
+ ids[item["uid"]] = id_
285
+ return ids
@@ -0,0 +1,5 @@
1
+ """Camada de domínio: tipos e regras puras, sem I/O.
2
+
3
+ Não importa nada de `armazenamento` nem de `tela`, nem módulos de I/O
4
+ (arquivo, banco, rede, terminal). O teste de camadas confere isso.
5
+ """
@@ -0,0 +1,10 @@
1
+ """Formatação de valores em centavos inteiros."""
2
+
3
+
4
+ def formatar(centavos: int) -> str:
5
+ """Formata centavos como BRL sem sinal: 123456 -> '1.234,56'.
6
+
7
+ O sinal nunca aparece no texto: negativo é mostrado só em vermelho pela tela.
8
+ """
9
+ reais, cents = divmod(abs(centavos), 100)
10
+ return f"{reais:,}".replace(",", ".") + f",{cents:02d}"
@@ -0,0 +1,63 @@
1
+ """Filtro da tela principal (D12, D26). Puro: lê o texto; a tela resolve os nomes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from money_cli.dominio.linha import ErroDeLinha
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class FiltroDigitado:
12
+ """O que foi digitado depois de `/`, antes de virar ids."""
13
+
14
+ conta: str | None = None # @nome
15
+ categoria: str | None = None # #nome
16
+ qualquer: str | None = None # nome sem prefixo: conta ou categoria
17
+
18
+ @property
19
+ def vazio(self) -> bool:
20
+ return self.conta is None and self.categoria is None and self.qualquer is None
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Filtro:
25
+ """Filtro já resolvido em ids. Vale o "e" entre conta e categoria."""
26
+
27
+ conta_id: int | None = None
28
+ categoria_id: int | None = None
29
+
30
+ @property
31
+ def ativo(self) -> bool:
32
+ return self.conta_id is not None or self.categoria_id is not None
33
+
34
+
35
+ SEM_FILTRO = Filtro()
36
+
37
+
38
+ def interpretar_filtro(texto: str) -> FiltroDigitado:
39
+ """`@nubank`, `#casa`, `@nubank #casa` ou `nubank`. Levanta `ErroDeLinha`."""
40
+ conta = categoria = qualquer = None
41
+ soltas: list[str] = []
42
+ for parte in texto.split():
43
+ if parte.startswith("@"):
44
+ if conta is not None:
45
+ raise ErroDeLinha("só uma @conta no filtro")
46
+ conta = _nome(parte, "conta")
47
+ elif parte.startswith("#"):
48
+ if categoria is not None:
49
+ raise ErroDeLinha("só uma #categoria no filtro")
50
+ categoria = _nome(parte, "categoria")
51
+ else:
52
+ soltas.append(parte)
53
+ if soltas:
54
+ if conta is not None or categoria is not None:
55
+ raise ErroDeLinha("use @conta e #categoria juntos, ou só um nome")
56
+ qualquer = " ".join(soltas)
57
+ return FiltroDigitado(conta, categoria, qualquer)
58
+
59
+
60
+ def _nome(parte: str, oque: str) -> str:
61
+ if len(parte) == 1:
62
+ raise ErroDeLinha(f"falta o nome da {oque} depois de {parte}")
63
+ return parte[1:]