opencomb 0.2.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.
opencomb/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ OpenComb – Smart Combiner for Code, Configs, Prompts, Templates, Recipes
3
+ and Combinatorial Generation.
4
+
5
+ A practical toolkit for developers.
6
+ """
7
+
8
+ from opencomb.combiner import CodeCombiner, ConfigMerger
9
+ from opencomb.combinatorial import CombinatorialGenerator
10
+ from opencomb.prompt import PromptCombiner
11
+ from opencomb.recipe import RecipeRunner
12
+ from opencomb.template import TemplateRenderer
13
+
14
+ __version__ = "0.2.0"
15
+ __all__ = [
16
+ "CodeCombiner",
17
+ "ConfigMerger",
18
+ "CombinatorialGenerator",
19
+ "PromptCombiner",
20
+ "TemplateRenderer",
21
+ "RecipeRunner",
22
+ "__version__",
23
+ ]
opencomb/cli.py ADDED
@@ -0,0 +1,323 @@
1
+ """OpenComb command-line interface – powerful and beautiful."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+ import yaml
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.syntax import Syntax
14
+ from rich.table import Table
15
+ from rich.markdown import Markdown
16
+
17
+ from opencomb import __version__
18
+ from opencomb.combiner import CodeCombiner, ConfigMerger
19
+ from opencomb.combinatorial import CombinatorialGenerator
20
+ from opencomb.prompt import PromptCombiner
21
+ from opencomb.recipe import RecipeRunner
22
+ from opencomb.template import TemplateRenderer
23
+
24
+ app = typer.Typer(
25
+ name="opencomb",
26
+ help="OpenComb – Smart Combiner for Code, Configs, Prompts, Templates, Recipes & more",
27
+ add_completion=True,
28
+ no_args_is_help=True,
29
+ rich_markup_mode="rich",
30
+ )
31
+ console = Console()
32
+
33
+
34
+ def version_callback(value: bool) -> None:
35
+ if value:
36
+ console.print(f"[bold cyan]OpenComb[/] version [green]{__version__}[/]")
37
+ raise typer.Exit()
38
+
39
+
40
+ @app.callback()
41
+ def main(
42
+ version: Optional[bool] = typer.Option(
43
+ None,
44
+ "--version",
45
+ "-V",
46
+ callback=version_callback,
47
+ is_eager=True,
48
+ help="Show version and exit.",
49
+ ),
50
+ ) -> None:
51
+ """OpenComb – combine code, merge configs, build prompts, render templates, run recipes."""
52
+ pass
53
+
54
+
55
+ @app.command("combine")
56
+ def combine_cmd(
57
+ files: list[Path] = typer.Argument(..., help="Python files to combine"),
58
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write result to file"),
59
+ no_headers: bool = typer.Option(False, "--no-headers", help="Do not add source headers"),
60
+ no_dedupe: bool = typer.Option(False, "--no-dedupe", help="Do not deduplicate imports"),
61
+ ) -> None:
62
+ """Combine multiple Python source files into one clean module."""
63
+ combiner = CodeCombiner()
64
+ try:
65
+ result = combiner.combine_files(
66
+ files,
67
+ add_headers=not no_headers,
68
+ deduplicate_imports=not no_dedupe,
69
+ )
70
+ except Exception as e:
71
+ console.print(f"[red]Error:[/] {e}")
72
+ raise typer.Exit(1)
73
+
74
+ if output:
75
+ output.write_text(result, encoding="utf-8")
76
+ console.print(f"[green]✓[/] Combined {len(files)} files → [cyan]{output}[/]")
77
+ else:
78
+ syntax = Syntax(result, "python", theme="monokai", line_numbers=True)
79
+ console.print(Panel(syntax, title="Combined Code", border_style="cyan"))
80
+
81
+
82
+ @app.command("merge")
83
+ def merge_cmd(
84
+ files: list[Path] = typer.Argument(..., help="Config files to merge (later override earlier)"),
85
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write merged config"),
86
+ strategy: str = typer.Option("deep", "--strategy", "-s", help="deep or shallow"),
87
+ format: Optional[str] = typer.Option(None, "--format", "-f", help="yaml | json | toml"),
88
+ ) -> None:
89
+ """Intelligently merge YAML / JSON / TOML configuration files."""
90
+ merger = ConfigMerger()
91
+ try:
92
+ result = merger.merge_files(files, strategy=strategy)
93
+ except Exception as e:
94
+ console.print(f"[red]Error:[/] {e}")
95
+ raise typer.Exit(1)
96
+
97
+ if output:
98
+ try:
99
+ merger.save(result, output, format=format)
100
+ console.print(f"[green]✓[/] Merged {len(files)} configs → [cyan]{output}[/]")
101
+ except Exception as e:
102
+ console.print(f"[red]Error saving:[/] {e}")
103
+ raise typer.Exit(1)
104
+ else:
105
+ text = yaml.dump(result, default_flow_style=False, allow_unicode=True, sort_keys=False)
106
+ syntax = Syntax(text, "yaml", theme="monokai", line_numbers=True)
107
+ console.print(Panel(syntax, title="Merged Config", border_style="green"))
108
+
109
+
110
+ @app.command("generate")
111
+ def generate_cmd(
112
+ params: Optional[Path] = typer.Option(None, "--params", "-p", help="YAML/JSON params file"),
113
+ method: str = typer.Option("cartesian", "--method", "-m", help="cartesian | pairwise | sample"),
114
+ limit: Optional[int] = typer.Option(None, "--limit", "-n", help="Max combinations"),
115
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write as JSON Lines"),
116
+ seed: Optional[int] = typer.Option(None, "--seed", help="Random seed"),
117
+ report: bool = typer.Option(False, "--report", help="Generate Markdown report"),
118
+ ) -> None:
119
+ """Generate parameter combinations (cartesian / pairwise / sample)."""
120
+ if params is None:
121
+ console.print("[yellow]No --params given.[/] Using demo parameters.\n")
122
+ parameters = {
123
+ "learning_rate": [0.001, 0.01, 0.1],
124
+ "batch_size": [16, 32],
125
+ "optimizer": ["adam", "sgd"],
126
+ }
127
+ else:
128
+ text = params.read_text(encoding="utf-8")
129
+ if params.suffix.lower() in (".yaml", ".yml"):
130
+ parameters = yaml.safe_load(text)
131
+ else:
132
+ parameters = json.loads(text)
133
+
134
+ if not isinstance(parameters, dict):
135
+ console.print("[red]Parameters must be a mapping of name → list[/]")
136
+ raise typer.Exit(1)
137
+
138
+ gen = CombinatorialGenerator(seed=seed)
139
+
140
+ if method == "cartesian":
141
+ combos = gen.cartesian(parameters, limit=limit)
142
+ elif method == "pairwise":
143
+ combos = gen.pairwise(parameters, limit=limit)
144
+ elif method == "sample":
145
+ combos = gen.sample(parameters, n=limit or 10)
146
+ else:
147
+ console.print(f"[red]Unknown method:[/] {method}")
148
+ raise typer.Exit(1)
149
+
150
+ if output:
151
+ with output.open("w", encoding="utf-8") as f:
152
+ for c in combos:
153
+ f.write(json.dumps(c, ensure_ascii=False) + "\n")
154
+ console.print(f"[green]✓[/] Generated [cyan]{len(combos)}[/] combinations → [cyan]{output}[/]")
155
+ else:
156
+ table = Table(title=f"Generated Combinations ({method})", show_header=True)
157
+ if combos:
158
+ for key in combos[0].keys():
159
+ table.add_column(str(key), style="cyan")
160
+ for combo in combos[:40]:
161
+ table.add_row(*[str(v) for v in combo.values()])
162
+ if len(combos) > 40:
163
+ console.print(f"[dim]… and {len(combos) - 40} more[/]")
164
+ console.print(table)
165
+ console.print(f"\n[bold]Total:[/] {len(combos)} combinations")
166
+
167
+ if report and combos:
168
+ md_lines = [
169
+ f"# Combination Report ({method})",
170
+ f"\nTotal combinations: **{len(combos)}**\n",
171
+ "| # | " + " | ".join(combos[0].keys()) + " |",
172
+ "|---|" + "|".join(["---"] * len(combos[0])) + "|",
173
+ ]
174
+ for i, c in enumerate(combos, 1):
175
+ md_lines.append(f"| {i} | " + " | ".join(str(v) for v in c.values()) + " |")
176
+ report_path = Path("opencomb_report.md")
177
+ report_path.write_text("\n".join(md_lines) + "\n", encoding="utf-8")
178
+ console.print(f"[green]✓[/] Markdown report → [cyan]{report_path}[/]")
179
+
180
+
181
+ @app.command("prompt")
182
+ def prompt_cmd(
183
+ system: Optional[list[Path]] = typer.Option(None, "--system", "-s", help="System prompt file(s)"),
184
+ instruction: Optional[Path] = typer.Option(None, "--instruction", "-i", help="Instruction file"),
185
+ context: Optional[list[Path]] = typer.Option(None, "--context", "-c", help="Context file(s)"),
186
+ examples: Optional[Path] = typer.Option(None, "--examples", "-e", help="YAML examples file"),
187
+ user: Optional[Path] = typer.Option(None, "--user", "-u", help="User query file"),
188
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write final prompt"),
189
+ ) -> None:
190
+ """Build a structured LLM prompt from components."""
191
+ combiner = PromptCombiner()
192
+
193
+ try:
194
+ result = combiner.from_files(
195
+ system_files=system,
196
+ instruction_file=instruction,
197
+ context_files=context,
198
+ examples_file=examples,
199
+ user_file=user,
200
+ )
201
+ except Exception as e:
202
+ console.print(f"[red]Error:[/] {e}")
203
+ raise typer.Exit(1)
204
+
205
+ if not result.strip():
206
+ console.print("[yellow]Nothing to combine. Provide at least one component.[/]")
207
+ raise typer.Exit(1)
208
+
209
+ if output:
210
+ output.write_text(result, encoding="utf-8")
211
+ console.print(f"[green]✓[/] Prompt written → [cyan]{output}[/]")
212
+ else:
213
+ console.print(Panel(Markdown(result), title="Combined Prompt", border_style="magenta"))
214
+
215
+
216
+ @app.command("template")
217
+ def template_cmd(
218
+ templates: list[Path] = typer.Argument(..., help="Jinja2 template file(s)"),
219
+ data: Optional[Path] = typer.Option(None, "--data", "-d", help="YAML/JSON data file"),
220
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write rendered result"),
221
+ strict: bool = typer.Option(True, "--strict/--no-strict", help="Strict undefined variables"),
222
+ ) -> None:
223
+ """Render one or more Jinja2 templates with data."""
224
+ context: dict = {}
225
+ if data:
226
+ text = data.read_text(encoding="utf-8")
227
+ if data.suffix.lower() in (".yaml", ".yml"):
228
+ context = yaml.safe_load(text) or {}
229
+ else:
230
+ context = json.loads(text)
231
+
232
+ renderer = TemplateRenderer(strict=strict)
233
+
234
+ try:
235
+ if len(templates) == 1:
236
+ content = templates[0].read_text(encoding="utf-8")
237
+ result = renderer.render_string(content, **context)
238
+ else:
239
+ result = renderer.combine_and_render(templates, data=context)
240
+ except Exception as e:
241
+ console.print(f"[red]Template error:[/] {e}")
242
+ raise typer.Exit(1)
243
+
244
+ if output:
245
+ output.write_text(result, encoding="utf-8")
246
+ console.print(f"[green]✓[/] Rendered → [cyan]{output}[/]")
247
+ else:
248
+ syntax = Syntax(result, "text", theme="monokai", line_numbers=True)
249
+ console.print(Panel(syntax, title="Rendered Template", border_style="blue"))
250
+
251
+
252
+ @app.command("recipe")
253
+ def recipe_cmd(
254
+ recipe_file: Path = typer.Argument(..., help="Path to recipe YAML"),
255
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be done"),
256
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show intermediate results"),
257
+ ) -> None:
258
+ """Run a declarative OpenComb recipe (combine + merge + template + prompt + generate)."""
259
+ runner = RecipeRunner(base_dir=recipe_file.parent)
260
+
261
+ try:
262
+ results = runner.run(recipe_file, dry_run=dry_run)
263
+ except Exception as e:
264
+ console.print(f"[red]Recipe error:[/] {e}")
265
+ raise typer.Exit(1)
266
+
267
+ name = results.get("_recipe", "recipe")
268
+ console.print(f"[green]✓[/] Recipe [cyan]{name}[/] executed successfully")
269
+
270
+ if dry_run:
271
+ console.print("[yellow]Dry-run mode – no files written[/]")
272
+
273
+ table = Table(title="Recipe Steps", show_header=True)
274
+ table.add_column("Step", style="cyan")
275
+ table.add_column("Status")
276
+ table.add_column("Output")
277
+
278
+ for key in ("combine", "merge", "template", "prompt", "generate"):
279
+ if key in results:
280
+ out_key = f"{key}_output"
281
+ out = results.get(out_key, "—")
282
+ table.add_row(key, "[green]done[/]", str(out))
283
+
284
+ console.print(table)
285
+
286
+ if verbose:
287
+ for key in ("combine", "merge", "template", "prompt"):
288
+ if key in results and isinstance(results[key], str):
289
+ console.print(Panel(
290
+ Syntax(results[key][:2000], "text", theme="monokai"),
291
+ title=f"{key} preview",
292
+ border_style="dim",
293
+ ))
294
+
295
+
296
+ @app.command("info")
297
+ def info_cmd() -> None:
298
+ """Show information about OpenComb."""
299
+ console.print(
300
+ Panel.fit(
301
+ f"""[bold cyan]OpenComb[/] v{__version__}
302
+
303
+ Smart Combiner for developers – code, configs, prompts, templates & recipes.
304
+
305
+ [bold]Commands:[/]
306
+ [cyan]combine[/] Combine multiple Python files into one
307
+ [cyan]merge[/] Deep-merge YAML / JSON / TOML configs
308
+ [cyan]generate[/] Generate combinatorial parameter sets
309
+ [cyan]prompt[/] Build structured LLM prompts
310
+ [cyan]template[/] Render Jinja2 templates
311
+ [cyan]recipe[/] Run a full declarative recipe
312
+ [cyan]info[/] Show this information
313
+
314
+ [bold]Repository:[/] https://github.com/SlabyLol/OpenComb
315
+ """,
316
+ title="OpenComb",
317
+ border_style="cyan",
318
+ )
319
+ )
320
+
321
+
322
+ if __name__ == "__main__":
323
+ app()
@@ -0,0 +1,169 @@
1
+ """Combinatorial generation utilities – useful for testing and experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ import random
7
+ from typing import Any, Iterator
8
+
9
+
10
+ class CombinatorialGenerator:
11
+ """
12
+ Generate combinations of parameters.
13
+
14
+ Supports full cartesian product and efficient pairwise (all-pairs) testing.
15
+ """
16
+
17
+ def __init__(self, seed: int | None = None):
18
+ self.rng = random.Random(seed)
19
+
20
+ def cartesian(
21
+ self,
22
+ parameters: dict[str, list[Any]],
23
+ *,
24
+ limit: int | None = None,
25
+ ) -> list[dict[str, Any]]:
26
+ """
27
+ Generate the full cartesian product of parameter values.
28
+
29
+ Args:
30
+ parameters: Mapping of parameter name → list of possible values.
31
+ limit: Optional maximum number of combinations to return.
32
+
33
+ Returns:
34
+ List of dictionaries, each representing one combination.
35
+ """
36
+ if not parameters:
37
+ return [{}]
38
+
39
+ keys = list(parameters.keys())
40
+ value_lists = [parameters[k] for k in keys]
41
+
42
+ combos = []
43
+ for values in itertools.product(*value_lists):
44
+ combos.append(dict(zip(keys, values)))
45
+ if limit is not None and len(combos) >= limit:
46
+ break
47
+
48
+ return combos
49
+
50
+ def pairwise(
51
+ self,
52
+ parameters: dict[str, list[Any]],
53
+ *,
54
+ limit: int | None = None,
55
+ ) -> list[dict[str, Any]]:
56
+ """
57
+ Generate a (near) minimal set of combinations that cover all pairwise
58
+ interactions between parameters. Very useful for efficient testing.
59
+
60
+ This is a simple greedy implementation – good enough for most practical cases.
61
+ """
62
+ if not parameters:
63
+ return [{}]
64
+
65
+ keys = list(parameters.keys())
66
+ if len(keys) == 1:
67
+ return [{keys[0]: v} for v in parameters[keys[0]]]
68
+
69
+ # Generate all required pairs
70
+ required_pairs: set[tuple[tuple[str, Any], tuple[str, Any]]] = set()
71
+ for i, k1 in enumerate(keys):
72
+ for k2 in keys[i + 1 :]:
73
+ for v1 in parameters[k1]:
74
+ for v2 in parameters[k2]:
75
+ required_pairs.add(((k1, v1), (k2, v2)))
76
+
77
+ # Greedy covering
78
+ uncovered = required_pairs.copy()
79
+ result: list[dict[str, Any]] = []
80
+
81
+ # Start with a few random full combinations to seed
82
+ for _ in range(min(5, len(list(itertools.product(*[parameters[k] for k in keys]))))):
83
+ combo = {k: self.rng.choice(parameters[k]) for k in keys}
84
+ result.append(combo)
85
+ self._cover(combo, uncovered)
86
+
87
+ # Keep adding combinations that cover the most remaining pairs
88
+ max_iterations = 1000
89
+ iteration = 0
90
+ while uncovered and iteration < max_iterations:
91
+ iteration += 1
92
+ best_combo = None
93
+ best_cover = 0
94
+
95
+ # Try a limited number of candidates
96
+ for _ in range(50):
97
+ candidate = {k: self.rng.choice(parameters[k]) for k in keys}
98
+ cover_count = self._count_cover(candidate, uncovered)
99
+ if cover_count > best_cover:
100
+ best_cover = cover_count
101
+ best_combo = candidate
102
+
103
+ if best_combo is None or best_cover == 0:
104
+ break
105
+
106
+ result.append(best_combo)
107
+ self._cover(best_combo, uncovered)
108
+
109
+ if limit is not None and len(result) >= limit:
110
+ break
111
+
112
+ return result
113
+
114
+ def sample(
115
+ self,
116
+ parameters: dict[str, list[Any]],
117
+ n: int,
118
+ ) -> list[dict[str, Any]]:
119
+ """Randomly sample n combinations (with replacement if necessary)."""
120
+ if not parameters:
121
+ return [{}] * n
122
+
123
+ keys = list(parameters.keys())
124
+ result = []
125
+ for _ in range(n):
126
+ result.append({k: self.rng.choice(parameters[k]) for k in keys})
127
+ return result
128
+
129
+ def iterate(
130
+ self,
131
+ parameters: dict[str, list[Any]],
132
+ method: str = "cartesian",
133
+ ) -> Iterator[dict[str, Any]]:
134
+ """Lazy iterator over combinations."""
135
+ if method == "cartesian":
136
+ keys = list(parameters.keys())
137
+ value_lists = [parameters[k] for k in keys]
138
+ for values in itertools.product(*value_lists):
139
+ yield dict(zip(keys, values))
140
+ else:
141
+ for combo in self.pairwise(parameters):
142
+ yield combo
143
+
144
+ @staticmethod
145
+ def _cover(
146
+ combo: dict[str, Any],
147
+ uncovered: set[tuple[tuple[str, Any], tuple[str, Any]]],
148
+ ) -> None:
149
+ keys = list(combo.keys())
150
+ for i, k1 in enumerate(keys):
151
+ for k2 in keys[i + 1 :]:
152
+ pair = ((k1, combo[k1]), (k2, combo[k2]))
153
+ uncovered.discard(pair)
154
+ # also the reverse order just in case
155
+ uncovered.discard(((k2, combo[k2]), (k1, combo[k1])))
156
+
157
+ @staticmethod
158
+ def _count_cover(
159
+ combo: dict[str, Any],
160
+ uncovered: set[tuple[tuple[str, Any], tuple[str, Any]]],
161
+ ) -> int:
162
+ count = 0
163
+ keys = list(combo.keys())
164
+ for i, k1 in enumerate(keys):
165
+ for k2 in keys[i + 1 :]:
166
+ pair = ((k1, combo[k1]), (k2, combo[k2]))
167
+ if pair in uncovered or ((k2, combo[k2]), (k1, combo[k1])) in uncovered:
168
+ count += 1
169
+ return count