trackparse 0.3.2__tar.gz → 0.4.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.
Files changed (37) hide show
  1. {trackparse-0.3.2 → trackparse-0.4.0}/PKG-INFO +39 -6
  2. {trackparse-0.3.2 → trackparse-0.4.0}/README.md +37 -4
  3. {trackparse-0.3.2 → trackparse-0.4.0}/pyproject.toml +2 -2
  4. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/__init__.py +1 -1
  5. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_data.py +8 -1
  6. trackparse-0.4.0/src/trackparse/_tracklist_emphasis.py +86 -0
  7. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_kinds.py +20 -1
  8. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_parse.py +19 -9
  9. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_times.py +58 -6
  10. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_types.py +3 -0
  11. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_warnings.py +2 -1
  12. {trackparse-0.3.2 → trackparse-0.4.0}/.gitignore +0 -0
  13. {trackparse-0.3.2 → trackparse-0.4.0}/LICENSE +0 -0
  14. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_artists.py +0 -0
  15. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_assemble.py +0 -0
  16. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_classify.py +0 -0
  17. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_context.py +0 -0
  18. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_credits.py +0 -0
  19. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_digits.py +0 -0
  20. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_format.py +0 -0
  21. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_mode.py +0 -0
  22. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_normalize.py +0 -0
  23. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_parser.py +0 -0
  24. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_prefix.py +0 -0
  25. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_scanner.py +0 -0
  26. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_separator.py +0 -0
  27. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tables.py +0 -0
  28. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_title.py +0 -0
  29. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_edits.py +0 -0
  30. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_options.py +0 -0
  31. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_positions.py +0 -0
  32. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_split.py +0 -0
  33. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_words.py +0 -0
  34. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/options.py +0 -0
  35. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/py.typed +0 -0
  36. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/tracklist.py +0 -0
  37. {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/types.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: trackparse
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Spec-driven parser for music track titles and tracklists: artists, feat, remixes, versions, junk, positions, times.
5
5
  Project-URL: Homepage, https://4matic.github.io/trackparse/
6
- Project-URL: Documentation, https://4matic.github.io/trackparse/guides/python/
6
+ Project-URL: Documentation, https://4matic.github.io/trackparse/start/getting-started/
7
7
  Project-URL: Repository, https://github.com/4matic/trackparse
8
8
  Project-URL: Issues, https://github.com/4matic/trackparse/issues
9
9
  Project-URL: Changelog, https://github.com/4matic/trackparse/blob/main/python/CHANGELOG.md
@@ -38,7 +38,7 @@ Description-Content-Type: text/markdown
38
38
  [![python](https://img.shields.io/github/actions/workflow/status/4matic/trackparse/python.yml?branch=main&label=tests&logo=github)](https://github.com/4matic/trackparse/actions/workflows/python.yml)
39
39
  [![license](https://img.shields.io/badge/license-MIT-blue)](https://github.com/4matic/trackparse/blob/main/LICENSE)
40
40
 
41
- **[Documentation](https://4matic.github.io/trackparse/)** · **[Python guide](https://4matic.github.io/trackparse/guides/python/)** · **[Playground](https://4matic.github.io/trackparse/playground/)**
41
+ **[Documentation](https://4matic.github.io/trackparse/)** · **[Getting started](https://4matic.github.io/trackparse/start/getting-started/)** · **[Playground](https://4matic.github.io/trackparse/playground/)**
42
42
 
43
43
  **Turn messy track names into structured data: who made it, who's featured, who remixed it, and
44
44
  what's just noise.**
@@ -54,7 +54,7 @@ doesn't recognise stays in the title, word for word.
54
54
 
55
55
  This is the Python port of [trackparse](https://github.com/4matic/trackparse). Its behaviour is
56
56
  defined by a [shared spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md) and pinned
57
- by 12,965 fixtures. It passes all of them, and for every fixture input its output is byte-identical
57
+ by 13,124 fixtures. It passes all of them, and for every fixture input its output is byte-identical
58
58
  to the JavaScript package.
59
59
 
60
60
  - Python 3.9 to 3.14
@@ -72,6 +72,8 @@ uv add trackparse
72
72
 
73
73
  ## Quick start
74
74
 
75
+ **Docs:** [Getting started](https://4matic.github.io/trackparse/start/getting-started/)
76
+
75
77
  ```python
76
78
  from trackparse import format_track, parse
77
79
 
@@ -143,6 +145,8 @@ print(json.dumps(track.to_dict(), indent=2))
143
145
 
144
146
  ## API
145
147
 
148
+ **Docs:** [Other functions](https://4matic.github.io/trackparse/reference/functions/)
149
+
146
150
  ```python
147
151
  parse(input, *, mode="auto", uploader=None, known_artists=(), split_and="auto", keywords=None) -> ParsedTrack
148
152
  parse_artists(input, *, <same options>) -> list[Artist]
@@ -156,6 +160,8 @@ SPEC_VERSION, __version__
156
160
 
157
161
  ### `parse(input, **options)`
158
162
 
163
+ **Docs:** [parse and ParsedTrack](https://4matic.github.io/trackparse/reference/parse/) · [Options](https://4matic.github.io/trackparse/reference/options/)
164
+
159
165
  | Option | Default | |
160
166
  |---|---|---|
161
167
  | `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, `"tracklist"`, or `"auto"` to detect from the string |
@@ -196,6 +202,8 @@ the defaults. Passing something that isn't a `str` raises `TypeError`.
196
202
 
197
203
  ### `ParsedTrack`
198
204
 
205
+ **Docs:** [ParsedTrack fields](https://4matic.github.io/trackparse/reference/parse/#parsedtrack)
206
+
199
207
  | Attribute | Type | |
200
208
  |---|---|---|
201
209
  | `input` | `str` | The original string, untouched |
@@ -238,6 +246,8 @@ Version types, junk kinds and warnings are listed in the
238
246
 
239
247
  ### `to_dict()` and `from_dict()`
240
248
 
249
+ **Docs:** [JSON and Python objects](https://4matic.github.io/trackparse/reference/parse/#json-and-python-objects)
250
+
241
251
  Every result class has `to_dict()`, which returns plain dicts and lists matching
242
252
  [`parsed-track.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/parsed-track.schema.json)
243
253
  (`fullTitle`, `unknownArtist`, ...). With `json.dumps(track.to_dict(), separators=(",", ":"),
@@ -250,6 +260,8 @@ ParsedTrack.from_dict(track.to_dict()) == track # True
250
260
 
251
261
  ### `parse_artists(input, **options)`
252
262
 
263
+ **Docs:** [parseArtists](https://4matic.github.io/trackparse/reference/functions/#parseartistsinput-options)
264
+
253
265
  Splits a bare artist string. Takes the same options as `parse`.
254
266
 
255
267
  ```python
@@ -261,6 +273,8 @@ from trackparse import parse_artists
261
273
 
262
274
  ### `format_track(track, **options)`
263
275
 
276
+ **Docs:** [format](https://4matic.github.io/trackparse/reference/functions/#formattrack-options) · [Formatting](https://4matic.github.io/trackparse/guides/formatting/)
277
+
264
278
  Renders a track back to a string.
265
279
 
266
280
  | Option | Default | |
@@ -290,6 +304,8 @@ format_track(track, position=True)
290
304
 
291
305
  ### `normalize(s)`
292
306
 
307
+ **Docs:** [normalize](https://4matic.github.io/trackparse/reference/functions/#normalizeinput)
308
+
293
309
  The Unicode cleanup every function runs first: NFC, invisible characters, and dash, bracket and
294
310
  quote variants.
295
311
 
@@ -302,6 +318,8 @@ normalize("Noisia​ – Stigma (VIP)")
302
318
 
303
319
  ### `all_artists(track)`
304
320
 
321
+ **Docs:** [allArtists](https://4matic.github.io/trackparse/reference/functions/#allartiststrack)
322
+
305
323
  Credits plus every remixer, deduplicated.
306
324
 
307
325
  ```python
@@ -313,6 +331,8 @@ from trackparse import all_artists
313
331
 
314
332
  ### `create_parser(**options)`
315
333
 
334
+ **Docs:** [createParser](https://4matic.github.io/trackparse/reference/functions/#createparseroptions)
335
+
316
336
  Compiles the options once, for many calls. Per-call options are merged over the parser's: a value
317
337
  passed per call replaces the base one (`known_artists` included), `None` leaves it alone, and
318
338
  `keywords` dicts are merged.
@@ -335,6 +355,8 @@ released together and always share one version number.
335
355
 
336
356
  ## Modes
337
357
 
358
+ **Docs:** [Modes](https://4matic.github.io/trackparse/guides/modes/)
359
+
338
360
  | Mode | For | Adds |
339
361
  |---|---|---|
340
362
  | `clean` | Store metadata, tags | Only a spaced dash separates artist and title |
@@ -358,6 +380,8 @@ parse("Shelter", mode="youtube", uploader="Porter Robinson").artists
358
380
 
359
381
  ## Tracklists
360
382
 
383
+ **Docs:** [Tracklists](https://4matic.github.io/trackparse/guides/tracklists/) · [Editing tracklists](https://4matic.github.io/trackparse/guides/editing-tracklists/)
384
+
361
385
  `trackparse.tracklist` parses a whole tracklist, one entry per line, and checks it. Each entry
362
386
  keeps its line exactly as written plus its own line ending, so `"".join(e.raw + e.eol for e in
363
387
  tl.entries)` is always the input, CRLF or not.
@@ -437,7 +461,7 @@ apply_edits(text, edits, *, <same options>) -> EditResult
437
461
  | Result | Fields |
438
462
  |---|---|
439
463
  | `Tracklist` | `spec_version`, `entries`, `warnings`, `stats` |
440
- | `TracklistEntry` | `line` (1-based), `kind` (`track`, `marker`, `header`, `link`, `note`, `blank`), `eol`, `raw`, `parts`, `time`, `track` (`ParsedTrack` or `None`) |
464
+ | `TracklistEntry` | `line` (1-based), `kind` (`track`, `marker`, `header`, `link`, `note`, `blank`), `eol`, `raw`, `parts`, `time`, `track` (`ParsedTrack` or `None`), `layered` (the track text after any time starts with `w/`) |
441
465
  | `Parts` | `lead`, `time`, `gap`, `rest`, joined they give `raw` |
442
466
  | `TracklistWarning` | `code`, `line`, `severity` (`info` or `warning`), `suggestion` |
443
467
  | `TimeValue` | `raw`, `seconds` (`entry.time`, or `None` on untimed lines) |
@@ -445,6 +469,13 @@ apply_edits(text, edits, *, <same options>) -> EditResult
445
469
  | `Stats` | `lines`, `tracks`, `markers`, `timed`, `first_time`, `last_time` |
446
470
  | `EditResult` | `text`, `changed`, `warnings` (`EditWarning(code, edit, line)`) |
447
471
 
472
+ Lists pasted with list bullets or Markdown emphasis (`* 14:21 Artist - Title`,
473
+ `**[**14:21**] Artist - Title**`) read like plain lines; the decoration stays in `raw` and `parts`.
474
+ A line whose track text (after any time) starts with `w/` is `layered`: a track played over the
475
+ previous one
476
+ ([Markdown](https://4matic.github.io/trackparse/guides/tracklists/#markdown),
477
+ [layered tracks](https://4matic.github.io/trackparse/guides/tracklists/#layered-tracks)).
478
+
448
479
  Warning codes: `time-missing`, `time-out-of-order`, `time-duplicate`, `no-start-at-zero`,
449
480
  `time-after-audio-end` (needs `audio_seconds`), `invalid-time`, `position-gap`,
450
481
  `position-duplicate`, `line-unparsed`. Edits: `set-time {line, seconds}`, `shift {seconds, lines? | from?}`
@@ -467,6 +498,8 @@ parse("Electrosoul System - Liquid Metal", mode="tracklist").title
467
498
 
468
499
  ## Typing
469
500
 
501
+ **Docs:** [Types](https://4matic.github.io/trackparse/reference/parse/#types)
502
+
470
503
  The package ships `py.typed` and passes `mypy --strict`. `ParsedTrack`, `Artist`, `Version`,
471
504
  `Junk`, `Flags`, `Position` and `Timestamp` are frozen dataclasses with snake_case attributes, and
472
505
  so are the tracklist results in `trackparse.tracklist`.
@@ -495,7 +528,7 @@ same spec rule IDs. Behaviour changes start in the spec, not here: see
495
528
 
496
529
  ## Links
497
530
 
498
- - [Documentation](https://4matic.github.io/trackparse/), with a [Python guide](https://4matic.github.io/trackparse/guides/python/) and a
531
+ - [Documentation](https://4matic.github.io/trackparse/), with every example in Python and JavaScript, and a
499
532
  [playground](https://4matic.github.io/trackparse/playground/) that runs the parser in your browser
500
533
  - [Spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md)
501
534
  - [Project README](https://github.com/4matic/trackparse#readme)
@@ -5,7 +5,7 @@
5
5
  [![python](https://img.shields.io/github/actions/workflow/status/4matic/trackparse/python.yml?branch=main&label=tests&logo=github)](https://github.com/4matic/trackparse/actions/workflows/python.yml)
6
6
  [![license](https://img.shields.io/badge/license-MIT-blue)](https://github.com/4matic/trackparse/blob/main/LICENSE)
7
7
 
8
- **[Documentation](https://4matic.github.io/trackparse/)** · **[Python guide](https://4matic.github.io/trackparse/guides/python/)** · **[Playground](https://4matic.github.io/trackparse/playground/)**
8
+ **[Documentation](https://4matic.github.io/trackparse/)** · **[Getting started](https://4matic.github.io/trackparse/start/getting-started/)** · **[Playground](https://4matic.github.io/trackparse/playground/)**
9
9
 
10
10
  **Turn messy track names into structured data: who made it, who's featured, who remixed it, and
11
11
  what's just noise.**
@@ -21,7 +21,7 @@ doesn't recognise stays in the title, word for word.
21
21
 
22
22
  This is the Python port of [trackparse](https://github.com/4matic/trackparse). Its behaviour is
23
23
  defined by a [shared spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md) and pinned
24
- by 12,965 fixtures. It passes all of them, and for every fixture input its output is byte-identical
24
+ by 13,124 fixtures. It passes all of them, and for every fixture input its output is byte-identical
25
25
  to the JavaScript package.
26
26
 
27
27
  - Python 3.9 to 3.14
@@ -39,6 +39,8 @@ uv add trackparse
39
39
 
40
40
  ## Quick start
41
41
 
42
+ **Docs:** [Getting started](https://4matic.github.io/trackparse/start/getting-started/)
43
+
42
44
  ```python
43
45
  from trackparse import format_track, parse
44
46
 
@@ -110,6 +112,8 @@ print(json.dumps(track.to_dict(), indent=2))
110
112
 
111
113
  ## API
112
114
 
115
+ **Docs:** [Other functions](https://4matic.github.io/trackparse/reference/functions/)
116
+
113
117
  ```python
114
118
  parse(input, *, mode="auto", uploader=None, known_artists=(), split_and="auto", keywords=None) -> ParsedTrack
115
119
  parse_artists(input, *, <same options>) -> list[Artist]
@@ -123,6 +127,8 @@ SPEC_VERSION, __version__
123
127
 
124
128
  ### `parse(input, **options)`
125
129
 
130
+ **Docs:** [parse and ParsedTrack](https://4matic.github.io/trackparse/reference/parse/) · [Options](https://4matic.github.io/trackparse/reference/options/)
131
+
126
132
  | Option | Default | |
127
133
  |---|---|---|
128
134
  | `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, `"tracklist"`, or `"auto"` to detect from the string |
@@ -163,6 +169,8 @@ the defaults. Passing something that isn't a `str` raises `TypeError`.
163
169
 
164
170
  ### `ParsedTrack`
165
171
 
172
+ **Docs:** [ParsedTrack fields](https://4matic.github.io/trackparse/reference/parse/#parsedtrack)
173
+
166
174
  | Attribute | Type | |
167
175
  |---|---|---|
168
176
  | `input` | `str` | The original string, untouched |
@@ -205,6 +213,8 @@ Version types, junk kinds and warnings are listed in the
205
213
 
206
214
  ### `to_dict()` and `from_dict()`
207
215
 
216
+ **Docs:** [JSON and Python objects](https://4matic.github.io/trackparse/reference/parse/#json-and-python-objects)
217
+
208
218
  Every result class has `to_dict()`, which returns plain dicts and lists matching
209
219
  [`parsed-track.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/parsed-track.schema.json)
210
220
  (`fullTitle`, `unknownArtist`, ...). With `json.dumps(track.to_dict(), separators=(",", ":"),
@@ -217,6 +227,8 @@ ParsedTrack.from_dict(track.to_dict()) == track # True
217
227
 
218
228
  ### `parse_artists(input, **options)`
219
229
 
230
+ **Docs:** [parseArtists](https://4matic.github.io/trackparse/reference/functions/#parseartistsinput-options)
231
+
220
232
  Splits a bare artist string. Takes the same options as `parse`.
221
233
 
222
234
  ```python
@@ -228,6 +240,8 @@ from trackparse import parse_artists
228
240
 
229
241
  ### `format_track(track, **options)`
230
242
 
243
+ **Docs:** [format](https://4matic.github.io/trackparse/reference/functions/#formattrack-options) · [Formatting](https://4matic.github.io/trackparse/guides/formatting/)
244
+
231
245
  Renders a track back to a string.
232
246
 
233
247
  | Option | Default | |
@@ -257,6 +271,8 @@ format_track(track, position=True)
257
271
 
258
272
  ### `normalize(s)`
259
273
 
274
+ **Docs:** [normalize](https://4matic.github.io/trackparse/reference/functions/#normalizeinput)
275
+
260
276
  The Unicode cleanup every function runs first: NFC, invisible characters, and dash, bracket and
261
277
  quote variants.
262
278
 
@@ -269,6 +285,8 @@ normalize("Noisia​ – Stigma (VIP)")
269
285
 
270
286
  ### `all_artists(track)`
271
287
 
288
+ **Docs:** [allArtists](https://4matic.github.io/trackparse/reference/functions/#allartiststrack)
289
+
272
290
  Credits plus every remixer, deduplicated.
273
291
 
274
292
  ```python
@@ -280,6 +298,8 @@ from trackparse import all_artists
280
298
 
281
299
  ### `create_parser(**options)`
282
300
 
301
+ **Docs:** [createParser](https://4matic.github.io/trackparse/reference/functions/#createparseroptions)
302
+
283
303
  Compiles the options once, for many calls. Per-call options are merged over the parser's: a value
284
304
  passed per call replaces the base one (`known_artists` included), `None` leaves it alone, and
285
305
  `keywords` dicts are merged.
@@ -302,6 +322,8 @@ released together and always share one version number.
302
322
 
303
323
  ## Modes
304
324
 
325
+ **Docs:** [Modes](https://4matic.github.io/trackparse/guides/modes/)
326
+
305
327
  | Mode | For | Adds |
306
328
  |---|---|---|
307
329
  | `clean` | Store metadata, tags | Only a spaced dash separates artist and title |
@@ -325,6 +347,8 @@ parse("Shelter", mode="youtube", uploader="Porter Robinson").artists
325
347
 
326
348
  ## Tracklists
327
349
 
350
+ **Docs:** [Tracklists](https://4matic.github.io/trackparse/guides/tracklists/) · [Editing tracklists](https://4matic.github.io/trackparse/guides/editing-tracklists/)
351
+
328
352
  `trackparse.tracklist` parses a whole tracklist, one entry per line, and checks it. Each entry
329
353
  keeps its line exactly as written plus its own line ending, so `"".join(e.raw + e.eol for e in
330
354
  tl.entries)` is always the input, CRLF or not.
@@ -404,7 +428,7 @@ apply_edits(text, edits, *, <same options>) -> EditResult
404
428
  | Result | Fields |
405
429
  |---|---|
406
430
  | `Tracklist` | `spec_version`, `entries`, `warnings`, `stats` |
407
- | `TracklistEntry` | `line` (1-based), `kind` (`track`, `marker`, `header`, `link`, `note`, `blank`), `eol`, `raw`, `parts`, `time`, `track` (`ParsedTrack` or `None`) |
431
+ | `TracklistEntry` | `line` (1-based), `kind` (`track`, `marker`, `header`, `link`, `note`, `blank`), `eol`, `raw`, `parts`, `time`, `track` (`ParsedTrack` or `None`), `layered` (the track text after any time starts with `w/`) |
408
432
  | `Parts` | `lead`, `time`, `gap`, `rest`, joined they give `raw` |
409
433
  | `TracklistWarning` | `code`, `line`, `severity` (`info` or `warning`), `suggestion` |
410
434
  | `TimeValue` | `raw`, `seconds` (`entry.time`, or `None` on untimed lines) |
@@ -412,6 +436,13 @@ apply_edits(text, edits, *, <same options>) -> EditResult
412
436
  | `Stats` | `lines`, `tracks`, `markers`, `timed`, `first_time`, `last_time` |
413
437
  | `EditResult` | `text`, `changed`, `warnings` (`EditWarning(code, edit, line)`) |
414
438
 
439
+ Lists pasted with list bullets or Markdown emphasis (`* 14:21 Artist - Title`,
440
+ `**[**14:21**] Artist - Title**`) read like plain lines; the decoration stays in `raw` and `parts`.
441
+ A line whose track text (after any time) starts with `w/` is `layered`: a track played over the
442
+ previous one
443
+ ([Markdown](https://4matic.github.io/trackparse/guides/tracklists/#markdown),
444
+ [layered tracks](https://4matic.github.io/trackparse/guides/tracklists/#layered-tracks)).
445
+
415
446
  Warning codes: `time-missing`, `time-out-of-order`, `time-duplicate`, `no-start-at-zero`,
416
447
  `time-after-audio-end` (needs `audio_seconds`), `invalid-time`, `position-gap`,
417
448
  `position-duplicate`, `line-unparsed`. Edits: `set-time {line, seconds}`, `shift {seconds, lines? | from?}`
@@ -434,6 +465,8 @@ parse("Electrosoul System - Liquid Metal", mode="tracklist").title
434
465
 
435
466
  ## Typing
436
467
 
468
+ **Docs:** [Types](https://4matic.github.io/trackparse/reference/parse/#types)
469
+
437
470
  The package ships `py.typed` and passes `mypy --strict`. `ParsedTrack`, `Artist`, `Version`,
438
471
  `Junk`, `Flags`, `Position` and `Timestamp` are frozen dataclasses with snake_case attributes, and
439
472
  so are the tracklist results in `trackparse.tracklist`.
@@ -462,7 +495,7 @@ same spec rule IDs. Behaviour changes start in the spec, not here: see
462
495
 
463
496
  ## Links
464
497
 
465
- - [Documentation](https://4matic.github.io/trackparse/), with a [Python guide](https://4matic.github.io/trackparse/guides/python/) and a
498
+ - [Documentation](https://4matic.github.io/trackparse/), with every example in Python and JavaScript, and a
466
499
  [playground](https://4matic.github.io/trackparse/playground/) that runs the parser in your browser
467
500
  - [Spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md)
468
501
  - [Project README](https://github.com/4matic/trackparse#readme)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "trackparse"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  description = "Spec-driven parser for music track titles and tracklists: artists, feat, remixes, versions, junk, positions, times."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -45,7 +45,7 @@ dependencies = []
45
45
 
46
46
  [project.urls]
47
47
  Homepage = "https://4matic.github.io/trackparse/"
48
- Documentation = "https://4matic.github.io/trackparse/guides/python/"
48
+ Documentation = "https://4matic.github.io/trackparse/start/getting-started/"
49
49
  Repository = "https://github.com/4matic/trackparse"
50
50
  Issues = "https://github.com/4matic/trackparse/issues"
51
51
  Changelog = "https://github.com/4matic/trackparse/blob/main/python/CHANGELOG.md"
@@ -33,7 +33,7 @@ from .types import (
33
33
  Warning,
34
34
  )
35
35
 
36
- __version__ = "0.3.2" # x-release-please-version
36
+ __version__ = "0.4.0" # x-release-please-version
37
37
 
38
38
  __all__ = [
39
39
  "SPEC_VERSION",
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  from typing import Any
7
7
 
8
- SPEC_VERSION = "0.3.2" # x-release-please-version
8
+ SPEC_VERSION = "0.4.0" # x-release-please-version
9
9
 
10
10
  # spec/data/descriptors.json
11
11
  DESCRIPTORS_DATA: dict[str, Any] = {
@@ -350,6 +350,13 @@ JUNK_DATA: dict[str, Any] = {
350
350
  ],
351
351
  }
352
352
 
353
+ # spec/data/layered-words.json
354
+ LAYERED_WORDS_DATA: dict[str, Any] = {
355
+ "words": [
356
+ "w/",
357
+ ],
358
+ }
359
+
353
360
  # spec/data/marker-words.json
354
361
  MARKER_WORDS_DATA: dict[str, Any] = {
355
362
  "words": [
@@ -0,0 +1,86 @@
1
+ """R13.12 Markdown emphasis markers in a tracklist line (js/src/tracklist/emphasis.ts)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from ._words import char_at, is_whitespace
8
+
9
+
10
+ def _run_length(s: str, at: int) -> int:
11
+ """Length of the maximal run of ``*`` or ``_`` at ``at`` (0 when there is none)."""
12
+ ch = char_at(s, at)
13
+ j = at
14
+ if ch == "*" or ch == "_":
15
+ n = len(s)
16
+ while j < n and s[j] == ch:
17
+ j += 1
18
+ return j - at
19
+
20
+
21
+ def emphasis_run(s: str, at: int) -> int:
22
+ """R13.2.1 / R13.2.3: the length of an emphasis run at ``at`` (a maximal run of 1 or 2 ``*``
23
+ or ``_``), else 0. Used next to the time, where runs are read by position, not paired."""
24
+ n = _run_length(s, at)
25
+ return n if n <= 2 else 0
26
+
27
+
28
+ def _is_outer(ch: Optional[str]) -> bool:
29
+ """R13.12.2 outer side: line edge, R0.2 whitespace, a bracket, or a ``*`` / ``_``."""
30
+ return ch is None or is_whitespace(ch) or ch in "[]()*_"
31
+
32
+
33
+ def _is_inner(ch: Optional[str]) -> bool:
34
+ """R13.12.2 inner side: a character that is not R0.2 whitespace."""
35
+ return ch is not None and not is_whitespace(ch)
36
+
37
+
38
+ def without_markers(raw: str, frm: int, open_run: Optional[str] = None) -> str:
39
+ """R13.12: ``raw[frm:]`` (the line's ``rest``) without its emphasis markers. A run of 1 or 2
40
+ ``*`` or ``_`` closes when the character before it is an inner side and the one after it an
41
+ outer side, and opens the other way round. A closer pairs with the nearest opener of the same
42
+ characters; that opener and every opener after it leave the stack. One stack per run keeps
43
+ this linear. Runs before ``frm`` (lead and gap) never pair, except that ``open_run``, a lead
44
+ run the gap left open (R13.12.4), pairs with a closing run of the same character at the end
45
+ of the line."""
46
+ n = len(raw)
47
+ marked = [False] * n
48
+ stacks: dict[str, list[int]] = {"*": [], "**": [], "_": [], "__": []}
49
+ i = frm
50
+ while i < n:
51
+ length = _run_length(raw, i) or 1
52
+ own = stacks.get(raw[i : i + length])
53
+ before = raw[i - 1] if i > 0 else None
54
+ after = char_at(raw, i + length)
55
+ at: Optional[int] = None
56
+ if own and _is_inner(before) and _is_outer(after):
57
+ at = own.pop()
58
+ if at is not None:
59
+ for m in range(length):
60
+ marked[at + m] = True
61
+ marked[i + m] = True
62
+ for st in stacks.values():
63
+ while st and st[-1] > at:
64
+ st.pop()
65
+ elif own is not None and _is_outer(before) and _is_inner(after):
66
+ own.append(i)
67
+ i += length
68
+ # R13.12.4: the last run of the line, after which there is only R0.2 whitespace.
69
+ end = n
70
+ while end > frm and is_whitespace(raw[end - 1]):
71
+ end -= 1
72
+ start = end
73
+ while start > frm and raw[start - 1] == open_run:
74
+ start -= 1
75
+ if (
76
+ open_run is not None
77
+ and end - start <= 2
78
+ and start > frm
79
+ and raw[start - 1] != open_run
80
+ and _is_inner(raw[start - 1])
81
+ ):
82
+ m = start
83
+ while m < end and not marked[m]:
84
+ marked[m] = True
85
+ m += 1
86
+ return "".join(raw[k] for k in range(frm, n) if not marked[k])
@@ -4,12 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Optional
6
6
 
7
- from ._data import HEADER_WORDS_DATA, MARKER_WORDS_DATA
7
+ from ._data import HEADER_WORDS_DATA, LAYERED_WORDS_DATA, MARKER_WORDS_DATA
8
8
  from ._tables import _str_list
9
9
  from ._words import PhraseTable, ascii_lower, in_vocab, is_whitespace, split_words
10
10
  from .types import ParsedTrack
11
11
 
12
12
  _HEADER_WORDS: frozenset[str] = frozenset(HEADER_WORDS_DATA["words"])
13
+ _LAYERED_WORDS: frozenset[str] = frozenset(LAYERED_WORDS_DATA["words"])
13
14
  _default_markers: Optional[PhraseTable[bool]] = None
14
15
 
15
16
 
@@ -63,6 +64,24 @@ def marker_words(text: str) -> list[str]:
63
64
  return out
64
65
 
65
66
 
67
+ def layered_prefix(text: str) -> int:
68
+ """R13.13: the length of a layered prefix at the start of the line text: R0.2 whitespace, a
69
+ word of ``data/layered-words.json`` (word key, R0.3), then R0.2 whitespace, with more text
70
+ after it. 0 when there is none."""
71
+ n = len(text)
72
+ i = 0
73
+ while i < n and is_whitespace(text[i]):
74
+ i += 1
75
+ j = i
76
+ while j < n and not is_whitespace(text[j]):
77
+ j += 1
78
+ if j in (i, n) or not in_vocab(text[i:j], _LAYERED_WORDS):
79
+ return 0
80
+ while j < n and is_whitespace(text[j]):
81
+ j += 1
82
+ return j if j < n else 0
83
+
84
+
66
85
  def is_blank(raw: str) -> bool:
67
86
  """R13.3.1"""
68
87
  return all(is_whitespace(c) for c in raw)
@@ -13,6 +13,7 @@ from ._tracklist_kinds import (
13
13
  is_blank,
14
14
  is_header,
15
15
  is_link,
16
+ layered_prefix,
16
17
  marker_table,
17
18
  )
18
19
  from ._tracklist_options import ResolvedOptions
@@ -30,11 +31,13 @@ class _Row:
30
31
  "blank",
31
32
  "candidate",
32
33
  "dash",
34
+ "layered",
33
35
  "line",
34
36
  "marker",
35
37
  "probe",
36
38
  "scan",
37
39
  "separated",
40
+ "text",
38
41
  "track",
39
42
  )
40
43
 
@@ -42,6 +45,11 @@ class _Row:
42
45
  self.line = line
43
46
  self.scan = scan
44
47
  self.blank = blank
48
+ layered = 0 if blank else layered_prefix(scan.text)
49
+ #: R13.4.1 line text: rest without emphasis markers (R13.12) and a layered prefix (R13.13).
50
+ self.text = scan.text[layered:]
51
+ #: R13.13
52
+ self.layered = layered > 0
45
53
  #: R13.4.2
46
54
  self.candidate = False
47
55
  self.dash = False
@@ -68,11 +76,12 @@ def parse_tracklist_resolved(text: str, opts: ResolvedOptions) -> Tracklist:
68
76
  rows.append(row)
69
77
  if row.blank:
70
78
  continue
71
- probe = lp.probe(scan.parts.rest)
79
+ probe = lp.probe(row.text) # R13.4.1
72
80
  row.probe = probe
73
81
  row.dash = probe.spaced_dash
74
82
  row.candidate = scan.time is not None or row.dash
75
- row.bare = bare_candidate(scan.parts.rest, probe.position) # R13.5
83
+ # R13.5 / R13.13.3: a layered line never has a bare position.
84
+ row.bare = None if row.layered else bare_candidate(row.text, probe.position)
76
85
  candidates = 0
77
86
  dashes = 0
78
87
  for r in rows:
@@ -84,15 +93,16 @@ def parse_tracklist_resolved(text: str, opts: ResolvedOptions) -> Tracklist:
84
93
  list_context = candidates > 0 and 2 * dashes >= candidates
85
94
 
86
95
  # R13.5: confirmed bare positions are removed before parsing. R13.4.1: one parse per line.
87
- confirmed = confirm_positions([r.bare for r in rows], [r.candidate for r in rows])
96
+ # R13.13.3: layered lines are not neighbours for the R13.5 search.
97
+ confirmed = confirm_positions(
98
+ [r.bare for r in rows], [r.candidate and not r.layered for r in rows]
99
+ )
88
100
  for i, r in enumerate(rows):
89
101
  if r.blank or r.probe is None:
90
102
  continue
91
103
  bare = r.bare if confirmed[i] else None
92
104
  probe = lp.probe(bare.stripped) if bare is not None else r.probe
93
- r.marker = has_marker_word(
94
- bare.stripped if bare is not None else r.scan.parts.rest, markers
95
- )
105
+ r.marker = has_marker_word(bare.stripped if bare is not None else r.text, markers)
96
106
  # R13.4.2: the R6.10 tight dash only on timed lines without a marker word.
97
107
  track = probe.finish(list_context and r.scan.time is not None and not r.marker)
98
108
  if bare is not None:
@@ -114,6 +124,7 @@ def parse_tracklist_resolved(text: str, opts: ResolvedOptions) -> Tracklist:
114
124
  parts=r.scan.parts,
115
125
  time=r.scan.time,
116
126
  track=r.track if keep else None,
127
+ layered=r.layered,
117
128
  )
118
129
  )
119
130
 
@@ -138,10 +149,9 @@ def _kind_of(r: _Row) -> EntryKind:
138
149
  marker = not r.separated and r.marker
139
150
  if r.scan.time is not None:
140
151
  return "marker" if marker else "track"
141
- rest = r.scan.parts.rest
142
- if is_link(rest):
152
+ if is_link(r.text):
143
153
  return "link"
144
- if is_header(rest):
154
+ if is_header(r.text):
145
155
  return "header"
146
156
  if marker:
147
157
  return "marker"
@@ -6,6 +6,7 @@ import math
6
6
  from typing import NamedTuple, Optional
7
7
 
8
8
  from ._digits import digit_run
9
+ from ._tracklist_emphasis import emphasis_run, without_markers
9
10
  from ._tracklist_types import Parts, TimeValue
10
11
  from ._words import char_at, is_digit, is_whitespace
11
12
 
@@ -33,6 +34,8 @@ class TimeScan(NamedTuple):
33
34
  style: Optional[TimeStyle]
34
35
  #: R13.2.4: a ``digits:digits`` shape after lead that fails R13.2.2.
35
36
  invalid: bool
37
+ #: R13.12: ``rest`` without its paired emphasis markers (the line text, before R13.13 / R13.5).
38
+ text: str
36
39
 
37
40
 
38
41
  def _is_gap_separator(ch: Optional[str]) -> bool:
@@ -49,7 +52,7 @@ def _skip_whitespace(s: str, frm: int) -> int:
49
52
 
50
53
 
51
54
  def _is_bullet(ch: Optional[str]) -> bool:
52
- """R13.2.1 bullets of a line without a time."""
55
+ """R13.2.1 bullets (before a time, or alone on a line without one)."""
53
56
  return ch == "-" or ch == "–" or ch == "—" or ch == "*" or ch == "•"
54
57
 
55
58
 
@@ -61,7 +64,20 @@ def _untimed(raw: str, invalid: bool) -> TimeScan:
61
64
  lead_end = 0
62
65
  if _is_bullet(char_at(raw, at)) and is_whitespace(char_at(raw, at + 1)):
63
66
  lead_end = _skip_whitespace(raw, at + 1)
64
- return TimeScan(Parts(raw[:lead_end], "", "", raw[lead_end:]), None, None, invalid)
67
+ return TimeScan(
68
+ Parts(raw[:lead_end], "", "", raw[lead_end:]),
69
+ None,
70
+ None,
71
+ invalid,
72
+ without_markers(raw, lead_end),
73
+ )
74
+
75
+
76
+ def _closing_run(raw: str, at: int, open_ch: Optional[str]) -> int:
77
+ """R13.2.3: a closing run for an opening run of ``open_ch`` (same character, 1 or 2 long)."""
78
+ if open_ch is None or char_at(raw, at) != open_ch:
79
+ return at
80
+ return at + emphasis_run(raw, at)
65
81
 
66
82
 
67
83
  def seconds_of(whole: int, frac: str) -> float:
@@ -113,12 +129,28 @@ def shape_seconds(parts: list[str]) -> Optional[int]:
113
129
 
114
130
  def scan_time(raw: str) -> TimeScan:
115
131
  """R13.2: lead, time and gap at the start of ``raw``; the rest is everything after."""
116
- # R13.2.1 lead: whitespace, then an optional `[` / `(`.
132
+ # R13.2.1 lead: whitespace, an optional bullet, an optional emphasis run, an optional `[` /
133
+ # `(`, then (after a bracket) another optional emphasis run. A run counts only when the next
134
+ # character is a bracket (first run) or a digit.
117
135
  i = _skip_whitespace(raw, 0)
136
+ if _is_bullet(char_at(raw, i)) and is_whitespace(char_at(raw, i + 1)):
137
+ i = _skip_whitespace(raw, i + 1)
138
+ outer_open: Optional[str] = None
139
+ n = emphasis_run(raw, i)
140
+ if n:
141
+ after = char_at(raw, i + n)
142
+ if after == "[" or after == "(" or is_digit(after):
143
+ outer_open = raw[i]
144
+ i += n
118
145
  first_ch = char_at(raw, i)
119
146
  closer = "]" if first_ch == "[" else ")" if first_ch == "(" else None
147
+ inner_open: Optional[str] = None
120
148
  if closer is not None:
121
149
  i += 1
150
+ n = emphasis_run(raw, i)
151
+ if n and is_digit(char_at(raw, i + n)):
152
+ inner_open = raw[i]
153
+ i += n
122
154
  lead_end = i
123
155
 
124
156
  # R13.2.2 time
@@ -141,13 +173,32 @@ def scan_time(raw: str) -> TimeScan:
141
173
  j += 1 + n
142
174
  time_end = j
143
175
 
144
- # R13.2.3 gap
176
+ # R13.2.3 gap: closing runs for the lead's runs (same character), around the closing bracket.
177
+ def follows_time(at: int) -> bool:
178
+ return at >= len(raw) or is_whitespace(raw[at]) or _is_gap_separator(raw[at])
179
+
180
+ # R13.12.4: a lead run the gap leaves open may pair with a run at the end of the line.
181
+ open_run: Optional[str] = None
145
182
  if closer is not None:
183
+ k = _closing_run(raw, j, inner_open)
184
+ if k == j:
185
+ open_run = inner_open
186
+ j = k
146
187
  if char_at(raw, j) != closer:
147
188
  return _untimed(raw, False)
148
189
  j += 1
149
- elif j < len(raw) and not is_whitespace(raw[j]) and not _is_gap_separator(raw[j]):
150
- return _untimed(raw, False)
190
+ k = _closing_run(raw, j, outer_open)
191
+ if k > j and follows_time(k):
192
+ j = k
193
+ elif outer_open is not None:
194
+ open_run = outer_open
195
+ else:
196
+ k = _closing_run(raw, j, outer_open)
197
+ if k == j:
198
+ open_run = outer_open
199
+ j = k
200
+ if not follows_time(j):
201
+ return _untimed(raw, False)
151
202
  j = _skip_whitespace(raw, j)
152
203
  if _is_gap_separator(char_at(raw, j)):
153
204
  j = _skip_whitespace(raw, j + 1)
@@ -181,6 +232,7 @@ def scan_time(raw: str) -> TimeScan:
181
232
  frac_digits=len(frac),
182
233
  ),
183
234
  False,
235
+ without_markers(raw, j, open_run),
184
236
  )
185
237
 
186
238
 
@@ -77,6 +77,8 @@ class TracklistEntry:
77
77
  time: Optional[TimeValue]
78
78
  #: The line's ParsedTrack (``track`` and ``marker`` entries only).
79
79
  track: Optional[ParsedTrack]
80
+ #: R13.13: the line starts with ``w/``: played over the previous track.
81
+ layered: bool
80
82
 
81
83
  def to_dict(self) -> dict[str, Any]:
82
84
  return {
@@ -87,6 +89,7 @@ class TracklistEntry:
87
89
  "parts": self.parts.to_dict(),
88
90
  "time": None if self.time is None else self.time.to_dict(),
89
91
  "track": None if self.track is None else self.track.to_dict(),
92
+ "layered": self.layered,
90
93
  }
91
94
 
92
95
 
@@ -181,7 +181,8 @@ def tracklist_warnings(
181
181
  timed = [i for i, e in enumerate(entries) if e.time is not None]
182
182
 
183
183
  for i, e in enumerate(entries):
184
- if e.kind == "track" and e.time is None and len(timed) > 0:
184
+ # R13.13.3: an untimed layered line plays over the previous track, at its time.
185
+ if e.kind == "track" and e.time is None and not e.layered and len(timed) > 0:
185
186
  add(i, "time-missing")
186
187
  if invalid[i]:
187
188
  add(i, "invalid-time")
File without changes
File without changes