puremagic 2.0.2__tar.gz → 2.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.
Files changed (138) hide show
  1. puremagic-2.1.0/.claude/skills/changelog.md +53 -0
  2. {puremagic-2.0.2 → puremagic-2.1.0}/.pre-commit-config.yaml +11 -0
  3. {puremagic-2.0.2 → puremagic-2.1.0}/CHANGELOG.md +9 -0
  4. {puremagic-2.0.2 → puremagic-2.1.0}/PKG-INFO +1 -1
  5. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/magic_data.json +1 -1
  6. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/main.py +85 -2
  7. puremagic-2.1.0/puremagic/scanners/cfbf_scanner.py +97 -0
  8. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/mpeg_audio_scanner.py +3 -3
  9. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic.egg-info/PKG-INFO +1 -1
  10. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic.egg-info/SOURCES.txt +3 -0
  11. {puremagic-2.0.2 → puremagic-2.1.0}/pyproject.toml +2 -0
  12. puremagic-2.1.0/test/resources/office/test.msg +0 -0
  13. {puremagic-2.0.2 → puremagic-2.1.0}/test/test_common_extensions.py +69 -0
  14. {puremagic-2.0.2 → puremagic-2.1.0}/uv.lock +14 -3
  15. {puremagic-2.0.2 → puremagic-2.1.0}/.coveragerc +0 -0
  16. {puremagic-2.0.2 → puremagic-2.1.0}/.github/workflows/pythonpublish.yml +0 -0
  17. {puremagic-2.0.2 → puremagic-2.1.0}/.github/workflows/tests.yml +0 -0
  18. {puremagic-2.0.2 → puremagic-2.1.0}/.gitignore +0 -0
  19. {puremagic-2.0.2 → puremagic-2.1.0}/AUTHORS.rst +0 -0
  20. {puremagic-2.0.2 → puremagic-2.1.0}/LICENSE +0 -0
  21. {puremagic-2.0.2 → puremagic-2.1.0}/MANIFEST.in +0 -0
  22. {puremagic-2.0.2 → puremagic-2.1.0}/README.rst +0 -0
  23. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/__init__.py +0 -0
  24. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/__main__.py +0 -0
  25. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/py.typed +0 -0
  26. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/__init__.py +0 -0
  27. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/hdf5_scanner.py +0 -0
  28. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/helpers.py +0 -0
  29. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/json_scanner.py +0 -0
  30. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/pdf_scanner.py +0 -0
  31. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/python_scanner.py +0 -0
  32. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/sndhdr_scanner.py +0 -0
  33. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/text_scanner.py +0 -0
  34. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/zip_scanner.py +0 -0
  35. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic.egg-info/dependency_links.txt +0 -0
  36. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic.egg-info/entry_points.txt +0 -0
  37. {puremagic-2.0.2 → puremagic-2.1.0}/puremagic.egg-info/top_level.txt +0 -0
  38. {puremagic-2.0.2 → puremagic-2.1.0}/requirements-test.txt +0 -0
  39. {puremagic-2.0.2 → puremagic-2.1.0}/scripts/parse_ftk_kessler_sigs.py +0 -0
  40. {puremagic-2.0.2 → puremagic-2.1.0}/setup.cfg +0 -0
  41. {puremagic-2.0.2 → puremagic-2.1.0}/test/__init__.py +0 -0
  42. {puremagic-2.0.2 → puremagic-2.1.0}/test/common.py +0 -0
  43. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.7z +0 -0
  44. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.big_endian.pcapng +0 -0
  45. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.bz2 +0 -0
  46. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.cb7 +0 -0
  47. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.cbt +0 -0
  48. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.cbz +0 -0
  49. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.fb2.zip +0 -0
  50. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.fbz +0 -0
  51. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.gz +0 -0
  52. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.little_endian.pcapng +0 -0
  53. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.pcapng +0 -0
  54. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.rar +0 -0
  55. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.tar +0 -0
  56. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.txt.lz4 +0 -0
  57. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.txt.zst +0 -0
  58. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.xz +0 -0
  59. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.zip +0 -0
  60. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.aac +0 -0
  61. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.aif +0 -0
  62. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.flac +0 -0
  63. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.mp3 +0 -0
  64. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.opus +0 -0
  65. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.sndr +0 -0
  66. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.wav +0 -0
  67. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_info_128k_notags.mp3 +0 -0
  68. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_3di_id3v1.mp3 +0 -0
  69. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_apev1.mp3 +0 -0
  70. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_apev1_id3v1.mp3 +0 -0
  71. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_apev2_tagplus_id3v1.mp3 +0 -0
  72. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_ext_id3v1.mp3 +0 -0
  73. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_lyrics3v2_id3v1.mp3 +0 -0
  74. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_notags.mp3 +0 -0
  75. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_tagplus_apev2_id3v1.mp3 +0 -0
  76. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_tagplus_id3v1.mp3 +0 -0
  77. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mpeg2_mp3_VBR_128k_id3v2_24.mp3 +0 -0
  78. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/fake_file +0 -0
  79. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.avif +0 -0
  80. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.bmp +0 -0
  81. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.gif +0 -0
  82. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.ico +0 -0
  83. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.jpg +0 -0
  84. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.png +0 -0
  85. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.psd +0 -0
  86. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.svg +0 -0
  87. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.tga +0 -0
  88. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.tif +0 -0
  89. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.webp +0 -0
  90. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.xcf +0 -0
  91. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test_varriant.svg +0 -0
  92. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test_varriant_2.svg +0 -0
  93. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/media/test (single).vmdk +0 -0
  94. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/media/test (split).vmdk +0 -0
  95. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/media/test.iso +0 -0
  96. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/media/test.mdf +0 -0
  97. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/media/test.swf +0 -0
  98. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.doc +0 -0
  99. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.docm +0 -0
  100. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.docx +0 -0
  101. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.dotm +0 -0
  102. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.dotx +0 -0
  103. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.eml +0 -0
  104. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.fb2 +0 -0
  105. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.odp +0 -0
  106. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.ods +0 -0
  107. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.odt +0 -0
  108. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.pdf +0 -0
  109. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.potm +0 -0
  110. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.potx +0 -0
  111. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.ppt +0 -0
  112. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.pptm +0 -0
  113. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.pptx +0 -0
  114. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.txt +0 -0
  115. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.xls +0 -0
  116. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.xlsb +0 -0
  117. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.xlsm +0 -0
  118. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.xlsx +0 -0
  119. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.xltm +0 -0
  120. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.xltx +0 -0
  121. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test_utf16le.csv +0 -0
  122. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/text_cr.txt +0 -0
  123. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/text_crlf.txt +0 -0
  124. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/text_lf.txt +0 -0
  125. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/system/test.exe +0 -0
  126. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/system/test.json +0 -0
  127. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/system/test.puremagic_multi_footer +0 -0
  128. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/system/test.py +0 -0
  129. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/system/test_list.json +0 -0
  130. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.3g2 +0 -0
  131. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.avi +0 -0
  132. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.flv +0 -0
  133. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.jxsv +0 -0
  134. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.mkv +0 -0
  135. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.mp4 +0 -0
  136. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.mpg +0 -0
  137. {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.wmv +0 -0
  138. {puremagic-2.0.2 → puremagic-2.1.0}/test/test_scanners.py +0 -0
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: changelog
3
+ description: Update the CHANGELOG.md changelog file with new entries
4
+ user_invocable: true
5
+ ---
6
+
7
+ # Changelog Skill
8
+
9
+ When updating the `CHANGELOG.md` file, follow these rules:
10
+
11
+ ## Entry Format
12
+
13
+ Each entry is a single bullet point starting with `- `:
14
+
15
+ ```
16
+ - {Verb} {description}
17
+ ```
18
+
19
+ ## Verbs and Ordering
20
+
21
+ Entries MUST use one of these four starting verbs, and MUST appear in this order within each version section:
22
+
23
+ 1. **Adding** — new features
24
+ 2. **Changing** — modifications to existing behavior
25
+ 3. **Fixing** — bug fixes
26
+ 4. **Removing** — removed features or deprecated items
27
+
28
+ ## GitHub Issue Entries
29
+
30
+ - Entries that reference a GitHub issue include the issue number after the verb: `* Fixing #725 description...`
31
+ - Within each verb group, entries WITH issue numbers come FIRST, sorted by issue number ascending (smallest to largest)
32
+ - Entries WITHOUT issue numbers follow after
33
+
34
+ ## Thanks Attribution
35
+
36
+ - When an entry references a GitHub issue, thank the issue author by their **GitHub display name** (not username)
37
+ - Look up the display name via `gh api users/{username} --jq '.name // .login'`
38
+ - Format: `(thanks to {display name})`
39
+ - If multiple people contributed (e.g., reporter and commenter with the fix), thank all of them
40
+ - The thanks attribution goes at the end of the entry
41
+
42
+ ## Example
43
+
44
+ ```
45
+ Version 1.27
46
+ ------------
47
+
48
+ - Adding #92 include py.typed in sdist (thanks to Nicholas Bollweg - bollwyvl)
49
+ - Adding #93 Improve PDF file detection, fix json description (thanks to Péter - peterekepeter)
50
+ - Adding new verbose output to command line with `-v` or `--verbose`
51
+ - Fixing #96 #86 stream does not work properly on opened small files (thanks to Felipe Lema and Andy - NebularNerd)
52
+ - Removing expected invalid WinZip signature
53
+ ```
@@ -52,6 +52,17 @@ repos:
52
52
  types: [python]
53
53
  pass_filenames: false
54
54
 
55
+ - repo: https://github.com/codespell-project/codespell
56
+ rev: v2.4.1
57
+ hooks:
58
+ - id: codespell
59
+ args: [--ignore-words-list, "addin,caf,tye,wors"]
60
+ exclude: |
61
+ (?x)^(
62
+ test/resources/|
63
+ .*\.json$
64
+ )
65
+
55
66
  - repo: https://github.com/tox-dev/pyproject-fmt
56
67
  rev: v2.16.2
57
68
  hooks:
@@ -1,6 +1,15 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
+ Version 2.1.0
5
+ -------------
6
+
7
+ - Adding #6 `from_extension()` and `magic_extension()` to look up MIME types by file extension
8
+ - Adding #119 CFBF/OLE2 scanner to distinguish Word, Excel, PowerPoint, Outlook MSG, Visio, and Publisher files (thanks to Simeon Stoykov and Andy - NebularNerd)
9
+ - Adding #139 codespell check (thanks to Christian Clauss)
10
+ - Adding `-e`/`--extension` CLI flag for extension-based MIME type lookup
11
+ - Fixing #104 WAV MIME type changed from `audio/wave` to `audio/wav` (thanks to Simon Willison and Andy - NebularNerd)
12
+
4
13
  Version 2.0.2
5
14
  -------------
6
15
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puremagic
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: Pure python implementation of magic file detection
5
5
  Author-email: Chris Griffith <chris@cdgriffith.com>
6
6
  License-Expression: MIT
@@ -100,7 +100,7 @@
100
100
  ["53434448", 8, ".sc2", "", "SimCity 2000 Map File"]
101
101
  ],
102
102
  "52494646": [
103
- ["57415645", 8, ".wav", "audio/wave", "Waveform Audio File Format"],
103
+ ["57415645", 8, ".wav", "audio/wav", "Waveform Audio File Format"],
104
104
  ["41564920", 8, ".avi", "video/avi", "Audio Video Interleave"],
105
105
  ["57454250", 8, ".webp", "image/webp", "WebP graphics file format"],
106
106
  ["41434f4e", 8, ".ani", "", "Animated cursor"],
@@ -31,17 +31,20 @@ if os.getenv("PUREMAGIC_DEEPSCAN") != "0":
31
31
  sndhdr_scanner,
32
32
  mpeg_audio_scanner,
33
33
  hdf5_scanner,
34
+ cfbf_scanner,
34
35
  )
35
36
 
36
37
  __author__ = "Chris Griffith"
37
- __version__ = "2.0.2"
38
+ __version__ = "2.1.0"
38
39
  __all__ = [
39
40
  "magic_file",
40
41
  "magic_string",
41
42
  "magic_stream",
43
+ "magic_extension",
42
44
  "from_file",
43
45
  "from_string",
44
46
  "from_stream",
47
+ "from_extension",
45
48
  "ext_from_filename",
46
49
  "PureError",
47
50
  "PureMagic",
@@ -390,6 +393,51 @@ def magic_stream(
390
393
  return info
391
394
 
392
395
 
396
+ def from_extension(extension: str, mime: bool = True) -> str:
397
+ """Look up a file type by its extension and return the MIME type or name.
398
+
399
+ :param extension: file extension with or without leading dot (e.g. ".pdf" or "pdf")
400
+ :param mime: Return mime type (default True), or human-readable name if False
401
+ :return: MIME type string or name
402
+ :raises PureError: if no match is found
403
+ """
404
+ ext = extension.strip().lower()
405
+ if not ext.startswith("."):
406
+ ext = f".{ext}"
407
+
408
+ matches = []
409
+ for entry in chain(magic_header_array, magic_footer_array, extension_only_array):
410
+ if entry.extension == ext:
411
+ matches.append(entry)
412
+
413
+ if not matches:
414
+ raise PureError(f"Could not find extension {ext!r} in magic database")
415
+
416
+ # Prefer entries with longer byte_match (more specific signatures)
417
+ matches.sort(key=lambda x: len(x.byte_match), reverse=True)
418
+ best = matches[0]
419
+ return best.mime_type if mime else best.name
420
+
421
+
422
+ def magic_extension(extension: str) -> list[PureMagicWithConfidence]:
423
+ """Return all matches for a given file extension.
424
+
425
+ :param extension: file extension with or without leading dot (e.g. ".pdf" or "pdf")
426
+ :return: list of PureMagicWithConfidence sorted by confidence descending
427
+ """
428
+ ext = extension.strip().lower()
429
+ if not ext.startswith("."):
430
+ ext = f".{ext}"
431
+
432
+ matches = []
433
+ for entry in chain(magic_header_array, magic_footer_array, extension_only_array):
434
+ if entry.extension == ext:
435
+ con = 0.8 if len(entry.byte_match) >= 9 else float(f"0.{len(entry.byte_match)}")
436
+ matches.append(PureMagicWithConfidence(confidence=con, **entry._asdict()))
437
+
438
+ return sorted(matches, key=lambda x: (x.confidence, len(x.byte_match)), reverse=True)
439
+
440
+
393
441
  def single_deep_scan(
394
442
  bytes_match: bytes | bytearray | None,
395
443
  filename: os.PathLike | str,
@@ -417,6 +465,8 @@ def single_deep_scan(
417
465
  result = mpeg_audio_scanner.main(filename, head, foot)
418
466
  if result and result.confidence > confidence:
419
467
  return result
468
+ case cfbf_scanner.match_bytes | cfbf_scanner.match_bytes_short:
469
+ return cfbf_scanner.main(filename, head, foot)
420
470
 
421
471
  if eml_result := text_scanner.eml_check(head):
422
472
  return eml_result
@@ -544,10 +594,43 @@ def command_line_entry(*args):
544
594
  help="Return the mime type instead of file type",
545
595
  )
546
596
  parser.add_argument("-v", "--verbose", action="store_true", dest="verbose", help="Print verbose output")
547
- parser.add_argument("files", nargs="+", type=Path)
597
+ parser.add_argument(
598
+ "-e",
599
+ "--extension",
600
+ dest="extension",
601
+ help="Look up MIME type for a file extension (e.g. pdf or .pdf)",
602
+ )
603
+ parser.add_argument("files", nargs="*", type=Path)
548
604
  parser.add_argument("--version", action="version", version=puremagic.__version__)
549
605
  args = parser.parse_args(args if args else sys.argv[1:])
550
606
 
607
+ if args.extension:
608
+ if args.verbose:
609
+ matches = magic_extension(args.extension)
610
+ if not matches:
611
+ print(f"No matches found for extension '{args.extension}'")
612
+ else:
613
+ print(f"Total Possible Matches: {len(matches)}")
614
+ for i, result in enumerate(matches):
615
+ if i == 0:
616
+ print("\n\tBest Match")
617
+ else:
618
+ print(f"\tAlternative Match #{i}")
619
+ print(f"\tName: {result.name}")
620
+ print(f"\tConfidence: {int(result.confidence * 100)}%")
621
+ print(f"\tExtension: {result.extension}")
622
+ print(f"\tMime Type: {result.mime_type}")
623
+ print(f"\tByte Match: {result.byte_match}\n")
624
+ else:
625
+ try:
626
+ print(from_extension(args.extension, mime=not args.mime))
627
+ except PureError as e:
628
+ print(str(e))
629
+ return
630
+
631
+ if not args.files:
632
+ parser.error("the following arguments are required: files (or use -e/--extension)")
633
+
551
634
  for fn in args.files:
552
635
  if not fn.exists():
553
636
  print(f"File '{fn}' does not exist!")
@@ -0,0 +1,97 @@
1
+ import os
2
+ import struct
3
+
4
+ from puremagic.scanners.helpers import Match
5
+
6
+ match_bytes = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
7
+ match_bytes_short = b"\xd0\xcf\x11\xe0"
8
+
9
+ # Stream names that identify specific CFBF-based formats, checked in priority order.
10
+ # Each entry: (stream_name, extension, name, mime_type)
11
+ # Using startswith for prefix matching where noted.
12
+ _STREAM_MATCHES = [
13
+ ("__nameid_version1.0", ".msg", "Outlook Message", "application/vnd.ms-outlook"),
14
+ ("PowerPoint Document", ".ppt", "PowerPoint Presentation", "application/vnd.ms-powerpoint"),
15
+ ("Current User", ".ppt", "PowerPoint Presentation", "application/vnd.ms-powerpoint"),
16
+ ("Workbook", ".xls", "Excel Spreadsheet", "application/vnd.ms-excel"),
17
+ ("Book", ".xls", "Excel Spreadsheet", "application/vnd.ms-excel"),
18
+ ("WordDocument", ".doc", "Word Document", "application/msword"),
19
+ ("VisioDocument", ".vsd", "Visio Drawing", "application/x-visio"),
20
+ ("Quill", ".pub", "Publisher Document", "application/x-mspublisher"),
21
+ ]
22
+
23
+ _PREFIX_MATCHES = [
24
+ ("__substg1.0_", ".msg", "Outlook Message", "application/vnd.ms-outlook"),
25
+ ]
26
+
27
+
28
+ def _extract_stream_names(dir_data: bytes) -> set[str]:
29
+ """Parse CFBF directory entries and return the set of stream/storage names."""
30
+ names: set[str] = set()
31
+ for i in range(0, len(dir_data), 128):
32
+ entry = dir_data[i : i + 128]
33
+ if len(entry) < 128:
34
+ break
35
+ name_size = struct.unpack_from("<H", entry, 64)[0]
36
+ if name_size < 2 or name_size > 64:
37
+ continue
38
+ obj_type = entry[66]
39
+ # obj_type: 0=unknown, 1=storage, 2=stream, 5=root
40
+ if obj_type not in (1, 2, 5):
41
+ continue
42
+ name = entry[: name_size - 2].decode("utf-16-le", errors="ignore")
43
+ if name:
44
+ names.add(name)
45
+ return names
46
+
47
+
48
+ def _identify_format(stream_names: set[str]) -> Match | None:
49
+ """Match stream names against known CFBF format signatures."""
50
+ # Check prefix matches first (e.g. __substg1.0_ for MSG)
51
+ for name in stream_names:
52
+ for prefix, ext, fmt_name, mime in _PREFIX_MATCHES:
53
+ if name.startswith(prefix):
54
+ return Match(ext, fmt_name, mime)
55
+
56
+ # Check exact stream name matches in priority order
57
+ for stream_name, ext, fmt_name, mime in _STREAM_MATCHES:
58
+ if stream_name in stream_names:
59
+ return Match(ext, fmt_name, mime)
60
+
61
+ return None
62
+
63
+
64
+ def main(file_path: os.PathLike, head: bytes, foot: bytes) -> Match | None:
65
+ if len(head) < 76:
66
+ return None
67
+
68
+ # Verify magic bytes
69
+ if head[:8] != match_bytes:
70
+ if head[:4] != match_bytes_short:
71
+ return None
72
+
73
+ # Parse CFBF header
74
+ sector_shift = struct.unpack_from("<H", head, 30)[0]
75
+ if sector_shift not in (9, 12):
76
+ return None
77
+ sector_size = 1 << sector_shift
78
+
79
+ first_dir_secid = struct.unpack_from("<i", head, 48)[0]
80
+ if first_dir_secid < 0:
81
+ return None
82
+
83
+ # Directory sector offset: header occupies first sector_size bytes
84
+ dir_offset = (first_dir_secid + 1) * sector_size
85
+
86
+ try:
87
+ with open(file_path, "rb") as f:
88
+ f.seek(dir_offset)
89
+ dir_data = f.read(sector_size)
90
+ except (OSError, ValueError):
91
+ return None
92
+
93
+ if not dir_data:
94
+ return None
95
+
96
+ stream_names = _extract_stream_names(dir_data)
97
+ return _identify_format(stream_names)
@@ -228,7 +228,7 @@ class EndOfFileTags:
228
228
  assert self.foot_string is not None
229
229
  tag_size = 128
230
230
  tag_plus_size = 227
231
- speed_loc = 184 # Speed byte posistion in tag
231
+ speed_loc = 184 # Speed byte position in tag
232
232
  combined_size = tag_plus_size + tag_size
233
233
 
234
234
  if self.foot_size < combined_size: # TAG+ + ID3v1
@@ -466,7 +466,7 @@ class EndOfFileTags:
466
466
  b"MCN",
467
467
  )
468
468
  id3v1_size = 128
469
- max_tag_size = 1048576 # This is a pratical scan range of 1MB, Ape v2 in theory can be 4GB
469
+ max_tag_size = 1048576 # This is a practical scan range of 1MB, Ape v2 in theory can be 4GB
470
470
  combined_size = (max_tag_size + id3v1_size) if id3v1 else max_tag_size
471
471
 
472
472
  if self.foot_size < combined_size: # APE OR APE + ID3v1
@@ -493,7 +493,7 @@ class EndOfFileTags:
493
493
  f_size = struct.unpack("<I", self.foot_string[end_tag_start + 12 : end_tag_start + 16])[0]
494
494
 
495
495
  if f_version == 1000: # v1
496
- # Reach first key in tag, in APE the tag key name is preceeded by 8 bytes associated with it.
496
+ # Reach first key in tag, in APE the tag key name is preceded by 8 bytes associated with it.
497
497
  first_key = combined_size - ((end_size + f_size) - apextag_size) + 8
498
498
  # APE does not care about case for tag keys, but Title and UPPER are commonly accepted as standard
499
499
  if not self.foot_string[first_key:].title().startswith(common_ape_keys):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puremagic
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: Pure python implementation of magic file detection
5
5
  Author-email: Chris Griffith <chris@cdgriffith.com>
6
6
  License-Expression: MIT
@@ -10,6 +10,7 @@ pyproject.toml
10
10
  requirements-test.txt
11
11
  setup.cfg
12
12
  uv.lock
13
+ .claude/skills/changelog.md
13
14
  .github/workflows/pythonpublish.yml
14
15
  .github/workflows/tests.yml
15
16
  puremagic/__init__.py
@@ -23,6 +24,7 @@ puremagic.egg-info/dependency_links.txt
23
24
  puremagic.egg-info/entry_points.txt
24
25
  puremagic.egg-info/top_level.txt
25
26
  puremagic/scanners/__init__.py
27
+ puremagic/scanners/cfbf_scanner.py
26
28
  puremagic/scanners/hdf5_scanner.py
27
29
  puremagic/scanners/helpers.py
28
30
  puremagic/scanners/json_scanner.py
@@ -99,6 +101,7 @@ test/resources/office/test.dotm
99
101
  test/resources/office/test.dotx
100
102
  test/resources/office/test.eml
101
103
  test/resources/office/test.fb2
104
+ test/resources/office/test.msg
102
105
  test/resources/office/test.odp
103
106
  test/resources/office/test.ods
104
107
  test/resources/office/test.odt
@@ -30,6 +30,7 @@ scripts.puremagic = "puremagic.main:command_line_entry"
30
30
 
31
31
  [dependency-groups]
32
32
  dev = [
33
+ "codespell>=2.4.2",
33
34
  "coverage>=7.13.4",
34
35
  "poethepoet>=0.42.1",
35
36
  "pre-commit>=4.5.1",
@@ -108,3 +109,4 @@ tasks.test = "pytest --cov=puremagic test/"
108
109
  tasks.lint = "ruff check --fix"
109
110
  tasks.format = "ruff format"
110
111
  tasks.typecheck = "ty check"
112
+ tasks.spellcheck = "codespell --ignore-words-list=addin,caf,tye,wors --skip='*.json' puremagic/"
@@ -220,6 +220,75 @@ def test_bad_magic_input():
220
220
  puremagic.main.perform_magic(None, None, None) # type: ignore[invalid-argument-type]
221
221
 
222
222
 
223
+ def test_from_extension():
224
+ """Test from_extension lookup"""
225
+ assert puremagic.from_extension(".pdf") == "application/pdf"
226
+ assert puremagic.from_extension("pdf") == "application/pdf"
227
+ assert puremagic.from_extension("PDF") == "application/pdf"
228
+ assert puremagic.from_extension(".jpg") == "image/jpeg"
229
+ assert puremagic.from_extension("png") == "image/png"
230
+ # Test name mode
231
+ name = puremagic.from_extension(".pdf", mime=False)
232
+ assert name != ""
233
+ # Test unknown extension raises PureError
234
+ with pytest.raises(puremagic.PureError):
235
+ puremagic.from_extension(".xyz_unknown_ext")
236
+
237
+
238
+ def test_magic_extension():
239
+ """Test magic_extension returns list of matches"""
240
+ results = puremagic.magic_extension(".pdf")
241
+ assert len(results) >= 1
242
+ assert results[0].mime_type == "application/pdf"
243
+ assert results[0].extension == ".pdf"
244
+ # Check sorted by confidence descending
245
+ for i in range(len(results) - 1):
246
+ assert results[i].confidence >= results[i + 1].confidence
247
+ # Unknown extension returns empty list
248
+ assert puremagic.magic_extension(".xyz_unknown_ext") == []
249
+
250
+
251
+ def test_cmd_extension_option():
252
+ """Test CLI -e option"""
253
+ from puremagic.main import command_line_entry # noqa: PLC0415
254
+
255
+ command_line_entry("-e", "pdf")
256
+ command_line_entry("-e", ".jpg")
257
+ command_line_entry("-e", "pdf", "-v")
258
+ command_line_entry("-e", "xyz_unknown_ext_123")
259
+
260
+
223
261
  def test_fake_file():
224
262
  results = puremagic.magic_file(filename=Path(LOCAL_DIR, "resources", "fake_file"))
225
263
  assert results[0].confidence == 0.5, results
264
+
265
+
266
+ def test_riff_wav_mime():
267
+ """RIFF scanner returns audio/wav (not audio/wave) for WAV files"""
268
+ mime = puremagic.from_file(os.path.join(AUDIO_DIR, "test.wav"), mime=True)
269
+ assert mime == "audio/wav"
270
+
271
+
272
+
273
+ def test_cfbf_doc():
274
+ """CFBF scanner correctly identifies Word .doc"""
275
+ ext = puremagic.from_file(os.path.join(OFFICE_DIR, "test.doc"))
276
+ assert ext == ".doc"
277
+ mime = puremagic.from_file(os.path.join(OFFICE_DIR, "test.doc"), mime=True)
278
+ assert mime == "application/msword"
279
+
280
+
281
+ def test_cfbf_ppt():
282
+ """CFBF scanner correctly identifies PowerPoint .ppt"""
283
+ ext = puremagic.from_file(os.path.join(OFFICE_DIR, "test.ppt"))
284
+ assert ext == ".ppt"
285
+ mime = puremagic.from_file(os.path.join(OFFICE_DIR, "test.ppt"), mime=True)
286
+ assert mime == "application/vnd.ms-powerpoint"
287
+
288
+
289
+ def test_cfbf_msg():
290
+ """CFBF scanner correctly identifies Outlook .msg"""
291
+ ext = puremagic.from_file(os.path.join(OFFICE_DIR, "test.msg"))
292
+ assert ext == ".msg"
293
+ mime = puremagic.from_file(os.path.join(OFFICE_DIR, "test.msg"), mime=True)
294
+ assert mime == "application/vnd.ms-outlook"
@@ -92,6 +92,15 @@ wheels = [
92
92
  { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
93
93
  ]
94
94
 
95
+ [[package]]
96
+ name = "codespell"
97
+ version = "2.4.2"
98
+ source = { registry = "https://pypi.org/simple" }
99
+ sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" }
100
+ wheels = [
101
+ { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" },
102
+ ]
103
+
95
104
  [[package]]
96
105
  name = "colorama"
97
106
  version = "0.4.6"
@@ -495,6 +504,7 @@ source = { editable = "." }
495
504
 
496
505
  [package.dev-dependencies]
497
506
  dev = [
507
+ { name = "codespell" },
498
508
  { name = "coverage" },
499
509
  { name = "poethepoet" },
500
510
  { name = "pre-commit" },
@@ -511,14 +521,15 @@ dev = [
511
521
 
512
522
  [package.metadata.requires-dev]
513
523
  dev = [
524
+ { name = "codespell", specifier = ">=2.4.2" },
514
525
  { name = "coverage", specifier = ">=7.13.4" },
515
526
  { name = "poethepoet", specifier = ">=0.42.1" },
516
527
  { name = "pre-commit", specifier = ">=4.5.1" },
517
528
  { name = "pytest", specifier = ">=9.0.2" },
518
- { name = "pytest-cov", specifier = ">=7.0.0" },
529
+ { name = "pytest-cov", specifier = ">=7" },
519
530
  { name = "ruff", specifier = ">=0.15.5" },
520
- { name = "setuptools", specifier = ">=82.0.0" },
521
- { name = "twine", specifier = ">=6.2.0" },
531
+ { name = "setuptools", specifier = ">=82" },
532
+ { name = "twine", specifier = ">=6.2" },
522
533
  { name = "ty", specifier = ">=0.0.21" },
523
534
  { name = "wheel", specifier = ">=0.46.3" },
524
535
  ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes