parseforge 0.2.1__tar.gz → 0.2.2__tar.gz

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.
Files changed (53) hide show
  1. {parseforge-0.2.1/parseforge.egg-info → parseforge-0.2.2}/PKG-INFO +8 -4
  2. {parseforge-0.2.1 → parseforge-0.2.2}/README.md +4 -3
  3. {parseforge-0.2.1 → parseforge-0.2.2}/SPEC.md +9 -7
  4. parseforge-0.2.2/parseforge/__init__.py +1 -0
  5. parseforge-0.2.2/parseforge/cli/main.py +202 -0
  6. parseforge-0.2.2/parseforge/generation.py +112 -0
  7. parseforge-0.2.2/parseforge/integration.py +314 -0
  8. parseforge-0.2.2/parseforge/naming/__init__.py +30 -0
  9. parseforge-0.2.2/parseforge/naming/assemble.py +60 -0
  10. {parseforge-0.2.1 → parseforge-0.2.2}/parseforge/naming/cache.py +4 -3
  11. parseforge-0.2.2/parseforge/naming/llm.py +88 -0
  12. parseforge-0.2.2/parseforge/naming/prompts.py +14 -0
  13. parseforge-0.2.2/parseforge/naming/prompts.yaml +29 -0
  14. parseforge-0.2.2/parseforge/naming/providers/__init__.py +4 -0
  15. parseforge-0.2.2/parseforge/naming/providers/anthropic.py +105 -0
  16. parseforge-0.2.2/parseforge/naming/providers/cost.py +29 -0
  17. parseforge-0.2.2/parseforge/naming/providers/deepseek.py +123 -0
  18. parseforge-0.2.2/parseforge/naming/providers/errors.py +63 -0
  19. parseforge-0.2.2/parseforge/naming/providers/models.py +26 -0
  20. parseforge-0.2.2/parseforge/naming/providers/models.yaml +24 -0
  21. parseforge-0.2.2/parseforge/naming/providers/text.py +13 -0
  22. parseforge-0.2.2/parseforge/naming/resolver.py +82 -0
  23. parseforge-0.2.2/parseforge/paths.py +168 -0
  24. parseforge-0.2.2/parseforge/pipeline.py +220 -0
  25. parseforge-0.2.2/parseforge/promotion.py +517 -0
  26. parseforge-0.2.2/parseforge/sampling/__init__.py +3 -0
  27. parseforge-0.2.2/parseforge/sampling/backends/__init__.py +3 -0
  28. parseforge-0.2.2/parseforge/sampling/backends/netmiko.py +35 -0
  29. parseforge-0.2.1/parseforge/sampling.py → parseforge-0.2.2/parseforge/sampling/core.py +3 -2
  30. parseforge-0.2.2/parseforge/validation.py +36 -0
  31. {parseforge-0.2.1 → parseforge-0.2.2/parseforge.egg-info}/PKG-INFO +8 -4
  32. parseforge-0.2.2/parseforge.egg-info/SOURCES.txt +39 -0
  33. {parseforge-0.2.1 → parseforge-0.2.2}/parseforge.egg-info/requires.txt +3 -0
  34. {parseforge-0.2.1 → parseforge-0.2.2}/pyproject.toml +11 -1
  35. parseforge-0.2.1/parseforge/__init__.py +0 -1
  36. parseforge-0.2.1/parseforge/cli/main.py +0 -55
  37. parseforge-0.2.1/parseforge/generation.py +0 -40
  38. parseforge-0.2.1/parseforge/naming/__init__.py +0 -15
  39. parseforge-0.2.1/parseforge/naming/assemble.py +0 -35
  40. parseforge-0.2.1/parseforge/naming/llm.py +0 -62
  41. parseforge-0.2.1/parseforge/naming/resolver.py +0 -45
  42. parseforge-0.2.1/parseforge/paths.py +0 -68
  43. parseforge-0.2.1/parseforge/pipeline.py +0 -49
  44. parseforge-0.2.1/parseforge/promotion.py +0 -43
  45. parseforge-0.2.1/parseforge/validation.py +0 -50
  46. parseforge-0.2.1/parseforge.egg-info/SOURCES.txt +0 -25
  47. {parseforge-0.2.1 → parseforge-0.2.2}/LICENSE +0 -0
  48. {parseforge-0.2.1 → parseforge-0.2.2}/MANIFEST.in +0 -0
  49. {parseforge-0.2.1 → parseforge-0.2.2}/parseforge/cli/__init__.py +0 -0
  50. {parseforge-0.2.1 → parseforge-0.2.2}/parseforge.egg-info/dependency_links.txt +0 -0
  51. {parseforge-0.2.1 → parseforge-0.2.2}/parseforge.egg-info/entry_points.txt +0 -0
  52. {parseforge-0.2.1 → parseforge-0.2.2}/parseforge.egg-info/top_level.txt +0 -0
  53. {parseforge-0.2.1 → parseforge-0.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parseforge
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: LLM-driven pipeline that forges, validates, and promotes TextFSM templates from network CLI output
5
5
  Author-email: Tuyen Mathew Duong <tuyen@geekstrident.com>
6
6
  Maintainer-email: Tuyen Mathew Duong <tuyen@geekstrident.com>
@@ -26,6 +26,9 @@ License-File: LICENSE
26
26
  Requires-Dist: click>=8.1
27
27
  Requires-Dist: PyYAML>=6.0
28
28
  Requires-Dist: textfsm>=1.1.0
29
+ Requires-Dist: anthropic>=0.40.0
30
+ Requires-Dist: openai>=1.0.0
31
+ Requires-Dist: textfsm-ai>=0.5.1
29
32
  Provides-Extra: sampling
30
33
  Requires-Dist: netmiko>=4.0; extra == "sampling"
31
34
  Provides-Extra: dev
@@ -55,11 +58,12 @@ parseforge/
55
58
  promotion.py authoritative promotion gate + drift status (SPEC §3.3, §5 steps 9-10)
56
59
  pipeline.py orchestrates the above; Mode LOOP (MVP) vs Mode BATCH (SPEC §4)
57
60
  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
61
  ```
62
62
 
63
+ The runtime template tree (trials/ -> integration/ -> authoritative/) is generated at
64
+ `~/.parseforge/tests` by default (see `paths.DEFAULT_STORE_ROOT`), not checked into
65
+ this repo.
66
+
63
67
  `naming.py` and `paths.py` are implemented and tested. `sampling.py`, `generation.py`,
64
68
  `validation.py`, `promotion.py`, and `pipeline.py` are stub interfaces — the SPEC.md
65
69
  section cited in each docstring is the next thing to implement.
@@ -17,11 +17,12 @@ parseforge/
17
17
  promotion.py authoritative promotion gate + drift status (SPEC §3.3, §5 steps 9-10)
18
18
  pipeline.py orchestrates the above; Mode LOOP (MVP) vs Mode BATCH (SPEC §4)
19
19
  cli/ `parseforge` command-line entry point
20
-
21
- store/ runtime template tree: trials/ -> integration/ -> authoritative/
22
- (generated at runtime, not checked in — see .gitignore)
23
20
  ```
24
21
 
22
+ The runtime template tree (trials/ -> integration/ -> authoritative/) is generated at
23
+ `~/.parseforge/tests` by default (see `paths.DEFAULT_STORE_ROOT`), not checked into
24
+ this repo.
25
+
25
26
  `naming.py` and `paths.py` are implemented and tested. `sampling.py`, `generation.py`,
26
27
  `validation.py`, `promotion.py`, and `pipeline.py` are stub interfaces — the SPEC.md
27
28
  section cited in each docstring is the next thing to implement.
@@ -48,16 +48,18 @@ This mirrors the candidate → staging → production pattern used for ML model
48
48
 
49
49
  Shared path prefix under all three tiers:
50
50
  ```
51
- <vendor>/<device-family>/<os>/<version>/<cli-name>/
51
+ <vendor>/<device-family>/<os>/<cli-name>/
52
52
  ```
53
- e.g. `cisco/catalyst9200/ios-xe/17.9.1/show-interface-var1-status/`
53
+ e.g. `cisco/catalyst9200/ios-xe/show-interface-var1-status/`
54
54
 
55
55
  Note: use `ios-xe` / `nx-os` (hyphenated) rather than `xe` / `ios` alone once multiple Cisco OS families share the tree — `xe` alone is ambiguous without the `ios-` prefix once IOS classic, IOS XE, and IOS XR all live under `cisco/`.
56
56
 
57
+ Deliberately no `<version>` segment in the path: a cli-name's output structure usually doesn't change across minor OS versions, and when it legitimately does, that's exactly the variance §5 step 8's group clustering is built to catch — lumping versions together under one path gives more evidence per group instead of silently fragmenting it across per-version directories. The OS version a trial was sampled from is recorded per trial instead, in that trial's `summary.json` (`command_info.version`).
58
+
57
59
  ### 3.1 `trials/`
58
60
 
59
61
  ```
60
- trials/<vendor>/<family>/<os>/<version>/<cli-name>/
62
+ trials/<vendor>/<family>/<os>/<cli-name>/
61
63
  <yyyymmdd-HHMMSS-shortid>/
62
64
  input.txt
63
65
  raw-llm-response.txt
@@ -90,7 +92,7 @@ Keep the timestamp+shortid directories (not `result1..N`) — chronological orde
90
92
  ### 3.2 `integration/` (no human review yet)
91
93
 
92
94
  ```
93
- integration/<vendor>/<family>/<os>/<version>/<cli-name>/
95
+ integration/<vendor>/<family>/<os>/<cli-name>/
94
96
  common-result/
95
97
  template.textfsm ← the winning candidate, copied from trials/
96
98
  selection-report.json ← which trials were considered, why this one won
@@ -106,7 +108,7 @@ integration/<vendor>/<family>/<os>/<version>/<cli-name>/
106
108
  ### 3.3 `authoritative/` (approved via human review, or confidence-gated auto-promotion)
107
109
 
108
110
  ```
109
- authoritative/<vendor>/<family>/<os>/<version>/<cli-name>/
111
+ authoritative/<vendor>/<family>/<os>/<cli-name>/
110
112
  template.textfsm ← current approved template
111
113
  artifact/ ← carried over from the winning integration candidate
112
114
  history/
@@ -139,7 +141,7 @@ Promotion from `integration/common-result` to `authoritative/` should default to
139
141
 
140
142
  1. **Input intake** — device OS/version/family, auth, command list, mode selection.
141
143
  2. **Name generation** — tokenize each command → canonical `cli-name` per §2.
142
- 3. **Path resolution** — compute `<vendor>/<device-family>/<os>/<version>/<cli-name>/` per §3.
144
+ 3. **Path resolution** — compute `<vendor>/<device-family>/<os>/<cli-name>/` per §3.
143
145
  4. **Sampling** — connect (Netmiko/similar), run command(s), capture raw output → `trials/.../<run-id>/input.txt`.
144
146
  5. **Generation** — send `input.txt` (+ prior context if Mode 1) to LLM → `raw-llm-response.txt`, `usage.txt`.
145
147
  6. **Extraction & cleanup** — pull template from response → `raw-template` → cleaned `template.textfsm`.
@@ -167,7 +169,7 @@ Where a fourth tier is tempting but better handled as **metadata instead of a ne
167
169
 
168
170
  ## 7. Open Questions for Next Iteration
169
171
 
170
- - Do you want a **registry/index file** (e.g. `catalog.json`) at the repo root listing every `<vendor>/<family>/<os>/<version>/<cli-name>` combination that exists, plus its authoritative status, for fast lookup without walking the filesystem?
172
+ - Do you want a **registry/index file** (e.g. `catalog.json`) at the repo root listing every `<vendor>/<family>/<os>/<cli-name>` combination that exists, plus its authoritative status, for fast lookup without walking the filesystem?
171
173
  - What **confidence threshold** (match-rate %, sample count minimum) should gate auto-promotion vs. human review in step 9 — worth making this configurable per-project rather than hardcoded?
172
174
  - Should `recognizers.txt` support **one-of-many matching** from day one (per the multi-variant note in §6), or is that a v2 concern?
173
175
  - What's the **LLM provider/model** for generation — worth pinning per-project so `usage.txt` costs are comparable across runs?
@@ -0,0 +1 @@
1
+ __version__ = "0.2.2"
@@ -0,0 +1,202 @@
1
+ """parseforge CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from parseforge import naming
8
+
9
+ _BUILDERS: dict[str, type[naming.RegexBuilder]] = {
10
+ "anthropic": naming.AnthropicRegexBuilder,
11
+ "deepseek": naming.DeepSeekRegexBuilder,
12
+ }
13
+
14
+
15
+ def _build_regex_builder(
16
+ provider: str, api_key: str | None, model: str | None
17
+ ) -> naming.RegexBuilder:
18
+ kwargs: dict[str, str] = {}
19
+ if api_key is not None:
20
+ kwargs["api_key"] = api_key
21
+ if model is not None:
22
+ kwargs["model"] = model
23
+ return _BUILDERS[provider](**kwargs)
24
+
25
+
26
+ @click.group()
27
+ @click.version_option(package_name="parseforge")
28
+ def main() -> None:
29
+ """ParseForge — forge, validate, and promote TextFSM templates from CLI output."""
30
+
31
+
32
+ @main.command("name")
33
+ @click.option("--vendor", required=True)
34
+ @click.option("--family", required=True)
35
+ @click.option("--os", "os_", required=True)
36
+ @click.option("--version", required=True)
37
+ @click.option(
38
+ "--provider",
39
+ type=click.Choice(sorted(_BUILDERS)),
40
+ default="anthropic",
41
+ show_default=True,
42
+ )
43
+ @click.option(
44
+ "--api-key",
45
+ default=None,
46
+ help="Provider API key. Defaults to that provider's own API key environment "
47
+ "variable (ANTHROPIC_API_KEY, DEEPSEEK_API_KEY); only needed on a cache miss.",
48
+ )
49
+ @click.option(
50
+ "--model",
51
+ default=None,
52
+ help="Defaults to the selected provider's own default model.",
53
+ )
54
+ @click.argument("command", nargs=-1, required=True)
55
+ def name_cmd(
56
+ vendor: str,
57
+ family: str,
58
+ os_: str,
59
+ version: str,
60
+ provider: str,
61
+ api_key: str | None,
62
+ model: str | None,
63
+ command: tuple[str, ...],
64
+ ) -> None:
65
+ """Print the canonical cli-name for a raw CLI COMMAND.
66
+
67
+ Looked up in the local cache first; only sent to the selected --provider
68
+ on a cache miss.
69
+ """
70
+ context = naming.CliContext(vendor=vendor, family=family, os=os_, version=version)
71
+ builder = _build_regex_builder(provider, api_key, model)
72
+ click.echo(naming.cli_name(" ".join(command), context, builder=builder))
73
+
74
+
75
+ @main.command("run")
76
+ @click.option("--vendor", required=True)
77
+ @click.option("--family", required=True)
78
+ @click.option("--os", "os_", required=True)
79
+ @click.option("--version", required=True)
80
+ @click.option("--host", required=True, help="Device host/IP to sample from.")
81
+ @click.option("--username", required=True)
82
+ @click.option(
83
+ "--password",
84
+ required=True,
85
+ envvar="PARSEFORGE_DEVICE_PASSWORD",
86
+ help="Device password. Defaults to the PARSEFORGE_DEVICE_PASSWORD "
87
+ "environment variable.",
88
+ )
89
+ @click.option(
90
+ "--device-type",
91
+ required=True,
92
+ help='Netmiko device_type, e.g. "cisco_ios".',
93
+ )
94
+ @click.option(
95
+ "--naming-provider",
96
+ type=click.Choice(sorted(_BUILDERS)),
97
+ default="anthropic",
98
+ show_default=True,
99
+ )
100
+ @click.option(
101
+ "--naming-api-key",
102
+ default=None,
103
+ help="API key for the naming LLM call. Defaults to that provider's own "
104
+ "API key environment variable; only needed on a cache miss.",
105
+ )
106
+ @click.option(
107
+ "--naming-model",
108
+ default=None,
109
+ help="Defaults to the naming provider's own default model.",
110
+ )
111
+ @click.option(
112
+ "--generation-provider",
113
+ required=True,
114
+ help="LLM provider for template generation (textfsm-ai's own registry, "
115
+ 'e.g. "anthropic", "deepseek").',
116
+ )
117
+ @click.option(
118
+ "--generation-api-key",
119
+ required=True,
120
+ help="API key for the template-generation LLM call.",
121
+ )
122
+ @click.option(
123
+ "--generation-model",
124
+ required=True,
125
+ help="Model for the template-generation LLM call.",
126
+ )
127
+ @click.option(
128
+ "--store-root",
129
+ default=None,
130
+ help="Root directory for trial output. Defaults to ~/.parseforge/tests.",
131
+ )
132
+ @click.option("--project", default=None)
133
+ @click.option("--email", default=None)
134
+ @click.option("--description", default=None)
135
+ @click.argument("command", nargs=-1, required=True)
136
+ def run_cmd(
137
+ vendor: str,
138
+ family: str,
139
+ os_: str,
140
+ version: str,
141
+ host: str,
142
+ username: str,
143
+ password: str,
144
+ device_type: str,
145
+ naming_provider: str,
146
+ naming_api_key: str | None,
147
+ naming_model: str | None,
148
+ generation_provider: str,
149
+ generation_api_key: str,
150
+ generation_model: str,
151
+ store_root: str | None,
152
+ project: str | None,
153
+ email: str | None,
154
+ description: str | None,
155
+ command: tuple[str, ...],
156
+ ) -> None:
157
+ """Run a single trial: sample COMMAND from a device, generate a
158
+ TextFSM template for it, and write the result under a trial
159
+ directory (SPEC.md §5 steps 1-7)."""
160
+ from pathlib import Path
161
+
162
+ from parseforge import paths
163
+ from parseforge.pipeline import (
164
+ LLMProviderConfig,
165
+ TrialMetadata,
166
+ run_command_pipeline,
167
+ )
168
+ from parseforge.sampling import DeviceConnection
169
+ from parseforge.sampling.backends import NetmikoSampler
170
+
171
+ context = naming.CliContext(vendor=vendor, family=family, os=os_, version=version)
172
+ connection = DeviceConnection(
173
+ host=host, username=username, password=password, device_type=device_type
174
+ )
175
+ naming_builder = _build_regex_builder(naming_provider, naming_api_key, naming_model)
176
+ generation_config = LLMProviderConfig(
177
+ provider=generation_provider, api_key=generation_api_key, model=generation_model
178
+ )
179
+ metadata = TrialMetadata(
180
+ project=project,
181
+ username=username,
182
+ email=email,
183
+ description=description,
184
+ )
185
+
186
+ result = run_command_pipeline(
187
+ " ".join(command),
188
+ context,
189
+ connection,
190
+ naming_builder,
191
+ NetmikoSampler(),
192
+ generation_config,
193
+ store_root=Path(store_root) if store_root else paths.DEFAULT_STORE_ROOT,
194
+ metadata=metadata,
195
+ )
196
+ click.echo(f"cli_name : {result.cli_name}")
197
+ click.echo(f"passed : {result.passed}")
198
+ click.echo(f"run_dir : {result.run_dir}")
199
+
200
+
201
+ if __name__ == "__main__":
202
+ main()
@@ -0,0 +1,112 @@
1
+ """Generation stage — LLM call, extraction, cleanup, and DSL compile
2
+ (SPEC.md §5 steps 5-6), delegated to textfsm-ai's delivery pipeline
3
+ rather than reimplemented here.
4
+
5
+ textfsm-ai's run_pipeline() already covers extraction + cleanup + DSL
6
+ compilation (canonical template, readable DSL, recognizers) in one
7
+ call — see https://github.com/Geeks-Trident-LLC/textfsm-ai. Always run
8
+ in debug mode with JSON output, for full usage/timing/pipeline detail
9
+ (see GenerationResult.raw).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from textfsm_ai import run_pipeline
19
+
20
+ _SENSITIVE_KEYS = {"api_key"}
21
+
22
+
23
+ def _redact(value: Any) -> Any:
24
+ """Strip credential-shaped fields from a parsed debug payload.
25
+
26
+ textfsm-ai's Serializable.to_dict()/to_json() run plain
27
+ dataclasses.asdict() — masking (mask_middle()) only happens in its
28
+ *text*-mode output, not JSON. Debug mode's JSON embeds the raw API
29
+ key unmasked, so it must be stripped before this result is stored
30
+ or logged anywhere.
31
+ """
32
+ if isinstance(value, dict):
33
+ return {
34
+ k: "<redacted>" if k in _SENSITIVE_KEYS else _redact(v)
35
+ for k, v in value.items()
36
+ }
37
+ if isinstance(value, list):
38
+ return [_redact(v) for v in value]
39
+ return value
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class TokenUsage:
44
+ input_tokens: int
45
+ output_tokens: int
46
+ total_tokens: int
47
+ estimated_cost: float
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class GenerationResult:
52
+ """Structured result of generating + DSL-compiling a template from a sample."""
53
+
54
+ template: str # canonical, DSL-compiled TextFSM template
55
+ raw_template: str # pre-cleanup template, as the LLM returned it
56
+ readable_dsl: str
57
+ recognizers: list[str]
58
+ records: list[dict[str, str]]
59
+ usage: TokenUsage
60
+ duration_ms: float
61
+ ready: bool
62
+ reason: str
63
+ raw: dict[str, Any] = field(repr=False)
64
+
65
+
66
+ def generate(
67
+ sample: str,
68
+ provider: str,
69
+ api_key: str,
70
+ model: str,
71
+ **kwargs: Any,
72
+ ) -> GenerationResult:
73
+ """Run textfsm-ai's full sample -> template -> DSL-compiled pipeline.
74
+
75
+ Always runs in debug mode with JSON output (not overridable — pass
76
+ ``mode``/``as_json`` in ``kwargs`` and this raises, same as calling
77
+ ``run_pipeline()`` twice for the same argument would). ``**kwargs``
78
+ forwards to ``run_pipeline()`` for everything else (``endpoint``,
79
+ ``region``, ``max_tries``, ...).
80
+
81
+ Never raises for a failed generation — check ``.ready``.
82
+ """
83
+ result = run_pipeline(
84
+ sample, provider, api_key, model, mode="debug", as_json=True, **kwargs
85
+ )
86
+ debug = _redact(json.loads(result.output))
87
+
88
+ usage_data = debug.get("usage") or {}
89
+ usage = TokenUsage(
90
+ input_tokens=usage_data.get("input_tokens", 0),
91
+ output_tokens=usage_data.get("output_tokens", 0),
92
+ total_tokens=usage_data.get("total_tokens", 0),
93
+ estimated_cost=usage_data.get("estimated_cost", 0.0),
94
+ )
95
+
96
+ gen_stage = (debug.get("generation_pipeline") or {}).get("last_stage") or {}
97
+ gen_metadata = gen_stage.get("metadata") or {}
98
+
99
+ dsl = (debug.get("dsl_pipeline") or {}).get("dsl") or {}
100
+
101
+ return GenerationResult(
102
+ template=dsl.get("canonical") or "",
103
+ raw_template=dsl.get("raw_template") or gen_metadata.get("template") or "",
104
+ readable_dsl=dsl.get("readable") or "",
105
+ recognizers=dsl.get("recognizers") or [],
106
+ records=gen_metadata.get("records") or [],
107
+ usage=usage,
108
+ duration_ms=debug.get("duration_ms", 0.0),
109
+ ready=result.passed,
110
+ reason=result.error,
111
+ raw=debug,
112
+ )