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.
- puremagic-2.1.0/.claude/skills/changelog.md +53 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/.pre-commit-config.yaml +11 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/CHANGELOG.md +9 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/PKG-INFO +1 -1
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/magic_data.json +1 -1
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/main.py +85 -2
- puremagic-2.1.0/puremagic/scanners/cfbf_scanner.py +97 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/mpeg_audio_scanner.py +3 -3
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic.egg-info/PKG-INFO +1 -1
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic.egg-info/SOURCES.txt +3 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/pyproject.toml +2 -0
- puremagic-2.1.0/test/resources/office/test.msg +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/test_common_extensions.py +69 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/uv.lock +14 -3
- {puremagic-2.0.2 → puremagic-2.1.0}/.coveragerc +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/.github/workflows/pythonpublish.yml +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/.github/workflows/tests.yml +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/.gitignore +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/AUTHORS.rst +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/LICENSE +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/MANIFEST.in +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/README.rst +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/__init__.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/__main__.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/py.typed +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/__init__.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/hdf5_scanner.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/helpers.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/json_scanner.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/pdf_scanner.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/python_scanner.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/sndhdr_scanner.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/text_scanner.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic/scanners/zip_scanner.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic.egg-info/dependency_links.txt +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic.egg-info/entry_points.txt +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/puremagic.egg-info/top_level.txt +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/requirements-test.txt +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/scripts/parse_ftk_kessler_sigs.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/setup.cfg +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/__init__.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/common.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.7z +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.big_endian.pcapng +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.bz2 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.cb7 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.cbt +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.cbz +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.fb2.zip +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.fbz +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.gz +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.little_endian.pcapng +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.pcapng +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.rar +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.tar +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.txt.lz4 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.txt.zst +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.xz +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/archive/test.zip +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.aac +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.aif +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.flac +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.mp3 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.opus +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.sndr +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test.wav +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_info_128k_notags.mp3 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_3di_id3v1.mp3 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_apev1.mp3 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_apev1_id3v1.mp3 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_apev2_tagplus_id3v1.mp3 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_ext_id3v1.mp3 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_lyrics3v2_id3v1.mp3 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_notags.mp3 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_tagplus_apev2_id3v1.mp3 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_tagplus_id3v1.mp3 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mpeg2_mp3_VBR_128k_id3v2_24.mp3 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/fake_file +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.avif +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.bmp +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.gif +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.ico +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.jpg +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.png +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.psd +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.svg +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.tga +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.tif +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.webp +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test.xcf +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test_varriant.svg +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/images/test_varriant_2.svg +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/media/test (single).vmdk +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/media/test (split).vmdk +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/media/test.iso +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/media/test.mdf +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/media/test.swf +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.doc +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.docm +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.docx +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.dotm +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.dotx +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.eml +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.fb2 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.odp +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.ods +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.odt +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.pdf +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.potm +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.potx +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.ppt +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.pptm +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.pptx +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.txt +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.xls +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.xlsb +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.xlsm +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.xlsx +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.xltm +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test.xltx +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/test_utf16le.csv +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/text_cr.txt +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/text_crlf.txt +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/office/text_lf.txt +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/system/test.exe +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/system/test.json +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/system/test.puremagic_multi_footer +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/system/test.py +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/system/test_list.json +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.3g2 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.avi +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.flv +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.jxsv +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.mkv +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.mp4 +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.mpg +0 -0
- {puremagic-2.0.2 → puremagic-2.1.0}/test/resources/video/test.wmv +0 -0
- {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
|
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
["53434448", 8, ".sc2", "", "SimCity 2000 Map File"]
|
|
101
101
|
],
|
|
102
102
|
"52494646": [
|
|
103
|
-
["57415645", 8, ".wav", "audio/
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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):
|
|
@@ -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/"
|
|
Binary file
|
|
@@ -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
|
|
529
|
+
{ name = "pytest-cov", specifier = ">=7" },
|
|
519
530
|
{ name = "ruff", specifier = ">=0.15.5" },
|
|
520
|
-
{ name = "setuptools", specifier = ">=82
|
|
521
|
-
{ name = "twine", specifier = ">=6.2
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_3di_id3v1.mp3
RENAMED
|
File without changes
|
|
File without changes
|
{puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_apev1_id3v1.mp3
RENAMED
|
File without changes
|
|
File without changes
|
{puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_ext_id3v1.mp3
RENAMED
|
File without changes
|
{puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_lyrics3v2_id3v1.mp3
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mp3_vbr_xing_128k_tagplus_id3v1.mp3
RENAMED
|
File without changes
|
{puremagic-2.0.2 → puremagic-2.1.0}/test/resources/audio/test_mpeg2_mp3_VBR_128k_id3v2_24.mp3
RENAMED
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|