parseforge 0.2.1__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.
- parseforge/__init__.py +1 -0
- parseforge/cli/__init__.py +0 -0
- parseforge/cli/main.py +55 -0
- parseforge/generation.py +40 -0
- parseforge/naming/__init__.py +15 -0
- parseforge/naming/assemble.py +35 -0
- parseforge/naming/cache.py +48 -0
- parseforge/naming/llm.py +62 -0
- parseforge/naming/resolver.py +45 -0
- parseforge/paths.py +68 -0
- parseforge/pipeline.py +49 -0
- parseforge/promotion.py +43 -0
- parseforge/sampling.py +36 -0
- parseforge/validation.py +50 -0
- parseforge-0.2.1.dist-info/METADATA +79 -0
- parseforge-0.2.1.dist-info/RECORD +20 -0
- parseforge-0.2.1.dist-info/WHEEL +5 -0
- parseforge-0.2.1.dist-info/entry_points.txt +2 -0
- parseforge-0.2.1.dist-info/licenses/LICENSE +21 -0
- parseforge-0.2.1.dist-info/top_level.txt +1 -0
parseforge/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.1"
|
|
File without changes
|
parseforge/cli/main.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""parseforge CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from parseforge import naming
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
@click.version_option(package_name="parseforge")
|
|
12
|
+
def main() -> None:
|
|
13
|
+
"""ParseForge — forge, validate, and promote TextFSM templates from CLI output."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@main.command("name")
|
|
17
|
+
@click.option("--vendor", required=True)
|
|
18
|
+
@click.option("--family", required=True)
|
|
19
|
+
@click.option("--os", "os_", required=True)
|
|
20
|
+
@click.option("--version", required=True)
|
|
21
|
+
@click.argument("command", nargs=-1, required=True)
|
|
22
|
+
def name_cmd(
|
|
23
|
+
vendor: str, family: str, os_: str, version: str, command: tuple[str, ...]
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Print the canonical cli-name for a raw CLI COMMAND.
|
|
26
|
+
|
|
27
|
+
Looked up in the local cache first; only sent to an LLM (via a
|
|
28
|
+
RegexBuilder) on a cache miss. No RegexBuilder is wired into the
|
|
29
|
+
scaffold yet, so this only succeeds for commands already in
|
|
30
|
+
~/.parseforge/.cli-name.json until one is implemented.
|
|
31
|
+
"""
|
|
32
|
+
context = naming.CliContext(vendor=vendor, family=family, os=os_, version=version)
|
|
33
|
+
click.echo(naming.cli_name(" ".join(command), context))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@main.command("run")
|
|
37
|
+
@click.option("--vendor", required=True)
|
|
38
|
+
@click.option("--family", required=True)
|
|
39
|
+
@click.option("--os", "os_", required=True)
|
|
40
|
+
@click.option("--version", required=True)
|
|
41
|
+
@click.argument("command", nargs=-1, required=True)
|
|
42
|
+
def run_cmd(
|
|
43
|
+
vendor: str, family: str, os_: str, version: str, command: tuple[str, ...]
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Run the pipeline for a single CLI COMMAND against a device (§5)."""
|
|
46
|
+
from parseforge.pipeline import run_command_pipeline
|
|
47
|
+
|
|
48
|
+
run_command_pipeline(
|
|
49
|
+
" ".join(command),
|
|
50
|
+
key_fields={"vendor": vendor, "family": family, "os": os_, "version": version},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
parseforge/generation.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Generation stage — LLM call and template extraction (SPEC.md §5 steps 5-6).
|
|
2
|
+
|
|
3
|
+
Writes raw-llm-response.txt / usage.txt (step 5), then extracts and
|
|
4
|
+
cleans the template into raw-template / template.textfsm (step 6).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class GenerationResult:
|
|
15
|
+
raw_response: str
|
|
16
|
+
usage: str
|
|
17
|
+
raw_template: str
|
|
18
|
+
template: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Generator(Protocol):
|
|
22
|
+
"""An LLM backend capable of producing a TextFSM template from sample input.
|
|
23
|
+
|
|
24
|
+
``prior_context`` carries additional samples of the same command when
|
|
25
|
+
running in Mode 1 (batch) per §4.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def generate(
|
|
29
|
+
self, input_text: str, prior_context: list[str] | None = None
|
|
30
|
+
) -> GenerationResult: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def extract_template(raw_response: str) -> str:
|
|
34
|
+
"""Pull the template body out of a raw LLM response (step 6, pre-cleanup)."""
|
|
35
|
+
raise NotImplementedError("extraction rule TBD — see SPEC.md §5 step 6")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def clean_template(raw_template: str) -> str:
|
|
39
|
+
"""Normalize an extracted template into a valid template.textfsm body."""
|
|
40
|
+
raise NotImplementedError("cleanup rule TBD — see SPEC.md §5 step 6")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .assemble import pattern_to_cli_name
|
|
2
|
+
from .cache import DEFAULT_INDEX_PATH, NameIndex
|
|
3
|
+
from .llm import CliContext, RegexBuilder, UnimplementedRegexBuilder, build_prompt
|
|
4
|
+
from .resolver import cli_name
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"cli_name",
|
|
8
|
+
"CliContext",
|
|
9
|
+
"RegexBuilder",
|
|
10
|
+
"UnimplementedRegexBuilder",
|
|
11
|
+
"build_prompt",
|
|
12
|
+
"NameIndex",
|
|
13
|
+
"DEFAULT_INDEX_PATH",
|
|
14
|
+
"pattern_to_cli_name",
|
|
15
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Turn a validated regex pattern into a canonical, kebab-case cli-name.
|
|
2
|
+
|
|
3
|
+
Fixed-text segments pass through as-is; named capture groups
|
|
4
|
+
(``(?P<var1>...)``) become their group name. Tokens are joined with
|
|
5
|
+
``-`` and lowercased, matching SPEC.md §2's naming convention.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
_LEADING_FLAGS = re.compile(r"^\(\?[a-zA-Z]+\)")
|
|
13
|
+
_NAMED_GROUP = re.compile(r"^\(\?P<(?P<name>\w+)>.*\)$")
|
|
14
|
+
_ESCAPED_CHAR = re.compile(r"\\(.)")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pattern_to_cli_name(pattern: str) -> str:
|
|
18
|
+
"""
|
|
19
|
+
>>> pattern_to_cli_name(r"(?i)show\\s+version")
|
|
20
|
+
'show-version'
|
|
21
|
+
>>> pattern_to_cli_name(r"(?i)show\\s+interface\\s+(?P<var1>\\S+)\\s+status")
|
|
22
|
+
'show-interface-var1-status'
|
|
23
|
+
"""
|
|
24
|
+
body = _LEADING_FLAGS.sub("", pattern)
|
|
25
|
+
tokens = [t for t in re.split(r"\\s\+", body) if t.strip()]
|
|
26
|
+
|
|
27
|
+
parts: list[str] = []
|
|
28
|
+
for token in tokens:
|
|
29
|
+
token = token.strip()
|
|
30
|
+
group = _NAMED_GROUP.match(token)
|
|
31
|
+
if group:
|
|
32
|
+
parts.append(group.group("name"))
|
|
33
|
+
else:
|
|
34
|
+
parts.append(_ESCAPED_CHAR.sub(r"\1", token))
|
|
35
|
+
return "-".join(parts).lower()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""On-disk cli-name -> regex index, so a CLI is only ever sent to the LLM once.
|
|
2
|
+
|
|
3
|
+
Persisted as a flat JSON dict (SPEC.md naming discussion):
|
|
4
|
+
{"show-version": "(?i)show\\s+version", ...}
|
|
5
|
+
|
|
6
|
+
The cli-name segment is vendor/OS-agnostic by design — vendor, family, os,
|
|
7
|
+
and version are already distinguishing directory levels in the storage
|
|
8
|
+
layout (SPEC.md §3), so a single global index is intentional rather than
|
|
9
|
+
one index per device context.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
DEFAULT_INDEX_PATH = Path.home() / ".parseforge" / ".cli-name.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NameIndex:
|
|
22
|
+
"""Loads, queries, and persists the cli-name -> regex mapping."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, path: Path = DEFAULT_INDEX_PATH) -> None:
|
|
25
|
+
self.path = path
|
|
26
|
+
self._entries: dict[str, str] = self._load()
|
|
27
|
+
|
|
28
|
+
def _load(self) -> dict[str, str]:
|
|
29
|
+
if not self.path.exists():
|
|
30
|
+
return {}
|
|
31
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
32
|
+
|
|
33
|
+
def match(self, command: str) -> str | None:
|
|
34
|
+
"""Return the cli-name of the first stored pattern that fully matches
|
|
35
|
+
``command``, or None if this command hasn't been named before."""
|
|
36
|
+
for cli_name, pattern in self._entries.items():
|
|
37
|
+
if re.fullmatch(pattern, command):
|
|
38
|
+
return cli_name
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def add(self, cli_name: str, pattern: str) -> None:
|
|
42
|
+
self._entries[cli_name] = pattern
|
|
43
|
+
|
|
44
|
+
def save(self) -> None:
|
|
45
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
self.path.write_text(
|
|
47
|
+
json.dumps(self._entries, indent=2, sort_keys=True), encoding="utf-8"
|
|
48
|
+
)
|
parseforge/naming/llm.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""LLM-driven regex construction for a CLI command.
|
|
2
|
+
|
|
3
|
+
Only called on a cache miss (see resolver.py) — once a command's pattern
|
|
4
|
+
is in the index, matching is pure regex and costs no tokens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
PROMPT_TEMPLATE = """\
|
|
13
|
+
This is CLI "{command}" of {vendor} {family} {os} version {version}.
|
|
14
|
+
Create a regex pattern to match it:
|
|
15
|
+
- If a token is fixed text, use it as-is.
|
|
16
|
+
- If a token is variable text, use a regex to match it, and name the \
|
|
17
|
+
capture group var1, var2, ... in left-to-right order of appearance.
|
|
18
|
+
- Use \\s+ to match whitespace between tokens.
|
|
19
|
+
|
|
20
|
+
Example 1: "show version" -> "show" and "version" are fixed text ->
|
|
21
|
+
r"(?i)show\\s+version"
|
|
22
|
+
|
|
23
|
+
Example 2: "show interface Ge1.1 status" -> "show", "interface", and \
|
|
24
|
+
"status" are fixed text ->
|
|
25
|
+
r"(?i)show\\s+interface\\s+(?P<var1>\\S+)\\s+status"
|
|
26
|
+
|
|
27
|
+
Respond with only the regex pattern, nothing else.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class CliContext:
|
|
33
|
+
vendor: str
|
|
34
|
+
family: str
|
|
35
|
+
os: str
|
|
36
|
+
version: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_prompt(command: str, context: CliContext) -> str:
|
|
40
|
+
return PROMPT_TEMPLATE.format(
|
|
41
|
+
command=command,
|
|
42
|
+
vendor=context.vendor,
|
|
43
|
+
family=context.family,
|
|
44
|
+
os=context.os,
|
|
45
|
+
version=context.version,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RegexBuilder(Protocol):
|
|
50
|
+
"""An LLM backend that turns a raw CLI command into a regex pattern."""
|
|
51
|
+
|
|
52
|
+
def build_pattern(self, command: str, context: CliContext) -> str: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UnimplementedRegexBuilder:
|
|
56
|
+
"""Default RegexBuilder — no LLM client is wired into the scaffold yet."""
|
|
57
|
+
|
|
58
|
+
def build_pattern(self, command: str, context: CliContext) -> str:
|
|
59
|
+
raise NotImplementedError(
|
|
60
|
+
"no RegexBuilder configured — wire an LLM client that sends "
|
|
61
|
+
"build_prompt(command, context) and returns the regex pattern"
|
|
62
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Resolve a raw CLI command to its canonical cli-name, LLM-backed with caching.
|
|
2
|
+
|
|
3
|
+
Flow:
|
|
4
|
+
1. Check the on-disk index (:mod:`parseforge.naming.cache`) for a stored
|
|
5
|
+
pattern that already matches this command — no LLM call on a hit.
|
|
6
|
+
2. On a miss, ask the LLM to build a regex for it
|
|
7
|
+
(:mod:`parseforge.naming.llm`).
|
|
8
|
+
3. Validate the LLM's pattern actually matches its own source command via
|
|
9
|
+
``re.fullmatch`` before trusting it.
|
|
10
|
+
4. Assemble the cli-name from the pattern (:mod:`parseforge.naming.assemble`)
|
|
11
|
+
and persist the new cli-name -> pattern entry to the index.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .assemble import pattern_to_cli_name
|
|
20
|
+
from .cache import DEFAULT_INDEX_PATH, NameIndex
|
|
21
|
+
from .llm import CliContext, RegexBuilder, UnimplementedRegexBuilder
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cli_name(
|
|
25
|
+
command: str,
|
|
26
|
+
context: CliContext,
|
|
27
|
+
builder: RegexBuilder = UnimplementedRegexBuilder(),
|
|
28
|
+
index_path: Path = DEFAULT_INDEX_PATH,
|
|
29
|
+
) -> str:
|
|
30
|
+
index = NameIndex(index_path)
|
|
31
|
+
|
|
32
|
+
cached = index.match(command)
|
|
33
|
+
if cached is not None:
|
|
34
|
+
return cached
|
|
35
|
+
|
|
36
|
+
pattern = builder.build_pattern(command, context)
|
|
37
|
+
if not re.fullmatch(pattern, command):
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"LLM-built pattern {pattern!r} does not match its own command {command!r}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
name = pattern_to_cli_name(pattern)
|
|
43
|
+
index.add(name, pattern)
|
|
44
|
+
index.save()
|
|
45
|
+
return name
|
parseforge/paths.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Storage layout — three-tier promotion path resolution (SPEC.md §3).
|
|
2
|
+
|
|
3
|
+
Shared path prefix under all three tiers:
|
|
4
|
+
<vendor>/<device-family>/<os>/<version>/<cli-name>/
|
|
5
|
+
|
|
6
|
+
Use hyphenated OS family names (``ios-xe``, ``nx-os``) once multiple
|
|
7
|
+
Cisco OS families share the tree, per §3's note.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import random
|
|
13
|
+
import string
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
TRIALS = "trials"
|
|
19
|
+
INTEGRATION = "integration"
|
|
20
|
+
AUTHORITATIVE = "authoritative"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class DeviceKey:
|
|
25
|
+
"""Identifies a template family: vendor/device-family/os/version/cli-name."""
|
|
26
|
+
|
|
27
|
+
vendor: str
|
|
28
|
+
family: str
|
|
29
|
+
os: str
|
|
30
|
+
version: str
|
|
31
|
+
cli_name: str
|
|
32
|
+
|
|
33
|
+
def relative_path(self) -> Path:
|
|
34
|
+
return Path(self.vendor, self.family, self.os, self.version, self.cli_name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def tier_root(store_root: Path, tier: str) -> Path:
|
|
38
|
+
if tier not in (TRIALS, INTEGRATION, AUTHORITATIVE):
|
|
39
|
+
raise ValueError(f"unknown tier: {tier!r}")
|
|
40
|
+
return store_root / tier
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def tier_path(store_root: Path, tier: str, key: DeviceKey) -> Path:
|
|
44
|
+
"""Resolve the directory for ``key`` under the given tier."""
|
|
45
|
+
return tier_root(store_root, tier) / key.relative_path()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def new_run_id() -> str:
|
|
49
|
+
"""Timestamp+shortid run identifier (§3.1) — chronological, collision-safe."""
|
|
50
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
51
|
+
shortid = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
|
52
|
+
return f"{timestamp}-{shortid}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def trial_run_dir(store_root: Path, key: DeviceKey, run_id: str | None = None) -> Path:
|
|
56
|
+
"""Directory for a single trial run: trials/.../<cli-name>/<run-id>/."""
|
|
57
|
+
run_id = run_id or new_run_id()
|
|
58
|
+
return tier_path(store_root, TRIALS, key) / run_id
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def integration_result_dir(store_root: Path, key: DeviceKey) -> Path:
|
|
62
|
+
"""The single common-result candidate directory for a cli-name (§3.2)."""
|
|
63
|
+
return tier_path(store_root, INTEGRATION, key) / "common-result"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def authoritative_dir(store_root: Path, key: DeviceKey) -> Path:
|
|
67
|
+
"""The approved, in-production directory for a cli-name (§3.3)."""
|
|
68
|
+
return tier_path(store_root, AUTHORITATIVE, key)
|
parseforge/pipeline.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Pipeline orchestration (SPEC.md §4, §5).
|
|
2
|
+
|
|
3
|
+
Mode 2 (loop: sample -> generate, per command) is the MVP per §4's
|
|
4
|
+
recommendation. Mode 1 (batch: gather-then-generate) is a config flag
|
|
5
|
+
that only changes the sampling stage — generation, storage, and
|
|
6
|
+
validation stay identical between modes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from parseforge import naming, paths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Mode(str, Enum):
|
|
17
|
+
LOOP = "loop" # MVP: sample -> generate, per command (§4)
|
|
18
|
+
BATCH = "batch" # collect N samples per command before generation (§4)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_command_pipeline(
|
|
22
|
+
command: str,
|
|
23
|
+
key_fields: dict,
|
|
24
|
+
regex_builder: naming.RegexBuilder = naming.UnimplementedRegexBuilder(),
|
|
25
|
+
mode: Mode = Mode.LOOP,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Execute steps 1-7 of the pipeline for a single CLI command.
|
|
28
|
+
|
|
29
|
+
1. Input intake is the caller's responsibility (device info, auth,
|
|
30
|
+
command list, mode selection already resolved into ``key_fields``,
|
|
31
|
+
which must supply vendor/family/os/version).
|
|
32
|
+
2. Name generation — :func:`parseforge.naming.cli_name` (LLM-backed,
|
|
33
|
+
cached — see :mod:`parseforge.naming`).
|
|
34
|
+
3. Path resolution — :mod:`parseforge.paths`.
|
|
35
|
+
4. Sampling — :mod:`parseforge.sampling`.
|
|
36
|
+
5-6. Generation — :mod:`parseforge.generation`.
|
|
37
|
+
7. Self-validation — :mod:`parseforge.validation`.
|
|
38
|
+
|
|
39
|
+
Steps 8-11 (integration selection, authoritative promotion, drift
|
|
40
|
+
monitoring, repeat/loop) run out-of-band across all trials for a
|
|
41
|
+
cli-name, not per single pipeline invocation — see
|
|
42
|
+
:mod:`parseforge.validation` and :mod:`parseforge.promotion`.
|
|
43
|
+
"""
|
|
44
|
+
context = naming.CliContext(**key_fields)
|
|
45
|
+
name = naming.cli_name(command, context, builder=regex_builder)
|
|
46
|
+
key = paths.DeviceKey(cli_name=name, **key_fields)
|
|
47
|
+
raise NotImplementedError(
|
|
48
|
+
f"wire sampling -> generation -> validation for {key!r} (mode={mode.value})"
|
|
49
|
+
)
|
parseforge/promotion.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Authoritative promotion and drift monitoring (SPEC.md §3.3, §5 steps 9-10)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PromotionDecision(str, Enum):
|
|
10
|
+
AUTO_PROMOTED = "auto_promoted"
|
|
11
|
+
QUEUED_FOR_REVIEW = "queued_for_review"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PromotionGate:
|
|
16
|
+
"""Confidence threshold gating auto-promotion vs. human review (§3.3, §7).
|
|
17
|
+
|
|
18
|
+
Left configurable per-project rather than hardcoded — see the open
|
|
19
|
+
question in SPEC.md §7.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
match_rate_threshold: float = 1.0
|
|
23
|
+
min_sample_count: int = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def decide_promotion(
|
|
27
|
+
match_rate: float, sample_count: int, gate: PromotionGate
|
|
28
|
+
) -> PromotionDecision:
|
|
29
|
+
if (
|
|
30
|
+
sample_count >= gate.min_sample_count
|
|
31
|
+
and match_rate >= gate.match_rate_threshold
|
|
32
|
+
):
|
|
33
|
+
return PromotionDecision.AUTO_PROMOTED
|
|
34
|
+
return PromotionDecision.QUEUED_FOR_REVIEW
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class DriftStatus:
|
|
39
|
+
match_rate: float
|
|
40
|
+
status: str # "ok" | "drifting" | "superseded" — see SPEC.md §6
|
|
41
|
+
|
|
42
|
+
def breached(self, threshold: float) -> bool:
|
|
43
|
+
return self.match_rate < threshold
|
parseforge/sampling.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Sampling stage — connect to a device and capture raw CLI output (SPEC.md §5 step 4).
|
|
2
|
+
|
|
3
|
+
Kept as a separable stage so that Mode 1 (batch: collect N samples per
|
|
4
|
+
command before generation) and Mode 2 (loop: sample-then-generate per
|
|
5
|
+
command) share the same generation/storage/validation stages and differ
|
|
6
|
+
only in how many samples this stage collects before handing off — see
|
|
7
|
+
§4.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class DeviceConnection:
|
|
18
|
+
host: str
|
|
19
|
+
username: str
|
|
20
|
+
password: str
|
|
21
|
+
device_type: str # e.g. "cisco_ios", "cisco_nxos"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Sampler(Protocol):
|
|
25
|
+
"""A backend capable of running a command on a device and returning raw text.
|
|
26
|
+
|
|
27
|
+
A Netmiko-backed implementation is the expected default; see the
|
|
28
|
+
optional ``sampling`` extra in pyproject.toml.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def run_command(self, connection: DeviceConnection, command: str) -> str: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sample(sampler: Sampler, connection: DeviceConnection, command: str) -> str:
|
|
35
|
+
"""Run ``command`` against ``connection`` and return raw output for input.txt."""
|
|
36
|
+
return sampler.run_command(connection, command)
|
parseforge/validation.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Validation — self-validation (SPEC.md §5 step 7) and cross-validation
|
|
2
|
+
for integration selection (§3.2, §5 step 8).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ParseResult:
|
|
12
|
+
records: list[dict]
|
|
13
|
+
errors: list[str]
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def passed(self) -> bool:
|
|
17
|
+
return not self.errors
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse(template_text: str, input_text: str) -> ParseResult:
|
|
21
|
+
"""Run a template.textfsm against an input.txt sample.
|
|
22
|
+
|
|
23
|
+
Used both for self-validation (a template against its own sample,
|
|
24
|
+
step 7) and cross-validation (every candidate against every known
|
|
25
|
+
sample for a cli-name, step 8).
|
|
26
|
+
"""
|
|
27
|
+
raise NotImplementedError("wrap textfsm.TextFSM — see SPEC.md §5 steps 7-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CandidateScore:
|
|
32
|
+
run_id: str
|
|
33
|
+
match_rate: float
|
|
34
|
+
failed_samples: list[str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def select_common_result(
|
|
38
|
+
candidates: dict[str, str], samples: dict[str, str]
|
|
39
|
+
) -> CandidateScore:
|
|
40
|
+
"""Cross-validate every candidate template against every known sample.
|
|
41
|
+
|
|
42
|
+
``candidates`` maps trial run_id -> template.textfsm text.
|
|
43
|
+
``samples`` maps sample id -> input.txt text.
|
|
44
|
+
|
|
45
|
+
Promotes whichever template parses the largest share of *all* known
|
|
46
|
+
samples correctly (not just the sample it was generated from) — an
|
|
47
|
+
overfit template that only matches its own source sample must lose
|
|
48
|
+
to a template that generalizes, per §3.2.
|
|
49
|
+
"""
|
|
50
|
+
raise NotImplementedError("scoring/selection rule — see SPEC.md §3.2")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parseforge
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: LLM-driven pipeline that forges, validates, and promotes TextFSM templates from network CLI output
|
|
5
|
+
Author-email: Tuyen Mathew Duong <tuyen@geekstrident.com>
|
|
6
|
+
Maintainer-email: Tuyen Mathew Duong <tuyen@geekstrident.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: textfsm,network automation,cli parsing,template generator,ai,llm,netmiko,device output parsing
|
|
9
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: Information Technology
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
20
|
+
Classifier: Topic :: Text Processing
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: click>=8.1
|
|
27
|
+
Requires-Dist: PyYAML>=6.0
|
|
28
|
+
Requires-Dist: textfsm>=1.1.0
|
|
29
|
+
Provides-Extra: sampling
|
|
30
|
+
Requires-Dist: netmiko>=4.0; extra == "sampling"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
|
35
|
+
Requires-Dist: black>=24.0.0; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=1.10.0; extra == "dev"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# ParseForge
|
|
40
|
+
|
|
41
|
+
LLM-driven pipeline that forges, cross-validates, and promotes [TextFSM](https://github.com/google/textfsm)
|
|
42
|
+
templates from network device CLI output.
|
|
43
|
+
|
|
44
|
+
Full design plan: [SPEC.md](SPEC.md).
|
|
45
|
+
|
|
46
|
+
## Layout
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
parseforge/
|
|
50
|
+
naming.py CLI command -> canonical cli-name (SPEC §2)
|
|
51
|
+
paths.py trials/integration/authoritative path resolution (SPEC §3)
|
|
52
|
+
sampling.py device connection + command capture (SPEC §5 step 4)
|
|
53
|
+
generation.py LLM call + template extraction (SPEC §5 steps 5-6)
|
|
54
|
+
validation.py self-validation + cross-validation scoring (SPEC §5 steps 7-8)
|
|
55
|
+
promotion.py authoritative promotion gate + drift status (SPEC §3.3, §5 steps 9-10)
|
|
56
|
+
pipeline.py orchestrates the above; Mode LOOP (MVP) vs Mode BATCH (SPEC §4)
|
|
57
|
+
cli/ `parseforge` command-line entry point
|
|
58
|
+
|
|
59
|
+
store/ runtime template tree: trials/ -> integration/ -> authoritative/
|
|
60
|
+
(generated at runtime, not checked in — see .gitignore)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`naming.py` and `paths.py` are implemented and tested. `sampling.py`, `generation.py`,
|
|
64
|
+
`validation.py`, `promotion.py`, and `pipeline.py` are stub interfaces — the SPEC.md
|
|
65
|
+
section cited in each docstring is the next thing to implement.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
pip install -e ".[dev,sampling]"
|
|
71
|
+
pytest
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## CLI
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
parseforge name show interface GE1.1 status
|
|
78
|
+
# -> show-interface-var1-status
|
|
79
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
parseforge/__init__.py,sha256=HfjVOrpTnmZ-xVFCYSVmX50EXaBQeJteUHG-PD6iQs8,22
|
|
2
|
+
parseforge/generation.py,sha256=bGUZqUramOJP5M4buqQY3iOcLG1v32tQ_QScUP_XkOg,1215
|
|
3
|
+
parseforge/paths.py,sha256=CWqAKL7oTMGTS7pxgcj_JCHKGd2EOLdOwVb-ATBObCA,2205
|
|
4
|
+
parseforge/pipeline.py,sha256=NDRt4Ennexz4mzhXkdiJ-btvI8JNU4iTXytb1k2Mypo,1905
|
|
5
|
+
parseforge/promotion.py,sha256=QZGatglsUzP3b-l00o0m97mAa_8DYcqA4er8ANqXMXc,1154
|
|
6
|
+
parseforge/sampling.py,sha256=iXQ5iVPoyVxIvms-A9m9Xh7qz4QP66ZrKqVceD81K-8,1182
|
|
7
|
+
parseforge/validation.py,sha256=GJYO2BibehU6zN5kBNsqC2zomgzOCDnLcqur-Gwgzy8,1518
|
|
8
|
+
parseforge/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
parseforge/cli/main.py,sha256=bhOmXezGt4eqntTDmVpnffNSQupP3FCHt_n-tyWQsUE,1779
|
|
10
|
+
parseforge/naming/__init__.py,sha256=UTN8x94Xc9vi2ESiLfOjbmBTG27z-KMRAKl7GBoRhq0,397
|
|
11
|
+
parseforge/naming/assemble.py,sha256=jjta415g5oQRRs6HB8mAAdH--Iw5DsSVvdKFmHSf67I,1114
|
|
12
|
+
parseforge/naming/cache.py,sha256=6UYRN_bBAnnfrh6XwuCBxGdRauBwbtEyFuF2t8UI2Fo,1661
|
|
13
|
+
parseforge/naming/llm.py,sha256=2uRaVp4x0JEedGMEGsMCMoooTR6_adhRO5GeDoVOlFQ,1868
|
|
14
|
+
parseforge/naming/resolver.py,sha256=PzWLAsvxIcdyfXdonOMWvHTLy836noSss-VwXtM_VPE,1421
|
|
15
|
+
parseforge-0.2.1.dist-info/licenses/LICENSE,sha256=LOxmz9EcTI5ykp5TQZioE-5QY7tsUinZjlXwrEEGkgc,1074
|
|
16
|
+
parseforge-0.2.1.dist-info/METADATA,sha256=SUXrIV2dRuogsmUL08k65YAkf3hTvzY18S_iU9Rls34,2983
|
|
17
|
+
parseforge-0.2.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
18
|
+
parseforge-0.2.1.dist-info/entry_points.txt,sha256=NhSC-qotioae8Lpw2TR2WY_ibzLJog-Bhu2IrYM7l0I,56
|
|
19
|
+
parseforge-0.2.1.dist-info/top_level.txt,sha256=IVTjnydw5oh5ylVuUAzW-BONPa82JP0hHJrhypCw8G0,11
|
|
20
|
+
parseforge-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Geeks Trident LLC
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
parseforge
|