autogrouper 0.1.0__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 autogrouper contributors
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,585 @@
1
+ Metadata-Version: 2.4
2
+ Name: autogrouper
3
+ Version: 0.1.0
4
+ Summary: Automatic prefix-based grouping of string collections — no delimiter, no schema, no external dependencies.
5
+ Author: Rajat Kumar Mondal
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rajat-kumar-mondal/autogrouper
8
+ Project-URL: Documentation, https://github.com/rajat-kumar-mondal/autogrouper#readme
9
+ Project-URL: Repository, https://github.com/rajat-kumar-mondal/autogrouper
10
+ Project-URL: Bug Tracker, https://github.com/rajat-kumar-mondal/autogrouper/issues
11
+ Keywords: prefix,grouping,clustering,string,rdf,ontology,semantic-web,bioinformatics,namespace
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.6
18
+ Classifier: Programming Language :: Python :: 3.7
19
+ Classifier: Programming Language :: Python :: 3.8
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Topic :: Text Processing :: Linguistic
27
+ Requires-Python: >=3.6
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=7.0; extra == "dev"
32
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
33
+ Requires-Dist: build>=1.0; extra == "dev"
34
+ Requires-Dist: twine>=4.0; extra == "dev"
35
+ Provides-Extra: benchmark
36
+ Requires-Dist: matplotlib>=3.5; extra == "benchmark"
37
+ Requires-Dist: pandas>=1.4; extra == "benchmark"
38
+ Dynamic: license-file
39
+
40
+ # autogrouper
41
+
42
+ Automatic prefix-based grouping of string collections. No delimiter. No schema. No external dependencies. Pure Python 3.6+.
43
+
44
+ [![PyPI version](https://badge.fury.io/py/autogrouper.svg)](https://pypi.org/project/autogrouper/)
45
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
46
+ [![Python 3.6+](https://img.shields.io/badge/python-3.6+-blue.svg)](https://www.python.org/downloads/)
47
+
48
+ ---
49
+
50
+ ## The story 📖
51
+
52
+ While working with large collections of ontology terms and semantic web URIs, a simple need came up: group these strings by their shared prefix. The strings looked like `http://www.w3.org/1999/02/22-rdf-syntax-ns#type`, `http://www.w3.org/2002/07/owl#Class`, `http://purl.obolibrary.org/obo/GO_0001234`, `http://purl.obolibrary.org/obo/RO_0000001`. Hundreds of them, from different namespaces, mixed together in a flat list.
53
+
54
+ The obvious question was: does a tool exist that can do this automatically, without being told what the delimiter is, without a predefined schema, without any prior knowledge of the data?
55
+
56
+ After searching through Python libraries, bioinformatics tools, and string processing utilities, the answer was no. Everything found either required you to specify a delimiter, a regex pattern, or a known prefix list upfront. Nothing could look at a flat list of strings and simply discover the structure on its own.
57
+
58
+ So `autogrouper` was built to do exactly that.
59
+
60
+ ---
61
+
62
+ ## What it does ✨
63
+
64
+ Given a flat list of strings, `autogrouper` automatically finds the shared prefix between strings and groups them under it. You do not need to know the prefix, its length, its delimiter, or how many groups exist. The algorithm discovers all of that from the data.
65
+
66
+ ```python
67
+ from autogrouper import AutoPrefixGrouper
68
+
69
+ strings = [
70
+ "user_id", "user_name", "user_email",
71
+ "order_id", "order_date", "order_total",
72
+ "product_id", "product_name",
73
+ ]
74
+
75
+ AutoPrefixGrouper().fit(strings).get_groups()
76
+
77
+ # {
78
+ # "order_": ["order_date", "order_id", "order_total"],
79
+ # "product_": ["product_id", "product_name"],
80
+ # "user_": ["user_email", "user_id", "user_name"],
81
+ # }
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Installation
87
+
88
+ ```bash
89
+ pip install autogrouper
90
+ ```
91
+
92
+ Requires Python 3.6 or later. No external dependencies.
93
+
94
+ ---
95
+
96
+ ## Basic usage
97
+
98
+ ```python
99
+ from autogrouper import AutoPrefixGrouper
100
+
101
+ g = AutoPrefixGrouper()
102
+ g.fit(["rdf:type", "rdf:label", "owl:Class", "owl:ObjectProperty"])
103
+ g.get_groups()
104
+ # {"owl:": ["owl:Class", "owl:ObjectProperty"], "rdf:": ["rdf:label", "rdf:type"]}
105
+ ```
106
+
107
+ `fit()` stores the result inside the object. All other methods read from that stored result. Call `fit()` once before anything else.
108
+
109
+ ### Method chaining
110
+
111
+ `fit()` returns `self`, so you can chain:
112
+
113
+ ```python
114
+ groups = AutoPrefixGrouper().fit(strings).get_groups()
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Understanding the output
120
+
121
+ The key in each group is the **longest common character-level prefix** shared by the strings in that group. It is not based on any delimiter.
122
+
123
+ ```python
124
+ AutoPrefixGrouper().fit(["GO:0001234", "GO:0005678"]).get_groups()
125
+ # {"GO:000": ["GO:0001234", "GO:0005678"]}
126
+ ```
127
+
128
+ The algorithm found `GO:000` because that is the longest prefix shared by both strings, not `GO:`. Use `prefix_depth` and `position_index` if you want to control where the prefix stops.
129
+
130
+ A string with no matching partner receives its own key:
131
+
132
+ ```python
133
+ AutoPrefixGrouper().fit(["rdf:type", "rdf:label", "owl:Class"]).get_groups()
134
+ # {"owl:Class": ["owl:Class"], "rdf:": ["rdf:label", "rdf:type"]}
135
+ ```
136
+
137
+ `owl:Class` has no neighbour that starts with `owl:`, so it becomes its own key.
138
+
139
+ ---
140
+
141
+ ## Controlling prefix depth
142
+
143
+ Sometimes the algorithm finds a prefix that is longer than you want. For example:
144
+
145
+ ```python
146
+ uris = [
147
+ "http://semanticscience.org/resource/SIO_000001",
148
+ "http://semanticscience.org/resource/SIO_000255",
149
+ "http://semanticscience.org/resource/SIO_000772",
150
+ ]
151
+
152
+ AutoPrefixGrouper().fit(uris).get_groups()
153
+ # {"http://semanticscience.org/resource/SIO_000": [...]}
154
+ ```
155
+
156
+ You may want the prefix to stop at `http://semanticscience.org/resource/` instead. Use `prefix_depth` and `position_index` on `fit()`.
157
+
158
+ ### Step 1: inspect the string
159
+
160
+ Use `inspect_prefix()` on a representative string to see what each `position_index` value produces:
161
+
162
+ ```python
163
+ g = AutoPrefixGrouper()
164
+ g.inspect_prefix("http://semanticscience.org/resource/SIO_000001", "/")
165
+
166
+ # position_index=1 : "http:/"
167
+ # position_index=2 : "http://"
168
+ # position_index=3 : "http://semanticscience.org/"
169
+ # position_index=4 : "http://semanticscience.org/resource/"
170
+ # position_index=5 : "http://semanticscience.org/resource/SIO_000001"
171
+ ```
172
+
173
+ `inspect_prefix()` does not require `fit()` to have been called first.
174
+
175
+ ### Step 2: preview the result
176
+
177
+ Use `preview_fit()` to see what `fit()` would produce before committing:
178
+
179
+ ```python
180
+ g.preview_fit(uris, prefix_depth="/", position_index=4)
181
+ # {"http://semanticscience.org/resource/": [...]}
182
+ ```
183
+
184
+ `preview_fit()` does not change the fitted state of the object.
185
+
186
+ ### Step 3: fit with the chosen depth
187
+
188
+ ```python
189
+ g.fit(uris, prefix_depth="/", position_index=4)
190
+ g.get_groups()
191
+ # {"http://semanticscience.org/resource/": [
192
+ # "http://semanticscience.org/resource/SIO_000001",
193
+ # "http://semanticscience.org/resource/SIO_000255",
194
+ # "http://semanticscience.org/resource/SIO_000772",
195
+ # ]}
196
+ ```
197
+
198
+ `position_index=-1` (the default) trims at the last occurrence of the delimiter within the discovered prefix.
199
+
200
+ ### When groups collapse after trimming
201
+
202
+ If two separate groups trim to the same prefix, `autogrouper` automatically re-runs the algorithm on the combined members to restore the distinction:
203
+
204
+ ```python
205
+ strings = [
206
+ "http://purl.obolibrary.org/obo/GO_0000001",
207
+ "http://purl.obolibrary.org/obo/GO_0000002",
208
+ "http://purl.obolibrary.org/obo/RO_0000001",
209
+ "http://purl.obolibrary.org/obo/RO_0000002",
210
+ ]
211
+
212
+ AutoPrefixGrouper().fit(strings, prefix_depth="/", position_index=4).get_groups()
213
+ # {
214
+ # "http://purl.obolibrary.org/obo/GO_000000": ["...GO_0000001", "...GO_0000002"],
215
+ # "http://purl.obolibrary.org/obo/RO_000000": ["...RO_0000001", "...RO_0000002"],
216
+ # }
217
+ ```
218
+
219
+ Both groups were originally trimmed to `http://purl.obolibrary.org/obo/` but the algorithm detected that they could be split further and did so automatically.
220
+
221
+ ---
222
+
223
+ ## Recursive grouping
224
+
225
+ `get_groups_recursive()` applies the algorithm inside each group repeatedly until no further sub-grouping is possible.
226
+
227
+ ```python
228
+ strings = ["hasGlycan", "hasGlycanBinding", "hasX", "hasY", "abc"]
229
+ g = AutoPrefixGrouper().fit(strings)
230
+ ```
231
+
232
+ The top-level result groups everything starting with `has` together:
233
+
234
+ ```python
235
+ g.get_groups()
236
+ # {"abc": ["abc"], "has": ["hasGlycan", "hasGlycanBinding", "hasX", "hasY"]}
237
+ ```
238
+
239
+ `get_groups_recursive()` goes deeper and finds `hasGlycan` inside the `has` group:
240
+
241
+ ```python
242
+ g.get_groups_recursive(mode="flat", singletons=False)
243
+ # {
244
+ # "abc": ["abc"],
245
+ # "has": ["hasGlycan", "hasGlycanBinding", "hasX", "hasY"],
246
+ # "hasGlycan": ["hasGlycan", "hasGlycanBinding"],
247
+ # }
248
+ ```
249
+
250
+ ### mode parameter
251
+
252
+ `mode="flat"` (default) merges all levels into one dict. Coarser and finer prefixes appear as separate keys side by side.
253
+
254
+ `mode="nested"` keeps the hierarchy. Each group that could be sub-grouped becomes a nested dict:
255
+
256
+ ```python
257
+ g.get_groups_recursive(mode="nested", singletons=False)
258
+ # {
259
+ # "abc": ["abc"],
260
+ # "has": {"hasGlycan": ["hasGlycan", "hasGlycanBinding"]},
261
+ # }
262
+ ```
263
+
264
+ ### singletons parameter
265
+
266
+ `singletons=True` (default) includes sub-prefix keys that cover only one string:
267
+
268
+ ```python
269
+ g.get_groups_recursive(mode="flat", singletons=True)
270
+ # {
271
+ # "abc": ["abc"],
272
+ # "has": ["hasGlycan", "hasGlycanBinding", "hasX", "hasY"],
273
+ # "hasGlycan": ["hasGlycan", "hasGlycanBinding"],
274
+ # "hasX": ["hasX"],
275
+ # "hasY": ["hasY"],
276
+ # }
277
+ ```
278
+
279
+ `singletons=False` omits them, showing only groups with two or more members.
280
+
281
+ ---
282
+
283
+ ## Lookup methods
284
+
285
+ ```python
286
+ g = AutoPrefixGrouper().fit(["rdf:type", "rdf:label", "owl:Class", "owl:ObjectProperty"])
287
+
288
+ g.get_prefix_for("rdf:type") # "rdf:"
289
+ g.get_prefix_for("unknown") # None
290
+ g.get_prefixes() # ["owl:", "rdf:"]
291
+ g.get_strings_for("owl:") # ["owl:Class", "owl:ObjectProperty"]
292
+ g.summary() # {"owl:": 2, "rdf:": 2}
293
+ ```
294
+
295
+ ---
296
+
297
+ ## Python operators
298
+
299
+ ```python
300
+ len(g) # number of discovered groups
301
+ "rdf:" in g # True if that prefix key exists
302
+ ```
303
+
304
+ Iteration yields `(prefix, members)` pairs:
305
+
306
+ ```python
307
+ for prefix, members in g:
308
+ print(prefix, members)
309
+ ```
310
+
311
+ `repr()` shows the fitted state:
312
+
313
+ ```python
314
+ repr(g)
315
+ # "AutoPrefixGrouper(fitted=True, n_strings=4, n_groups=2)"
316
+ ```
317
+
318
+ ---
319
+
320
+ ## Export
321
+
322
+ Serialise the fitted groups to a string for downstream use.
323
+
324
+ ```python
325
+ g = AutoPrefixGrouper().fit(["rdf:type", "rdf:label", "owl:Class", "owl:ObjectProperty"])
326
+ ```
327
+
328
+ **JSON** (default):
329
+
330
+ ```python
331
+ print(g.export("json"))
332
+ # {
333
+ # "owl:": ["owl:Class", "owl:ObjectProperty"],
334
+ # "rdf:": ["rdf:label", "rdf:type"]
335
+ # }
336
+ ```
337
+
338
+ **CSV**:
339
+
340
+ ```python
341
+ print(g.export("csv"))
342
+ # prefix,string
343
+ # owl:,owl:Class
344
+ # owl:,owl:ObjectProperty
345
+ # rdf:,rdf:label
346
+ # rdf:,rdf:type
347
+ ```
348
+
349
+ **TSV**:
350
+
351
+ ```python
352
+ print(g.export("tsv"))
353
+ # prefix string
354
+ # owl: owl:Class
355
+ # ...
356
+ ```
357
+
358
+ **Markdown**:
359
+
360
+ ```python
361
+ print(g.export("markdown"))
362
+ # | prefix | string |
363
+ # | --- | --- |
364
+ # | owl: | owl:Class |
365
+ # ...
366
+ ```
367
+
368
+ Format names are case-insensitive. `export()` with no argument defaults to JSON.
369
+
370
+ To write to a file:
371
+
372
+ ```python
373
+ with open("groups.json", "w") as f:
374
+ f.write(g.export("json"))
375
+ ```
376
+
377
+ ---
378
+
379
+ ## Non-string inputs
380
+
381
+ If the list contains integers, floats, or a mix of types, `autogrouper` converts everything to string and prints a warning:
382
+
383
+ ```python
384
+ AutoPrefixGrouper().fit([1, 2, 10, 11, 100])
385
+ # [autogrouper] Warning: multiple or non-string types detected: int.
386
+ # All values converted to string for processing.
387
+ ```
388
+
389
+ An empty list raises `ValueError`. Duplicate strings are preserved.
390
+
391
+ ---
392
+
393
+ ## Where can you use this? 🗺️
394
+
395
+ `autogrouper` is useful whenever you have a flat list of strings that carry hidden structure in their prefixes and you want to surface that structure without writing custom parsing code.
396
+
397
+ **Semantic web and ontologies:**
398
+ A SPARQL query returns a flat list of URIs like `http://www.w3.org/1999/02/22-rdf-syntax-ns#type`, `http://www.w3.org/2002/07/owl#Class`, `http://purl.obolibrary.org/obo/GO_0001234`, and `http://purl.obolibrary.org/obo/RO_0000001` all in the same column. The same situation arises with structured web URLs such as `https://www.alliancegenome.org/disease/DOID:9976`, `https://www.alliancegenome.org/disease/DOID:999`, and `https://www.alliancegenome.org/disease/DOID:9993`. `autogrouper` groups both kinds by their shared prefix without a hardcoded prefix list.
399
+
400
+ **Bioinformatics identifiers:**
401
+ Accession numbers, gene IDs, protein IDs, and pathway identifiers from multiple databases land in the same column. `autogrouper` separates them by their natural prefix pattern (`ENSG`, `hsa-mir`, `UniRef50_`, `IPR`) without you knowing those patterns in advance.
402
+
403
+ **Log analysis:**
404
+ Cloud service logs, S3 keys, or application log lines share structural prefixes like `prod/api/`, `prod/db/`, `staging/api/`. `autogrouper` groups them so you can analyse each subsystem separately.
405
+
406
+ **API and schema exploration:**
407
+ You receive a JSON response or a database schema you have never seen before. Field names like `user_id`, `user_name`, `order_id`, `order_total` carry their own grouping. `autogrouper` surfaces it in one line.
408
+
409
+ **File system analysis:**
410
+ A list of file paths from an unfamiliar directory tree. `autogrouper` groups them by shared path prefix so you can see the structure before you decide what to do with the files.
411
+
412
+ **Configuration and environment variables:**
413
+ Environment variables like `DB_HOST`, `DB_PORT`, `DB_NAME`, `REDIS_HOST`, `REDIS_PORT` are easier to audit and validate when grouped by their natural prefix.
414
+
415
+ **Any flat list you did not design:**
416
+ If you received the list from a third-party API, a collaborator, or a legacy system and want to understand its structure quickly, `autogrouper` is the first tool to reach for.
417
+
418
+ ---
419
+
420
+ ## Full API reference
421
+
422
+ | Method | Requires fit | Description |
423
+ |---|---|---|
424
+ | `fit(strings, prefix_depth, position_index)` | No | Discover groups. Returns `self`. |
425
+ | `get_groups()` | Yes | `dict[str, list[str]]` of all groups. |
426
+ | `get_groups_recursive(mode, singletons)` | Yes | Groups at every depth level. |
427
+ | `get_prefix_for(string)` | Yes | Prefix key for a string, or `None`. |
428
+ | `get_prefixes()` | Yes | Sorted list of all prefix keys. |
429
+ | `get_strings_for(prefix)` | Yes | All strings under a prefix. |
430
+ | `summary()` | Yes | `dict[str, int]` count per prefix. |
431
+ | `export(format)` | Yes | Serialise to `json`, `csv`, `tsv`, or `markdown`. |
432
+ | `inspect_prefix(string, delimiter)` | No | Print `position_index=N` values for a delimiter. |
433
+ | `preview_fit(strings, prefix_depth, position_index)` | No | Dry run without changing state. |
434
+ | `len(g)` | Yes | Number of groups. |
435
+ | `prefix in g` | Yes | Check if a prefix key exists. |
436
+ | `for prefix, members in g` | Yes | Iterate over groups. |
437
+
438
+ ---
439
+
440
+ ## Algorithm
441
+
442
+ The algorithm works in two stages: sort, then scan.
443
+
444
+ ![Algorithm diagram](docs/algorithm.png)
445
+
446
+ **Stage 1: sort**
447
+
448
+ The input list is sorted by this key:
449
+
450
+ ```python
451
+ key = lambda x: (x[0] if x else "", len(x))
452
+ ```
453
+
454
+ The first character groups strings from the same namespace together. The length criterion places the shortest string at the front of each block. This matters because the prefix extracted from a pair is bounded above by the shorter string.
455
+
456
+ For example, given `["hasSomething", "hasProteinBinding", "hasX", "hasY", "xyzCoord", "xyzValue", "abc"]`, the sorted order is:
457
+
458
+ ```
459
+ abc (first char 'a', length 3)
460
+ hasX (first char 'h', length 4)
461
+ hasY (first char 'h', length 4)
462
+ hasSomething (first char 'h', length 12)
463
+ hasProteinBinding (first char 'h', length 17)
464
+ xyzCoord (first char 'x', length 8)
465
+ xyzValue (first char 'x', length 8)
466
+ ```
467
+
468
+ **Stage 2: scan**
469
+
470
+ The algorithm maintains a pointer at position `i`. At each step:
471
+
472
+ 1. Take the string at position `i` and the string at position `i + 1`.
473
+ 2. Find the longest common character-level prefix of this pair.
474
+ 3. Walk forward from `i + 2`, collecting every string that starts with that prefix.
475
+ 4. All collected strings, including the original pair, form one group under that prefix.
476
+ 5. Advance `i` to the first string not consumed in step 3. Repeat.
477
+
478
+ Consider `["hasSomething", "hasProteinBinding", "hasX", "hasY", "xyzCoord", "xyzValue", "abc"]`.
479
+
480
+ After sorting the list becomes `["abc", "hasX", "hasY", "hasSomething", "hasProteinBinding", "xyzCoord", "xyzValue"]`.
481
+
482
+ The scan then proceeds:
483
+
484
+ - `"abc"` has no neighbour sharing a prefix with it. It becomes a singleton group. Pointer advances to index 1.
485
+ - Pair 1 and 2: `"hasX"` and `"hasY"` share `"has"`. The scan walks forward and finds `"hasSomething"` and `"hasProteinBinding"` also start with `"has"`. All four are collected. Pointer advances to index 5.
486
+ - Pair 5 and 6: `"xyzCoord"` and `"xyzValue"` share `"xyz"`. Both collected. Pointer advances to index 7.
487
+ - End of list. Result: `{"abc": [...], "has": [...], "xyz": [...]}`.
488
+
489
+ A string with no matching neighbour forms its own group, with itself as both the key and the sole member.
490
+
491
+ **Why consecutive pairs?**
492
+
493
+ After sorting, strings from the same namespace land in adjacent positions. The longest shared prefix of any two strings in a namespace is fully captured by looking at the shortest string in that block against its immediate neighbour. No global comparison is needed.
494
+
495
+ **Complexity**
496
+
497
+ Time: O(N squared x L), where N is the number of strings and L is average string length. Each string is compared against its neighbour, and the prefix check is linear in L.
498
+
499
+ Space: O(N x L) for storing the result.
500
+
501
+ No trie, hash table, or suffix array is required. Zero imports. Runs on CPython 3.6+.
502
+
503
+ ---
504
+
505
+ ## AI usage disclosure 🤖
506
+
507
+ This package was built by Rajat Kumar Mondal. The core algorithm, its design, and all architectural decisions are original.
508
+
509
+ Claude Sonnet 4.6 by Anthropic was used during development. Specifically, it assisted with code review and edge case identification, writing and refining docstrings, generating and expanding the test suite from an initial set to 287 cases, and structuring and writing this documentation.
510
+
511
+ The model was accessed through the Claude.ai interface. No other AI tools, code generation assistants, or large language models were used at any stage.
512
+
513
+ All output from the model was reviewed manually before inclusion. Every test case was checked against real algorithm output. Any suggested code that did not match observed behaviour was corrected or discarded.
514
+
515
+ ---
516
+
517
+ ## Development
518
+
519
+ ```bash
520
+ git clone https://github.com/rajat-kumar-mondal/autogrouper
521
+ cd autogrouper
522
+ pip install -e ".[dev]"
523
+ ```
524
+
525
+ Run all tests:
526
+
527
+ ```bash
528
+ pytest tests/
529
+ ```
530
+
531
+ Run with detailed output per test:
532
+
533
+ ```bash
534
+ pytest tests/ -v
535
+ ```
536
+
537
+ Run with full output including print statements:
538
+
539
+ ```bash
540
+ pytest tests/ -v -s
541
+ ```
542
+
543
+ 287 test cases cover the core algorithm, recursive grouping, prefix depth control, inspect, preview, and all four export formats.
544
+
545
+ ---
546
+
547
+ ## Project structure
548
+
549
+ ```
550
+ autogrouper/
551
+ ├── autogrouper/
552
+ │ ├── __init__.py # Package entry point and version
553
+ │ └── grouper.py # Core algorithm and AutoPrefixGrouper class
554
+ ├── docs/
555
+ │ ├── algorithm.png # Algorithm diagram (embedded in README)
556
+ │ └── algorithm.svg # Algorithm diagram source
557
+ ├── tests/
558
+ │ └── test_grouper.py # 287 test cases
559
+ ├── CONTRIBUTORS # Author and contribution record
560
+ ├── LICENSE # MIT license
561
+ ├── README.md # Full documentation
562
+ └── pyproject.toml # Build configuration and metadata
563
+ ```
564
+
565
+ ---
566
+
567
+ ## Contributing
568
+
569
+ Contributions are welcome. If you find a bug, have a feature request, or want to improve the documentation, open an issue or a pull request on GitHub. Please include a minimal reproducible example when reporting a bug.
570
+
571
+ ---
572
+
573
+ ## License
574
+
575
+ MIT 2026
576
+
577
+ ---
578
+
579
+ Built with 💙 & overthinking.
580
+
581
+ Inspired by intuition & simplicity.
582
+
583
+ Drinking Boss Black Coffee & Itoen Oi Tea while building.
584
+
585
+ Beautified and documented with Claude.