wildling 2.0.1__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.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: wildling
3
+ Version: 2.0.1
4
+ Summary: Pattern based string generator library and CLI
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/dotmonk/wildling
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+
10
+ # wildling
11
+
12
+ Python library and CLI for pattern-based string generation. **Zero third-party dependencies** (stdlib only). Requires Python 3.9+.
13
+
14
+ <!-- wildling:preamble -->
15
+ **Docs:** [Website](https://dotmonk.github.io/wildling/) · [Sandbox](https://dotmonk.github.io/wildling/sandbox.html) · [Syntax](https://dotmonk.github.io/wildling/syntax.html) · [Source](https://github.com/dotmonk/wildling/tree/main/python)
16
+
17
+ **Registry:** [PyPI](https://pypi.org/project/wildling/)
18
+
19
+ ## Example
20
+
21
+ ```text
22
+ http://${'dev,stage,prod'}\-${'api,web'}#{0-2}.example.${'com,net,org'}/@.html
23
+ ```
24
+
25
+ (The `\-` is a literal hyphen; bare `-` would mean “one letter or digit”. `@` is one lowercase letter.)
26
+
27
+ That builds **URL-shaped** candidates: scheme `http://`, then environment × service × optional digits × TLD, then a one-letter path page. Three environments, two services, zero–two digits (`''`, `0`–`9`, `00`–`99`), three TLDs, and `a`–`z` → **51948** strings — the kind of list you generate for fuzzing links or probing staging hosts, not type out.
28
+
29
+ A few of them:
30
+
31
+ - `http://dev-api.example.com/a.html` / `http://stage-web.example.com/z.html`
32
+ - `http://dev-api0.example.net/a.html` / `http://prod-web9.example.org/m.html`
33
+ - `http://dev-api00.example.com/a.html` / `http://prod-web99.example.org/z.html`
34
+
35
+ Named dictionaries (`%{'hosts'}`) work the same way when the word lists live in files.
36
+
37
+ Try it in the [sandbox](https://dotmonk.github.io/wildling/sandbox.html?pattern=http%3A%2F%2F%24%7B%27dev%2Cstage%2Cprod%27%7D%5C-%24%7B%27api%2Cweb%27%7D%23%7B0-2%7D.example.%24%7B%27com%2Cnet%2Corg%27%7D%2F%40.html), or see [pattern syntax](https://dotmonk.github.io/wildling/syntax.html) for length ranges, dictionaries, and escapes.
38
+ <!-- /wildling:preamble -->
39
+
40
+ ## Install
41
+
42
+ From this repository:
43
+
44
+ ```bash
45
+ cd python
46
+ ./build.sh
47
+ # or without Docker:
48
+ cp ../docs/help.txt wildling/help.txt
49
+ ```
50
+
51
+ The CLI is `./bin/wildling`.
52
+
53
+ **Registry:** `pip install wildling`
54
+
55
+ **Git:**
56
+
57
+ ```bash
58
+ pip install "git+https://github.com/dotmonk/wildling.git@v2.0.0#subdirectory=python"
59
+ ```
60
+
61
+ ## Library
62
+
63
+ ```python
64
+ from wildling import create_wildling
65
+
66
+ wildling = create_wildling(
67
+ patterns=["abrakadabra", "Year 19##"],
68
+ dictionaries={"colors": ["red", "blue"]},
69
+ )
70
+
71
+ wildling.count() # 101
72
+ wildling.get(0) # "abrakadabra"
73
+
74
+ value = wildling.next()
75
+ while value is not False:
76
+ print(value)
77
+ value = wildling.next()
78
+
79
+ wildling.reset()
80
+ ```
81
+
82
+ ### API
83
+
84
+ | Method | Description |
85
+ |--------|-------------|
86
+ | `next()` | Next combination, or `False` when exhausted |
87
+ | `get(index)` | Combination at `index`, or `False` if out of range |
88
+ | `count()` | Total combinations across all patterns |
89
+ | `index()` | Current position (after `next` calls) |
90
+ | `reset()` | Reset iteration to the start |
91
+ | `generators()` | Per-pattern generators |
92
+
93
+ ## CLI
94
+
95
+ ```bash
96
+ ./bin/wildling "Year 19##"
97
+ ./bin/wildling --dictionary planets:../dictionaries/planets.txt "%{'planets'}"
98
+ ./bin/wildling --template ./config.json
99
+ ```
100
+
101
+ Help text and `--check` output follow [`docs/cli.md`](../docs/cli.md) / [`docs/help.txt`](../docs/help.txt).
102
+
103
+ ## Build
104
+
105
+ ```bash
106
+ ./build.sh # Docker: copy help.txt + byte-compile
107
+ ```
108
+
109
+ Project tests live in `../tests/` and are run with `../test.sh`.
@@ -0,0 +1,100 @@
1
+ # wildling
2
+
3
+ Python library and CLI for pattern-based string generation. **Zero third-party dependencies** (stdlib only). Requires Python 3.9+.
4
+
5
+ <!-- wildling:preamble -->
6
+ **Docs:** [Website](https://dotmonk.github.io/wildling/) · [Sandbox](https://dotmonk.github.io/wildling/sandbox.html) · [Syntax](https://dotmonk.github.io/wildling/syntax.html) · [Source](https://github.com/dotmonk/wildling/tree/main/python)
7
+
8
+ **Registry:** [PyPI](https://pypi.org/project/wildling/)
9
+
10
+ ## Example
11
+
12
+ ```text
13
+ http://${'dev,stage,prod'}\-${'api,web'}#{0-2}.example.${'com,net,org'}/@.html
14
+ ```
15
+
16
+ (The `\-` is a literal hyphen; bare `-` would mean “one letter or digit”. `@` is one lowercase letter.)
17
+
18
+ That builds **URL-shaped** candidates: scheme `http://`, then environment × service × optional digits × TLD, then a one-letter path page. Three environments, two services, zero–two digits (`''`, `0`–`9`, `00`–`99`), three TLDs, and `a`–`z` → **51948** strings — the kind of list you generate for fuzzing links or probing staging hosts, not type out.
19
+
20
+ A few of them:
21
+
22
+ - `http://dev-api.example.com/a.html` / `http://stage-web.example.com/z.html`
23
+ - `http://dev-api0.example.net/a.html` / `http://prod-web9.example.org/m.html`
24
+ - `http://dev-api00.example.com/a.html` / `http://prod-web99.example.org/z.html`
25
+
26
+ Named dictionaries (`%{'hosts'}`) work the same way when the word lists live in files.
27
+
28
+ Try it in the [sandbox](https://dotmonk.github.io/wildling/sandbox.html?pattern=http%3A%2F%2F%24%7B%27dev%2Cstage%2Cprod%27%7D%5C-%24%7B%27api%2Cweb%27%7D%23%7B0-2%7D.example.%24%7B%27com%2Cnet%2Corg%27%7D%2F%40.html), or see [pattern syntax](https://dotmonk.github.io/wildling/syntax.html) for length ranges, dictionaries, and escapes.
29
+ <!-- /wildling:preamble -->
30
+
31
+ ## Install
32
+
33
+ From this repository:
34
+
35
+ ```bash
36
+ cd python
37
+ ./build.sh
38
+ # or without Docker:
39
+ cp ../docs/help.txt wildling/help.txt
40
+ ```
41
+
42
+ The CLI is `./bin/wildling`.
43
+
44
+ **Registry:** `pip install wildling`
45
+
46
+ **Git:**
47
+
48
+ ```bash
49
+ pip install "git+https://github.com/dotmonk/wildling.git@v2.0.0#subdirectory=python"
50
+ ```
51
+
52
+ ## Library
53
+
54
+ ```python
55
+ from wildling import create_wildling
56
+
57
+ wildling = create_wildling(
58
+ patterns=["abrakadabra", "Year 19##"],
59
+ dictionaries={"colors": ["red", "blue"]},
60
+ )
61
+
62
+ wildling.count() # 101
63
+ wildling.get(0) # "abrakadabra"
64
+
65
+ value = wildling.next()
66
+ while value is not False:
67
+ print(value)
68
+ value = wildling.next()
69
+
70
+ wildling.reset()
71
+ ```
72
+
73
+ ### API
74
+
75
+ | Method | Description |
76
+ |--------|-------------|
77
+ | `next()` | Next combination, or `False` when exhausted |
78
+ | `get(index)` | Combination at `index`, or `False` if out of range |
79
+ | `count()` | Total combinations across all patterns |
80
+ | `index()` | Current position (after `next` calls) |
81
+ | `reset()` | Reset iteration to the start |
82
+ | `generators()` | Per-pattern generators |
83
+
84
+ ## CLI
85
+
86
+ ```bash
87
+ ./bin/wildling "Year 19##"
88
+ ./bin/wildling --dictionary planets:../dictionaries/planets.txt "%{'planets'}"
89
+ ./bin/wildling --template ./config.json
90
+ ```
91
+
92
+ Help text and `--check` output follow [`docs/cli.md`](../docs/cli.md) / [`docs/help.txt`](../docs/help.txt).
93
+
94
+ ## Build
95
+
96
+ ```bash
97
+ ./build.sh # Docker: copy help.txt + byte-compile
98
+ ```
99
+
100
+ Project tests live in `../tests/` and are run with `../test.sh`.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wildling"
7
+ version = "2.0.1"
8
+ description = "Pattern based string generator library and CLI"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ dependencies = []
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/dotmonk/wildling"
16
+
17
+ [project.scripts]
18
+ wildling = "wildling.cli:main"
19
+
20
+ [tool.setuptools.packages.find]
21
+ include = ["wildling*"]
22
+
23
+ [tool.setuptools.package-data]
24
+ wildling = ["help.txt"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Optional, Union
4
+
5
+ from .generator import Generator, create_generator
6
+ from .parse_pattern import Dictionaries
7
+
8
+ __version__ = "2.0.1"
9
+
10
+ WildlingResult = Union[str, bool]
11
+
12
+
13
+ class Wildling:
14
+ def __init__(
15
+ self,
16
+ patterns: List[str],
17
+ dictionaries: Optional[Dictionaries] = None,
18
+ ) -> None:
19
+ self._dictionaries: Dictionaries = dictionaries or {}
20
+ self._generators = [
21
+ create_generator(pattern, self._dictionaries) for pattern in patterns
22
+ ]
23
+ self._pattern_count = sum(generator.count() for generator in self._generators)
24
+ self._internal_index = 0
25
+
26
+ def index(self) -> int:
27
+ return self._internal_index
28
+
29
+ def count(self) -> int:
30
+ return self._pattern_count
31
+
32
+ def reset(self) -> None:
33
+ self._internal_index = 0
34
+
35
+ def next(self) -> WildlingResult:
36
+ if self._internal_index == self._pattern_count:
37
+ return False
38
+ self._internal_index += 1
39
+ return self.get(self._internal_index - 1)
40
+
41
+ def generators(self) -> List[Generator]:
42
+ return self._generators
43
+
44
+ def get(self, index: int) -> WildlingResult:
45
+ if index > self._pattern_count - 1 or index < 0:
46
+ return False
47
+
48
+ segment_index = 0
49
+ for generator in self._generators:
50
+ pattern_index = index - segment_index
51
+ if pattern_index < generator.count():
52
+ return generator.get(pattern_index)
53
+ segment_index += generator.count()
54
+ return False
55
+
56
+
57
+ def create_wildling(
58
+ patterns: List[str],
59
+ dictionaries: Optional[Dictionaries] = None,
60
+ ) -> Wildling:
61
+ return Wildling(patterns, dictionaries)
62
+
63
+
64
+ # Alias matching the JavaScript default export name in docs
65
+ createWildling = create_wildling
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from typing import Dict, List, Optional, Tuple, Union
8
+
9
+ from . import __version__, create_wildling
10
+ from .generator import Generator
11
+
12
+ Dictionary = Dict[str, List[str]]
13
+
14
+
15
+ @dataclass
16
+ class CliArgs:
17
+ selects: List[int] = field(default_factory=list)
18
+ ranges: List[Tuple[int, int]] = field(default_factory=list)
19
+ check: bool = False
20
+ dictionaries: Dictionary = field(default_factory=dict)
21
+ patterns: List[str] = field(default_factory=list)
22
+ help: bool = False
23
+ version: bool = False
24
+
25
+
26
+ def parse_range(value: str) -> Optional[Tuple[int, int]]:
27
+ parts = value.split("-", 1)
28
+ if len(parts) != 2 or not parts[0].isdigit() or not parts[1].isdigit():
29
+ return None
30
+ start = int(parts[0])
31
+ end = int(parts[1])
32
+ return (start, end) if start <= end else None
33
+
34
+
35
+ def load_dictionary_file(path: str) -> List[str]:
36
+ with open(path, encoding="utf-8") as handle:
37
+ return [line.strip() for line in handle.read().splitlines() if line.strip()]
38
+
39
+
40
+ def apply_dictionary(result: CliArgs, name: str, value: Union[str, List[str]]) -> None:
41
+ if isinstance(value, list):
42
+ result.dictionaries[name] = [str(item) for item in value]
43
+ return
44
+ if isinstance(value, str) and os.path.exists(value):
45
+ try:
46
+ result.dictionaries[name] = load_dictionary_file(value)
47
+ except OSError:
48
+ pass
49
+
50
+
51
+ def apply_template(result: CliArgs, path: str) -> None:
52
+ if not os.path.exists(path):
53
+ print(f"Template file not found: {path}", file=sys.stderr)
54
+ sys.exit(1)
55
+
56
+ try:
57
+ with open(path, encoding="utf-8") as handle:
58
+ template = json.load(handle)
59
+ except (OSError, json.JSONDecodeError):
60
+ print(f"Invalid JSON template: {path}", file=sys.stderr)
61
+ sys.exit(1)
62
+
63
+ if not isinstance(template, dict):
64
+ print(f"Invalid JSON template: {path}", file=sys.stderr)
65
+ sys.exit(1)
66
+
67
+ if template.get("check") is True:
68
+ result.check = True
69
+
70
+ select = template.get("select")
71
+ if isinstance(select, list):
72
+ for val in select:
73
+ try:
74
+ number = int(val)
75
+ except (TypeError, ValueError):
76
+ continue
77
+ if number >= 0:
78
+ result.selects.append(number)
79
+
80
+ ranges = template.get("range")
81
+ if isinstance(ranges, list):
82
+ for range_str in ranges:
83
+ parsed = parse_range(str(range_str))
84
+ if parsed is not None:
85
+ result.ranges.append(parsed)
86
+
87
+ dictionaries = template.get("dictionaries")
88
+ if isinstance(dictionaries, dict):
89
+ for name, value in dictionaries.items():
90
+ if isinstance(value, (str, list)):
91
+ apply_dictionary(result, str(name), value)
92
+
93
+ patterns = template.get("patterns")
94
+ if isinstance(patterns, list):
95
+ for pattern in patterns:
96
+ result.patterns.append(str(pattern))
97
+
98
+
99
+ def parse_args(args: List[str]) -> CliArgs:
100
+ result = CliArgs()
101
+ i = 0
102
+ while i < len(args):
103
+ arg = args[i]
104
+
105
+ if arg in ("--help", "-h"):
106
+ result.help = True
107
+ i += 1
108
+ continue
109
+
110
+ if arg in ("--version", "-v"):
111
+ result.version = True
112
+ i += 1
113
+ continue
114
+
115
+ if arg == "--check":
116
+ result.check = True
117
+ i += 1
118
+ continue
119
+
120
+ if arg == "--select":
121
+ i += 1
122
+ if i >= len(args):
123
+ break
124
+ try:
125
+ val = int(args[i])
126
+ except ValueError:
127
+ val = -1
128
+ if val >= 0:
129
+ result.selects.append(val)
130
+ i += 1
131
+ continue
132
+
133
+ if arg == "--range":
134
+ i += 1
135
+ if i >= len(args):
136
+ break
137
+ parsed = parse_range(args[i])
138
+ if parsed is not None:
139
+ result.ranges.append(parsed)
140
+ i += 1
141
+ continue
142
+
143
+ if arg == "--dictionary":
144
+ i += 1
145
+ if i >= len(args):
146
+ break
147
+ name, sep, path = args[i].partition(":")
148
+ if sep and name and path:
149
+ apply_dictionary(result, name, path)
150
+ i += 1
151
+ continue
152
+
153
+ if arg == "--template":
154
+ i += 1
155
+ if i >= len(args):
156
+ print("Missing path for --template", file=sys.stderr)
157
+ sys.exit(1)
158
+ apply_template(result, args[i])
159
+ i += 1
160
+ continue
161
+
162
+ result.patterns.append(arg)
163
+ i += 1
164
+
165
+ return result
166
+
167
+
168
+ def load_help_text() -> str:
169
+ here = os.path.dirname(os.path.abspath(__file__))
170
+ candidates = [
171
+ os.path.join(here, "help.txt"),
172
+ os.path.join(here, "..", "..", "docs", "help.txt"),
173
+ ]
174
+ for path in candidates:
175
+ if os.path.exists(path):
176
+ with open(path, encoding="utf-8") as handle:
177
+ return handle.read()
178
+ return "wildling - pattern based string generator\n\nHelp text unavailable.\n"
179
+
180
+
181
+ def format_list(values: List[Union[str, int]]) -> str:
182
+ return "" if not values else " " + " ".join(str(value) for value in values)
183
+
184
+
185
+ def format_check_output(args: CliArgs, total: int, generators: List[Generator]) -> str:
186
+ lines = [
187
+ f"patterns:{format_list(args.patterns)}",
188
+ f"dictionaries:{format_list(list(args.dictionaries.keys()))}",
189
+ f"select:{format_list(args.selects)}",
190
+ f"range:{format_list([f'{start}-{end}' for start, end in args.ranges])}",
191
+ f"total: {total}",
192
+ ]
193
+ for gen in generators:
194
+ lines.append(f"generator: {gen.source} {gen.count()}")
195
+ return "\n".join(lines)
196
+
197
+
198
+ def main(argv: Optional[List[str]] = None) -> None:
199
+ args = parse_args(sys.argv[1:] if argv is None else argv)
200
+
201
+ if args.help:
202
+ print(load_help_text().rstrip())
203
+ sys.exit(0)
204
+
205
+ if args.version:
206
+ print(f"wildling {__version__}")
207
+ sys.exit(0)
208
+
209
+ if not args.patterns:
210
+ print("No pattern provided. Use --help for usage information.", file=sys.stderr)
211
+ sys.exit(1)
212
+
213
+ wildcard = create_wildling(args.patterns, args.dictionaries)
214
+
215
+ if args.check:
216
+ print(format_check_output(args, wildcard.count(), wildcard.generators()))
217
+ sys.exit(0)
218
+
219
+ if args.selects or args.ranges:
220
+ oor = False
221
+ for index in args.selects:
222
+ value = wildcard.get(index)
223
+ if value is False:
224
+ print(f"out of range: {index}", file=sys.stderr)
225
+ oor = True
226
+ else:
227
+ print(value)
228
+ for start, end in args.ranges:
229
+ for index in range(start, end + 1):
230
+ value = wildcard.get(index)
231
+ if value is False:
232
+ print(f"out of range: {index}", file=sys.stderr)
233
+ oor = True
234
+ else:
235
+ print(value)
236
+ sys.exit(1 if oor else 0)
237
+
238
+ value = wildcard.next()
239
+ while value is not False:
240
+ print(value)
241
+ value = wildcard.next()
242
+
243
+
244
+ if __name__ == "__main__":
245
+ main()
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List
4
+
5
+ from .parse_pattern import Dictionaries, parse_pattern
6
+ from .token import Token
7
+
8
+
9
+ class Generator:
10
+ def __init__(self, input_pattern: str, dictionaries: Dictionaries) -> None:
11
+ self.source = input_pattern
12
+ self._tokens = parse_pattern(input_pattern, dictionaries)
13
+ self._count = 1
14
+ for token in self._tokens:
15
+ self._count *= token.count()
16
+
17
+ def count(self) -> int:
18
+ return self._count
19
+
20
+ def tokens(self) -> List[Token]:
21
+ return self._tokens
22
+
23
+ def get(self, index: int) -> str:
24
+ if index > self._count - 1 or index < 0:
25
+ return ""
26
+
27
+ string_array: List[str] = []
28
+ index_with_offset = index
29
+ for token in self._tokens:
30
+ string_array.append(token.get(index_with_offset % token.count()))
31
+ index_with_offset //= token.count()
32
+ return "".join(string_array)
33
+
34
+
35
+ def create_generator(input_pattern: str, dictionaries: Dictionaries) -> Generator:
36
+ return Generator(input_pattern, dictionaries)
@@ -0,0 +1,43 @@
1
+ wildling - pattern based string generator
2
+
3
+ USAGE
4
+ wildling [options] [pattern ...]
5
+
6
+ OPTIONS
7
+ --select # Print only combination number # (repeatable)
8
+ --range #-# Print combinations from # to # inclusive (repeatable)
9
+ --check Print generation info instead of results
10
+ --dictionary <name>:<path> Load a dictionary file as <name> (repeatable)
11
+ --template <path> Load options from a JSON template file
12
+ --help, -h Show this help message
13
+ --version, -v Show version
14
+
15
+ PATTERNS
16
+ # #{N} #{N-M} digits 0-9; optional fixed or ranged length
17
+ @ @{N} @{N-M} lowercase a-z
18
+ * *{N} *{N-M} lowercase a-z and digits 0-9
19
+ & &{N} &{N-M} lowercase and uppercase letters
20
+ ! !{N} !{N-M} uppercase A-Z
21
+ ? ?{N} ?{N-M} uppercase A-Z and digits 0-9
22
+ - -{N} -{N-M} letters and digits
23
+ ${'a,b',N-M} combinations from a comma-separated word list
24
+ %{'name'} %{'name',N-M} words from a named dictionary
25
+ \# \@ \$ ... escape a wildcard character
26
+
27
+ TEMPLATE JSON
28
+ {
29
+ "patterns": ["Year 19##", "%{'colors'}"],
30
+ "dictionaries": {
31
+ "colors": "path/to/colors.txt",
32
+ "inline": ["red", "blue"]
33
+ },
34
+ "select": [0, 2],
35
+ "range": ["5-7"],
36
+ "check": false
37
+ }
38
+
39
+ EXAMPLES
40
+ wildling --dictionary colors:./colors.txt "%{'colors'}#"
41
+ wildling --template ./config.json
42
+ wildling --select 0 --range 8-9 "##"
43
+ wildling "${'blue,red',1-2}"
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Callable, Dict, List, Union
5
+
6
+ from .token import Token, TokenOptions, create_token
7
+
8
+ Dictionaries = Dict[str, List[str]]
9
+
10
+ TOKEN_PARSING_REGEX = re.compile(
11
+ r"(\\[%@$*#&?!-]|[%@$*#&?!-]\{.*?\}|[%@$*#&?!-])"
12
+ )
13
+
14
+
15
+ def parse_length_with_variants(part: str, variants: List[str]) -> TokenOptions:
16
+ length_arg_regex = re.compile(r"\{((\d+)-(\d+)|(\d+))\}")
17
+ match = length_arg_regex.search(part)
18
+
19
+ start_length = 1
20
+ end_length = 1
21
+
22
+ if match is not None and match.group(2):
23
+ start_length = int(match.group(2))
24
+ end_length = int(match.group(3))
25
+ elif match is not None and match.group(1):
26
+ start_length = int(match.group(1))
27
+ end_length = start_length
28
+
29
+ return {
30
+ "variants": variants,
31
+ "startLength": start_length,
32
+ "endLength": end_length,
33
+ "src": part,
34
+ }
35
+
36
+
37
+ def parse_length_with_string(part: str) -> Union[TokenOptions, bool]:
38
+ length_arg_regex = re.compile(r"\{'(.*)'(?:,(\d+)-(\d+))?(?:,(\d+))?\}")
39
+ match = length_arg_regex.search(part)
40
+
41
+ if match is None:
42
+ return False
43
+
44
+ if match.group(2) is not None and match.group(3) is not None:
45
+ return {
46
+ "string": match.group(1) or "",
47
+ "startLength": int(match.group(2)),
48
+ "endLength": int(match.group(3)),
49
+ "src": part,
50
+ }
51
+
52
+ if match.group(4) is not None:
53
+ length = int(match.group(4))
54
+ return {
55
+ "string": match.group(1) or "",
56
+ "startLength": length,
57
+ "endLength": length,
58
+ "src": part,
59
+ }
60
+
61
+ return {
62
+ "string": match.group(1) or "",
63
+ "startLength": 1,
64
+ "endLength": 1,
65
+ "src": part,
66
+ }
67
+
68
+
69
+ def simple_tokenizer(variants_string: str) -> Callable[[str], Token]:
70
+ variants = list(variants_string)
71
+
72
+ def tokenizer(part: str) -> Token:
73
+ return create_token(parse_length_with_variants(part, variants))
74
+
75
+ return tokenizer
76
+
77
+
78
+ def _dictionary_tokenizer(part: str, dictionaries: Dictionaries) -> Token:
79
+ options = parse_length_with_string(part)
80
+ if options is False or (
81
+ isinstance(options, dict)
82
+ and options.get("string")
83
+ and options["string"] not in dictionaries
84
+ ):
85
+ options = {
86
+ "variants": [part],
87
+ "startLength": 1,
88
+ "endLength": 1,
89
+ "src": part,
90
+ }
91
+ else:
92
+ assert isinstance(options, dict)
93
+ options["variants"] = dictionaries.get(options.get("string") or "", [])
94
+ return create_token(options)
95
+
96
+
97
+ def _words_tokenizer(part: str) -> Token:
98
+ options = parse_length_with_string(part)
99
+
100
+ if options is False:
101
+ options = {
102
+ "variants": [part],
103
+ "startLength": 1,
104
+ "endLength": 1,
105
+ "src": part,
106
+ }
107
+ else:
108
+ assert isinstance(options, dict)
109
+ variants: List[str] = []
110
+ work_string = options.get("string") or ""
111
+ index = 0
112
+ while index < len(work_string):
113
+ if work_string[index : index + 2] == "\\,":
114
+ index += 2
115
+ elif work_string[index] == ",":
116
+ variants.append(work_string[:index])
117
+ work_string = work_string[index + 1 :]
118
+ index = 0
119
+ else:
120
+ index += 1
121
+ variants.append(work_string)
122
+ options["variants"] = [variant.replace("\\,", ",") for variant in variants]
123
+
124
+ return create_token(options)
125
+
126
+
127
+ def part_to_token(part: str, dictionaries: Dictionaries) -> Token:
128
+ tokenizers: Dict[str, Callable[[str], Token]] = {
129
+ "#": simple_tokenizer("0123456789"),
130
+ "@": simple_tokenizer("abcdefghijklmnopqrstuvwxyz"),
131
+ "*": simple_tokenizer("abcdefghijklmnopqrstuvwxyz0123456789"),
132
+ "-": simple_tokenizer(
133
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
134
+ ),
135
+ "!": simple_tokenizer("ABCDEFGHIJKLMNOPQRSTUVWXYZ"),
136
+ "?": simple_tokenizer("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"),
137
+ "&": simple_tokenizer("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"),
138
+ "%": lambda p: _dictionary_tokenizer(p, dictionaries),
139
+ "$": _words_tokenizer,
140
+ }
141
+
142
+ tokenizer = tokenizers.get(part[0]) if part else None
143
+ is_escaped_token = (
144
+ len(part) > 1 and part[0] == "\\" and part[1] in tokenizers
145
+ )
146
+
147
+ if tokenizer is not None:
148
+ return tokenizer(part)
149
+ if is_escaped_token:
150
+ return create_token(
151
+ {
152
+ "variants": [re.sub(r"^\\", "", part)],
153
+ "src": part,
154
+ }
155
+ )
156
+ return create_token({"variants": [part], "src": part})
157
+
158
+
159
+ def parse_pattern(input_pattern: str, dictionaries: Dictionaries) -> List[Token]:
160
+ parts = [part for part in TOKEN_PARSING_REGEX.split(input_pattern) if part]
161
+ return [part_to_token(part, dictionaries) for part in parts]
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, TypedDict
4
+
5
+
6
+ class TokenOptions(TypedDict, total=False):
7
+ string: str
8
+ startLength: int
9
+ endLength: int
10
+ variants: List[str]
11
+ src: str
12
+
13
+
14
+ def _default_integer_option(option: object, fallback: int) -> int:
15
+ return option if isinstance(option, int) and option >= 0 else fallback
16
+
17
+
18
+ class Token:
19
+ def __init__(self, options: TokenOptions) -> None:
20
+ self._src = options.get("src", "")
21
+ self._start_length = _default_integer_option(options.get("startLength"), 1)
22
+ self._end_length = _default_integer_option(options.get("endLength"), 1)
23
+ self._variants = options.get("variants") or []
24
+ self._count = 0
25
+ for length in range(self._start_length, self._end_length + 1):
26
+ self._count += len(self._variants) ** length
27
+
28
+ def count(self) -> int:
29
+ return self._count
30
+
31
+ def src(self) -> str:
32
+ return self._src
33
+
34
+ def get(self, index: int) -> str:
35
+ if index > self._count - 1 or index < 0:
36
+ return ""
37
+
38
+ if index == 0 and self._start_length == 0:
39
+ return ""
40
+
41
+ index_with_offset = index
42
+ string_length = self._start_length
43
+ for string_length in range(self._start_length, self._end_length + 1):
44
+ offset_count = len(self._variants) ** string_length
45
+ if index_with_offset < offset_count:
46
+ break
47
+ index_with_offset -= offset_count
48
+
49
+ string_array: List[str] = []
50
+ for _ in range(string_length):
51
+ variant_index = index_with_offset % len(self._variants)
52
+ index_with_offset //= len(self._variants)
53
+ string_array.append(self._variants[variant_index])
54
+ return "".join(string_array)
55
+
56
+
57
+ def create_token(options: TokenOptions) -> Token:
58
+ return Token(options)
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: wildling
3
+ Version: 2.0.1
4
+ Summary: Pattern based string generator library and CLI
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/dotmonk/wildling
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+
10
+ # wildling
11
+
12
+ Python library and CLI for pattern-based string generation. **Zero third-party dependencies** (stdlib only). Requires Python 3.9+.
13
+
14
+ <!-- wildling:preamble -->
15
+ **Docs:** [Website](https://dotmonk.github.io/wildling/) · [Sandbox](https://dotmonk.github.io/wildling/sandbox.html) · [Syntax](https://dotmonk.github.io/wildling/syntax.html) · [Source](https://github.com/dotmonk/wildling/tree/main/python)
16
+
17
+ **Registry:** [PyPI](https://pypi.org/project/wildling/)
18
+
19
+ ## Example
20
+
21
+ ```text
22
+ http://${'dev,stage,prod'}\-${'api,web'}#{0-2}.example.${'com,net,org'}/@.html
23
+ ```
24
+
25
+ (The `\-` is a literal hyphen; bare `-` would mean “one letter or digit”. `@` is one lowercase letter.)
26
+
27
+ That builds **URL-shaped** candidates: scheme `http://`, then environment × service × optional digits × TLD, then a one-letter path page. Three environments, two services, zero–two digits (`''`, `0`–`9`, `00`–`99`), three TLDs, and `a`–`z` → **51948** strings — the kind of list you generate for fuzzing links or probing staging hosts, not type out.
28
+
29
+ A few of them:
30
+
31
+ - `http://dev-api.example.com/a.html` / `http://stage-web.example.com/z.html`
32
+ - `http://dev-api0.example.net/a.html` / `http://prod-web9.example.org/m.html`
33
+ - `http://dev-api00.example.com/a.html` / `http://prod-web99.example.org/z.html`
34
+
35
+ Named dictionaries (`%{'hosts'}`) work the same way when the word lists live in files.
36
+
37
+ Try it in the [sandbox](https://dotmonk.github.io/wildling/sandbox.html?pattern=http%3A%2F%2F%24%7B%27dev%2Cstage%2Cprod%27%7D%5C-%24%7B%27api%2Cweb%27%7D%23%7B0-2%7D.example.%24%7B%27com%2Cnet%2Corg%27%7D%2F%40.html), or see [pattern syntax](https://dotmonk.github.io/wildling/syntax.html) for length ranges, dictionaries, and escapes.
38
+ <!-- /wildling:preamble -->
39
+
40
+ ## Install
41
+
42
+ From this repository:
43
+
44
+ ```bash
45
+ cd python
46
+ ./build.sh
47
+ # or without Docker:
48
+ cp ../docs/help.txt wildling/help.txt
49
+ ```
50
+
51
+ The CLI is `./bin/wildling`.
52
+
53
+ **Registry:** `pip install wildling`
54
+
55
+ **Git:**
56
+
57
+ ```bash
58
+ pip install "git+https://github.com/dotmonk/wildling.git@v2.0.0#subdirectory=python"
59
+ ```
60
+
61
+ ## Library
62
+
63
+ ```python
64
+ from wildling import create_wildling
65
+
66
+ wildling = create_wildling(
67
+ patterns=["abrakadabra", "Year 19##"],
68
+ dictionaries={"colors": ["red", "blue"]},
69
+ )
70
+
71
+ wildling.count() # 101
72
+ wildling.get(0) # "abrakadabra"
73
+
74
+ value = wildling.next()
75
+ while value is not False:
76
+ print(value)
77
+ value = wildling.next()
78
+
79
+ wildling.reset()
80
+ ```
81
+
82
+ ### API
83
+
84
+ | Method | Description |
85
+ |--------|-------------|
86
+ | `next()` | Next combination, or `False` when exhausted |
87
+ | `get(index)` | Combination at `index`, or `False` if out of range |
88
+ | `count()` | Total combinations across all patterns |
89
+ | `index()` | Current position (after `next` calls) |
90
+ | `reset()` | Reset iteration to the start |
91
+ | `generators()` | Per-pattern generators |
92
+
93
+ ## CLI
94
+
95
+ ```bash
96
+ ./bin/wildling "Year 19##"
97
+ ./bin/wildling --dictionary planets:../dictionaries/planets.txt "%{'planets'}"
98
+ ./bin/wildling --template ./config.json
99
+ ```
100
+
101
+ Help text and `--check` output follow [`docs/cli.md`](../docs/cli.md) / [`docs/help.txt`](../docs/help.txt).
102
+
103
+ ## Build
104
+
105
+ ```bash
106
+ ./build.sh # Docker: copy help.txt + byte-compile
107
+ ```
108
+
109
+ Project tests live in `../tests/` and are run with `../test.sh`.
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ wildling/__init__.py
4
+ wildling/__main__.py
5
+ wildling/cli.py
6
+ wildling/generator.py
7
+ wildling/help.txt
8
+ wildling/parse_pattern.py
9
+ wildling/token.py
10
+ wildling.egg-info/PKG-INFO
11
+ wildling.egg-info/SOURCES.txt
12
+ wildling.egg-info/dependency_links.txt
13
+ wildling.egg-info/entry_points.txt
14
+ wildling.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wildling = wildling.cli:main
@@ -0,0 +1 @@
1
+ wildling