fec-lint 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.
fec_lint/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Free structural linter for French FEC files (Fichier des Écritures Comptables)."""
2
+
3
+ from fec_lint.core import lint_file
4
+
5
+ __all__ = ['lint_file']
6
+ __version__ = '0.1.0'
7
+
8
+ FULL_PRE_AUDIT_URL = 'https://apify.com/highfiddle/fec-checker'
fec_lint/cli.py ADDED
@@ -0,0 +1,63 @@
1
+ """fec-lint command line: lint one or more FEC files, print a summary or JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from fec_lint import FULL_PRE_AUDIT_URL, __version__
10
+ from fec_lint.core import lint_file
11
+
12
+
13
+ def _print_result(result: dict) -> None:
14
+ status = 'OK' if result['ok'] else 'FAILED'
15
+ stats = result['stats']
16
+ details = ', '.join(f'{k} {v}' for k, v in (('encoding', stats['encoding']), ('separator', stats['separator']),
17
+ ('lines', stats['lineCount'])) if v)
18
+ print(f'{result["file"]}: {status}' + (f' ({details})' if details else ''))
19
+ for label, groups in (('error', result['errors']), ('warning', result['warnings'])):
20
+ for group in groups:
21
+ first = group['occurrences'][0] if group['occurrences'] else {}
22
+ lines = [str(o['line']) for o in group['occurrences'] if o.get('line')]
23
+ where = f' - line(s) {", ".join(lines[:10])}{"..." if group["truncated"] or len(lines) > 10 else ""}' if lines else ''
24
+ print(f' {label} {group["rule"]} x{group["count"]}: {first.get("message", "")}{where}')
25
+
26
+
27
+ def main(argv: list[str] | None = None) -> int:
28
+ parser = argparse.ArgumentParser(
29
+ prog='fec-lint',
30
+ description='Structural check of French FEC files: name, encoding, separator, 18 headers, field formats.',
31
+ )
32
+ parser.add_argument('files', nargs='+', help='FEC files (.txt, or a .zip holding one)')
33
+ parser.add_argument('--json', action='store_true', help='print the full result as JSON')
34
+ parser.add_argument('--name', help='file name to check against SIRENFECAAAAMMJJ.txt (one file only)')
35
+ parser.add_argument('--quiet', action='store_true', help='hide the closing line about the full pre-audit')
36
+ parser.add_argument('--version', action='version', version=f'fec-lint {__version__}')
37
+ args = parser.parse_args(argv)
38
+ if args.name and len(args.files) > 1:
39
+ parser.error('--name works with a single file')
40
+
41
+ results = []
42
+ for path in args.files:
43
+ try:
44
+ results.append(lint_file(path, args.name))
45
+ except OSError as e:
46
+ print(f'{path}: cannot read ({e.strerror or e})', file=sys.stderr)
47
+ return 2
48
+
49
+ if args.json:
50
+ print(json.dumps(results[0] if len(results) == 1 else results, ensure_ascii=False, indent=2))
51
+ else:
52
+ for result in results:
53
+ _print_result(result)
54
+
55
+ all_ok = all(r['ok'] for r in results)
56
+ if not args.quiet and not args.json:
57
+ verdict = 'Structure OK.' if all_ok else 'Structure has errors.'
58
+ print(f'{verdict} Full pre-audit (balance, numbering, risk signals): {FULL_PRE_AUDIT_URL}')
59
+ return 0 if all_ok else 1
60
+
61
+
62
+ if __name__ == '__main__':
63
+ sys.exit(main())
fec_lint/core.py ADDED
@@ -0,0 +1,359 @@
1
+ """Structural lint for French FEC files (art. A47 A-1 du Livre des procédures fiscales).
2
+
3
+ Checks what a file looks like, line by line: file name, encoding, separator, the 18 headers and the
4
+ format of each field. It does not check balance, entry numbering or audit-risk patterns; the full
5
+ FEC Checker on Apify does (https://apify.com/highfiddle/fec-checker).
6
+
7
+ The file is streamed, so memory stays flat whatever its size.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import codecs
13
+ import re
14
+ import tempfile
15
+ import zipfile
16
+ from dataclasses import dataclass, field
17
+ from datetime import date
18
+ from pathlib import Path
19
+
20
+ EXPECTED_HEADERS = (
21
+ 'JournalCode', 'JournalLib', 'EcritureNum', 'EcritureDate', 'CompteNum', 'CompteLib',
22
+ 'CompAuxNum', 'CompAuxLib', 'PieceRef', 'PieceDate', 'EcritureLib', 'Debit', 'Credit',
23
+ 'EcritureLet', 'DateLet', 'ValidDate', 'Montantdevise', 'Idevise',
24
+ )
25
+ REQUIRED_FIELDS = (
26
+ 'JournalCode', 'JournalLib', 'EcritureNum', 'EcritureDate', 'CompteNum', 'CompteLib',
27
+ 'PieceRef', 'PieceDate', 'EcritureLib', 'Debit', 'Credit', 'ValidDate',
28
+ )
29
+ DATE_FIELDS = ('EcritureDate', 'PieceDate', 'DateLet', 'ValidDate')
30
+ FIELD_PAIRS = (('CompAuxNum', 'CompAuxLib'), ('EcritureLet', 'DateLet'), ('Montantdevise', 'Idevise'))
31
+
32
+ FILE_NAME_RE = re.compile(r'^(\d{9})FEC(\d{8})\.txt$', re.IGNORECASE)
33
+ AMOUNT_RE = re.compile(r'^(-?)(\d+)(?:,(\d{1,2}))?$')
34
+ DOT_AMOUNT_RE = re.compile(r'^(-?)(\d+)\.(\d{1,2})$')
35
+
36
+ MAX_OCCURRENCES = 20
37
+ MAX_VALUE_CHARS = 60
38
+ SNIFF_BYTES = 64 * 1024
39
+
40
+ ERROR = 'error'
41
+ WARNING = 'warning'
42
+
43
+ MESSAGES = {
44
+ 'not_a_fec': (ERROR, 'The file is not a FEC text file: {kind}.'),
45
+ 'file_name': (ERROR, 'The file name must follow the SIRENFECAAAAMMJJ.txt pattern.'),
46
+ 'file_name_date': (ERROR, 'The closing date in the file name is not a valid date.'),
47
+ 'siren_checksum': (WARNING, 'The SIREN in the file name fails the Luhn checksum.'),
48
+ 'encoding_utf16': (WARNING, 'The file is UTF-16 encoded; only ASCII, ISO-8859-15 and UTF-8 are expected.'),
49
+ 'separator': (ERROR, 'No separator found: the header line must be tab- or pipe-separated.'),
50
+ 'separator_semicolon': (ERROR, 'The file is semicolon-separated; only tab or pipe (|) is allowed.'),
51
+ 'header_count': (ERROR, 'The header must contain exactly 18 columns.'),
52
+ 'header_missing': (ERROR, 'Mandatory column missing from the header: {field}.'),
53
+ 'header_unknown': (ERROR, 'Header column not defined by article A47 A-1.'),
54
+ 'header_order': (ERROR, 'The header columns are not in the required order.'),
55
+ 'header_case': (ERROR, 'Column name has the wrong letter case: {field}.'),
56
+ 'empty_line': (WARNING, 'Empty line.'),
57
+ 'field_count': (ERROR, 'The line does not have the same number of fields as the header.'),
58
+ 'required_field': (ERROR, 'Mandatory field is empty: {field}.'),
59
+ 'pair_incomplete': (ERROR, 'Fields {pair} must be filled together; {field} is empty.'),
60
+ 'date_format': (ERROR, 'Wrong date format in {field} (expected AAAAMMJJ).'),
61
+ 'date_invalid': (ERROR, 'Non-existent date in {field}.'),
62
+ 'amount_format': (ERROR, 'Unreadable amount in {field} (digits and a decimal comma only).'),
63
+ 'decimal_point': (ERROR, 'The amount in {field} uses a decimal point instead of a comma.'),
64
+ 'debit_and_credit': (ERROR, 'The line has both a debit and a credit amount.'),
65
+ 'zero_line': (WARNING, 'Line with zero debit and zero credit.'),
66
+ 'negative_amount': (WARNING, 'Negative amount in {field}.'),
67
+ }
68
+
69
+ BINARY_MAGIC = (
70
+ (b'%PDF', 'a PDF document'),
71
+ (b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1', 'an old Excel or Word file'),
72
+ (b'\x89PNG', 'a PNG image'),
73
+ (b'\xff\xd8\xff', 'a JPEG image'),
74
+ (b'\x1f\x8b', 'a gzip archive'),
75
+ (b'<?xml', 'an XML file (only the flat-file FEC is supported)'),
76
+ )
77
+
78
+
79
+ @dataclass
80
+ class _Rule:
81
+ count: int = 0
82
+ occurrences: list[dict] = field(default_factory=list)
83
+
84
+
85
+ class _Issues:
86
+ def __init__(self) -> None:
87
+ self.rules: dict[str, _Rule] = {}
88
+
89
+ def add(self, rule: str, line: int | None = None, field_name: str | None = None,
90
+ value: str | None = None, **params: str) -> None:
91
+ entry = self.rules.setdefault(rule, _Rule())
92
+ entry.count += 1
93
+ if len(entry.occurrences) < MAX_OCCURRENCES:
94
+ occurrence = {'line': line, 'field': field_name}
95
+ if value is not None:
96
+ occurrence['value'] = value[:MAX_VALUE_CHARS]
97
+ occurrence['message'] = MESSAGES[rule][1].format(field=field_name or '?', **params)
98
+ entry.occurrences.append(occurrence)
99
+
100
+
101
+ def luhn_valid(digits: str) -> bool:
102
+ total = 0
103
+ for i, ch in enumerate(reversed(digits)):
104
+ d = int(ch) * (2 if i % 2 else 1)
105
+ total += d - 9 if d > 9 else d
106
+ return total % 10 == 0
107
+
108
+
109
+ def parse_yyyymmdd(value: str) -> date | None:
110
+ if len(value) != 8 or not value.isdigit():
111
+ return None
112
+ try:
113
+ return date(int(value[:4]), int(value[4:6]), int(value[6:]))
114
+ except ValueError:
115
+ return None
116
+
117
+
118
+ def utf16_variant(head: bytes) -> str | None:
119
+ if head.startswith((b'\xff\xfe', b'\xfe\xff')):
120
+ return 'utf-16'
121
+ sample = head[:4096]
122
+ if len(sample) < 16:
123
+ return None
124
+ half = len(sample) / 2
125
+ even_zeros, odd_zeros = sample[0::2].count(0), sample[1::2].count(0)
126
+ if odd_zeros > 0.4 * half and even_zeros < 0.05 * half:
127
+ return 'utf-16-le'
128
+ if even_zeros > 0.4 * half and odd_zeros < 0.05 * half:
129
+ return 'utf-16-be'
130
+ return None
131
+
132
+
133
+ def _detect_encoding(path: Path) -> str:
134
+ with path.open('rb') as f:
135
+ utf16 = utf16_variant(f.read(4096))
136
+ if utf16:
137
+ return utf16
138
+ decoder = codecs.getincrementaldecoder('utf-8')()
139
+ try:
140
+ with path.open('rb') as f:
141
+ while chunk := f.read(1 << 20):
142
+ decoder.decode(chunk)
143
+ decoder.decode(b'', final=True)
144
+ except UnicodeDecodeError:
145
+ return 'iso-8859-15'
146
+ return 'utf-8'
147
+
148
+
149
+ def _binary_kind(head: bytes) -> str | None:
150
+ for magic, kind in BINARY_MAGIC:
151
+ if head.startswith(magic):
152
+ return kind
153
+ if b'\x00' in head and utf16_variant(head) is None:
154
+ return 'a binary file'
155
+ return None
156
+
157
+
158
+ def _unzip(path: Path, workdir: Path) -> tuple[Path | None, str | None, str | None]:
159
+ """(file, name inside the zip, problem). A zip must hold exactly one .txt FEC."""
160
+ try:
161
+ with zipfile.ZipFile(path) as archive:
162
+ infos = [i for i in archive.infolist() if not i.is_dir()]
163
+ if any(i.filename == '[Content_Types].xml' for i in infos):
164
+ return None, None, 'an Excel or Word document (.xlsx/.docx)'
165
+ fecs = [i for i in infos if i.filename.lower().endswith('.txt') and not Path(i.filename).name.startswith('.')]
166
+ if len(fecs) != 1:
167
+ return None, None, f'a zip archive with {len(fecs)} .txt files (exactly one is expected)'
168
+ target = workdir / 'unzipped.fec'
169
+ with archive.open(fecs[0]) as src, target.open('wb') as dst:
170
+ while chunk := src.read(1 << 20):
171
+ dst.write(chunk)
172
+ return target, Path(fecs[0].filename).name, None
173
+ except (zipfile.BadZipFile, RuntimeError):
174
+ return None, None, 'a zip archive that cannot be opened'
175
+
176
+
177
+ def _check_file_name(name: str, issues: _Issues) -> None:
178
+ match = FILE_NAME_RE.match(name)
179
+ if not match:
180
+ issues.add('file_name', value=name)
181
+ return
182
+ siren, closing = match.groups()
183
+ if not luhn_valid(siren):
184
+ issues.add('siren_checksum', value=siren)
185
+ if parse_yyyymmdd(closing) is None:
186
+ issues.add('file_name_date', value=closing)
187
+
188
+
189
+ def _check_header(names: list[str], issues: _Issues) -> dict[str, int]:
190
+ names = [n.strip() for n in names]
191
+ if tuple(names) == EXPECTED_HEADERS:
192
+ return {n: i for i, n in enumerate(names)}
193
+ if len(names) != len(EXPECTED_HEADERS):
194
+ issues.add('header_count', 1, value=str(len(names)))
195
+ exact = {n: i for i, n in enumerate(names)}
196
+ lowered = {n.lower(): i for i, n in enumerate(names)}
197
+ columns: dict[str, int] = {}
198
+ for expected in EXPECTED_HEADERS:
199
+ if expected in exact:
200
+ columns[expected] = exact[expected]
201
+ elif expected.lower() in lowered:
202
+ columns[expected] = lowered[expected.lower()]
203
+ issues.add('header_case', 1, expected, names[columns[expected]])
204
+ else:
205
+ issues.add('header_missing', 1, expected)
206
+ known = {e.lower() for e in EXPECTED_HEADERS}
207
+ for name in names:
208
+ if name.lower() not in known:
209
+ issues.add('header_unknown', 1, value=name)
210
+ order = [n for n, _ in sorted(columns.items(), key=lambda kv: kv[1])]
211
+ if order != [n for n in EXPECTED_HEADERS if n in columns]:
212
+ issues.add('header_order', 1, value=','.join(order))
213
+ return columns
214
+
215
+
216
+ def _amount(value: str, field_name: str, line: int, issues: _Issues) -> int | None:
217
+ match = AMOUNT_RE.match(value)
218
+ if match is None:
219
+ match = DOT_AMOUNT_RE.match(value)
220
+ if match is None:
221
+ issues.add('amount_format', line, field_name, value)
222
+ return None
223
+ issues.add('decimal_point', line, field_name, value)
224
+ sign, units, decimals = match.groups()
225
+ cents = int(units) * 100 + int((decimals or '0').ljust(2, '0'))
226
+ return -cents if sign else cents
227
+
228
+
229
+ def _check_rows(f, separator: str, columns: dict[str, int], width: int, issues: _Issues) -> tuple[int, int]:
230
+ add = issues.add
231
+ required = [(n, columns[n]) for n in REQUIRED_FIELDS if n in columns]
232
+ dates = [(n, columns[n]) for n in DATE_FIELDS if n in columns]
233
+ pairs = [(a, b) for a, b in FIELD_PAIRS if a in columns and b in columns]
234
+ i_debit, i_credit, i_fx = columns.get('Debit'), columns.get('Credit'), columns.get('Montantdevise')
235
+ date_cache: dict[str, int] = {} # 0 = ok, 1 = bad format, 2 = non-existent
236
+ line_no, rows = 1, 0
237
+ for raw in f:
238
+ line_no += 1
239
+ text = raw.rstrip('\r\n')
240
+ if not text.strip():
241
+ add('empty_line', line_no)
242
+ continue
243
+ rows += 1
244
+ row = text.split(separator)
245
+ if len(row) != width:
246
+ add('field_count', line_no, value=str(len(row)))
247
+ continue
248
+ row = [v.strip() for v in row]
249
+ for name, idx in required:
250
+ if not row[idx]:
251
+ add('required_field', line_no, name)
252
+ for a, b in pairs:
253
+ if bool(row[columns[a]]) != bool(row[columns[b]]):
254
+ add('pair_incomplete', line_no, b if row[columns[a]] else a, pair=f'{a}/{b}')
255
+ for name, idx in dates:
256
+ value = row[idx]
257
+ if not value:
258
+ continue
259
+ status = date_cache.get(value)
260
+ if status is None:
261
+ status = 1 if len(value) != 8 or not value.isdigit() else (0 if parse_yyyymmdd(value) else 2)
262
+ if len(date_cache) < 100_000:
263
+ date_cache[value] = status
264
+ if status == 1:
265
+ add('date_format', line_no, name, value)
266
+ elif status == 2:
267
+ add('date_invalid', line_no, name, value)
268
+ if i_debit is not None and i_credit is not None:
269
+ debit = _amount(row[i_debit], 'Debit', line_no, issues) if row[i_debit] else None
270
+ credit = _amount(row[i_credit], 'Credit', line_no, issues) if row[i_credit] else None
271
+ if debit is not None and credit is not None:
272
+ if debit < 0 or credit < 0:
273
+ add('negative_amount', line_no, 'Debit' if debit < 0 else 'Credit')
274
+ if debit and credit:
275
+ add('debit_and_credit', line_no)
276
+ elif not debit and not credit:
277
+ add('zero_line', line_no)
278
+ if i_fx is not None and row[i_fx] and not (AMOUNT_RE.match(row[i_fx]) or DOT_AMOUNT_RE.match(row[i_fx])):
279
+ add('amount_format', line_no, 'Montantdevise', row[i_fx])
280
+ return line_no, rows
281
+
282
+
283
+ def _result(name: str, issues: _Issues, stats: dict) -> dict:
284
+ groups: dict[str, list[dict]] = {ERROR: [], WARNING: []}
285
+ for rule, entry in issues.rules.items():
286
+ severity = MESSAGES[rule][0]
287
+ groups[severity].append({
288
+ 'rule': rule,
289
+ 'count': entry.count,
290
+ 'truncated': entry.count > len(entry.occurrences),
291
+ 'occurrences': entry.occurrences,
292
+ })
293
+ return {
294
+ 'ok': not groups[ERROR],
295
+ 'file': name,
296
+ 'errors': groups[ERROR],
297
+ 'warnings': groups[WARNING],
298
+ 'stats': stats,
299
+ }
300
+
301
+
302
+ def lint_file(path: str | Path, file_name: str | None = None) -> dict:
303
+ """Lint one FEC file. Returns {ok, file, errors[], warnings[], stats}.
304
+
305
+ `ok` is True when no structural error was found. `file_name` overrides the name used for the
306
+ SIRENFECAAAAMMJJ.txt rule. A .zip holding exactly one .txt FEC is unpacked first.
307
+ """
308
+ path = Path(path)
309
+ if not path.is_file():
310
+ raise FileNotFoundError(f'No such file: {path}')
311
+ issues = _Issues()
312
+ name = file_name or path.name
313
+ stats: dict = {'encoding': None, 'separator': None, 'columns': None, 'lineCount': 0}
314
+
315
+ with tempfile.TemporaryDirectory(prefix='fec-lint-') as workdir:
316
+ with path.open('rb') as f:
317
+ head = f.read(SNIFF_BYTES)
318
+ if head.startswith(b'PK\x03\x04'):
319
+ inner, inner_name, problem = _unzip(path, Path(workdir))
320
+ if problem:
321
+ issues.add('not_a_fec', kind=problem)
322
+ return _result(name, issues, stats)
323
+ path, name = inner, file_name or inner_name
324
+ with path.open('rb') as f:
325
+ head = f.read(SNIFF_BYTES)
326
+ if not head.strip():
327
+ issues.add('not_a_fec', kind='an empty file')
328
+ return _result(name, issues, stats)
329
+ kind = _binary_kind(head)
330
+ if kind:
331
+ issues.add('not_a_fec', kind=kind)
332
+ return _result(name, issues, stats)
333
+
334
+ _check_file_name(name, issues)
335
+ encoding = _detect_encoding(path)
336
+ stats['encoding'] = encoding
337
+ if encoding.startswith('utf-16'):
338
+ issues.add('encoding_utf16', value=encoding)
339
+
340
+ with path.open('r', encoding=encoding, newline='') as f:
341
+ header = f.readline().lstrip('').rstrip('\r\n')
342
+ if '\t' in header:
343
+ separator = '\t'
344
+ elif '|' in header:
345
+ separator = '|'
346
+ elif header.count(';') >= len(EXPECTED_HEADERS) // 2:
347
+ issues.add('separator_semicolon', 1)
348
+ return _result(name, issues, stats)
349
+ else:
350
+ issues.add('separator', 1)
351
+ return _result(name, issues, stats)
352
+ stats['separator'] = 'tab' if separator == '\t' else 'pipe'
353
+ names = header.split(separator)
354
+ stats['columns'] = len(names)
355
+ columns = _check_header(names, issues)
356
+ if columns:
357
+ _, rows = _check_rows(f, separator, columns, len(names), issues)
358
+ stats['lineCount'] = rows
359
+ return _result(name, issues, stats)
fec_lint/mcp_server.py ADDED
@@ -0,0 +1,32 @@
1
+ """fec-lint MCP server (stdio): exposes the structural check as the `lint_fec` tool."""
2
+
3
+ from mcp.server.mcpserver import MCPServer
4
+ from mcp.types import ToolAnnotations
5
+
6
+ from fec_lint import __version__
7
+ from fec_lint.core import lint_file
8
+
9
+ mcp = MCPServer('fec-lint', version=__version__)
10
+
11
+
12
+ DESCRIPTION = (
13
+ "Check a French FEC file's name, encoding, separator, 18 headers and field formats. "
14
+ '`path` is a local path to the FEC (.txt, or a .zip holding one). Returns '
15
+ '{ok, file, errors[], warnings[], stats}; each error has a rule code, a count and line numbers. '
16
+ 'Structural check only; for entry balance, numbering and audit-risk analysis, use the full '
17
+ 'FEC Checker on Apify: https://apify.com/highfiddle/fec-checker'
18
+ )
19
+
20
+
21
+ @mcp.tool(description=DESCRIPTION,
22
+ annotations=ToolAnnotations(title='Lint a FEC file', readOnlyHint=True, openWorldHint=False))
23
+ def lint_fec(path: str) -> dict:
24
+ return lint_file(path)
25
+
26
+
27
+ def main() -> None:
28
+ mcp.run()
29
+
30
+
31
+ if __name__ == '__main__':
32
+ main()
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.5
2
+ Name: fec-lint
3
+ Version: 0.1.0
4
+ Summary: Free structural linter for French FEC files (Fichier des Écritures Comptables), CLI + MCP server
5
+ Project-URL: Homepage, https://github.com/vincenz-cdf/fec-lint
6
+ Project-URL: Full pre-audit, https://apify.com/highfiddle/fec-checker
7
+ Project-URL: Issues, https://github.com/vincenz-cdf/fec-lint/issues
8
+ Author: Vincent
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: accounting,comptabilite,dgfip,fec,france,mcp
12
+ Classifier: Natural Language :: English
13
+ Classifier: Natural Language :: French
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: mcp<3,>=2
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest>=8; extra == 'test'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # fec-lint
24
+
25
+ **[English](#english) · [Français](#français)**
26
+
27
+ <!-- mcp-name: io.github.vincenz-cdf/fec-lint -->
28
+
29
+ > **Technical check, not tax advice.** fec-lint checks the structure of a file against the published FEC format. It does not certify a file or replace an accountant's review.
30
+ >
31
+ > **Contrôle technique, pas un conseil fiscal.** fec-lint vérifie la structure d'un fichier au regard du format FEC publié. Il ne certifie pas un fichier et ne remplace pas la revue d'un expert-comptable.
32
+
33
+ ---
34
+
35
+ ## English
36
+
37
+ **Free structural linter for French FEC files (*Fichier des Écritures Comptables*, art. A47 A-1 LPF), as a command line tool and an MCP server.**
38
+
39
+ ### Install
40
+
41
+ ```bash
42
+ pipx install fec-lint
43
+ ```
44
+
45
+ ### Command line
46
+
47
+ ```bash
48
+ fec-lint 123456782FEC20251231.txt
49
+ ```
50
+
51
+ ```text
52
+ 123456782FEC20251231.txt: FAILED (encoding utf-8, separator pipe, lines 214)
53
+ error date_invalid x3: Non-existent date in EcritureDate. - line(s) 21, 22, 23
54
+ error date_format x3: Wrong date format in EcritureDate (expected AAAAMMJJ). - line(s) 64, 65, 66
55
+ Structure has errors. Full pre-audit (balance, numbering, risk signals): https://apify.com/highfiddle/fec-checker
56
+ ```
57
+
58
+ - `--json` prints the full result: `{ok, file, errors[], warnings[], stats}`.
59
+ - `--name 123456782FEC20251231.txt` sets the name checked against `SIRENFECAAAAMMJJ.txt`.
60
+ - `--quiet` hides the last line.
61
+ - Exit code: `0` when the structure is OK, `1` on errors, `2` when a file cannot be read. Several files can be given at once.
62
+ - A `.zip` holding exactly one `.txt` FEC is unpacked first.
63
+
64
+ ### MCP server
65
+
66
+ Add it to your MCP client (Claude Desktop, Claude Code, Cursor…):
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "fec-lint": {
72
+ "command": "uvx",
73
+ "args": ["--from", "fec-lint", "fec-lint-mcp"]
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ Without `uv`, install with `pipx install fec-lint` and use `"command": "fec-lint-mcp"`. The server exposes one tool, `lint_fec(path)`, which takes a local file path and returns the same JSON as `--json`.
80
+
81
+ ### Free vs full pre-audit
82
+
83
+ | Check | fec-lint (free) | [FEC Checker on Apify](https://apify.com/highfiddle/fec-checker) |
84
+ |---|---|---|
85
+ | File name `SIRENFECAAAAMMJJ.txt`, SIREN key | ✅ | ✅ |
86
+ | Encoding, separator, 18 headers | ✅ | ✅ |
87
+ | Field formats: dates, amounts, mandatory fields, pairs | ✅ | ✅ |
88
+ | Balance per entry and for the whole file | — | ✅ |
89
+ | EcritureNum gaps and duplicates, ValidDate chronology | — | ✅ |
90
+ | Risk signals: weekends, holidays, round amounts, Benford, cash 53x, suspense 471, after year end | — | ✅ |
91
+ | Verdict, 0-100 score, AI-written fix list (FR/EN) | — | ✅ |
92
+ | Runs | locally, nothing leaves your machine | on Apify, pay per file |
93
+
94
+ ---
95
+
96
+ ## Français
97
+
98
+ **Linter structurel gratuit pour les FEC (*Fichier des Écritures Comptables*, art. A47 A-1 LPF), en ligne de commande et en serveur MCP.**
99
+
100
+ ### Installation
101
+
102
+ ```bash
103
+ pipx install fec-lint
104
+ ```
105
+
106
+ ### Ligne de commande
107
+
108
+ ```bash
109
+ fec-lint 123456782FEC20251231.txt
110
+ ```
111
+
112
+ - `--json` affiche le résultat complet : `{ok, file, errors[], warnings[], stats}`.
113
+ - `--name 123456782FEC20251231.txt` indique le nom à contrôler selon `SIRENFECAAAAMMJJ.txt`.
114
+ - `--quiet` masque la dernière ligne.
115
+ - Code de sortie : `0` si la structure est correcte, `1` en cas d'erreur, `2` si un fichier est illisible. Plusieurs fichiers peuvent être passés à la fois.
116
+ - Un `.zip` contenant un seul FEC `.txt` est décompressé d'abord.
117
+
118
+ Les messages sont en anglais ; les codes de règle (`date_invalid`, `header_missing`…) sont stables.
119
+
120
+ ### Serveur MCP
121
+
122
+ Ajoutez-le à votre client MCP (Claude Desktop, Claude Code, Cursor…) avec la configuration ci-dessus. L'outil `lint_fec(path)` prend le chemin d'un fichier local et renvoie le même JSON que `--json`.
123
+
124
+ ### Gratuit ou pré-audit complet
125
+
126
+ | Contrôle | fec-lint (gratuit) | [FEC Checker sur Apify](https://apify.com/highfiddle/fec-checker) |
127
+ |---|---|---|
128
+ | Nom `SIRENFECAAAAMMJJ.txt`, clé du SIREN | ✅ | ✅ |
129
+ | Encodage, séparateur, 18 colonnes | ✅ | ✅ |
130
+ | Formats : dates, montants, champs obligatoires, couples de champs | ✅ | ✅ |
131
+ | Équilibre par écriture et du fichier | — | ✅ |
132
+ | Ruptures et doublons d'EcritureNum, chronologie de ValidDate | — | ✅ |
133
+ | Signaux de risque : week-ends, jours fériés, montants ronds, Benford, caisse 53x, attente 471, après clôture | — | ✅ |
134
+ | Verdict, score 0-100, plan de correction rédigé par IA (FR/EN) | — | ✅ |
135
+ | Exécution | en local, rien ne quitte votre machine | sur Apify, paiement par fichier |
136
+
137
+ ---
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ python -m venv .venv
143
+ .venv/bin/pip install -e ".[test]" # Windows: .venv\Scripts\pip
144
+ pytest
145
+ ```
146
+
147
+ The fixtures in `tests/fixtures/` are synthetic FEC files (fictitious SIREN 123456782). MIT license.
@@ -0,0 +1,9 @@
1
+ fec_lint/__init__.py,sha256=v9D8J2HUpN1rLzISfU3jMMTF3cbNSe_2oA5jzHiuV3Q,236
2
+ fec_lint/cli.py,sha256=Nr51DN9L3YHXEPPFaTGovh5OiNxfv_bXFx5H0A_cYjo,2794
3
+ fec_lint/core.py,sha256=Jd6L7Hl5vOvY_BgMzPVoKY76V7eHWEGkhoDACKAbqJU,15091
4
+ fec_lint/mcp_server.py,sha256=WGXjyBKaaeuOEYaIabjTVhV1t5GdLTbquDKbyBYoFpQ,1025
5
+ fec_lint-0.1.0.dist-info/METADATA,sha256=SElDM1MlWYOg-4mGlR0atpj0mliLpYQz4QMB61wXTxI,5633
6
+ fec_lint-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ fec_lint-0.1.0.dist-info/entry_points.txt,sha256=6mZW026yNw6Gs3wkKofN_5Gt4WakdiYqRuMSa8bNaIY,87
8
+ fec_lint-0.1.0.dist-info/licenses/LICENSE,sha256=p6vQMqw50HpbOAQZhghkgd9pvjlYPu7GLtJA9vrHC1Q,1064
9
+ fec_lint-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ fec-lint = fec_lint.cli:main
3
+ fec-lint-mcp = fec_lint.mcp_server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vincent
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.