puremagic 2.0.2__tar.gz → 2.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. puremagic-2.1.1/.claude/skills/changelog.md +53 -0
  2. {puremagic-2.0.2 → puremagic-2.1.1}/.pre-commit-config.yaml +11 -0
  3. {puremagic-2.0.2 → puremagic-2.1.1}/CHANGELOG.md +14 -0
  4. {puremagic-2.0.2 → puremagic-2.1.1}/PKG-INFO +1 -1
  5. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/magic_data.json +1 -1
  6. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/main.py +95 -8
  7. puremagic-2.1.1/puremagic/scanners/cfbf_scanner.py +97 -0
  8. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/scanners/mpeg_audio_scanner.py +3 -3
  9. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic.egg-info/PKG-INFO +1 -1
  10. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic.egg-info/SOURCES.txt +3 -0
  11. {puremagic-2.0.2 → puremagic-2.1.1}/pyproject.toml +2 -0
  12. puremagic-2.1.1/test/resources/office/test.msg +0 -0
  13. {puremagic-2.0.2 → puremagic-2.1.1}/test/test_common_extensions.py +68 -0
  14. {puremagic-2.0.2 → puremagic-2.1.1}/test/test_scanners.py +20 -1
  15. {puremagic-2.0.2 → puremagic-2.1.1}/uv.lock +14 -3
  16. {puremagic-2.0.2 → puremagic-2.1.1}/.coveragerc +0 -0
  17. {puremagic-2.0.2 → puremagic-2.1.1}/.github/workflows/pythonpublish.yml +0 -0
  18. {puremagic-2.0.2 → puremagic-2.1.1}/.github/workflows/tests.yml +0 -0
  19. {puremagic-2.0.2 → puremagic-2.1.1}/.gitignore +0 -0
  20. {puremagic-2.0.2 → puremagic-2.1.1}/AUTHORS.rst +0 -0
  21. {puremagic-2.0.2 → puremagic-2.1.1}/LICENSE +0 -0
  22. {puremagic-2.0.2 → puremagic-2.1.1}/MANIFEST.in +0 -0
  23. {puremagic-2.0.2 → puremagic-2.1.1}/README.rst +0 -0
  24. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/__init__.py +0 -0
  25. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/__main__.py +0 -0
  26. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/py.typed +0 -0
  27. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/scanners/__init__.py +0 -0
  28. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/scanners/hdf5_scanner.py +0 -0
  29. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/scanners/helpers.py +0 -0
  30. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/scanners/json_scanner.py +0 -0
  31. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/scanners/pdf_scanner.py +0 -0
  32. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/scanners/python_scanner.py +0 -0
  33. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/scanners/sndhdr_scanner.py +0 -0
  34. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/scanners/text_scanner.py +0 -0
  35. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic/scanners/zip_scanner.py +0 -0
  36. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic.egg-info/dependency_links.txt +0 -0
  37. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic.egg-info/entry_points.txt +0 -0
  38. {puremagic-2.0.2 → puremagic-2.1.1}/puremagic.egg-info/top_level.txt +0 -0
  39. {puremagic-2.0.2 → puremagic-2.1.1}/requirements-test.txt +0 -0
  40. {puremagic-2.0.2 → puremagic-2.1.1}/scripts/parse_ftk_kessler_sigs.py +0 -0
  41. {puremagic-2.0.2 → puremagic-2.1.1}/setup.cfg +0 -0
  42. {puremagic-2.0.2 → puremagic-2.1.1}/test/__init__.py +0 -0
  43. {puremagic-2.0.2 → puremagic-2.1.1}/test/common.py +0 -0
  44. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.7z +0 -0
  45. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.big_endian.pcapng +0 -0
  46. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.bz2 +0 -0
  47. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.cb7 +0 -0
  48. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.cbt +0 -0
  49. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.cbz +0 -0
  50. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.fb2.zip +0 -0
  51. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.fbz +0 -0
  52. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.gz +0 -0
  53. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.little_endian.pcapng +0 -0
  54. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.pcapng +0 -0
  55. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.rar +0 -0
  56. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.tar +0 -0
  57. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.txt.lz4 +0 -0
  58. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.txt.zst +0 -0
  59. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.xz +0 -0
  60. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/archive/test.zip +0 -0
  61. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test.aac +0 -0
  62. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test.aif +0 -0
  63. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test.flac +0 -0
  64. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test.mp3 +0 -0
  65. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test.opus +0 -0
  66. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test.sndr +0 -0
  67. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test.wav +0 -0
  68. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test_mp3_vbr_info_128k_notags.mp3 +0 -0
  69. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test_mp3_vbr_xing_128k_3di_id3v1.mp3 +0 -0
  70. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test_mp3_vbr_xing_128k_apev1.mp3 +0 -0
  71. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test_mp3_vbr_xing_128k_apev1_id3v1.mp3 +0 -0
  72. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test_mp3_vbr_xing_128k_apev2_tagplus_id3v1.mp3 +0 -0
  73. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test_mp3_vbr_xing_128k_ext_id3v1.mp3 +0 -0
  74. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test_mp3_vbr_xing_128k_lyrics3v2_id3v1.mp3 +0 -0
  75. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test_mp3_vbr_xing_128k_notags.mp3 +0 -0
  76. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test_mp3_vbr_xing_128k_tagplus_apev2_id3v1.mp3 +0 -0
  77. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test_mp3_vbr_xing_128k_tagplus_id3v1.mp3 +0 -0
  78. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/audio/test_mpeg2_mp3_VBR_128k_id3v2_24.mp3 +0 -0
  79. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/fake_file +0 -0
  80. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test.avif +0 -0
  81. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test.bmp +0 -0
  82. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test.gif +0 -0
  83. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test.ico +0 -0
  84. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test.jpg +0 -0
  85. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test.png +0 -0
  86. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test.psd +0 -0
  87. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test.svg +0 -0
  88. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test.tga +0 -0
  89. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test.tif +0 -0
  90. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test.webp +0 -0
  91. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test.xcf +0 -0
  92. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test_varriant.svg +0 -0
  93. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/images/test_varriant_2.svg +0 -0
  94. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/media/test (single).vmdk +0 -0
  95. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/media/test (split).vmdk +0 -0
  96. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/media/test.iso +0 -0
  97. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/media/test.mdf +0 -0
  98. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/media/test.swf +0 -0
  99. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.doc +0 -0
  100. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.docm +0 -0
  101. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.docx +0 -0
  102. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.dotm +0 -0
  103. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.dotx +0 -0
  104. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.eml +0 -0
  105. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.fb2 +0 -0
  106. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.odp +0 -0
  107. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.ods +0 -0
  108. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.odt +0 -0
  109. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.pdf +0 -0
  110. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.potm +0 -0
  111. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.potx +0 -0
  112. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.ppt +0 -0
  113. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.pptm +0 -0
  114. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.pptx +0 -0
  115. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.txt +0 -0
  116. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.xls +0 -0
  117. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.xlsb +0 -0
  118. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.xlsm +0 -0
  119. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.xlsx +0 -0
  120. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.xltm +0 -0
  121. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test.xltx +0 -0
  122. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/test_utf16le.csv +0 -0
  123. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/text_cr.txt +0 -0
  124. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/text_crlf.txt +0 -0
  125. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/office/text_lf.txt +0 -0
  126. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/system/test.exe +0 -0
  127. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/system/test.json +0 -0
  128. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/system/test.puremagic_multi_footer +0 -0
  129. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/system/test.py +0 -0
  130. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/system/test_list.json +0 -0
  131. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/video/test.3g2 +0 -0
  132. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/video/test.avi +0 -0
  133. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/video/test.flv +0 -0
  134. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/video/test.jxsv +0 -0
  135. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/video/test.mkv +0 -0
  136. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/video/test.mp4 +0 -0
  137. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/video/test.mpg +0 -0
  138. {puremagic-2.0.2 → puremagic-2.1.1}/test/resources/video/test.wmv +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,20 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
+ Version 2.1.1
5
+ -------------
6
+
7
+ - Fixing #141 deep scan no longer overrides valid binary format matches (e.g. JPEG) for files without extensions (thanks to marph91)
8
+
9
+ Version 2.1.0
10
+ -------------
11
+
12
+ - Adding #6 `from_extension()` and `magic_extension()` to look up MIME types by file extension
13
+ - Adding #119 CFBF/OLE2 scanner to distinguish Word, Excel, PowerPoint, Outlook MSG, Visio, and Publisher files (thanks to Simeon Stoykov and Andy - NebularNerd)
14
+ - Adding #139 codespell check (thanks to Christian Clauss)
15
+ - Adding `-e`/`--extension` CLI flag for extension-based MIME type lookup
16
+ - Fixing #104 WAV MIME type changed from `audio/wave` to `audio/wav` (thanks to Simon Willison and Andy - NebularNerd)
17
+
4
18
  Version 2.0.2
5
19
  -------------
6
20
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puremagic
3
- Version: 2.0.2
3
+ Version: 2.1.1
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.1"
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",
@@ -209,13 +212,12 @@ def perform_magic(header: bytes, footer: bytes, mime: bool, ext=None, filename=N
209
212
  raise PureValueError("Input was empty")
210
213
  infos = identify_all(header, footer, ext)
211
214
  if filename and os.path.isfile(filename) and os.getenv("PUREMAGIC_DEEPSCAN") != "0":
212
- results = run_deep_scan(infos, filename, header, footer, raise_on_none=True)
213
- if results:
214
- if results[0].extension == "":
215
- raise PureError("Could not identify file")
215
+ results = run_deep_scan(infos, filename, header, footer, raise_on_none=not infos)
216
+ if results and results[0].extension != "":
216
217
  if mime:
217
218
  return results[0].mime_type
218
219
  return results[0].extension or ""
220
+ # Deep scan returned empty extension or no results — fall through to original matches
219
221
  if not infos:
220
222
  raise PureError("Could not identify file")
221
223
  info = infos[0]
@@ -390,6 +392,51 @@ def magic_stream(
390
392
  return info
391
393
 
392
394
 
395
+ def from_extension(extension: str, mime: bool = True) -> str:
396
+ """Look up a file type by its extension and return the MIME type or name.
397
+
398
+ :param extension: file extension with or without leading dot (e.g. ".pdf" or "pdf")
399
+ :param mime: Return mime type (default True), or human-readable name if False
400
+ :return: MIME type string or name
401
+ :raises PureError: if no match is found
402
+ """
403
+ ext = extension.strip().lower()
404
+ if not ext.startswith("."):
405
+ ext = f".{ext}"
406
+
407
+ matches = []
408
+ for entry in chain(magic_header_array, magic_footer_array, extension_only_array):
409
+ if entry.extension == ext:
410
+ matches.append(entry)
411
+
412
+ if not matches:
413
+ raise PureError(f"Could not find extension {ext!r} in magic database")
414
+
415
+ # Prefer entries with longer byte_match (more specific signatures)
416
+ matches.sort(key=lambda x: len(x.byte_match), reverse=True)
417
+ best = matches[0]
418
+ return best.mime_type if mime else best.name
419
+
420
+
421
+ def magic_extension(extension: str) -> list[PureMagicWithConfidence]:
422
+ """Return all matches for a given file extension.
423
+
424
+ :param extension: file extension with or without leading dot (e.g. ".pdf" or "pdf")
425
+ :return: list of PureMagicWithConfidence sorted by confidence descending
426
+ """
427
+ ext = extension.strip().lower()
428
+ if not ext.startswith("."):
429
+ ext = f".{ext}"
430
+
431
+ matches = []
432
+ for entry in chain(magic_header_array, magic_footer_array, extension_only_array):
433
+ if entry.extension == ext:
434
+ con = 0.8 if len(entry.byte_match) >= 9 else float(f"0.{len(entry.byte_match)}")
435
+ matches.append(PureMagicWithConfidence(confidence=con, **entry._asdict()))
436
+
437
+ return sorted(matches, key=lambda x: (x.confidence, len(x.byte_match)), reverse=True)
438
+
439
+
393
440
  def single_deep_scan(
394
441
  bytes_match: bytes | bytearray | None,
395
442
  filename: os.PathLike | str,
@@ -417,6 +464,8 @@ def single_deep_scan(
417
464
  result = mpeg_audio_scanner.main(filename, head, foot)
418
465
  if result and result.confidence > confidence:
419
466
  return result
467
+ case cfbf_scanner.match_bytes | cfbf_scanner.match_bytes_short:
468
+ return cfbf_scanner.main(filename, head, foot)
420
469
 
421
470
  if eml_result := text_scanner.eml_check(head):
422
471
  return eml_result
@@ -506,13 +555,18 @@ def run_deep_scan(
506
555
 
507
556
  # No specific scanner matched — try the catch-all text scanner
508
557
  # Only override when existing matches are very low confidence (e.g. 2-byte BOM signatures)
509
- if matches[0].confidence < 0.5:
558
+ # Only let the catch-all text scanner override when existing matches are
559
+ # generic text types (e.g. BOM-only signatures). If the magic database
560
+ # already identified a specific non-text file type, trust it over a generic text guess.
561
+ best_mime = matches[0].mime_type or ""
562
+ is_generic = best_mime.startswith("text/") or best_mime == "application/octet-stream" or not best_mime
563
+ if matches[0].confidence < 0.5 and is_generic:
510
564
  try:
511
565
  result = catch_all_deep_scan(filename, head, foot)
512
566
  except Exception:
513
567
  pass
514
568
  else:
515
- if result and result.confidence > matches[0].confidence:
569
+ if result and result.extension and result.confidence > matches[0].confidence:
516
570
  return [
517
571
  PureMagicWithConfidence(
518
572
  confidence=result.confidence,
@@ -544,10 +598,43 @@ def command_line_entry(*args):
544
598
  help="Return the mime type instead of file type",
545
599
  )
546
600
  parser.add_argument("-v", "--verbose", action="store_true", dest="verbose", help="Print verbose output")
547
- parser.add_argument("files", nargs="+", type=Path)
601
+ parser.add_argument(
602
+ "-e",
603
+ "--extension",
604
+ dest="extension",
605
+ help="Look up MIME type for a file extension (e.g. pdf or .pdf)",
606
+ )
607
+ parser.add_argument("files", nargs="*", type=Path)
548
608
  parser.add_argument("--version", action="version", version=puremagic.__version__)
549
609
  args = parser.parse_args(args if args else sys.argv[1:])
550
610
 
611
+ if args.extension:
612
+ if args.verbose:
613
+ matches = magic_extension(args.extension)
614
+ if not matches:
615
+ print(f"No matches found for extension '{args.extension}'")
616
+ else:
617
+ print(f"Total Possible Matches: {len(matches)}")
618
+ for i, result in enumerate(matches):
619
+ if i == 0:
620
+ print("\n\tBest Match")
621
+ else:
622
+ print(f"\tAlternative Match #{i}")
623
+ print(f"\tName: {result.name}")
624
+ print(f"\tConfidence: {int(result.confidence * 100)}%")
625
+ print(f"\tExtension: {result.extension}")
626
+ print(f"\tMime Type: {result.mime_type}")
627
+ print(f"\tByte Match: {result.byte_match}\n")
628
+ else:
629
+ try:
630
+ print(from_extension(args.extension, mime=not args.mime))
631
+ except PureError as e:
632
+ print(str(e))
633
+ return
634
+
635
+ if not args.files:
636
+ parser.error("the following arguments are required: files (or use -e/--extension)")
637
+
551
638
  for fn in args.files:
552
639
  if not fn.exists():
553
640
  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.1
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,74 @@ 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
+ def test_cfbf_doc():
273
+ """CFBF scanner correctly identifies Word .doc"""
274
+ ext = puremagic.from_file(os.path.join(OFFICE_DIR, "test.doc"))
275
+ assert ext == ".doc"
276
+ mime = puremagic.from_file(os.path.join(OFFICE_DIR, "test.doc"), mime=True)
277
+ assert mime == "application/msword"
278
+
279
+
280
+ def test_cfbf_ppt():
281
+ """CFBF scanner correctly identifies PowerPoint .ppt"""
282
+ ext = puremagic.from_file(os.path.join(OFFICE_DIR, "test.ppt"))
283
+ assert ext == ".ppt"
284
+ mime = puremagic.from_file(os.path.join(OFFICE_DIR, "test.ppt"), mime=True)
285
+ assert mime == "application/vnd.ms-powerpoint"
286
+
287
+
288
+ def test_cfbf_msg():
289
+ """CFBF scanner correctly identifies Outlook .msg"""
290
+ ext = puremagic.from_file(os.path.join(OFFICE_DIR, "test.msg"))
291
+ assert ext == ".msg"
292
+ mime = puremagic.from_file(os.path.join(OFFICE_DIR, "test.msg"), mime=True)
293
+ assert mime == "application/vnd.ms-outlook"
@@ -1,5 +1,5 @@
1
1
  import puremagic
2
- from test.common import OFFICE_DIR, SYSTEM_DIR, AUDIO_DIR
2
+ from test.common import IMAGE_DIR, OFFICE_DIR, SYSTEM_DIR, AUDIO_DIR
3
3
  from puremagic.scanners import python_scanner, json_scanner, sndhdr_scanner
4
4
 
5
5
  sample_text = b"""Lorem ipsum dolor sit amet, consectetur adipiscing elit,{ending}
@@ -104,6 +104,25 @@ def test_eml_scanner():
104
104
  assert results[0].confidence == 1.0
105
105
 
106
106
 
107
+ def test_jpg_without_extension():
108
+ # GH #141: JPEG file without extension should still be identified as image/jpeg
109
+ import struct
110
+
111
+ data = b"\xff\xd8\xff\xe0"
112
+ data += struct.pack(">H", 16)
113
+ data += b"JFIF\x00\x01\x01\x00"
114
+ data += struct.pack(">HH", 1, 1)
115
+ data += b"\x00\x00\xff\xd9"
116
+
117
+ no_ext_file = IMAGE_DIR / "test_jpeg_no_ext"
118
+ no_ext_file.write_bytes(data)
119
+ try:
120
+ result = puremagic.from_file(no_ext_file, mime=True)
121
+ assert result == "image/jpeg", f"Expected image/jpeg, got {result}"
122
+ finally:
123
+ no_ext_file.unlink()
124
+
125
+
107
126
  def test_sndhdr_scanner():
108
127
  # Test the sndhdr scanner with sndr file
109
128
  sndr_file = AUDIO_DIR / "test.sndr"
@@ -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