drskill 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.
drskill/discovery.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from drskill.harnesses import HarnessDef
7
+ from drskill.models import BrokenSymlink, RawInstance
8
+
9
+
10
+ def _walk_dirs(base: Path):
11
+ """os.walk following symlinks, guarded against loops."""
12
+ seen: set[str] = set()
13
+ for dirpath, dirnames, filenames in os.walk(base, followlinks=True):
14
+ real = os.path.realpath(dirpath)
15
+ if real in seen:
16
+ dirnames[:] = []
17
+ continue
18
+ seen.add(real)
19
+ yield Path(dirpath), dirnames, filenames
20
+
21
+
22
+ def _find_skill_files(base: Path, recursive: bool) -> list[Path]:
23
+ if not recursive:
24
+ return sorted(base.glob("*/SKILL.md"))
25
+ out = []
26
+ for dirpath, _dirnames, filenames in _walk_dirs(base):
27
+ if "SKILL.md" in filenames:
28
+ out.append(dirpath / "SKILL.md")
29
+ return sorted(out)
30
+
31
+
32
+ def _find_broken_symlinks(base: Path, recursive: bool = True) -> list[Path]:
33
+ out = []
34
+ if not recursive:
35
+ # Check only entries directly in base and in base/* directories (depth matching */SKILL.md glob)
36
+ for name in os.listdir(base):
37
+ p = base / name
38
+ if p.is_symlink() and not p.exists():
39
+ out.append(p)
40
+ # Also check one level deep (base/*/...)
41
+ for subdir in base.iterdir():
42
+ if subdir.is_dir():
43
+ for name in os.listdir(subdir):
44
+ p = subdir / name
45
+ if p.is_symlink() and not p.exists():
46
+ out.append(p)
47
+ else:
48
+ for dirpath, dirnames, filenames in _walk_dirs(base):
49
+ for name in list(dirnames) + list(filenames):
50
+ p = dirpath / name
51
+ if p.is_symlink() and not p.exists():
52
+ out.append(p)
53
+ return sorted(out)
54
+
55
+
56
+ def _via_symlink(f: Path, base: Path) -> bool:
57
+ cur = f
58
+ while True:
59
+ if cur.is_symlink():
60
+ return True
61
+ if cur == base or cur.parent == cur:
62
+ return False
63
+ cur = cur.parent
64
+
65
+
66
+ def discover(
67
+ h: HarnessDef, project_root: Path, home: Path, global_only: bool = False
68
+ ) -> tuple[list[RawInstance], list[BrokenSymlink]]:
69
+ instances: list[RawInstance] = []
70
+ broken: list[BrokenSymlink] = []
71
+ for order, (base, scope, spec_str) in enumerate(
72
+ h.search_paths(project_root, home, global_only)
73
+ ):
74
+ if not base.is_dir():
75
+ continue
76
+ files = _find_skill_files(base, h.recursive)
77
+ if spec_str in h.root_md_paths:
78
+ files += sorted(
79
+ p for p in base.glob("*.md")
80
+ if p.name != "SKILL.md" and p.is_file()
81
+ )
82
+ for f in files:
83
+ # Skip dangling symlinks (they are already reported as broken)
84
+ if not f.exists():
85
+ continue
86
+ instances.append(
87
+ RawInstance(
88
+ harness=h.id,
89
+ scope=scope,
90
+ skill_file=f,
91
+ via_symlink=_via_symlink(f, base),
92
+ order=order,
93
+ )
94
+ )
95
+ broken += [BrokenSymlink(harness=h.id, path=p) for p in _find_broken_symlinks(base, h.recursive)]
96
+ return instances, broken
drskill/harnesses.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from functools import cache
5
+ from importlib import resources
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class HarnessDef(BaseModel):
13
+ id: str
14
+ display_name: str
15
+ verified: bool = False
16
+ detect: list[str] = Field(default_factory=list)
17
+ project_paths: list[str] = Field(default_factory=list)
18
+ global_paths: list[str] = Field(default_factory=list)
19
+ search_order: Literal["project-first", "global-first"] = "project-first"
20
+ recursive: bool = True
21
+ root_md_paths: list[str] = Field(default_factory=list)
22
+
23
+ def search_paths(
24
+ self, project_root: Path, home: Path, global_only: bool = False
25
+ ) -> list[tuple[Path, str, str]]:
26
+ """(directory, scope, spec_str) triples in precedence order."""
27
+ proj = [(project_root / s, "project", s) for s in self.project_paths]
28
+ glob = [(home / s.removeprefix("~/"), "user", s) for s in self.global_paths]
29
+ if global_only:
30
+ return glob
31
+ if self.search_order == "global-first":
32
+ return glob + proj
33
+ return proj + glob
34
+
35
+
36
+ @cache
37
+ def load_harnesses() -> tuple[HarnessDef, ...]:
38
+ text = resources.files("drskill.data").joinpath("harnesses.toml").read_text()
39
+ data = tomllib.loads(text)
40
+ return tuple(HarnessDef(**h) for h in data["harness"])
41
+
42
+
43
+ def detect_harnesses(
44
+ project_root: Path, home: Path, global_only: bool = False
45
+ ) -> list[HarnessDef]:
46
+ found = []
47
+ for h in load_harnesses():
48
+ for marker in h.detect:
49
+ if marker.startswith("~/"):
50
+ p = home / marker.removeprefix("~/")
51
+ elif global_only:
52
+ continue
53
+ else:
54
+ p = project_root / marker
55
+ if p.exists():
56
+ found.append(h)
57
+ break
58
+ return found
drskill/ledger.py ADDED
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ import tomllib
5
+ from pathlib import Path
6
+
7
+ import tomli_w
8
+ from pydantic import BaseModel, Field, ValidationError
9
+
10
+ from drskill.models import Finding
11
+
12
+
13
+ class LedgerError(Exception):
14
+ """Raised when a drskill.toml ledger file is malformed or schema-invalid.
15
+
16
+ Callers (the CLI layer) catch this and print a one-line error instead of
17
+ letting a raw tomllib/pydantic traceback reach the user."""
18
+
19
+
20
+ class Budget(BaseModel):
21
+ catalog_tokens_max: int = 6000
22
+ body_tokens_warn: int = 20000
23
+
24
+
25
+ class Thresholds(BaseModel):
26
+ near_duplicate: float = 0.85
27
+
28
+
29
+ class Ack(BaseModel):
30
+ check: str
31
+ skills: list[str]
32
+ fingerprint: str
33
+ note: str | None = None
34
+ date: dt.date | None = None
35
+
36
+
37
+ class Config(BaseModel):
38
+ budget: Budget = Budget()
39
+ thresholds: Thresholds = Thresholds()
40
+ ack: list[Ack] = Field(default_factory=list)
41
+
42
+
43
+ def ledger_path(project_root: Path, home: Path, global_mode: bool) -> Path:
44
+ return home / ".drskill.toml" if global_mode else project_root / "drskill.toml"
45
+
46
+
47
+ def _validation_one_liner(e: ValidationError) -> str:
48
+ first = e.errors()[0]
49
+ loc = ".".join(str(p) for p in first["loc"])
50
+ return f"{loc}: {first['msg']}" if loc else first["msg"]
51
+
52
+
53
+ def load_config(path: Path) -> Config:
54
+ if not path.is_file():
55
+ return Config()
56
+ try:
57
+ data = tomllib.loads(path.read_text())
58
+ except tomllib.TOMLDecodeError as e:
59
+ raise LedgerError(f"{path}: invalid TOML: {e}") from e
60
+ try:
61
+ return Config(**data)
62
+ except ValidationError as e:
63
+ raise LedgerError(f"{path}: {_validation_one_liner(e)}") from e
64
+
65
+
66
+ def append_ack(path: Path, ack: Ack) -> None:
67
+ data = tomllib.loads(path.read_text()) if path.is_file() else {}
68
+ entry = {k: v for k, v in ack.model_dump().items() if v is not None}
69
+ data.setdefault("ack", []).append(entry)
70
+ path.write_text(tomli_w.dumps(data))
71
+
72
+
73
+ def filter_findings(
74
+ findings: list[Finding], config: Config
75
+ ) -> tuple[list[Finding], list[Finding]]:
76
+ acked_fps = {a.fingerprint for a in config.ack}
77
+ active = [f for f in findings if f.fingerprint not in acked_fps]
78
+ acked = [f for f in findings if f.fingerprint in acked_fps]
79
+ return active, acked
drskill/models.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class RawInstance(BaseModel):
10
+ """A skill file as one harness sees it, before resolution."""
11
+
12
+ harness: str
13
+ scope: Literal["project", "user"]
14
+ skill_file: Path
15
+ via_symlink: bool
16
+ order: int # index of the containing search path in the harness's list
17
+
18
+
19
+ class Deployment(BaseModel):
20
+ harness: str
21
+ path: Path
22
+ scope: Literal["project", "user"]
23
+ via_symlink: bool
24
+ order: int
25
+ shadowed_by: str | None = None # contributor id of the winner, when shadowed
26
+
27
+
28
+ class Provenance(BaseModel):
29
+ kind: Literal["skills-lock", "gh-skill", "linked", "unmanaged"] = "unmanaged"
30
+ source: str | None = None
31
+
32
+
33
+ class TokenCost(BaseModel):
34
+ catalog_tokens: int # name + description, approximate
35
+ body_tokens: int # full body, approximate
36
+
37
+
38
+ class Contributor(BaseModel):
39
+ id: str # str(realpath of the skill file)
40
+ kind: Literal["skill"] = "skill"
41
+ name: str
42
+ source: Provenance = Provenance()
43
+ scope: Literal["project", "user"]
44
+ deployments: list[Deployment] = Field(default_factory=list)
45
+ routing_text: str = ""
46
+ body: str = ""
47
+ token_cost: TokenCost
48
+ content_hash: str
49
+ frontmatter_valid: bool = True
50
+ frontmatter: dict = Field(default_factory=dict)
51
+ frontmatter_text: str = ""
52
+
53
+
54
+ class Finding(BaseModel):
55
+ check_id: str
56
+ severity: Literal["error", "warning"]
57
+ contributors: list[str] # contributor ids
58
+ contributor_names: list[str]
59
+ harnesses: list[str]
60
+ message: str
61
+ fix_commands: list[str] = Field(default_factory=list)
62
+ fingerprint: str
63
+
64
+
65
+ class BrokenSymlink(BaseModel):
66
+ harness: str
67
+ path: Path
drskill/pipeline.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from drskill.checks import run_all
6
+ from drskill.checks.lockfile import load_lockfile
7
+ from drskill.discovery import discover
8
+ from drskill.harnesses import detect_harnesses, load_harnesses
9
+ from drskill.ledger import Config, ledger_path, load_config
10
+ from drskill.models import Finding, Provenance
11
+ from drskill.resolution import World, build_world
12
+
13
+
14
+ def run_scan(
15
+ project_root: Path,
16
+ home: Path,
17
+ global_only: bool = False,
18
+ config: Config | None = None,
19
+ harness: str | None = None,
20
+ ) -> tuple[World, list[Finding]]:
21
+ if config is None:
22
+ config = load_config(ledger_path(project_root, home, global_only))
23
+ if harness is None:
24
+ harnesses = detect_harnesses(project_root, home, global_only)
25
+ else:
26
+ harnesses = [h for h in load_harnesses() if h.id == harness]
27
+ instances, broken = [], []
28
+ for h in harnesses:
29
+ i, b = discover(h, project_root, home, global_only)
30
+ instances += i
31
+ broken += b
32
+ world = build_world(instances, {h.id: h for h in harnesses}, broken)
33
+ world.lockfile = load_lockfile(project_root)
34
+ if world.lockfile:
35
+ for c in world.contributors.values():
36
+ if c.source.kind in ("unmanaged", "linked") and c.name in world.lockfile:
37
+ entry = world.lockfile[c.name]
38
+ world.contributors[c.id] = c.model_copy(
39
+ update={
40
+ "source": Provenance(
41
+ kind="skills-lock", source=entry.get("source")
42
+ )
43
+ }
44
+ )
45
+ return world, run_all(world, config)
drskill/report.py ADDED
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shlex
5
+
6
+ from rich.console import Console
7
+ from rich.markup import escape
8
+ from rich.table import Table
9
+
10
+ from drskill.models import Finding
11
+ from drskill.resolution import World
12
+
13
+
14
+ def to_json(findings: list[Finding]) -> str:
15
+ rows = [
16
+ dict(sorted(f.model_dump(mode="json").items())) for f in findings
17
+ ]
18
+ return json.dumps(rows, indent=2)
19
+
20
+
21
+ def render_harness_tables(
22
+ world: World,
23
+ console: Console,
24
+ *,
25
+ tokens: bool = False,
26
+ harness: str | None = None,
27
+ show_all: bool = False,
28
+ ) -> None:
29
+ hidden: list[str] = []
30
+ for hid, hdef in sorted(world.harnesses.items()):
31
+ if harness and hid != harness:
32
+ continue
33
+ if not show_all and harness is None and not world.effective(hid):
34
+ hidden.append(hid)
35
+ continue
36
+ title = escape(hdef.display_name) + ("" if hdef.verified else " (best effort)")
37
+ table = Table(title=title)
38
+ table.add_column("skill")
39
+ table.add_column("scope")
40
+ table.add_column("source")
41
+ if tokens:
42
+ table.add_column("catalog", justify="right")
43
+ table.add_column("body", justify="right")
44
+ table.add_column("notes")
45
+ cat_total = body_total = 0
46
+ for c, d in world.harness_loads(hid):
47
+ notes = []
48
+ if d.shadowed_by:
49
+ notes.append("shadowed")
50
+ if d.via_symlink:
51
+ notes.append("symlink")
52
+ row = [escape(c.name), escape(d.scope), escape(c.source.kind)]
53
+ if tokens:
54
+ row += [str(c.token_cost.catalog_tokens), str(c.token_cost.body_tokens)]
55
+ if d.shadowed_by is None:
56
+ cat_total += c.token_cost.catalog_tokens
57
+ body_total += c.token_cost.body_tokens
58
+ row.append(escape(", ".join(notes)))
59
+ table.add_row(*row)
60
+ if tokens:
61
+ table.add_row("total (effective)", "", "", str(cat_total), str(body_total), "",
62
+ style="bold")
63
+ console.print(table)
64
+ if hidden:
65
+ plural = "es" if len(hidden) != 1 else ""
66
+ console.print(
67
+ f"[dim]{len(hidden)} more harness{plural} detected with no skills "
68
+ f"({escape(', '.join(sorted(hidden)))}); show with --all[/dim]"
69
+ )
70
+ if tokens:
71
+ console.print("[dim]token counts are approximate[/dim]")
72
+
73
+
74
+ def _print_finding(world: World, f: Finding, console: Console) -> None:
75
+ tags = ""
76
+ if any(
77
+ hid in world.harnesses and not world.harnesses[hid].verified
78
+ for hid in f.harnesses
79
+ ):
80
+ tags = " [dim](best effort)[/dim]"
81
+ console.print(f" [[bold]{escape(f.check_id)}[/bold]] {escape(f.message)}{tags}")
82
+ if f.harnesses:
83
+ console.print(f" harnesses: {escape(', '.join(f.harnesses))}")
84
+ for cmd in f.fix_commands:
85
+ console.print(f" fix: {escape(cmd)}")
86
+ if f.contributor_names:
87
+ names = " ".join(shlex.quote(n) for n in f.contributor_names)
88
+ console.print(f" or: drskill ack {escape(f.check_id)} {escape(names)}")
89
+ else:
90
+ console.print(f" or: drskill ack {escape(f.check_id)}")
91
+
92
+
93
+ def render(
94
+ world: World, active: list[Finding], acked: list[Finding], console: Console
95
+ ) -> None:
96
+ populated = [hid for hid in world.harnesses if world.effective(hid)]
97
+ empty = len(world.harnesses) - len(populated)
98
+ n_skills = len(world.contributors)
99
+ plural = "es" if len(populated) != 1 else ""
100
+ header = f"[bold]drskill scan[/bold] — {len(populated)} harness{plural}"
101
+ if empty:
102
+ header += f" ({empty} more empty)"
103
+ header += f", {n_skills} skills"
104
+ console.print(header)
105
+ errors = [f for f in active if f.severity == "error"]
106
+ warnings = [f for f in active if f.severity == "warning"]
107
+ if not active:
108
+ console.print("\n[green]No findings.[/green]", end="")
109
+ if errors:
110
+ console.print("\n[red bold]ERRORS[/red bold]")
111
+ for f in errors:
112
+ _print_finding(world, f, console)
113
+ if warnings:
114
+ console.print("\n[yellow bold]WARNINGS[/yellow bold]")
115
+ for f in warnings:
116
+ _print_finding(world, f, console)
117
+ summary = (
118
+ f"\n{len(errors)} error{'s' if len(errors) != 1 else ''}, "
119
+ f"{len(warnings)} warning{'s' if len(warnings) != 1 else ''}"
120
+ )
121
+ if acked:
122
+ summary += f" ({len(acked)} acknowledged)"
123
+ summary += " · token counts are approximate"
124
+ console.print(summary)
drskill/resolution.py ADDED
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+ from pydantic import BaseModel, Field
8
+
9
+ from drskill import tokens
10
+ from drskill.harnesses import HarnessDef
11
+ from drskill.models import BrokenSymlink, Contributor, Deployment, Provenance, RawInstance, TokenCost
12
+
13
+ # Frontmatter keys `gh skill` writes for provenance (repo, ref, tree SHA).
14
+ # Verify against a real `gh skill` install during Task 10 and adjust if the
15
+ # observed key names differ.
16
+ GH_PROVENANCE_KEYS: frozenset[str] = frozenset({"source", "ref", "tree_sha"})
17
+
18
+
19
+ def split_frontmatter(text: str) -> tuple[dict | None, str, str]:
20
+ if not text.startswith("---\n"):
21
+ return {}, "", text
22
+ end = text.find("\n---", 4)
23
+ if end == -1:
24
+ return {}, "", text
25
+ raw = text[4:end]
26
+ body = text[end + 4 :].lstrip("\n")
27
+ try:
28
+ parsed = yaml.safe_load(raw)
29
+ except yaml.YAMLError:
30
+ return None, raw, body
31
+ if not isinstance(parsed, dict):
32
+ return None, raw, body
33
+ return parsed, raw, body
34
+
35
+
36
+ def normalize_content(text: str) -> str:
37
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
38
+ fm, _raw, body = split_frontmatter(text)
39
+ if not fm: # None (invalid) or {} (absent): hash the raw text
40
+ return text
41
+ kept = {k: v for k, v in fm.items() if k not in GH_PROVENANCE_KEYS}
42
+ canonical_fm = yaml.safe_dump(kept, sort_keys=True)
43
+ return canonical_fm + "\n---\n" + body
44
+
45
+
46
+ def _in_agents_store(path: Path) -> bool:
47
+ """Layout heuristic: the realpath lives under a `.agents/skills` canonical
48
+ store, which is how installers like `npx skills` materialize skills. It is
49
+ evidence of installer management, not a claim about which installer."""
50
+ return any(
51
+ p.name == "skills" and p.parent.name == ".agents" for p in [path, *path.parents]
52
+ )
53
+
54
+
55
+ def content_hash(text: str) -> str:
56
+ digest = hashlib.sha256(normalize_content(text).encode()).hexdigest()
57
+ return "sha256:" + digest
58
+
59
+
60
+ class World(BaseModel):
61
+ contributors: dict[str, Contributor] = Field(default_factory=dict)
62
+ harnesses: dict[str, HarnessDef] = Field(default_factory=dict)
63
+ broken_symlinks: list[BrokenSymlink] = Field(default_factory=list)
64
+ unreadable: list[tuple[str, str]] = Field(default_factory=list) # (harness, path)
65
+ lockfile: dict[str, dict] | None = None
66
+
67
+ def harness_loads(self, harness_id: str) -> list[tuple[Contributor, Deployment]]:
68
+ out = [
69
+ (c, d)
70
+ for c in self.contributors.values()
71
+ for d in c.deployments
72
+ if d.harness == harness_id
73
+ ]
74
+ return sorted(out, key=lambda cd: (cd[1].order, str(cd[1].path)))
75
+
76
+ def effective(self, harness_id: str) -> list[Contributor]:
77
+ seen: list[Contributor] = []
78
+ for c, d in self.harness_loads(harness_id):
79
+ if d.shadowed_by is None and c not in seen:
80
+ seen.append(c)
81
+ return seen
82
+
83
+
84
+ def _skill_name(fm: dict | None, skill_file: Path) -> str:
85
+ if fm and isinstance(fm.get("name"), str) and fm["name"].strip():
86
+ return fm["name"].strip()
87
+ if skill_file.name == "SKILL.md":
88
+ return skill_file.parent.name
89
+ return skill_file.stem
90
+
91
+
92
+ def build_world(
93
+ instances: list[RawInstance],
94
+ harnesses: dict[str, HarnessDef],
95
+ broken: list[BrokenSymlink],
96
+ ) -> World:
97
+ world = World(harnesses=harnesses, broken_symlinks=broken)
98
+ for inst in instances:
99
+ real = inst.skill_file.resolve()
100
+ cid = str(real)
101
+ c = world.contributors.get(cid)
102
+ if c is None:
103
+ try:
104
+ text = real.read_text(encoding="utf-8", errors="replace")
105
+ except OSError:
106
+ world.unreadable.append((inst.harness, cid))
107
+ continue
108
+ fm, raw_fm, body = split_frontmatter(text)
109
+ name = _skill_name(fm, real)
110
+ description = ""
111
+ if fm and isinstance(fm.get("description"), str):
112
+ description = fm["description"]
113
+ provenance = Provenance()
114
+ if fm and GH_PROVENANCE_KEYS & fm.keys():
115
+ provenance = Provenance(kind="gh-skill", source=fm.get("source"))
116
+ elif _in_agents_store(real):
117
+ provenance = Provenance(kind="linked")
118
+ c = Contributor(
119
+ id=cid,
120
+ name=name,
121
+ scope=inst.scope,
122
+ source=provenance,
123
+ routing_text=description,
124
+ body=body,
125
+ token_cost=TokenCost(
126
+ catalog_tokens=tokens.count(f"{name}: {description}"),
127
+ body_tokens=tokens.count(body),
128
+ ),
129
+ content_hash=content_hash(text),
130
+ frontmatter_valid=fm is not None,
131
+ frontmatter=fm or {},
132
+ frontmatter_text=raw_fm,
133
+ )
134
+ world.contributors[cid] = c
135
+ if inst.scope == "project":
136
+ c.scope = "project"
137
+ c.deployments.append(
138
+ Deployment(
139
+ harness=inst.harness,
140
+ path=inst.skill_file,
141
+ scope=inst.scope,
142
+ via_symlink=inst.via_symlink,
143
+ order=inst.order,
144
+ )
145
+ )
146
+ _mark_shadows(world)
147
+ return world
148
+
149
+
150
+ def _mark_shadows(world: World) -> None:
151
+ for hid in world.harnesses:
152
+ first_by_name: dict[str, Contributor] = {}
153
+ for c, d in world.harness_loads(hid):
154
+ prior = first_by_name.get(c.name)
155
+ if prior is None:
156
+ first_by_name[c.name] = c
157
+ elif prior.id != c.id and prior.content_hash != c.content_hash:
158
+ d.shadowed_by = prior.id
drskill/tokens.py ADDED
@@ -0,0 +1,28 @@
1
+ """Approximate token counting. tiktoken loads lazily; if it cannot load
2
+ (or cannot fetch its encoding file offline) we fall back to len // 4."""
3
+
4
+ from __future__ import annotations
5
+
6
+ _encoder = None
7
+ _unavailable = False
8
+
9
+
10
+ def _fallback_count(text: str) -> int:
11
+ return len(text) // 4
12
+
13
+
14
+ def count(text: str) -> int:
15
+ global _encoder, _unavailable
16
+ if not text:
17
+ return 0
18
+ if _unavailable:
19
+ return _fallback_count(text)
20
+ if _encoder is None:
21
+ try:
22
+ import tiktoken
23
+
24
+ _encoder = tiktoken.get_encoding("o200k_base")
25
+ except Exception:
26
+ _unavailable = True
27
+ return _fallback_count(text)
28
+ return len(_encoder.encode(text))