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.
- autogrouper-0.1.0/LICENSE +21 -0
- autogrouper-0.1.0/PKG-INFO +585 -0
- autogrouper-0.1.0/README.md +546 -0
- autogrouper-0.1.0/autogrouper/__init__.py +29 -0
- autogrouper-0.1.0/autogrouper/grouper.py +779 -0
- autogrouper-0.1.0/autogrouper.egg-info/PKG-INFO +585 -0
- autogrouper-0.1.0/autogrouper.egg-info/SOURCES.txt +11 -0
- autogrouper-0.1.0/autogrouper.egg-info/dependency_links.txt +1 -0
- autogrouper-0.1.0/autogrouper.egg-info/requires.txt +10 -0
- autogrouper-0.1.0/autogrouper.egg-info/top_level.txt +1 -0
- autogrouper-0.1.0/pyproject.toml +79 -0
- autogrouper-0.1.0/setup.cfg +4 -0
- autogrouper-0.1.0/tests/test_grouper.py +2223 -0
|
@@ -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
|
+
[](https://pypi.org/project/autogrouper/)
|
|
45
|
+
[](https://opensource.org/licenses/MIT)
|
|
46
|
+
[](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
|
+

|
|
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.
|