codal-mcp 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.
codal_mcp/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Unofficial MCP server for codal.ir, the disclosure system of companies listed on Iranian exchanges."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("codal-mcp")
codal_mcp/codal.py ADDED
@@ -0,0 +1,538 @@
1
+ """Codal tools: search disclosures, read one letter, look up companies, letter types and fiscal years."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from html import unescape
8
+ from html.parser import HTMLParser
9
+ from typing import Annotated, Any
10
+ from urllib.parse import parse_qsl, urlsplit
11
+
12
+ from pydantic import Field
13
+
14
+ from .http import SEARCH, WWW, ApiError, fetch
15
+ from .registry import tool
16
+
17
+ LOOKUP_TTL = 12 * 3600 # companies, industry groups, categories: change rarely
18
+ LETTER_TTL = 3600 # a published letter does not change; keep it an hour
19
+ MAX_TEXT = 16_000 # characters of letter text or tables returned at once
20
+
21
+ _DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "0123456789" * 2)
22
+ _LETTERS = str.maketrans({"ي": "ی", "ى": "ی", "ك": "ک", "\u200c": " ", "\u200f": None, "\u200e": None})
23
+
24
+ # v1/companies field `st`, as the search page's PublisherStatus list names them
25
+ MARKETS = {
26
+ 0: "Tehran Stock Exchange (بورس تهران)",
27
+ 1: "Iran Fara Bourse (فرابورس)",
28
+ 2: "registered, not listed (ثبت شده پذیرفته نشده)",
29
+ 3: "not registered with SEO (ثبت نشده نزد سازمان)",
30
+ 4: "Iran Mercantile Exchange (بورس کالا)",
31
+ 5: "Iran Energy Exchange (بورس انرژی)",
32
+ }
33
+ # v1/companies field `t`, the publisher types of v1/categories
34
+ KINDS = {1: "publisher", 2: "broker", 3: "financial institution or fund", 4: "public entity", 5: "state company"}
35
+ # v1/companies field `RT`, the search page's ReportingType list
36
+ REPORTING = {
37
+ 1000000: "manufacturing",
38
+ 1000001: "construction",
39
+ 1000002: "investment",
40
+ 1000003: "bank",
41
+ 1000004: "leasing",
42
+ 1000005: "services",
43
+ 1000006: "insurance",
44
+ 1000007: "sea transport",
45
+ 1000008: "agriculture",
46
+ 1000009: "investment bank (تامین سرمایه)",
47
+ }
48
+
49
+
50
+ def _ascii(v: Any) -> str:
51
+ """Persian/Arabic digits to ASCII and runs of spaces collapsed, as the site renders them."""
52
+ return re.sub(r"\s+", " ", str(v or "").translate(_DIGITS)).strip()
53
+
54
+
55
+ def _key(v: str) -> str:
56
+ """Matching form of a symbol or name: ASCII digits, Persian ی/ک, no ZWNJ, single spaces."""
57
+ return re.sub(r"\s+", " ", v.translate(_DIGITS).translate(_LETTERS)).strip().lower()
58
+
59
+
60
+ def _jalali(v: str | None, name: str) -> str | None:
61
+ if not v:
62
+ return None
63
+ s = _ascii(v).replace("-", "/")
64
+ m = re.fullmatch(r"(\d{4})/(\d{1,2})/(\d{1,2})", s)
65
+ if not m or not 1300 <= int(m[1]) <= 1500 or not 1 <= int(m[2]) <= 12 or not 1 <= int(m[3]) <= 31:
66
+ raise ApiError(f"{name} must be a Jalali (Persian calendar) date like 1405/07/01, got {v!r}.")
67
+ return f"{m[1]}/{int(m[2]):02d}/{int(m[3]):02d}"
68
+
69
+
70
+ def _symbol(v: str | None) -> str | None:
71
+ return _ascii(v) or None
72
+
73
+
74
+ async def _search(params: dict[str, Any], ttl: float) -> dict[str, Any]:
75
+ # The flags codal's own search page sends (setDefaultSearchModel); -1 means any.
76
+ query = {
77
+ "Audited": "true",
78
+ "AuditorRef": -1,
79
+ "Category": -1,
80
+ "Childs": "true",
81
+ "CompanyState": -1,
82
+ "CompanyType": -1,
83
+ "Consolidatable": "true",
84
+ "IsNotAudited": "false",
85
+ "Length": -1,
86
+ "LetterType": -1,
87
+ "Mains": "true",
88
+ "NotAudited": "true",
89
+ "NotConsolidatable": "true",
90
+ "PageNumber": 1,
91
+ "Publisher": "false",
92
+ "ReportingType": -1,
93
+ "TracingNo": -1,
94
+ "search": "true",
95
+ **{k: v for k, v in params.items() if v is not None},
96
+ }
97
+ body = await fetch(f"{SEARCH}/v2/q", query, ttl=ttl)
98
+ if not isinstance(body, dict) or not isinstance(body.get("Letters"), list):
99
+ raise ApiError("codal.ir returned no search result.")
100
+ return body
101
+
102
+
103
+ def _row(x: dict[str, Any]) -> dict[str, Any]:
104
+ files = [
105
+ name
106
+ for name, flag in (
107
+ ("html", "HasHtml"),
108
+ ("pdf", "HasPdf"),
109
+ ("excel", "HasExcel"),
110
+ ("attachments", "HasAttachment"),
111
+ )
112
+ if x.get(flag)
113
+ ]
114
+ row = {
115
+ "tracing_no": x.get("TracingNo"),
116
+ "symbol": _ascii(x.get("Symbol")),
117
+ "company": _ascii(x.get("CompanyName")),
118
+ "title": _ascii(x.get("Title")),
119
+ "sent": _ascii(x.get("SentDateTime")),
120
+ "code": _ascii(x.get("LetterCode")),
121
+ "files": files,
122
+ }
123
+ if x.get("PublishDateTime") and x.get("PublishDateTime") != x.get("SentDateTime"):
124
+ row["published"] = _ascii(x["PublishDateTime"])
125
+ if x.get("UnderSupervision"):
126
+ row["under_supervision"] = True
127
+ return row
128
+
129
+
130
+ @tool("Search Codal disclosures")
131
+ async def cd_search_letters(
132
+ symbol: Annotated[
133
+ str | None,
134
+ Field(description="Exact trading symbol in Persian, e.g. فولاد or خودرو. Use cd_company to find it."),
135
+ ] = None,
136
+ category: Annotated[
137
+ int | None,
138
+ Field(
139
+ description="Letter group: 1 financial statements, board reports and dividend schedules; 2 material information, clarifications and EPS forecasts; 3 monthly activity and portfolio reports; 4 articles of association and prospectuses; 5 board and audit committee; 6 shareholder meetings (invitations, decisions); 7 capital increase; 8 exchange notices; 9 SEO (regulator) notices; 10 other; 11 debt securities.",
140
+ ge=1,
141
+ le=11,
142
+ ),
143
+ ] = None,
144
+ letter_type: Annotated[
145
+ int | None,
146
+ Field(
147
+ description="One letter type id from cd_letter_types, e.g. 6 interim/annual financial statements, 58 monthly activity report, 20 annual general meeting decisions, 11 material information, 30 dividend payment schedule.",
148
+ ge=1,
149
+ ),
150
+ ] = None,
151
+ title: Annotated[
152
+ str | None,
153
+ Field(description="Words that must appear in the letter title, in Persian, e.g. سود or افزایش سرمایه."),
154
+ ] = None,
155
+ from_date: Annotated[
156
+ str | None, Field(description="Sent on or after this Jalali date, YYYY/MM/DD, e.g. 1405/07/01.")
157
+ ] = None,
158
+ to_date: Annotated[str | None, Field(description="Sent on or before this Jalali date, YYYY/MM/DD.")] = None,
159
+ period_months: Annotated[
160
+ int | None,
161
+ Field(
162
+ description="Only reports covering this many months, e.g. 12 annual, 6 half-year, 3 quarterly, 1 monthly.",
163
+ ge=1,
164
+ le=12,
165
+ ),
166
+ ] = None,
167
+ tracing_no: Annotated[
168
+ int | None, Field(description="Codal tracing number (شماره پیگیری) of one letter, e.g. 1612323.", ge=1)
169
+ ] = None,
170
+ page: Annotated[int, Field(description="Result page, 20 letters each, newest first.", ge=1)] = 1,
171
+ ) -> dict[str, Any]:
172
+ """Search codal.ir disclosures (letters) of Iranian listed companies, newest first, 20 per page.
173
+
174
+ Use it for "latest letters of a company", financial statements, monthly reports, meeting decisions, dividend
175
+ schedules or capital increases. With no filters it lists the newest letters of all companies. Combine filters
176
+ to narrow down; `total` is the number of matching letters and `pages` the number of pages (pass `page` for more).
177
+ Each letter has its `tracing_no`: pass it to cd_letter to read the letter's text and tables and get its PDF,
178
+ Excel and attachment links. `files` lists what the letter has. Dates and times are Jalali (Tehran time).
179
+ No results: check the symbol with cd_company, widen the dates or drop a filter.
180
+ """
181
+ body = await _search(
182
+ {
183
+ "Symbol": _symbol(symbol),
184
+ "Category": category,
185
+ "LetterType": letter_type,
186
+ "Subject": _ascii(title) or None,
187
+ "FromDate": _jalali(from_date, "from_date"),
188
+ "ToDate": _jalali(to_date, "to_date"),
189
+ "Length": period_months,
190
+ "TracingNo": tracing_no,
191
+ "PageNumber": page,
192
+ },
193
+ ttl=120,
194
+ )
195
+ return {
196
+ "total": body.get("Total"),
197
+ "page": page,
198
+ "pages": body.get("Page"),
199
+ "letters": [_row(x) for x in body["Letters"] if isinstance(x, dict)],
200
+ }
201
+
202
+
203
+ class _Text(HTMLParser):
204
+ """Visible text of a letter page: drops scripts, styles and anything inside a display:none element.
205
+
206
+ Table cells are joined with ' | ', one table row per line.
207
+ """
208
+
209
+ VOID = {"br", "img", "input", "meta", "link", "hr", "col", "area", "base", "wbr", "source"}
210
+ BLOCK = {"div", "p", "tr", "table", "li", "h1", "h2", "h3", "h4", "h5", "h6", "br", "ul", "ol", "form"}
211
+
212
+ def __init__(self) -> None:
213
+ super().__init__(convert_charrefs=True)
214
+ self.out: list[str] = []
215
+ self.stack: list[tuple[str, bool]] = [] # (tag, hidden)
216
+
217
+ def _hidden(self) -> bool:
218
+ return bool(self.stack) and self.stack[-1][1]
219
+
220
+ def handle_starttag(self, tag, attrs):
221
+ a = dict(attrs)
222
+ style = re.sub(r"\s", "", (a.get("style") or "").lower())
223
+ hidden = (
224
+ self._hidden()
225
+ or tag in ("script", "style", "head", "select", "option", "noscript")
226
+ or "display:none" in style
227
+ or a.get("type") == "hidden"
228
+ )
229
+ if tag in self.VOID:
230
+ if tag == "br" and not hidden:
231
+ self.out.append("\n")
232
+ return
233
+ self.stack.append((tag, hidden))
234
+ if not hidden and tag in self.BLOCK:
235
+ self.out.append("\n")
236
+
237
+ def handle_endtag(self, tag):
238
+ if tag in self.VOID:
239
+ return
240
+ # Pop up to the matching tag; letter pages are not always well nested.
241
+ for i in range(len(self.stack) - 1, -1, -1):
242
+ if self.stack[i][0] == tag:
243
+ hidden = self.stack[i][1]
244
+ del self.stack[i:]
245
+ if not hidden:
246
+ self.out.append(" | " if tag in ("td", "th") else "\n" if tag in self.BLOCK else "")
247
+ return
248
+
249
+ def handle_data(self, data):
250
+ if not self._hidden():
251
+ self.out.append(re.sub(r"\s+", " ", data)) # source line breaks are not visible breaks
252
+
253
+ def text(self) -> str:
254
+ lines = []
255
+ for line in "".join(self.out).split("\n"):
256
+ line = _ascii(line).strip(" |")
257
+ line = re.sub(r"(\s*\|\s*){2,}", " | ", line)
258
+ if line and line != "|":
259
+ lines.append(line)
260
+ return "\n".join(lines)
261
+
262
+
263
+ def _visible_text(html: str) -> str:
264
+ p = _Text()
265
+ body = html.find("<body")
266
+ p.feed(html[body:] if body >= 0 else html)
267
+ p.close()
268
+ return p.text()
269
+
270
+
271
+ def _datasource(html: str) -> dict[str, Any] | None:
272
+ i = html.find("var datasource = ")
273
+ if i < 0:
274
+ return None
275
+ try:
276
+ d, _ = json.JSONDecoder().raw_decode(html, i + len("var datasource = "))
277
+ except ValueError:
278
+ return None
279
+ return d if isinstance(d, dict) else None
280
+
281
+
282
+ def _tables(ds: dict[str, Any]) -> list[dict[str, Any]]:
283
+ """Each table of the shown sheet as {title, rows}; a row is its cell values joined by ' | '.
284
+
285
+ Rows whose cells are all flagged invisible are hidden helper rows and are dropped. A single invisible cell is
286
+ kept: the site still shows columns such as the second 'درصد تغيير' (change vs. the last full year) that the
287
+ datasource flags isVisible=false (checked in Chrome on tracing no. 1612323).
288
+ """
289
+ out = []
290
+ for sheet in ds.get("sheets") or []:
291
+ for t in sheet.get("tables") or []:
292
+ rows: dict[int, list[dict[str, Any]]] = {}
293
+ for c in t.get("cells") or []:
294
+ if isinstance(c, dict):
295
+ rows.setdefault(c.get("rowSequence") or 0, []).append(c)
296
+ lines = []
297
+ for k in sorted(rows):
298
+ if not any(c.get("isVisible", True) for c in rows[k]):
299
+ continue
300
+ vals = [_ascii(c.get("value")) for c in sorted(rows[k], key=lambda c: c.get("columnSequence") or 0)]
301
+ while vals and not vals[-1]:
302
+ vals.pop()
303
+ if vals:
304
+ lines.append(" | ".join(vals))
305
+ if lines:
306
+ out.append({"title": _ascii(t.get("title_Fa") or sheet.get("title_Fa")), "rows": lines})
307
+ return out
308
+
309
+
310
+ def _sheets(html: str) -> list[dict[str, Any]]:
311
+ m = re.search(r'<select[^>]*name="ctl00\$ddlTable".*?</select>', html, re.S)
312
+ if not m:
313
+ return []
314
+ return [
315
+ {"sheet": int(v), "name": _ascii(unescape(n))}
316
+ for v, n in re.findall(r'<option[^>]*value="(-?\d+)"[^>]*>([^<]*)', m[0])
317
+ ]
318
+
319
+
320
+ def _label(html: str, id_: str) -> str:
321
+ m = re.search(rf'id="{id_}"[^>]*>([^<]*)<', html)
322
+ return _ascii(unescape(m[1])) if m else ""
323
+
324
+
325
+ def _clip(text: str) -> tuple[str, bool]:
326
+ return (text, False) if len(text) <= MAX_TEXT else (text[:MAX_TEXT], True)
327
+
328
+
329
+ def _attachments(html: str) -> list[dict[str, str]]:
330
+ out = []
331
+ # ASP.NET renders the grid with inline styles for Chrome and without for plain curl; match both.
332
+ rows = re.findall(r"window\.open\('(DownloadFile\.aspx\?id=[^']+)'\);\"[^>]*>(.*?)</tr>", unescape(html), re.S)
333
+ for link, cells in rows:
334
+ tds = [_ascii(re.sub(r"<[^>]+>", " ", td)) for td in re.findall(r"<td[^>]*>(.*?)</td>", cells, re.S)]
335
+ tds = [t for t in tds if t]
336
+ out.append(
337
+ {
338
+ "name": tds[0] if tds else "",
339
+ "added": tds[1] if len(tds) > 1 else "",
340
+ "url": f"{WWW}/Reports/{link}",
341
+ }
342
+ )
343
+ return out
344
+
345
+
346
+ def _split(path: str) -> tuple[str, dict[str, str]]:
347
+ """A site-relative link from the search API -> (absolute URL without query, query params)."""
348
+ u = urlsplit(path)
349
+ p = u.path if u.path.startswith("/") else f"/{u.path}"
350
+ return f"{WWW}{p}", dict(parse_qsl(u.query))
351
+
352
+
353
+ def _link(path: str) -> str:
354
+ if not path:
355
+ return ""
356
+ return path if path.startswith("http") else f"{WWW}{path if path.startswith('/') else '/' + path}"
357
+
358
+
359
+ @tool("Read one Codal letter")
360
+ async def cd_letter(
361
+ tracing_no: Annotated[
362
+ int, Field(description="Tracing number (شماره پیگیری) from cd_search_letters, e.g. 1612323.", ge=1)
363
+ ],
364
+ sheet: Annotated[
365
+ int | None,
366
+ Field(
367
+ description="For financial statements with several sheets: the `sheet` id from the `sheets` list of a previous call, e.g. 0 for the balance sheet. Omit for the first sheet."
368
+ ),
369
+ ] = None,
370
+ ) -> dict[str, Any]:
371
+ """Read one codal.ir letter: its text or tables, plus links to its page, PDF, Excel and attachments.
372
+
373
+ Use it after cd_search_letters to see what a letter says. Structured reports (financial statements, monthly
374
+ activity reports) come back as `tables`, one line per row with cells separated by ' | ' (amounts as on the
375
+ site, usually million Rial; see `unit_note`). Financial statements show one sheet at a time: `sheets` lists
376
+ them (income statement, balance sheet, cash flows ...); call again with `sheet` to read another. Other letters
377
+ come back as `text`. `attachments` are files the company attached (often the real content of an explanation
378
+ letter). Long letters are cut at 16,000 characters (`truncated`); the PDF link has the full letter.
379
+ """
380
+ body = await _search({"TracingNo": tracing_no}, ttl=LETTER_TTL)
381
+ found = [x for x in body["Letters"] if isinstance(x, dict) and x.get("TracingNo") == tracing_no]
382
+ if not found:
383
+ raise ApiError(f"No codal letter has tracing number {tracing_no}. Check the number with cd_search_letters.")
384
+ x = found[0]
385
+ out: dict[str, Any] = _row(x)
386
+ url = x.get("Url") or ""
387
+ out["url"] = _link(url)
388
+ if x.get("HasPdf") and x.get("PdfUrl"):
389
+ out["pdf"] = _link(x["PdfUrl"]) # relative to the site root (checked: /DownloadFile.aspx?hs=... is the PDF)
390
+ if x.get("HasExcel") and x.get("ExcelUrl"):
391
+ out["excel"] = _link(x["ExcelUrl"])
392
+
393
+ if x.get("HasHtml") and "Decision.aspx" in url:
394
+ page_url, params = _split(url)
395
+ if sheet is not None:
396
+ params["sheetId"] = str(sheet)
397
+ html = await fetch(page_url, params, text=True, ttl=LETTER_TTL)
398
+ ds = _datasource(html)
399
+ if ds:
400
+ out["report"] = _ascii(ds.get("title_Fa"))
401
+ out["period_months"] = ds.get("period")
402
+ out["period_end"] = _ascii(ds.get("periodEndToDate"))
403
+ out["year_end"] = _ascii(ds.get("yearEndToDate"))
404
+ out["audited"] = ds.get("isAudited")
405
+ out["consolidated"] = ds.get("isConsolidated")
406
+ out["unit_note"] = _label(html, "ctl00_pPriceNote")
407
+ out["listed_capital"] = _label(html, "ctl00_lblListedCapital")
408
+ sheets = _sheets(html)
409
+ if sheets:
410
+ out["sheets"] = sheets
411
+ shown = re.search(r'<option selected="selected" value="(-?\d+)"', html)
412
+ out["sheet"] = int(shown[1]) if shown else sheet
413
+ tables, size = [], 0
414
+ for t in _tables(ds):
415
+ size += sum(len(r) + 1 for r in t["rows"])
416
+ if size > MAX_TEXT and tables:
417
+ out["truncated"] = True
418
+ break
419
+ tables.append(t)
420
+ out["tables"] = tables
421
+ else:
422
+ out["text"], truncated = _clip(_visible_text(html))
423
+ if truncated:
424
+ out["truncated"] = True
425
+ if x.get("HasAttachment") and x.get("AttachmentUrl"):
426
+ page_url, params = _split(x["AttachmentUrl"])
427
+ out["attachments"] = _attachments(await fetch(page_url, params, text=True, ttl=LETTER_TTL))
428
+ return out
429
+
430
+
431
+ async def _companies() -> list[dict[str, Any]]:
432
+ body = await fetch(f"{SEARCH}/v1/companies", ttl=LOOKUP_TTL)
433
+ if not isinstance(body, list) or not body:
434
+ raise ApiError("codal.ir returned no company list.")
435
+ return [c for c in body if isinstance(c, dict)]
436
+
437
+
438
+ @tool("Find a company on Codal")
439
+ async def cd_company(
440
+ query: Annotated[
441
+ str,
442
+ Field(description="Trading symbol or part of the company name, in Persian, e.g. فولاد or فولاد مبارکه."),
443
+ ],
444
+ limit: Annotated[int, Field(description="Most companies to return.", ge=1, le=50)] = 10,
445
+ ) -> dict[str, Any]:
446
+ """Find companies and funds on codal.ir by symbol or name, with their market, industry and type.
447
+
448
+ Use it to get the exact `symbol` for cd_search_letters and cd_financial_years, or to answer what a symbol is.
449
+ Exact symbol or name matches come first, then symbols starting with the query, then names containing it;
450
+ within each, exchange-listed companies before unlisted ones. Covers the
451
+ ~5,400 publishers on codal.ir, including unlisted companies and funds. `matched` is the total number of hits.
452
+ """
453
+ q = _key(query)
454
+ if not q:
455
+ raise ApiError("query must not be empty.")
456
+ companies = await _companies()
457
+ groups = await fetch(f"{SEARCH}/v1/IndustryGroup", ttl=LOOKUP_TTL)
458
+ industry = {g.get("Id"): g.get("Name") for g in groups if isinstance(g, dict)} if isinstance(groups, list) else {}
459
+ ranked = []
460
+ for c in companies:
461
+ sy, name = _key(str(c.get("sy") or "")), _key(str(c.get("n") or ""))
462
+ rank = 0 if q in (sy, name) else 1 if sy.startswith(q) else 2 if q in name else 3 if q in sy else None
463
+ if rank is not None:
464
+ ranked.append((rank, c.get("st") not in (0, 1, 4, 5), len(sy), c)) # exchange-listed first
465
+ ranked.sort(key=lambda r: r[:3])
466
+ rows = []
467
+ for *_, c in ranked[:limit]:
468
+ row = {
469
+ "symbol": _ascii(c.get("sy")),
470
+ "name": _ascii(c.get("n")),
471
+ "isic": _ascii(c.get("i")),
472
+ "market": MARKETS.get(c.get("st")),
473
+ "kind": KINDS.get(c.get("t")),
474
+ "industry": _ascii(industry.get(c.get("IG"))) or None,
475
+ "reporting_type": REPORTING.get(c.get("RT")),
476
+ }
477
+ rows.append(row)
478
+ return {"matched": len(ranked), "companies": rows}
479
+
480
+
481
+ @tool("List Codal letter types")
482
+ async def cd_letter_types(
483
+ category: Annotated[
484
+ int | None,
485
+ Field(description="Letter group code (1-11) to list its letter types; omit to list the groups.", ge=1, le=11),
486
+ ] = None,
487
+ ) -> dict[str, Any]:
488
+ """List codal.ir letter groups, or the letter types of one group with the ids cd_search_letters takes.
489
+
490
+ Call it without arguments for the 11 groups, then with `category` for that group's letter types. Use the
491
+ `id` of a type as `letter_type` in cd_search_letters (with or without the same `category`).
492
+ """
493
+ body = await fetch(f"{SEARCH}/v1/categories", ttl=LOOKUP_TTL)
494
+ if not isinstance(body, list):
495
+ raise ApiError("codal.ir returned no category list.")
496
+ cats = [c for c in body if isinstance(c, dict) and isinstance(c.get("Code"), int) and c["Code"] > 0]
497
+
498
+ def types(c: dict[str, Any]) -> dict[int, str]:
499
+ seen: dict[int, str] = {}
500
+ for p in c.get("PublisherTypes") or []:
501
+ for lt in p.get("LetterTypes") or []:
502
+ if isinstance(lt, dict) and lt.get("Id") not in seen:
503
+ seen[lt.get("Id")] = _ascii(lt.get("Name"))
504
+ return seen
505
+
506
+ if category is None:
507
+ return {
508
+ "categories": [
509
+ {"category": c["Code"], "name": _ascii(c.get("Name")), "letter_types": len(types(c))} for c in cats
510
+ ]
511
+ }
512
+ match = [c for c in cats if c["Code"] == category]
513
+ if not match:
514
+ raise ApiError(f"Unknown category {category}; call cd_letter_types without arguments for the list.")
515
+ return {
516
+ "category": category,
517
+ "name": _ascii(match[0].get("Name")),
518
+ "letter_types": [{"id": k, "name": v} for k, v in sorted(types(match[0]).items())],
519
+ }
520
+
521
+
522
+ @tool("Fiscal years of a company")
523
+ async def cd_financial_years(
524
+ symbol: Annotated[str, Field(description="Exact trading symbol in Persian, e.g. فولاد.")],
525
+ ) -> dict[str, Any]:
526
+ """List the fiscal year-end dates codal.ir knows for one company, newest first (Jalali).
527
+
528
+ Use it to learn when a company's fiscal year ends (e.g. 12/29 or 03/31) before searching for its annual
529
+ statements with cd_search_letters (from_date after the year end, period_months 12). An empty list usually
530
+ means the symbol is wrong: check it with cd_company.
531
+ """
532
+ sy = _symbol(symbol)
533
+ if not sy:
534
+ raise ApiError("symbol must not be empty.")
535
+ body = await fetch(f"{SEARCH}/v1/financialYears", {"Symbol": sy}, ttl=LOOKUP_TTL)
536
+ if not isinstance(body, list):
537
+ raise ApiError("codal.ir returned no fiscal years.")
538
+ return {"symbol": sy, "year_ends": [_ascii(y) for y in body]}