Compare commits
80 Commits
Author | SHA1 | Date |
---|---|---|
dgelessus | d2bbab1f5d | |
dgelessus | a2663ae85d | |
dgelessus | 8f60fcdfc4 | |
dgelessus | ff9377dc8d | |
dgelessus | 0624d4eae9 | |
dgelessus | 4ad1d1d6b9 | |
dgelessus | b95c4917cc | |
dgelessus | ee767a106c | |
dgelessus | 82951f5d8e | |
dgelessus | b9fdac1c0b | |
dgelessus | 70d51c2907 | |
dgelessus | f891a6ee00 | |
dgelessus | b1e5e7c96e | |
dgelessus | 60709e386a | |
dgelessus | f437ee5f43 | |
dgelessus | 5c3bc5d7e5 | |
dgelessus | d74dbc41ba | |
dgelessus | 0642b1e8bf | |
dgelessus | 54ccdb0a47 | |
dgelessus | f76817c389 | |
dgelessus | 9e6dfacff6 | |
dgelessus | 8d39469e6e | |
dgelessus | 028be98e8d | |
dgelessus | 98551263b3 | |
dgelessus | 0054d0f7b5 | |
dgelessus | 126795239c | |
dgelessus | 2907d9f9e8 | |
dgelessus | 5c96baea29 | |
dgelessus | 664e992fa3 | |
dgelessus | 61247ec783 | |
dgelessus | 0f6018e4bf | |
dgelessus | 476a68916b | |
dgelessus | 4bbf2f7c14 | |
dgelessus | 82b5926b4f | |
dgelessus | 5456013bf4 | |
dgelessus | b595456a05 | |
dgelessus | d367a9238a | |
dgelessus | 33c4016124 | |
dgelessus | b01cfc77cf | |
dgelessus | a9f54b678c | |
dgelessus | b46018e666 | |
dgelessus | b0eefe3889 | |
dgelessus | 3e0bbcee04 | |
dgelessus | 13654c2560 | |
dgelessus | d5199bd503 | |
dgelessus | c5c3f24a10 | |
dgelessus | 7c77c4ef20 | |
dgelessus | f7b6080c0e | |
dgelessus | 007d15eb3d | |
dgelessus | 246b69e375 | |
dgelessus | d67ff64851 | |
dgelessus | 5391d66a78 | |
dgelessus | 5b2700bf17 | |
dgelessus | c41b25fea1 | |
dgelessus | a45dbd8eca | |
dgelessus | 3401ce65dd | |
dgelessus | 890dd24f76 | |
dgelessus | 67c2b4acf0 | |
dgelessus | 238c78a73e | |
dgelessus | fbd861edf4 | |
dgelessus | a7a407a1dd | |
dgelessus | ecee2616cf | |
dgelessus | ba284d1800 | |
dgelessus | f690caac24 | |
dgelessus | 3a805c3e56 | |
dgelessus | 6adf8eb88d | |
dgelessus | e132a91dea | |
dgelessus | 4e1cd05412 | |
dgelessus | 1a416defed | |
dgelessus | 1089a19c01 | |
dgelessus | 8fc24040ea | |
dgelessus | d492d9a6a8 | |
dgelessus | d0e1eaf262 | |
dgelessus | 1e55569442 | |
dgelessus | 2abf6e2a06 | |
dgelessus | 2b0bbb19ed | |
dgelessus | c009e8f80f | |
dgelessus | d67641d537 | |
dgelessus | d6dbfdb149 | |
dgelessus | b2502c48a2 |
|
@ -8,3 +8,7 @@ insert_final_newline = true
|
|||
[*.rst]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
on: [pull_request, push]
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [macos-latest, ubuntu-20.04, windows-latest]
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.6"
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- run: python -m pip install --upgrade tox
|
||||
- run: tox
|
|
@ -2,6 +2,9 @@
|
|||
*.py[co]
|
||||
__pycache__/
|
||||
|
||||
# tox
|
||||
.tox/
|
||||
|
||||
# setuptools
|
||||
*.egg-info/
|
||||
build/
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
# Note: See the PyPA documentation for a list of file names that are included/excluded by default:
|
||||
# https://packaging.python.org/guides/using-manifest-in/#how-files-are-included-in-an-sdist
|
||||
# Please only add entries here for files that are *not* already handled by default.
|
||||
|
||||
recursive-include tests *.py
|
||||
recursive-include tests/data *.rsrc
|
|
@ -0,0 +1,326 @@
|
|||
# `rsrcfork`
|
||||
|
||||
A pure Python, cross-platform library/tool for reading Macintosh resource data,
|
||||
as stored in resource forks and `.rsrc` files.
|
||||
|
||||
Resource forks were an important part of the Classic Mac OS,
|
||||
where they provided a standard way to store structured file data, metadata and application resources.
|
||||
This usage continued into Mac OS X (now called macOS) for backward compatibility,
|
||||
but over time resource forks became less commonly used in favor of simple data fork-only formats, application bundles, and extended attributes.
|
||||
|
||||
As of OS X 10.8 and the deprecation of the Carbon API,
|
||||
macOS no longer provides any officially supported APIs for using and manipulating resource data.
|
||||
Despite this, parts of macOS still support and use resource forks,
|
||||
for example to store custom file and folder icons set by the user.
|
||||
|
||||
## Features
|
||||
|
||||
* Pure Python, cross-platform - no native Mac APIs are used.
|
||||
* Provides both a Python API and a command-line tool.
|
||||
* Resource data can be read from either the resource fork or the data fork.
|
||||
|
||||
* On Mac systems, the correct fork is selected automatically when reading a file.
|
||||
This allows reading both regular resource forks and resource data stored in data forks (as with `.rsrc` and similar files).
|
||||
* On non-Mac systems, resource forks are not available, so the data fork is always used.
|
||||
|
||||
* Compressed resources (supported by System 7 through Mac OS 9) are automatically decompressed.
|
||||
|
||||
* Only the standard System 7.0 resource compression methods are supported.
|
||||
Resources that use non-standard decompressors cannot be decompressed.
|
||||
|
||||
* Object `repr`s are REPL-friendly:
|
||||
all relevant information is displayed,
|
||||
and long data is truncated to avoid filling up the screen by accident.
|
||||
|
||||
## Requirements
|
||||
|
||||
Python 3.6 or later.
|
||||
No other libraries are required.
|
||||
|
||||
## Installation
|
||||
|
||||
`rsrcfork` is available [on PyPI](https://pypi.org/project/rsrcfork/) and can be installed using `pip`:
|
||||
|
||||
```sh
|
||||
$ python3 -m pip install rsrcfork
|
||||
```
|
||||
|
||||
Alternatively you can download the source code manually,
|
||||
and run this command in the source code directory to install it:
|
||||
|
||||
```sh
|
||||
$ python3 -m pip install .
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple example
|
||||
|
||||
```python-repl
|
||||
>>> import rsrcfork
|
||||
>>> rf = rsrcfork.open("/Users/Shared/Test.textClipping")
|
||||
>>> rf
|
||||
<rsrcfork.ResourceFile at 0x1046e6048, attributes ResourceFileAttrs.0, containing 4 resource types: [b'utxt', b'utf8', b'TEXT', b'drag']>
|
||||
>>> rf[b"TEXT"]
|
||||
<Resource map for type b'TEXT', containing one resource: <Resource type b'TEXT', id 256, name None, attributes ResourceAttrs.0, data b'Here is some text'>>
|
||||
```
|
||||
|
||||
### Automatic selection of data/resource fork
|
||||
|
||||
```python-repl
|
||||
>>> import rsrcfork
|
||||
>>> datarf = rsrcfork.open("/System/Library/Fonts/Monaco.dfont") # Resources in data fork
|
||||
>>> datarf._stream
|
||||
<_io.BufferedReader name='/System/Library/Fonts/Monaco.dfont'>
|
||||
>>> resourcerf = rsrcfork.open("/Users/Shared/Test.textClipping") # Resources in resource fork
|
||||
>>> resourcerf._stream
|
||||
<_io.BufferedReader name='/Users/Shared/Test.textClipping/..namedfork/rsrc'>
|
||||
```
|
||||
|
||||
### Command-line interface
|
||||
|
||||
```sh
|
||||
$ rsrcfork list /Users/Shared/Test.textClipping
|
||||
4 resource types:
|
||||
'TEXT': 1 resources:
|
||||
(256): 17 bytes
|
||||
|
||||
'drag': 1 resources:
|
||||
(128): 64 bytes
|
||||
|
||||
'utf8': 1 resources:
|
||||
(256): 17 bytes
|
||||
|
||||
'utxt': 1 resources:
|
||||
(256): 34 bytes
|
||||
|
||||
$ rsrcfork read /Users/Shared/Test.textClipping "'TEXT' (256)"
|
||||
Resource 'TEXT' (256): 17 bytes:
|
||||
00000000 48 65 72 65 20 69 73 20 73 6f 6d 65 20 74 65 78 |Here is some tex|
|
||||
00000010 74 |t|
|
||||
00000011
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
This library only understands the resource file's general structure,
|
||||
i. e. the type codes, IDs, attributes, and data of the resources stored in the file.
|
||||
The data of individual resources is provided in raw bytes form and is not processed further -
|
||||
the format of this data is specific to each resource type.
|
||||
|
||||
Definitions of common resource types can be found inside Carbon and related frameworks in Apple's macOS SDKs as `.r` files,
|
||||
a format roughly similar to C struct definitions,
|
||||
which is used by the `Rez` and `DeRez` command-line tools to de/compile resource data.
|
||||
There doesn't seem to be an exact specification of this format,
|
||||
and most documentation on it is only available inside old manuals for MPW (Macintosh Programmer's Workshop) or similar development tools for old Mac systems.
|
||||
Some macOS text editors, such as BBEdit/TextWrangler and TextMate support syntax highlighting for `.r` files.
|
||||
|
||||
Writing resource data is not supported at all.
|
||||
|
||||
## Further info on resource files
|
||||
|
||||
For technical info and documentation about resource files and resources,
|
||||
see the ["resource forks" section of the mac_file_format_docs repo](https://github.com/dgelessus/mac_file_format_docs/blob/master/README.md#resource-forks).
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.8.1 (next version)
|
||||
|
||||
* Added `open` and `open_raw` methods to `Resource` objects,
|
||||
for stream-based access to resource data.
|
||||
* Fixed reading of compressed resource headers with the header length field incorrectly set to 0
|
||||
(because real Mac OS apparently accepts this).
|
||||
|
||||
### Version 1.8.0
|
||||
|
||||
* Removed the old (non-subcommand-based) CLI syntax.
|
||||
* Added filtering support to the `list` subcommand.
|
||||
* Added a `resource-info` subcommand to display technical information about resources
|
||||
(more detailed than what is displayed by `list` and `read`).
|
||||
* Added a `raw-compress-info` subcommand to display technical header information about standalone compressed resource data.
|
||||
* Made the library PEP 561-compliant by adding a py.typed file.
|
||||
* Fixed an incorrect `AssertionError` when using the `--no-decompress` command-line options.
|
||||
|
||||
### Version 1.7.0
|
||||
|
||||
* Added a `raw-decompress` subcommand to decompress compressed resource data stored in a standalone file rather than as a resource.
|
||||
* Optimized lazy loading of `Resource` objects.
|
||||
Previously, resource data would be read from disk whenever a `Resource` object was looked up,
|
||||
even if the data itself is never used.
|
||||
Now the resource data is only loaded once the `data` (or `data_raw`) attribute is accessed.
|
||||
|
||||
* The same optimization applies to the `name` attribute,
|
||||
although this is unlikely to make a difference in practice.
|
||||
* As a result, it is no longer possible to construct `Resource` objects without a resource file.
|
||||
This was previously possible, but had no practical use.
|
||||
|
||||
* Fixed a small error in the `'dcmp' (0)` decompression implementation.
|
||||
|
||||
### Version 1.6.0
|
||||
|
||||
* Added a new subcommand-based command-line syntax to the `rsrcfork` tool,
|
||||
similar to other CLI tools such as `git` or `diskutil`.
|
||||
|
||||
* This subcommand-based syntax is meant to replace the old CLI options,
|
||||
as the subcommand structure is easier to understand and more extensible in the future.
|
||||
* Currently there are three subcommands:
|
||||
`list` to list resources in a file,
|
||||
`read` to read/display resource data,
|
||||
and `read-header` to read a resource file's header data.
|
||||
These subcommands can be used to perform all operations that were also available with the old CLI syntax.
|
||||
* The old CLI syntax is still supported for now,
|
||||
but it will be removed soon.
|
||||
* The new syntax no longer supports reading CLI arguments from a file (using `@args_file.txt`),
|
||||
abbreviating long options (e. g. `--no-d` instead of `--no-decompress`),
|
||||
or the short option `-f` instead of `--fork`.
|
||||
If you have a need for any of these features,
|
||||
please open an issue.
|
||||
|
||||
### Version 1.5.0
|
||||
|
||||
* Added stream-based decompression methods to the `rsrcfork.compress` module.
|
||||
|
||||
* The internal decompressor implementations have been refactored to use streams.
|
||||
* This allows for incremental decompression of compressed resource data.
|
||||
In practice this has no noticeable effect yet,
|
||||
because the main `rsrcfork` API doesn't support incremental reading of resource data.
|
||||
|
||||
* Fixed the command line tool always displaying an incorrect error "Cannot specify an explicit fork when reading from stdin" when using `-` (stdin) as the input file.
|
||||
|
||||
### Version 1.4.0
|
||||
|
||||
* Added `length` and `length_raw` attributes to `Resource`.
|
||||
These attributes are equivalent to the `len` of `data` and `data_raw` respectively,
|
||||
but may be faster to access.
|
||||
|
||||
* Currently, the only optimized case is `length` for compressed resources,
|
||||
but more optimizations may be added in the future.
|
||||
|
||||
* Added a `compressed_info` attribute to `Resource` that provides access to the header information of compressed resources.
|
||||
* Improved handling of compressed resources when listing resource files with the command line tool.
|
||||
|
||||
* Metadata of compressed resources is now displayed even if no decompressor implementation is available
|
||||
(as long as the compressed data header can be parsed).
|
||||
* Performance has been improved -
|
||||
the data no longer needs to be fully decompressed to get its length,
|
||||
this information is now read from the header.
|
||||
* The `'dcmp'` ID used to decompress each resource is displayed.
|
||||
|
||||
* Fixed an incorrect `options.packages` in `setup.cfg`,
|
||||
which made the library unusable except when installing from source using `--editable`.
|
||||
* Fixed `ResourceFile.__enter__` returning `None`,
|
||||
which made it impossible to use `ResourceFile` properly in a `with` statement.
|
||||
* Fixed various minor errors reported by type checking with `mypy`.
|
||||
|
||||
### Version 1.3.0.post1
|
||||
|
||||
* Fixed an incorrect `options.packages` in `setup.cfg`,
|
||||
which made the library unusable except when installing from source using `--editable`.
|
||||
|
||||
### Version 1.2.0.post1
|
||||
|
||||
* Fixed an incorrect `options.packages` in `setup.cfg`,
|
||||
which made the library unusable except when installing from source using `--editable`.
|
||||
|
||||
### Version 1.3.0
|
||||
|
||||
* Added a `--group` command line option to group resources in list format by type (the default), ID, or with no grouping.
|
||||
* Added a `dump-text` output format to the command line tool.
|
||||
This format is identical to `dump`,
|
||||
but instead of a hex dump,
|
||||
it outputs the resource data as text.
|
||||
The data is decoded as MacRoman and classic Mac newlines (`\r`) are translated.
|
||||
This is useful for examining resources that contain mostly plain text.
|
||||
* Changed the command line tool to sort resources by type and ID,
|
||||
and added a `--no-sort` option to disable sorting and output resources in file order
|
||||
(which was the previous behavior).
|
||||
* Renamed the `rsrcfork.Resource` attributes `resource_type` and `resource_id` to `type` and ``id``, respectively.
|
||||
The old names have been deprecated and will be removed in the future,
|
||||
but are still supported for now.
|
||||
* Changed `--format=dump` output to match `hexdump -C`'s format -
|
||||
spacing has been adjusted,
|
||||
and multiple subsequent identical lines are collapsed into a single `*`.
|
||||
|
||||
### Version 1.2.0
|
||||
|
||||
* Added support for compressed resources.
|
||||
|
||||
* Compressed resource data is automatically decompressed,
|
||||
both in the Python API and on the command line.
|
||||
* This is technically a breaking change,
|
||||
since in previous versions the compressed resource data was returned directly.
|
||||
However, this change will not affect end users negatively,
|
||||
unless one has already implemented custom handling for compressed resources.
|
||||
* Currently, only the three standard System 7.0 compression formats (`'dcmp'` IDs 0, 1, 2) are supported.
|
||||
Attempting to access a resource compressed in an unsupported format results in a `DecompressError`.
|
||||
* To access the raw resource data as stored in the file,
|
||||
without automatic decompression,
|
||||
use the `res.data_raw` attribute (for the Python API),
|
||||
or the `--no-decompress` option (for the command-line interface).
|
||||
This can be used to read the resource data in its compressed form,
|
||||
even if the compression format is not supported.
|
||||
|
||||
* Improved automatic data/resource fork selection for files whose resource fork contains invalid data.
|
||||
|
||||
* This fixes reading certain system files with resource data in their data fork
|
||||
(such as HIToolbox.rsrc in HIToolbox.framework, or .dfont fonts)
|
||||
on recent macOS versions (at least macOS 10.14, possibly earlier).
|
||||
Although these files have no resource fork,
|
||||
recent macOS versions will successfully open the resource fork and return garbage data for it.
|
||||
This behavior is now detected and handled by using the data fork instead.
|
||||
|
||||
* Replaced the `rsrcfork` parameter of `rsrcfork.open`/`ResourceFork.open` with a new `fork` parameter.
|
||||
`fork` accepts string values (like the command line `--fork` option) rather than `rsrcfork`'s hard to understand `None`/`True`/`False`.
|
||||
|
||||
* The old `rsrcfork` parameter has been deprecated and will be removed in the future,
|
||||
but for now it still works as before.
|
||||
|
||||
* Added an explanatory message when a resource filter on the command line doesn't match any resources in the resource file.
|
||||
Previously there would either be no output or a confusing error,
|
||||
depending on the selected `--format`.
|
||||
* Changed resource type codes and names to be displayed in MacRoman instead of escaping all non-ASCII characters.
|
||||
* Cleaned up the resource descriptions in listings and dumps to improve readability.
|
||||
Previously they included some redundant or unnecessary information -
|
||||
for example, each resource with no attributes set would be explicitly marked as "no attributes".
|
||||
* Unified the formats of resource descriptions in listings and dumps,
|
||||
which were previously slightly different from each other.
|
||||
* Improved error messages when attempting to read multiple resources using `--format=hex` or `--format=raw`.
|
||||
* Fixed reading from non-seekable streams not working for some resource files.
|
||||
* Removed the `allow_seek` parameter of `ResourceFork.__init__` and the `--read-mode` command line option.
|
||||
They are no longer necessary,
|
||||
and were already practically useless before due to non-seekable stream reading being broken.
|
||||
|
||||
### Version 1.1.3.post1
|
||||
|
||||
* Fixed a formatting error in the README.rst to allow upload to PyPI.
|
||||
|
||||
### Version 1.1.3
|
||||
|
||||
**Note: This version is not available on PyPI, see version 1.1.3.post1 changelog for details.**
|
||||
|
||||
* Added a setuptools entry point for the command-line interface.
|
||||
This allows calling it using just `rsrcfork` instead of `python3 -m rsrcfork`.
|
||||
* Changed the default value of `ResourceFork.__init__`'s `close` keyword argument from `True` to `False`.
|
||||
This matches the behavior of classes like `zipfile.ZipFile` and `tarfile.TarFile`.
|
||||
* Fixed `ResourceFork.open` and `ResourceFork.__init__` not closing their streams in some cases.
|
||||
* Refactored the single `rsrcfork.py` file into a package.
|
||||
This is an internal change and should have no effect on how the `rsrcfork` module is used.
|
||||
|
||||
### Version 1.1.2
|
||||
|
||||
* Added support for the resource file attributes "Resources Locked" and "Printer Driver MultiFinder Compatible" from ResEdit.
|
||||
* Added more dummy constants for resource attributes with unknown meaning,
|
||||
so that resource files containing such attributes can be loaded without errors.
|
||||
|
||||
### Version 1.1.1
|
||||
|
||||
* Fixed overflow issue with empty resource files or empty resource type entries
|
||||
* Changed `_hexdump` to behave more like `hexdump -C`
|
||||
|
||||
### Version 1.1.0
|
||||
|
||||
* Added a command-line interface - run `python3 -m rsrcfork --help` for more info
|
||||
|
||||
### Version 1.0.0
|
||||
|
||||
* Initial version
|
244
README.rst
|
@ -1,244 +0,0 @@
|
|||
``rsrcfork``
|
||||
============
|
||||
|
||||
A pure Python, cross-platform library/tool for reading Macintosh resource data, as stored in resource forks and ``.rsrc`` files.
|
||||
|
||||
Resource forks were an important part of the Classic Mac OS, where they provided a standard way to store structured file data, metadata and application resources. This usage continued into Mac OS X (now called macOS) for backward compatibility, but over time resource forks became less commonly used in favor of simple data fork-only formats, application bundles, and extended attributes.
|
||||
|
||||
As of OS X 10.8 and the deprecation of the Carbon API, macOS no longer provides any officially supported APIs for using and manipulating resource data. Despite this, parts of macOS still support and use resource forks, for example to store custom file and folder icons set by the user.
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
* Pure Python, cross-platform - no native Mac APIs are used.
|
||||
* Provides both a Python API and a command-line tool.
|
||||
* Resource data can be read from either the resource fork or the data fork.
|
||||
|
||||
* On Mac systems, the correct fork is selected automatically when reading a file. This allows reading both regular resource forks and resource data stored in data forks (as with ``.rsrc`` and similar files).
|
||||
* On non-Mac systems, resource forks are not available, so the data fork is always used.
|
||||
|
||||
* Compressed resources (supported by System 7 through Mac OS 9) are automatically decompressed.
|
||||
|
||||
* Only the standard System 7.0 resource compression methods are supported. Resources that use non-standard decompressors cannot be decompressed.
|
||||
|
||||
* Object ``repr``\s are REPL-friendly: all relevant information is displayed, and long data is truncated to avoid filling up the screen by accident.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
Python 3.6 or later. No other libraries are required.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
``rsrcfork`` is available `on PyPI <https://pypi.org/project/rsrcfork/>`_ and can be installed using ``pip``:
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
python3 -m pip install rsrcfork
|
||||
|
||||
Alternatively you can download the source code manually, and run this command in the source code directory to install it:
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
python3 -m pip install .
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
Simple example
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>>> import rsrcfork
|
||||
>>> rf = rsrcfork.open("/Users/Shared/Test.textClipping")
|
||||
>>> rf
|
||||
<rsrcfork.ResourceFile at 0x1046e6048, attributes ResourceFileAttrs.0, containing 4 resource types: [b'utxt', b'utf8', b'TEXT', b'drag']>
|
||||
>>> rf[b"TEXT"]
|
||||
<rsrcfork.ResourceFile._LazyResourceMap at 0x10470ed30 containing one resource: rsrcfork.Resource(type=b'TEXT', id=256, name=None, attributes=ResourceAttrs.0, data=b'Here is some text')>
|
||||
|
||||
Automatic selection of data/resource fork
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>>> import rsrcfork
|
||||
>>> datarf = rsrcfork.open("/System/Library/Fonts/Monaco.dfont") # Resources in data fork
|
||||
>>> datarf._stream
|
||||
<_io.BufferedReader name='/System/Library/Fonts/Monaco.dfont'>
|
||||
>>> resourcerf = rsrcfork.open("/Users/Shared/Test.textClipping") # Resources in resource fork
|
||||
>>> resourcerf._stream
|
||||
<_io.BufferedReader name='/Users/Shared/Test.textClipping/..namedfork/rsrc'>
|
||||
|
||||
Command-line interface
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
$ python3 -m rsrcfork /Users/Shared/Test.textClipping
|
||||
4 resource types:
|
||||
'utxt': 1 resources:
|
||||
(256): 34 bytes
|
||||
|
||||
'utf8': 1 resources:
|
||||
(256): 17 bytes
|
||||
|
||||
'TEXT': 1 resources:
|
||||
(256): 17 bytes
|
||||
|
||||
'drag': 1 resources:
|
||||
(128): 64 bytes
|
||||
|
||||
$ python3 -m rsrcfork /Users/Shared/Test.textClipping "'TEXT' (256)"
|
||||
Resource 'TEXT' (256): 17 bytes:
|
||||
00000000 48 65 72 65 20 69 73 20 73 6f 6d 65 20 74 65 78 |Here is some tex|
|
||||
00000010 74 |t|
|
||||
00000011
|
||||
|
||||
Limitations
|
||||
-----------
|
||||
|
||||
This library only understands the resource file's general structure, i. e. the type codes, IDs, attributes, and data of the resources stored in the file. The data of individual resources is provided in raw bytes form and is not processed further - the format of this data is specific to each resource type.
|
||||
|
||||
Definitions of common resource types can be found inside Carbon and related frameworks in Apple's macOS SDKs as ``.r`` files, a format roughly similar to C struct definitions, which is used by the ``Rez`` and ``DeRez`` command-line tools to de/compile resource data. There doesn't seem to be an exact specification of this format, and most documentation on it is only available inside old manuals for MPW (Macintosh Programmer's Workshop) or similar development tools for old Mac systems. Some macOS text editors, such as BBEdit/TextWrangler and TextMate support syntax highlighting for ``.r`` files.
|
||||
|
||||
Writing resource data is not supported at all.
|
||||
|
||||
Further info on resource files
|
||||
------------------------------
|
||||
|
||||
For technical info and documentation about resource files and resources, see the `"resource forks" section of the mac_file_format_docs repo <https://github.com/dgelessus/mac_file_format_docs/blob/master/README.md#resource-forks>`_.
|
||||
|
||||
Changelog
|
||||
---------
|
||||
|
||||
Version 1.7
|
||||
^^^^^^^^^^^
|
||||
|
||||
* Added a ``raw-decompress`` subcommand to decompress compressed resource data stored in a standalone file rather than as a resource.
|
||||
* Optimized lazy loading of ``Resource`` objects. Previously, resource data would be read from disk whenever a ``Resource`` object was looked up, even if the data itself is never used. Now the resource data is only loaded once the ``data`` (or ``data_raw``) attribute is accessed.
|
||||
|
||||
* The same optimization applies to the ``name`` attribute, although this is unlikely to make a difference in practice.
|
||||
* As a result, it is no longer possible to construct ``Resource`` objects without a resource file. This was previously possible, but had no practical use.
|
||||
* Fixed a small error in the ``'dcmp' (0)`` decompression implementation.
|
||||
|
||||
Version 1.6.0
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
* Added a new subcommand-based command-line syntax to the ``rsrcfork`` tool, similar to other CLI tools such as ``git`` or ``diskutil``.
|
||||
|
||||
* This subcommand-based syntax is meant to replace the old CLI options, as the subcommand structure is easier to understand and more extensible in the future.
|
||||
* Currently there are three subcommands: ``list`` to list resources in a file, ``read`` to read/display resource data, and ``read-header`` to read a resource file's header data. These subcommands can be used to perform all operations that were also available with the old CLI syntax.
|
||||
* The old CLI syntax is still supported for now, but it will be removed soon.
|
||||
* The new syntax no longer supports reading CLI arguments from a file (using ``@args_file.txt``), abbreviating long options (e. g. ``--no-d`` instead of ``--no-decompress``), or the short option ``-f`` instead of ``--fork``. If you have a need for any of these features, please open an issue.
|
||||
|
||||
Version 1.5.0
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
* Added stream-based decompression methods to the ``rsrcfork.compress`` module.
|
||||
|
||||
* The internal decompressor implementations have been refactored to use streams.
|
||||
* This allows for incremental decompression of compressed resource data. In practice this has no noticeable effect yet, because the main ``rsrcfork`` API doesn't support incremental reading of resource data.
|
||||
|
||||
* Fixed the command line tool always displaying an incorrect error "Cannot specify an explicit fork when reading from stdin" when using ``-`` (stdin) as the input file.
|
||||
|
||||
Version 1.4.0
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
* Added ``length`` and ``length_raw`` attributes to ``Resource``. These attributes are equivalent to the ``len`` of ``data`` and ``data_raw`` respectively, but may be faster to access.
|
||||
|
||||
* Currently, the only optimized case is ``length`` for compressed resources, but more optimizations may be added in the future.
|
||||
|
||||
* Added a ``compressed_info`` attribute to ``Resource`` that provides access to the header information of compressed resources.
|
||||
* Improved handling of compressed resources when listing resource files with the command line tool.
|
||||
|
||||
* Metadata of compressed resources is now displayed even if no decompressor implementation is available (as long as the compressed data header can be parsed).
|
||||
* Performance has been improved - the data no longer needs to be fully decompressed to get its length, this information is now read from the header.
|
||||
* The ``'dcmp'`` ID used to decompress each resource is displayed.
|
||||
|
||||
* Fixed an incorrect ``options.packages`` in ``setup.cfg``, which made the library unusable except when installing from source using ``--editable``.
|
||||
* Fixed ``ResourceFile.__enter__`` returning ``None``, which made it impossible to use ``ResourceFile`` properly in a ``with`` statement.
|
||||
* Fixed various minor errors reported by type checking with ``mypy``.
|
||||
|
||||
Version 1.3.0.post1
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
* Fixed an incorrect ``options.packages`` in ``setup.cfg``, which made the library unusable except when installing from source using ``--editable``.
|
||||
|
||||
Version 1.2.0.post1
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
* Fixed an incorrect ``options.packages`` in ``setup.cfg``, which made the library unusable except when installing from source using ``--editable``.
|
||||
|
||||
Version 1.3.0
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
* Added a ``--group`` command line option to group resources in list format by type (the default), ID, or with no grouping.
|
||||
* Added a ``dump-text`` output format to the command line tool. This format is identical to ``dump``, but instead of a hex dump, it outputs the resource data as text. The data is decoded as MacRoman and classic Mac newlines (``\r``) are translated. This is useful for examining resources that contain mostly plain text.
|
||||
* Changed the command line tool to sort resources by type and ID, and added a ``--no-sort`` option to disable sorting and output resources in file order (which was the previous behavior).
|
||||
* Renamed the ``rsrcfork.Resource`` attributes ``resource_type`` and ``resource_id`` to ``type`` and ``id``, respectively. The old names have been deprecated and will be removed in the future, but are still supported for now.
|
||||
* Changed ``--format=dump`` output to match ``hexdump -C``'s format - spacing has been adjusted, and multiple subsequent identical lines are collapsed into a single ``*``.
|
||||
|
||||
Version 1.2.0
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
* Added support for compressed resources.
|
||||
|
||||
* Compressed resource data is automatically decompressed, both in the Python API and on the command line.
|
||||
* This is technically a breaking change, since in previous versions the compressed resource data was returned directly. However, this change will not affect end users negatively, unless one has already implemented custom handling for compressed resources.
|
||||
* Currently, only the three standard System 7.0 compression formats (``'dcmp'`` IDs 0, 1, 2) are supported. Attempting to access a resource compressed in an unsupported format results in a ``DecompressError``.
|
||||
* To access the raw resource data as stored in the file, without automatic decompression, use the ``res.data_raw`` attribute (for the Python API), or the ``--no-decompress`` option (for the command-line interface). This can be used to read the resource data in its compressed form, even if the compression format is not supported.
|
||||
|
||||
* Improved automatic data/resource fork selection for files whose resource fork contains invalid data.
|
||||
|
||||
* This fixes reading certain system files with resource data in their data fork (such as HIToolbox.rsrc in HIToolbox.framework, or .dfont fonts) on recent macOS versions (at least macOS 10.14, possibly earlier). Although these files have no resource fork, recent macOS versions will successfully open the resource fork and return garbage data for it. This behavior is now detected and handled by using the data fork instead.
|
||||
|
||||
* Replaced the ``rsrcfork`` parameter of ``rsrcfork.open``/``ResourceFork.open`` with a new ``fork`` parameter. ``fork`` accepts string values (like the command line ``--fork`` option) rather than ``rsrcfork``'s hard to understand ``None``/``True``/``False``.
|
||||
|
||||
* The old ``rsrcfork`` parameter has been deprecated and will be removed in the future, but for now it still works as before.
|
||||
|
||||
* Added an explanatory message when a resource filter on the command line doesn't match any resources in the resource file. Previously there would either be no output or a confusing error, depending on the selected ``--format``.
|
||||
* Changed resource type codes and names to be displayed in MacRoman instead of escaping all non-ASCII characters.
|
||||
* Cleaned up the resource descriptions in listings and dumps to improve readability. Previously they included some redundant or unnecessary information - for example, each resource with no attributes set would be explicitly marked as "no attributes".
|
||||
* Unified the formats of resource descriptions in listings and dumps, which were previously slightly different from each other.
|
||||
* Improved error messages when attempting to read multiple resources using ``--format=hex`` or ``--format=raw``.
|
||||
* Fixed reading from non-seekable streams not working for some resource files.
|
||||
* Removed the ``allow_seek`` parameter of ``ResourceFork.__init__`` and the ``--read-mode`` command line option. They are no longer necessary, and were already practically useless before due to non-seekable stream reading being broken.
|
||||
|
||||
Version 1.1.3.post1
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
* Fixed a formatting error in the README.rst to allow upload to PyPI.
|
||||
|
||||
Version 1.1.3
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
**Note: This version is not available on PyPI, see version 1.1.3.post1 changelog for details.**
|
||||
|
||||
* Added a setuptools entry point for the command-line interface. This allows calling it using just ``rsrcfork`` instead of ``python3 -m rsrcfork``.
|
||||
* Changed the default value of ``ResourceFork.__init__``'s ``close`` keyword argument from ``True`` to ``False``. This matches the behavior of classes like ``zipfile.ZipFile`` and ``tarfile.TarFile``.
|
||||
* Fixed ``ResourceFork.open`` and ``ResourceFork.__init__`` not closing their streams in some cases.
|
||||
* Refactored the single ``rsrcfork.py`` file into a package. This is an internal change and should have no effect on how the ``rsrcfork`` module is used.
|
||||
|
||||
Version 1.1.2
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
* Added support for the resource file attributes "Resources Locked" and "Printer Driver MultiFinder Compatible" from ResEdit.
|
||||
* Added more dummy constants for resource attributes with unknown meaning, so that resource files containing such attributes can be loaded without errors.
|
||||
|
||||
Version 1.1.1
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
* Fixed overflow issue with empty resource files or empty resource type entries
|
||||
* Changed ``_hexdump`` to behave more like ``hexdump -C``
|
||||
|
||||
Version 1.1.0
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
* Added a command-line interface - run ``python3 -m rsrcfork --help`` for more info
|
||||
|
||||
Version 1.0.0
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
* Initial version
|
|
@ -0,0 +1,6 @@
|
|||
[build-system]
|
||||
requires = [
|
||||
"setuptools >= 46.4.0",
|
||||
"wheel >= 0.32.0",
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
|
@ -1,854 +0,0 @@
|
|||
import argparse
|
||||
import collections
|
||||
import enum
|
||||
import itertools
|
||||
import pathlib
|
||||
import sys
|
||||
import textwrap
|
||||
import typing
|
||||
|
||||
from . import __version__, api, compress
|
||||
|
||||
# The encoding to use when rendering bytes as text (in four-char codes, strings, hex dumps, etc.) or reading a quoted byte string (from the command line).
|
||||
_TEXT_ENCODING = "MacRoman"
|
||||
|
||||
# Translation table to replace ASCII non-printable characters with periods.
|
||||
_TRANSLATE_NONPRINTABLES = {k: "." for k in [*range(0x20), 0x7f]}
|
||||
|
||||
_REZ_ATTR_NAMES = {
|
||||
api.ResourceAttrs.resSysRef: None, # "Illegal or reserved attribute"
|
||||
api.ResourceAttrs.resSysHeap: "sysheap",
|
||||
api.ResourceAttrs.resPurgeable: "purgeable",
|
||||
api.ResourceAttrs.resLocked: "locked",
|
||||
api.ResourceAttrs.resProtected: "protected",
|
||||
api.ResourceAttrs.resPreload: "preload",
|
||||
api.ResourceAttrs.resChanged: None, # "Illegal or reserved attribute"
|
||||
api.ResourceAttrs.resCompressed: None, # "Extended Header resource attribute"
|
||||
}
|
||||
|
||||
F = typing.TypeVar("F", bound=enum.Flag)
|
||||
def decompose_flags(value: F) -> typing.Sequence[F]:
|
||||
"""Decompose an enum.Flags instance into separate enum constants."""
|
||||
|
||||
return [bit for bit in type(value) if bit in value]
|
||||
|
||||
def is_printable(char: str) -> bool:
|
||||
"""Determine whether a character is printable for our purposes.
|
||||
|
||||
We mainly use Python's definition of printable (i. e. everything that Unicode does not consider a separator or "other" character). However, we also treat U+F8FF as printable, which is the private use codepoint used for the Apple logo character.
|
||||
"""
|
||||
|
||||
return char.isprintable() or char == "\uf8ff"
|
||||
|
||||
def bytes_unescape(string: str) -> bytes:
|
||||
"""Convert a string containing text (in _TEXT_ENCODING) and hex escapes to a bytestring.
|
||||
|
||||
(We implement our own unescaping mechanism here to not depend on any of Python's string/bytes escape syntax.)
|
||||
"""
|
||||
|
||||
out: typing.List[int] = []
|
||||
it = iter(string)
|
||||
for char in it:
|
||||
if char == "\\":
|
||||
try:
|
||||
esc = next(it)
|
||||
if esc in "\\\'\"":
|
||||
out.extend(esc.encode(_TEXT_ENCODING))
|
||||
elif esc == "x":
|
||||
x1, x2 = next(it), next(it)
|
||||
out.append(int(x1+x2, 16))
|
||||
else:
|
||||
raise ValueError(f"Unknown escape character: {esc}")
|
||||
except StopIteration:
|
||||
raise ValueError("End of string in escape sequence")
|
||||
else:
|
||||
out.extend(char.encode(_TEXT_ENCODING))
|
||||
|
||||
return bytes(out)
|
||||
|
||||
def bytes_escape(bs: bytes, *, quote: typing.Optional[str]=None) -> str:
|
||||
"""Convert a bytestring to a string (using _TEXT_ENCODING), with non-printable characters hex-escaped.
|
||||
|
||||
(We implement our own escaping mechanism here to not depend on Python's str or bytes repr.)
|
||||
"""
|
||||
|
||||
out = []
|
||||
for byte, char in zip(bs, bs.decode(_TEXT_ENCODING)):
|
||||
if char in {quote, "\\"}:
|
||||
out.append(f"\\{char}")
|
||||
elif is_printable(char):
|
||||
out.append(char)
|
||||
else:
|
||||
out.append(f"\\x{byte:02x}")
|
||||
|
||||
return "".join(out)
|
||||
|
||||
def filter_resources(rf: api.ResourceFile, filters: typing.Sequence[str]) -> typing.List[api.Resource]:
|
||||
matching: typing.MutableMapping[typing.Tuple[bytes, int], api.Resource] = collections.OrderedDict()
|
||||
|
||||
for filter in filters:
|
||||
if len(filter) == 4:
|
||||
try:
|
||||
resources = rf[filter.encode("ascii")]
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
for res in resources.values():
|
||||
matching[res.type, res.id] = res
|
||||
elif filter[0] == filter[-1] == "'":
|
||||
try:
|
||||
resources = rf[bytes_unescape(filter[1:-1])]
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
for res in resources.values():
|
||||
matching[res.type, res.id] = res
|
||||
else:
|
||||
pos = filter.find("'", 1)
|
||||
if pos == -1:
|
||||
raise ValueError(f"Invalid filter {filter!r}: Resource type must be single-quoted")
|
||||
elif filter[pos + 1] != " ":
|
||||
raise ValueError(f"Invalid filter {filter!r}: Resource type and ID must be separated by a space")
|
||||
|
||||
restype_str, resid_str = filter[:pos + 1], filter[pos + 2:]
|
||||
|
||||
if not restype_str[0] == restype_str[-1] == "'":
|
||||
raise ValueError(
|
||||
f"Invalid filter {filter!r}: Resource type is not a single-quoted type identifier: {restype_str!r}")
|
||||
restype = bytes_unescape(restype_str[1:-1])
|
||||
|
||||
if len(restype) != 4:
|
||||
raise ValueError(
|
||||
f"Invalid filter {filter!r}: Type identifier must be 4 bytes after replacing escapes, got {len(restype)} bytes: {restype!r}")
|
||||
|
||||
if resid_str[0] != "(" or resid_str[-1] != ")":
|
||||
raise ValueError(f"Invalid filter {filter!r}: Resource ID must be parenthesized")
|
||||
resid_str = resid_str[1:-1]
|
||||
|
||||
try:
|
||||
resources = rf[restype]
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
if resid_str[0] == resid_str[-1] == '"':
|
||||
name = bytes_unescape(resid_str[1:-1])
|
||||
|
||||
for res in resources.values():
|
||||
if res.name == name:
|
||||
matching[res.type, res.id] = res
|
||||
break
|
||||
elif ":" in resid_str:
|
||||
if resid_str.count(":") > 1:
|
||||
raise ValueError(f"Invalid filter {filter!r}: Too many colons in ID range expression: {resid_str!r}")
|
||||
start_str, end_str = resid_str.split(":")
|
||||
start, end = int(start_str), int(end_str)
|
||||
|
||||
for res in resources.values():
|
||||
if start <= res.id <= end:
|
||||
matching[res.type, res.id] = res
|
||||
else:
|
||||
resid = int(resid_str)
|
||||
try:
|
||||
res = resources[resid]
|
||||
except KeyError:
|
||||
continue
|
||||
matching[res.type, res.id] = res
|
||||
|
||||
return list(matching.values())
|
||||
|
||||
def hexdump(data: bytes) -> None:
|
||||
last_line = None
|
||||
asterisk_shown = False
|
||||
for i in range(0, len(data), 16):
|
||||
line = data[i:i + 16]
|
||||
# If the same 16-byte lines appear multiple times, print only the first one, and replace all further lines with a single line with an asterisk.
|
||||
# This is unambiguous - to find out how many lines were collapsed this way, the user can compare the addresses of the lines before and after the asterisk.
|
||||
if line == last_line:
|
||||
if not asterisk_shown:
|
||||
print("*")
|
||||
asterisk_shown = True
|
||||
else:
|
||||
line_hex_left = " ".join(f"{byte:02x}" for byte in line[:8])
|
||||
line_hex_right = " ".join(f"{byte:02x}" for byte in line[8:])
|
||||
line_char = line.decode(_TEXT_ENCODING).translate(_TRANSLATE_NONPRINTABLES)
|
||||
print(f"{i:08x} {line_hex_left:<{8*2+7}} {line_hex_right:<{8*2+7}} |{line_char}|")
|
||||
asterisk_shown = False
|
||||
last_line = line
|
||||
|
||||
if data:
|
||||
print(f"{len(data):08x}")
|
||||
|
||||
def raw_hexdump(data: bytes) -> None:
|
||||
for i in range(0, len(data), 16):
|
||||
print(" ".join(f"{byte:02x}" for byte in data[i:i + 16]))
|
||||
|
||||
def translate_text(data: bytes) -> str:
|
||||
return data.decode(_TEXT_ENCODING).replace("\r", "\n")
|
||||
|
||||
def describe_resource(res: api.Resource, *, include_type: bool, decompress: bool) -> str:
|
||||
id_desc_parts = [f"{res.id}"]
|
||||
|
||||
if res.name is not None:
|
||||
name = bytes_escape(res.name, quote='"')
|
||||
id_desc_parts.append(f'"{name}"')
|
||||
|
||||
id_desc = ", ".join(id_desc_parts)
|
||||
|
||||
content_desc_parts = []
|
||||
|
||||
if decompress and api.ResourceAttrs.resCompressed in res.attributes:
|
||||
try:
|
||||
res.compressed_info
|
||||
except compress.DecompressError:
|
||||
length_desc = f"unparseable compressed data header ({res.length_raw} bytes compressed)"
|
||||
else:
|
||||
assert res.compressed_info is not None
|
||||
length_desc = f"{res.length} bytes ({res.length_raw} bytes compressed, 'dcmp' ({res.compressed_info.dcmp_id}) format)"
|
||||
else:
|
||||
assert res.compressed_info is None
|
||||
length_desc = f"{res.length_raw} bytes"
|
||||
content_desc_parts.append(length_desc)
|
||||
|
||||
attrs = decompose_flags(res.attributes)
|
||||
if attrs:
|
||||
content_desc_parts.append(" | ".join(attr.name for attr in attrs))
|
||||
|
||||
content_desc = ", ".join(content_desc_parts)
|
||||
|
||||
desc = f"({id_desc}): {content_desc}"
|
||||
if include_type:
|
||||
restype = bytes_escape(res.type, quote="'")
|
||||
desc = f"'{restype}' {desc}"
|
||||
return desc
|
||||
|
||||
def parse_args_old(args: typing.List[str]) -> argparse.Namespace:
|
||||
ap = argparse.ArgumentParser(
|
||||
add_help=False,
|
||||
fromfile_prefix_chars="@",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
description=textwrap.dedent("""
|
||||
Read and display resources from a file's resource or data fork.
|
||||
|
||||
When specifying resource filters, each one may be of one of the
|
||||
following forms:
|
||||
|
||||
An unquoted type name (without escapes): TYPE
|
||||
A quoted type name: 'TYPE'
|
||||
A quoted type name and an ID: 'TYPE' (42)
|
||||
A quoted type name and an ID range: 'TYPE' (24:42)
|
||||
A quoted type name and a resource name: 'TYPE' ("foobar")
|
||||
|
||||
When multiple filters are specified, all resources matching any of them
|
||||
are displayed.
|
||||
"""),
|
||||
)
|
||||
|
||||
ap.add_argument("--help", action="help", help="Display this help message and exit")
|
||||
ap.add_argument("--version", action="version", version=__version__, help="Display version information and exit")
|
||||
ap.add_argument("-a", "--all", action="store_true", help="When no filters are given, show all resources in full, instead of an overview")
|
||||
ap.add_argument("-f", "--fork", choices=["auto", "data", "rsrc"], default="auto", help="The fork from which to read the resource data, or auto to guess (default: %(default)s)")
|
||||
ap.add_argument("--no-decompress", action="store_false", dest="decompress", help="Do not decompress compressed resources, output compressed resource data as-is")
|
||||
ap.add_argument("--format", choices=["dump", "dump-text", "hex", "raw", "derez"], default="dump", help="How to output the resources - human-readable info with hex dump (dump) (default), human-readable info with newline-translated data (dump-text), data only as hex (hex), data only as raw bytes (raw), or like DeRez with no resource definitions (derez)")
|
||||
ap.add_argument("--group", action="store", choices=["none", "type", "id"], default="type", help="Group resources in list view by type or ID, or disable grouping (default: type)")
|
||||
ap.add_argument("--no-sort", action="store_false", dest="sort", help="Output resources in the order in which they are stored in the file, instead of sorting them by type and ID")
|
||||
ap.add_argument("--header-system", action="store_true", help="Output system-reserved header data and nothing else")
|
||||
ap.add_argument("--header-application", action="store_true", help="Output application-specific header data and nothing else")
|
||||
|
||||
ap.add_argument("file", help="The file to read, or - for stdin")
|
||||
ap.add_argument("filter", nargs="*", help="One or more filters to select which resources to display, or omit to show an overview of all resources")
|
||||
|
||||
ns = ap.parse_args(args)
|
||||
return ns
|
||||
|
||||
def show_header_data(data: bytes, *, format: str) -> None:
|
||||
if format == "dump":
|
||||
hexdump(data)
|
||||
elif format == "dump-text":
|
||||
print(translate_text(data))
|
||||
elif format == "hex":
|
||||
raw_hexdump(data)
|
||||
elif format == "raw":
|
||||
sys.stdout.buffer.write(data)
|
||||
elif format == "derez":
|
||||
print("Cannot output file header data in derez format", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
raise ValueError(f"Unhandled output format: {format}")
|
||||
|
||||
def show_filtered_resources(resources: typing.Sequence[api.Resource], format: str, decompress: bool) -> None:
|
||||
if not resources:
|
||||
if format in ("dump", "dump-text"):
|
||||
print("No resources matched the filter")
|
||||
elif format in ("hex", "raw"):
|
||||
print("No resources matched the filter", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif format == "derez":
|
||||
print("/* No resources matched the filter */")
|
||||
else:
|
||||
raise AssertionError(f"Unhandled output format: {format}")
|
||||
elif format in ("hex", "raw") and len(resources) != 1:
|
||||
print(f"Format {format} can only output a single resource, but the filter matched {len(resources)} resources", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
for res in resources:
|
||||
if decompress:
|
||||
data = res.data
|
||||
else:
|
||||
data = res.data_raw
|
||||
|
||||
if format in ("dump", "dump-text"):
|
||||
# Human-readable info and hex or text dump
|
||||
desc = describe_resource(res, include_type=True, decompress=decompress)
|
||||
print(f"Resource {desc}:")
|
||||
if format == "dump":
|
||||
hexdump(data)
|
||||
elif format == "dump-text":
|
||||
print(translate_text(data))
|
||||
else:
|
||||
raise AssertionError(f"Unhandled format: {format!r}")
|
||||
print()
|
||||
elif format == "hex":
|
||||
# Data only as hex
|
||||
|
||||
raw_hexdump(data)
|
||||
elif format == "raw":
|
||||
# Data only as raw bytes
|
||||
|
||||
sys.stdout.buffer.write(data)
|
||||
elif format == "derez":
|
||||
# Like DeRez with no resource definitions
|
||||
|
||||
attrs = list(decompose_flags(res.attributes))
|
||||
|
||||
if decompress and api.ResourceAttrs.resCompressed in attrs:
|
||||
attrs.remove(api.ResourceAttrs.resCompressed)
|
||||
attrs_comment = " /* was compressed */"
|
||||
else:
|
||||
attrs_comment = ""
|
||||
|
||||
attr_descs_with_none = [_REZ_ATTR_NAMES[attr] for attr in attrs]
|
||||
if None in attr_descs_with_none:
|
||||
attr_descs = [f"${res.attributes.value:02X}"]
|
||||
else:
|
||||
attr_descs = typing.cast(typing.List[str], attr_descs_with_none)
|
||||
|
||||
parts = [str(res.id)]
|
||||
|
||||
if res.name is not None:
|
||||
name = bytes_escape(res.name, quote='"')
|
||||
parts.append(f'"{name}"')
|
||||
|
||||
parts += attr_descs
|
||||
|
||||
restype = bytes_escape(res.type, quote="'")
|
||||
print(f"data '{restype}' ({', '.join(parts)}{attrs_comment}) {{")
|
||||
|
||||
for i in range(0, len(data), 16):
|
||||
# Two-byte grouping is really annoying to implement.
|
||||
groups = []
|
||||
for j in range(0, 16, 2):
|
||||
if i+j >= len(data):
|
||||
break
|
||||
elif i+j+1 >= len(data):
|
||||
groups.append(f"{data[i+j]:02X}")
|
||||
else:
|
||||
groups.append(f"{data[i+j]:02X}{data[i+j+1]:02X}")
|
||||
|
||||
s = f'$"{" ".join(groups)}"'
|
||||
comment = "/* " + data[i:i + 16].decode(_TEXT_ENCODING).translate(_TRANSLATE_NONPRINTABLES) + " */"
|
||||
print(f"\t{s:<54s}{comment}")
|
||||
|
||||
print("};")
|
||||
print()
|
||||
else:
|
||||
raise ValueError(f"Unhandled output format: {format}")
|
||||
|
||||
def list_resource_file(rf: api.ResourceFile, *, sort: bool, group: str, decompress: bool) -> None:
|
||||
if len(rf) == 0:
|
||||
print("No resources (empty resource file)")
|
||||
return
|
||||
|
||||
if group == "none":
|
||||
all_resources: typing.List[api.Resource] = []
|
||||
for reses in rf.values():
|
||||
all_resources.extend(reses.values())
|
||||
if sort:
|
||||
all_resources.sort(key=lambda res: (res.type, res.id))
|
||||
print(f"{len(all_resources)} resources:")
|
||||
for res in all_resources:
|
||||
print(describe_resource(res, include_type=True, decompress=decompress))
|
||||
elif group == "type":
|
||||
print(f"{len(rf)} resource types:")
|
||||
restype_items: typing.Collection[typing.Tuple[bytes, typing.Mapping[int, api.Resource]]] = rf.items()
|
||||
if sort:
|
||||
restype_items = sorted(restype_items, key=lambda item: item[0])
|
||||
for typecode, resources_map in restype_items:
|
||||
restype = bytes_escape(typecode, quote="'")
|
||||
print(f"'{restype}': {len(resources_map)} resources:")
|
||||
resources_items: typing.Collection[typing.Tuple[int, api.Resource]] = resources_map.items()
|
||||
if sort:
|
||||
resources_items = sorted(resources_items, key=lambda item: item[0])
|
||||
for resid, res in resources_items:
|
||||
print(describe_resource(res, include_type=False, decompress=decompress))
|
||||
print()
|
||||
elif group == "id":
|
||||
all_resources = []
|
||||
for reses in rf.values():
|
||||
all_resources.extend(reses.values())
|
||||
all_resources.sort(key=lambda res: res.id)
|
||||
resources_by_id = {resid: list(reses) for resid, reses in itertools.groupby(all_resources, key=lambda res: res.id)}
|
||||
print(f"{len(resources_by_id)} resource IDs:")
|
||||
for resid, resources in resources_by_id.items():
|
||||
print(f"({resid}): {len(resources)} resources:")
|
||||
if sort:
|
||||
resources.sort(key=lambda res: res.type)
|
||||
for res in resources:
|
||||
print(describe_resource(res, include_type=True, decompress=decompress))
|
||||
print()
|
||||
else:
|
||||
raise AssertionError(f"Unhandled group mode: {group!r}")
|
||||
|
||||
def main_old(args: typing.List[str]) -> typing.NoReturn:
|
||||
ns = parse_args_old(args)
|
||||
|
||||
if ns.file == "-":
|
||||
if ns.fork != "auto":
|
||||
print("Cannot specify an explicit fork when reading from stdin", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
rf = api.ResourceFile(sys.stdin.buffer)
|
||||
else:
|
||||
rf = api.ResourceFile.open(ns.file, fork=ns.fork)
|
||||
|
||||
with rf:
|
||||
print("Warning: The syntax of the rsrcfork command has changed.", file=sys.stderr)
|
||||
|
||||
if ns.header_system or ns.header_application:
|
||||
if ns.header_system:
|
||||
print('Please use "rsrcfork read-header --part=system <file>" instead of "rsrcfork --header-system <file>".', file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
|
||||
data = rf.header_system_data
|
||||
else:
|
||||
print('Please use "rsrcfork read-header --part=application <file>" instead of "rsrcfork --header-application <file>".', file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
|
||||
data = rf.header_application_data
|
||||
|
||||
show_header_data(data, format=ns.format)
|
||||
elif ns.filter or ns.all:
|
||||
if ns.filter:
|
||||
print('Please use "rsrcfork read <file> <filters...>" instead of "rsrcfork <file> <filters...>".', file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
|
||||
resources = filter_resources(rf, ns.filter)
|
||||
else:
|
||||
print('Please use "rsrcfork read <file>" instead of "rsrcfork <file> --all".', file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
|
||||
resources = []
|
||||
for reses in rf.values():
|
||||
resources.extend(reses.values())
|
||||
|
||||
if ns.sort:
|
||||
resources.sort(key=lambda res: (res.type, res.id))
|
||||
|
||||
show_filtered_resources(resources, format=ns.format, decompress=ns.decompress)
|
||||
else:
|
||||
print('Please use "rsrcfork list <file>" instead of "rsrcfork <file>".', file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
|
||||
if rf.header_system_data != bytes(len(rf.header_system_data)):
|
||||
print("Header system data:")
|
||||
hexdump(rf.header_system_data)
|
||||
|
||||
if rf.header_application_data != bytes(len(rf.header_application_data)):
|
||||
print("Header application data:")
|
||||
hexdump(rf.header_application_data)
|
||||
|
||||
attrs = decompose_flags(rf.file_attributes)
|
||||
if attrs:
|
||||
print("File attributes: " + " | ".join(attr.name for attr in attrs))
|
||||
|
||||
list_resource_file(rf, sort=ns.sort, group=ns.group, decompress=ns.decompress)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def make_argument_parser(*, description: str, **kwargs: typing.Any) -> argparse.ArgumentParser:
|
||||
"""Create an argparse.ArgumentParser with some slightly modified defaults.
|
||||
|
||||
This function is used to ensure that all subcommands use the same base configuration for their ArgumentParser.
|
||||
"""
|
||||
|
||||
ap = argparse.ArgumentParser(
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
description=description,
|
||||
allow_abbrev=False,
|
||||
add_help=False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
ap.add_argument("--help", action="help", help="Display this help message and exit")
|
||||
|
||||
return ap
|
||||
|
||||
def add_resource_file_args(ap: argparse.ArgumentParser) -> None:
|
||||
"""Define common options/arguments for specifying an input resource file.
|
||||
|
||||
This includes a positional argument for the resource file's path, and the ``--fork`` option to select which fork of the file to use.
|
||||
"""
|
||||
|
||||
ap.add_argument("--fork", choices=["auto", "data", "rsrc"], default="auto", help="The fork from which to read the resource file data, or auto to guess. Default: %(default)s")
|
||||
ap.add_argument("file", help="The file from which to read resources, or - for stdin.")
|
||||
|
||||
def open_resource_file(file: str, *, fork: str = None) -> api.ResourceFile:
|
||||
"""Open a resource file at the given path, using the specified fork."""
|
||||
|
||||
if file == "-":
|
||||
if fork != "auto":
|
||||
print("Cannot specify an explicit fork when reading from stdin", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return api.ResourceFile(sys.stdin.buffer)
|
||||
else:
|
||||
return api.ResourceFile.open(file, fork=fork)
|
||||
|
||||
|
||||
def do_read_header(prog: str, args: typing.List[str]) -> typing.NoReturn:
|
||||
"""Read the header data from a resource file."""
|
||||
|
||||
ap = make_argument_parser(
|
||||
prog=prog,
|
||||
description="""
|
||||
Read and output a resource file's header data.
|
||||
|
||||
The header data consists of two parts:
|
||||
|
||||
The system-reserved data is 112 bytes long and used by the Classic Mac OS
|
||||
Finder as temporary storage space. It usually contains parts of the
|
||||
file metadata (name, type/creator code, etc.).
|
||||
|
||||
The application-specific data is 128 bytes long and is available for use by
|
||||
applications. In practice it usually contains junk data that happened to be in
|
||||
memory when the resource file was written.
|
||||
|
||||
Mac OS X does not use the header data fields anymore. Resource files written
|
||||
on Mac OS X normally have both parts of the header data set to all zero bytes.
|
||||
""",
|
||||
)
|
||||
|
||||
ap.add_argument("--format", choices=["dump", "dump-text", "hex", "raw"], default="dump", help="How to output the header data: human-readable info with hex dump (dump) (default), human-readable info with newline-translated data (dump-text), data only as hex (hex), or data only as raw bytes (raw). Default: %(default)s")
|
||||
ap.add_argument("--part", choices=["system", "application", "all"], default="all", help="Which part of the header to read. Default: %(default)s")
|
||||
add_resource_file_args(ap)
|
||||
|
||||
ns = ap.parse_args(args)
|
||||
|
||||
with open_resource_file(ns.file, fork=ns.fork) as rf:
|
||||
if ns.format in {"dump", "dump-text"}:
|
||||
if ns.format == "dump":
|
||||
dump_func = hexdump
|
||||
elif ns.format == "dump-text":
|
||||
def dump_func(d):
|
||||
print(translate_text(d))
|
||||
else:
|
||||
raise AssertionError(f"Unhandled --format: {ns.format!r}")
|
||||
|
||||
if ns.part in {"system", "all"}:
|
||||
print("System-reserved header data:")
|
||||
dump_func(rf.header_system_data)
|
||||
|
||||
if ns.part in {"application", "all"}:
|
||||
print("Application-specific header data:")
|
||||
dump_func(rf.header_application_data)
|
||||
elif ns.format in {"hex", "raw"}:
|
||||
if ns.part == "system":
|
||||
data = rf.header_system_data
|
||||
elif ns.part == "application":
|
||||
data = rf.header_application_data
|
||||
elif ns.part == "all":
|
||||
data = rf.header_system_data + rf.header_application_data
|
||||
else:
|
||||
raise AssertionError(f"Unhandled --part: {ns.part!r}")
|
||||
|
||||
if ns.format == "hex":
|
||||
raw_hexdump(data)
|
||||
elif ns.format == "raw":
|
||||
sys.stdout.buffer.write(data)
|
||||
else:
|
||||
raise AssertionError(f"Unhandled --format: {ns.format!r}")
|
||||
else:
|
||||
raise AssertionError(f"Unhandled --format: {ns.format!r}")
|
||||
|
||||
def do_info(prog: str, args: typing.List[str]) -> typing.NoReturn:
|
||||
"""Display technical information about the resource file."""
|
||||
|
||||
ap = make_argument_parser(
|
||||
prog=prog,
|
||||
description="""
|
||||
Display technical information and stats about the resource file.
|
||||
""",
|
||||
)
|
||||
add_resource_file_args(ap)
|
||||
|
||||
ns = ap.parse_args(args)
|
||||
|
||||
with open_resource_file(ns.file, fork=ns.fork) as rf:
|
||||
print("System-reserved header data:")
|
||||
hexdump(rf.header_system_data)
|
||||
print()
|
||||
print("Application-specific header data:")
|
||||
hexdump(rf.header_application_data)
|
||||
print()
|
||||
|
||||
print(f"Resource data starts at {rf.data_offset:#x} and is {rf.data_length:#x} bytes long")
|
||||
print(f"Resource map starts at {rf.map_offset:#x} and is {rf.map_length:#x} bytes long")
|
||||
attrs = decompose_flags(rf.file_attributes)
|
||||
if attrs:
|
||||
attrs_desc = " | ".join(attr.name for attr in attrs)
|
||||
else:
|
||||
attrs_desc = "(none)"
|
||||
print(f"Resource map attributes: {attrs_desc}")
|
||||
print(f"Resource map type list starts at {rf.map_type_list_offset:#x} (relative to map start) and contains {len(rf)} types")
|
||||
print(f"Resource map name list starts at {rf.map_name_list_offset:#x} (relative to map start)")
|
||||
|
||||
def do_list(prog: str, args: typing.List[str]) -> typing.NoReturn:
|
||||
"""List the resources in a file."""
|
||||
|
||||
ap = make_argument_parser(
|
||||
prog=prog,
|
||||
description="""
|
||||
List the resources stored in a resource file.
|
||||
|
||||
Each resource's type, ID, name (if any), attributes (if any), and data length
|
||||
are displayed. For compressed resources, the compressed and decompressed data
|
||||
length are displayed, as well as the ID of the 'dcmp' resource used to
|
||||
decompress the resource data.
|
||||
""",
|
||||
)
|
||||
|
||||
ap.add_argument("--no-decompress", action="store_false", dest="decompress", help="Do not parse the data header of compressed resources and only output their compressed length.")
|
||||
ap.add_argument("--group", action="store", choices=["none", "type", "id"], default="type", help="Group resources by type or ID, or disable grouping. Default: %(default)s")
|
||||
ap.add_argument("--no-sort", action="store_false", dest="sort", help="Output resources in the order in which they are stored in the file, instead of sorting them by type and ID.")
|
||||
add_resource_file_args(ap)
|
||||
|
||||
ns = ap.parse_args(args)
|
||||
|
||||
with open_resource_file(ns.file, fork=ns.fork) as rf:
|
||||
list_resource_file(rf, sort=ns.sort, group=ns.group, decompress=ns.decompress)
|
||||
|
||||
def do_read(prog: str, args: typing.List[str]) -> typing.NoReturn:
|
||||
"""Read data from resources."""
|
||||
|
||||
ap = make_argument_parser(
|
||||
prog=prog,
|
||||
description="""
|
||||
Read the data of one or more resources.
|
||||
|
||||
The resource filters use syntax similar to Rez (resource definition) files.
|
||||
Each filter can have one of the following forms:
|
||||
|
||||
An unquoted type name (without escapes): TYPE
|
||||
A quoted type name: 'TYPE'
|
||||
A quoted type name and an ID: 'TYPE' (42)
|
||||
A quoted type name and an ID range: 'TYPE' (24:42)
|
||||
A quoted type name and a resource name: 'TYPE' ("foobar")
|
||||
|
||||
Note that the resource filter syntax uses quotes, parentheses and spaces,
|
||||
which have special meanings in most shells. It is recommended to quote each
|
||||
resource filter (using double quotes) to ensure that it is not interpreted
|
||||
or rewritten by the shell.
|
||||
""",
|
||||
)
|
||||
|
||||
ap.add_argument("--no-decompress", action="store_false", dest="decompress", help="Do not decompress compressed resources, output the raw compressed resource data.")
|
||||
ap.add_argument("--format", choices=["dump", "dump-text", "hex", "raw", "derez"], default="dump", help="How to output the resources: human-readable info with hex dump (dump), human-readable info with newline-translated data (dump-text), data only as hex (hex), data only as raw bytes (raw), or like DeRez with no resource definitions (derez). Default: %(default)s")
|
||||
ap.add_argument("--no-sort", action="store_false", dest="sort", help="Output resources in the order in which they are stored in the file, instead of sorting them by type and ID.")
|
||||
add_resource_file_args(ap)
|
||||
ap.add_argument("filter", nargs="*", help="One or more filters to select which resources to read. If no filters are specified, all resources are read.")
|
||||
|
||||
ns = ap.parse_args(args)
|
||||
|
||||
with open_resource_file(ns.file, fork=ns.fork) as rf:
|
||||
if ns.filter:
|
||||
resources = filter_resources(rf, ns.filter)
|
||||
else:
|
||||
resources = []
|
||||
for reses in rf.values():
|
||||
resources.extend(reses.values())
|
||||
|
||||
if ns.sort:
|
||||
resources.sort(key=lambda res: (res.type, res.id))
|
||||
|
||||
show_filtered_resources(resources, format=ns.format, decompress=ns.decompress)
|
||||
|
||||
def do_raw_decompress(prog: str, args: typing.List[str]) -> typing.NoReturn:
|
||||
"""Decompress raw compressed resource data."""
|
||||
|
||||
ap = make_argument_parser(
|
||||
prog=prog,
|
||||
description="""
|
||||
Decompress raw compressed resource data that is stored in a standalone file
|
||||
and not as a resource in a resource file.
|
||||
|
||||
This subcommand can be used in a shell pipeline by passing - as the input and
|
||||
output file name, i. e. "%(prog)s - -".
|
||||
|
||||
Note: All other rsrcfork subcommands natively support compressed resources and
|
||||
will automatically decompress them as needed. This subcommand is only needed
|
||||
to decompress resource data that has been read from a resource file in
|
||||
compressed form (e. g. using --no-decompress or another tool that does not
|
||||
handle resource compression).
|
||||
""",
|
||||
)
|
||||
|
||||
ap.add_argument("--debug", action="store_true", help="Display debugging output from the decompressor on stdout. Cannot be used if the output file is - (stdout).")
|
||||
|
||||
ap.add_argument("input_file", help="The file from which to read the compressed resource data, or - for stdin.")
|
||||
ap.add_argument("output_file", help="The file to which to write the decompressed resource data, or - for stdout.")
|
||||
|
||||
ns = ap.parse_args(args)
|
||||
|
||||
if ns.input_file == "-":
|
||||
in_stream = sys.stdin.buffer
|
||||
close_in_stream = False
|
||||
else:
|
||||
in_stream = open(ns.input_file, "rb")
|
||||
close_in_stream = True
|
||||
|
||||
try:
|
||||
header_info = compress.CompressedHeaderInfo.parse_stream(in_stream)
|
||||
|
||||
# Open the output file only after parsing the header, so that the file is only created (or its existing contents deleted) if the input file is valid.
|
||||
if ns.output_file == "-":
|
||||
if ns.debug:
|
||||
print("Cannot use --debug if the decompression output file is - (stdout).", file=sys.stderr)
|
||||
print("The debug output goes to stdout and would conflict with the decompressed data.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
out_stream = sys.stdout.buffer
|
||||
close_out_stream = False
|
||||
else:
|
||||
out_stream = open(ns.output_file, "wb")
|
||||
close_out_stream = True
|
||||
|
||||
try:
|
||||
for chunk in compress.decompress_stream_parsed(header_info, in_stream, debug=ns.debug):
|
||||
out_stream.write(chunk)
|
||||
finally:
|
||||
if close_out_stream:
|
||||
out_stream.close()
|
||||
finally:
|
||||
if close_in_stream:
|
||||
in_stream.close()
|
||||
|
||||
|
||||
SUBCOMMANDS = {
|
||||
"read-header": do_read_header,
|
||||
"info": do_info,
|
||||
"list": do_list,
|
||||
"read": do_read,
|
||||
"raw-decompress": do_raw_decompress,
|
||||
}
|
||||
|
||||
|
||||
def format_subcommands_help() -> str:
|
||||
"""Return a formatted help text describing the availble subcommands.
|
||||
|
||||
Because we do not use argparse's native support for subcommands (see comments in main function), the main ArgumentParser's help does not include any information about the subcommands by default, so we have to format and add it ourselves.
|
||||
"""
|
||||
|
||||
# The list of subcommands is formatted using a "fake" ArgumentParser, which is never actually used to parse any arguments.
|
||||
# The options are chosen so that the help text will only include the subcommands list and epilog, but no usage or any other arguments.
|
||||
fake_ap = argparse.ArgumentParser(
|
||||
usage=argparse.SUPPRESS,
|
||||
epilog=textwrap.dedent("""
|
||||
Most of the above subcommands take additional arguments. Run a subcommand with
|
||||
the option --help for help about the options understood by that subcommand.
|
||||
"""),
|
||||
add_help=False,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
# The subcommands are added as positional arguments to a custom group with the title "subcommands".
|
||||
# Semantically this makes no sense, but it looks right in the formatted help text:
|
||||
# the result is a section "subcommands" with an aligned list of command names and short descriptions.
|
||||
fake_group = fake_ap.add_argument_group(title="subcommands")
|
||||
|
||||
for name, func in SUBCOMMANDS.items():
|
||||
# Each command's short description is taken from the implementation function's docstring.
|
||||
fake_group.add_argument(name, help=func.__doc__)
|
||||
|
||||
return fake_ap.format_help()
|
||||
|
||||
|
||||
def main() -> typing.NoReturn:
|
||||
"""Main function of the CLI.
|
||||
|
||||
This function is a valid setuptools entry point. Arguments are passed in sys.argv, and every execution path ends with a sys.exit call. (setuptools entry points are also permitted to return an integer, which will be treated as an exit code. We do not use this feature and instead always call sys.exit ourselves.)
|
||||
"""
|
||||
|
||||
prog = pathlib.PurePath(sys.argv[0]).name
|
||||
args = sys.argv[1:]
|
||||
|
||||
# The rsrcfork CLI is structured into subcommands, each implemented in a separate function.
|
||||
# The main function parses the command-line arguments enough to determine which subcommand to call, but leaves parsing of the rest of the arguments to the subcommand itself.
|
||||
# In addition, it detects use of the old, non-subcommand-based CLI syntax, and delegates to the old main function in that case.
|
||||
# This backwards compatibility handling is one of the reasons why we cannot use the subcommand support of argparse or other CLI parsing libraries, so we have to implement most of the subcommand handling ourselves.
|
||||
|
||||
ap = make_argument_parser(
|
||||
prog=prog,
|
||||
# Custom usage string to make "subcommand ..." show up in the usage, but not as "positional arguments" in the main help text.
|
||||
usage=f"{prog} (--help | --version | subcommand ...)",
|
||||
description="""
|
||||
%(prog)s is a tool for working with Classic Mac OS resource files.
|
||||
Currently this tool can only read resource files; modifying/writing resource
|
||||
files is not supported yet.
|
||||
|
||||
Note: This tool is intended for human users. The output format is not
|
||||
machine-readable and may change at any time. The command-line syntax usually
|
||||
does not change much across versions, but this should not be relied on.
|
||||
Automated scripts and programs should use the Python API provided by the
|
||||
rsrcfork library, which this tool is a part of.
|
||||
""",
|
||||
# The list of subcommands is shown in the epilog so that it appears under the list of optional arguments.
|
||||
epilog=format_subcommands_help(),
|
||||
)
|
||||
|
||||
ap.add_argument("--version", action="version", version=__version__, help="Display version information and exit.")
|
||||
|
||||
# The help of these arguments is set to argparse.SUPPRESS so that they do not cause a mostly useless "positional arguments" list to appear.
|
||||
# If the old, non-subcommand syntax is used, the subcommand argument can actually be a file name.
|
||||
ap.add_argument("subcommand", help=argparse.SUPPRESS)
|
||||
ap.add_argument("args", nargs=argparse.REMAINDER, help=argparse.SUPPRESS)
|
||||
|
||||
if not args:
|
||||
print(f"{prog}: Missing subcommand.", file=sys.stderr)
|
||||
ap.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
# First, parse only known arguments from the CLI.
|
||||
# This is so that we can extract the subcommand/file to check if the old CLI syntax was used, without causing CLI syntax errors because of unknown options before the subcommand/file.
|
||||
ns, _ = ap.parse_known_args(args)
|
||||
|
||||
try:
|
||||
# Check if the subcommand is valid.
|
||||
subcommand_func = SUBCOMMANDS[ns.subcommand]
|
||||
except KeyError:
|
||||
if ns.subcommand == "-" or pathlib.Path(ns.subcommand).exists():
|
||||
# Subcommand is actually a file path.
|
||||
# Call the old main function with the entire unparsed argument list, so that it can be reparsed and handled like in previous versions.
|
||||
main_old(args)
|
||||
else:
|
||||
# Subcommand is invalid and also not a path to an existing file. Display an error.
|
||||
print(f"{prog}: Unknown subcommand: {ns.subcommand}", file=sys.stderr)
|
||||
print(f"Run {prog} --help for a list of available subcommands.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
else:
|
||||
# Subcommand is valid. Parse the arguments again, this time without allowing unknown arguments before the subcommand.
|
||||
ns = ap.parse_args(args)
|
||||
# Call the looked up subcommand and pass on further arguments.
|
||||
subcommand_func(f"{prog} {ns.subcommand}", ns.args)
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
65
setup.cfg
|
@ -6,9 +6,6 @@ author = dgelessus
|
|||
classifiers =
|
||||
Development Status :: 4 - Beta
|
||||
Intended Audience :: Developers
|
||||
Topic :: Software Development :: Disassemblers
|
||||
Topic :: System
|
||||
Topic :: Utilities
|
||||
License :: OSI Approved :: MIT License
|
||||
Operating System :: MacOS :: MacOS 9
|
||||
Operating System :: MacOS :: MacOS X
|
||||
|
@ -18,11 +15,20 @@ classifiers =
|
|||
Programming Language :: Python :: 3 :: Only
|
||||
Programming Language :: Python :: 3.6
|
||||
Programming Language :: Python :: 3.7
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Programming Language :: Python :: 3.10
|
||||
Programming Language :: Python :: 3.11
|
||||
Topic :: Software Development :: Disassemblers
|
||||
Topic :: System
|
||||
Topic :: Utilities
|
||||
Typing :: Typed
|
||||
license = MIT
|
||||
license_file = LICENSE
|
||||
description = A pure Python, cross-platform library/tool for reading Macintosh resource data, as stored in resource forks and ``.rsrc`` files
|
||||
long_description = file: README.rst
|
||||
long_description_content_type = text/x-rst
|
||||
license_files =
|
||||
LICENSE
|
||||
description = A pure Python, cross-platform library/tool for reading Macintosh resource data, as stored in resource forks and .rsrc files
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
keywords =
|
||||
rsrc
|
||||
fork
|
||||
|
@ -33,22 +39,55 @@ keywords =
|
|||
macos
|
||||
|
||||
[options]
|
||||
setup_requires =
|
||||
setuptools>=39.2.0
|
||||
# mypy can only find type hints in the package if zip_safe is set to False,
|
||||
# see https://mypy.readthedocs.io/en/latest/installed_packages.html#making-pep-561-compatible-packages
|
||||
zip_safe = False
|
||||
python_requires = >=3.6
|
||||
packages = find:
|
||||
package_dir =
|
||||
= src
|
||||
|
||||
[options.package_data]
|
||||
rsrcfork =
|
||||
py.typed
|
||||
|
||||
[options.packages.find]
|
||||
include =
|
||||
rsrcfork
|
||||
rsrcfork.*
|
||||
where = src
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
rsrcfork = rsrcfork.__main__:main
|
||||
|
||||
[flake8]
|
||||
extend-exclude =
|
||||
.mypy_cache/,
|
||||
build/,
|
||||
dist/,
|
||||
|
||||
# The following issues are ignored because they do not match our code style:
|
||||
ignore =
|
||||
# These E1 checks report many false positives for code that is (consistently) indented with tabs alone.
|
||||
# indentation contains mixed spaces and tabs
|
||||
E101,
|
||||
# over-indented
|
||||
E117,
|
||||
# continuation line over-indented for hanging indent
|
||||
E126,
|
||||
# missing whitespace around arithmetic operator
|
||||
E226,
|
||||
# at least two spaces before inline comment
|
||||
E261,
|
||||
# line too long
|
||||
E501,
|
||||
# indentation contains tabs
|
||||
W191,
|
||||
# blank line contains whitespace
|
||||
W293,
|
||||
# line break before binary operator
|
||||
W503,
|
||||
|
||||
[mypy]
|
||||
files=rsrcfork/**/*.py
|
||||
files=src/**/*.py
|
||||
python_version = 3.6
|
||||
|
||||
disallow_untyped_calls = True
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# To release a new version:
|
||||
# * Remove the .dev suffix from the version number in this file.
|
||||
# * Update the changelog in the README.rst (rename the "next version" section to the correct version number).
|
||||
# * Update the changelog in the README.md (rename the "next version" section to the correct version number).
|
||||
# * Remove the ``dist`` directory (if it exists) to clean up any old release files.
|
||||
# * Run ``python3 setup.py sdist bdist_wheel`` to build the release files.
|
||||
# * Run ``python3 -m twine check dist/*`` to check the release files.
|
||||
|
@ -12,15 +12,15 @@
|
|||
# * Fast-forward the release branch to the new release commit.
|
||||
# * Push the master and release branches.
|
||||
# * Upload the release files to PyPI using ``python3 -m twine upload dist/*``.
|
||||
# * On the GitHub repo's Releases page, edit the new release tag and add the relevant changelog section from the README.rst. (Note: The README is in reStructuredText format, but GitHub's release notes use Markdown, so it may be necessary to adjust the markup syntax.)
|
||||
# * On the GitHub repo's Releases page, edit the new release tag and add the relevant changelog section from the README.md.
|
||||
|
||||
# After releasing:
|
||||
# * (optional) Remove the build and dist directories from the previous release as they are no longer needed.
|
||||
# * Bump the version number in this file to the next version and add a .dev suffix.
|
||||
# * Add a new empty section for the next version to the README.rst changelog.
|
||||
# * Add a new empty section for the next version to the README.md changelog.
|
||||
# * Commit and push the changes to master.
|
||||
|
||||
__version__ = "1.7.0"
|
||||
__version__ = "1.8.1.dev"
|
||||
|
||||
__all__ = [
|
||||
"Resource",
|
||||
|
@ -31,8 +31,8 @@ __all__ = [
|
|||
"open",
|
||||
]
|
||||
|
||||
from . import api, compress
|
||||
from .api import Resource, ResourceAttrs, ResourceFile, ResourceFileAttrs
|
||||
from . import compress
|
||||
|
||||
# noinspection PyShadowingBuiltins
|
||||
open = ResourceFile.open
|
|
@ -0,0 +1,876 @@
|
|||
import argparse
|
||||
import enum
|
||||
import io
|
||||
import itertools
|
||||
import shutil
|
||||
import sys
|
||||
import typing
|
||||
|
||||
from . import __version__, api, compress
|
||||
|
||||
# The encoding to use when rendering bytes as text (in four-char codes, strings, hex dumps, etc.) or reading a quoted byte string (from the command line).
|
||||
_TEXT_ENCODING = "MacRoman"
|
||||
|
||||
_REZ_ATTR_NAMES = {
|
||||
api.ResourceAttrs.resSysRef: None, # "Illegal or reserved attribute"
|
||||
api.ResourceAttrs.resSysHeap: "sysheap",
|
||||
api.ResourceAttrs.resPurgeable: "purgeable",
|
||||
api.ResourceAttrs.resLocked: "locked",
|
||||
api.ResourceAttrs.resProtected: "protected",
|
||||
api.ResourceAttrs.resPreload: "preload",
|
||||
api.ResourceAttrs.resChanged: None, # "Illegal or reserved attribute"
|
||||
api.ResourceAttrs.resCompressed: None, # "Extended Header resource attribute"
|
||||
}
|
||||
|
||||
F = typing.TypeVar("F", bound=enum.Flag)
|
||||
|
||||
|
||||
def decompose_flags(value: F) -> typing.Sequence[F]:
|
||||
"""Decompose an enum.Flags instance into separate enum constants."""
|
||||
|
||||
return [bit for bit in type(value) if bit in value]
|
||||
|
||||
|
||||
def join_flag_names(flags: typing.Iterable[F], sep: str = " | ") -> str:
|
||||
"""Join an iterable of enum.Flag instances into a string representation based on their names.
|
||||
|
||||
All values in ``flags`` should be named constants.
|
||||
"""
|
||||
|
||||
names: typing.List[str] = []
|
||||
for flag in flags:
|
||||
name = flag.name
|
||||
if name is None:
|
||||
names.append(str(flag))
|
||||
else:
|
||||
names.append(name)
|
||||
return sep.join(names)
|
||||
|
||||
|
||||
def is_printable(char: str) -> bool:
|
||||
"""Determine whether a character is printable for our purposes.
|
||||
|
||||
We mainly use Python's definition of printable (i. e. everything that Unicode does not consider a separator or "other" character). However, we also treat U+F8FF as printable, which is the private use codepoint used for the Apple logo character.
|
||||
"""
|
||||
|
||||
return char.isprintable() or char == "\uf8ff"
|
||||
|
||||
|
||||
# Translation table to replace non-printable characters with periods.
|
||||
_TRANSLATE_NONPRINTABLES = {ord(c): "." for c in bytes(range(256)).decode(_TEXT_ENCODING) if not is_printable(c)}
|
||||
|
||||
|
||||
def bytes_unescape(string: str) -> bytes:
|
||||
"""Convert a string containing text (in _TEXT_ENCODING) and hex escapes to a bytestring.
|
||||
|
||||
(We implement our own unescaping mechanism here to not depend on any of Python's string/bytes escape syntax.)
|
||||
"""
|
||||
|
||||
out: typing.List[int] = []
|
||||
it = iter(string)
|
||||
for char in it:
|
||||
if char == "\\":
|
||||
try:
|
||||
esc = next(it)
|
||||
if esc in "\\\'\"":
|
||||
out.extend(esc.encode(_TEXT_ENCODING))
|
||||
elif esc == "x":
|
||||
x1, x2 = next(it), next(it)
|
||||
out.append(int(x1+x2, 16))
|
||||
else:
|
||||
raise ValueError(f"Unknown escape character: {esc}")
|
||||
except StopIteration:
|
||||
raise ValueError("End of string in escape sequence")
|
||||
else:
|
||||
out.extend(char.encode(_TEXT_ENCODING))
|
||||
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def bytes_escape(bs: bytes, *, quote: typing.Optional[str] = None) -> str:
|
||||
"""Convert a bytestring to a string (using _TEXT_ENCODING), with non-printable characters hex-escaped.
|
||||
|
||||
(We implement our own escaping mechanism here to not depend on Python's str or bytes repr.)
|
||||
"""
|
||||
|
||||
out = []
|
||||
for byte, char in zip(bs, bs.decode(_TEXT_ENCODING)):
|
||||
if char in {quote, "\\"}:
|
||||
out.append(f"\\{char}")
|
||||
elif is_printable(char):
|
||||
out.append(char)
|
||||
else:
|
||||
out.append(f"\\x{byte:02x}")
|
||||
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def bytes_quote(bs: bytes, quote: str) -> str:
|
||||
"""Convert a bytestring to a quoted string (using _TEXT_ENCODING), with non-printable characters hex-escaped.
|
||||
|
||||
(We implement our own escaping mechanism here to not depend on Python's str or bytes repr.)
|
||||
"""
|
||||
|
||||
return quote + bytes_escape(bs, quote=quote) + quote
|
||||
|
||||
|
||||
MIN_RESOURCE_ID = -0x8000
|
||||
MAX_RESOURCE_ID = 0x7fff
|
||||
|
||||
|
||||
class ResourceFilter(object):
|
||||
type: bytes
|
||||
min_id: int
|
||||
max_id: int
|
||||
name: typing.Optional[bytes]
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, filter: str) -> "ResourceFilter":
|
||||
if len(filter) == 4:
|
||||
restype = filter.encode("ascii")
|
||||
return cls(restype, MIN_RESOURCE_ID, MAX_RESOURCE_ID, None)
|
||||
elif filter[0] == filter[-1] == "'":
|
||||
restype = bytes_unescape(filter[1:-1])
|
||||
return cls(restype, MIN_RESOURCE_ID, MAX_RESOURCE_ID, None)
|
||||
else:
|
||||
pos = filter.find("'", 1)
|
||||
if pos == -1:
|
||||
raise ValueError(f"Invalid filter {filter!r}: Resource type must be single-quoted")
|
||||
elif filter[pos + 1] != " ":
|
||||
raise ValueError(f"Invalid filter {filter!r}: Resource type and ID must be separated by a space")
|
||||
|
||||
restype_str, resid_str = filter[:pos + 1], filter[pos + 2:]
|
||||
|
||||
if not restype_str[0] == restype_str[-1] == "'":
|
||||
raise ValueError(
|
||||
f"Invalid filter {filter!r}: Resource type is not a single-quoted type identifier: {restype_str!r}")
|
||||
restype = bytes_unescape(restype_str[1:-1])
|
||||
|
||||
if resid_str[0] != "(" or resid_str[-1] != ")":
|
||||
raise ValueError(f"Invalid filter {filter!r}: Resource ID must be parenthesized")
|
||||
resid_str = resid_str[1:-1]
|
||||
|
||||
if resid_str[0] == resid_str[-1] == '"':
|
||||
name = bytes_unescape(resid_str[1:-1])
|
||||
return cls(restype, MIN_RESOURCE_ID, MAX_RESOURCE_ID, name)
|
||||
elif ":" in resid_str:
|
||||
if resid_str.count(":") > 1:
|
||||
raise ValueError(f"Invalid filter {filter!r}: Too many colons in ID range expression: {resid_str!r}")
|
||||
start_str, end_str = resid_str.split(":")
|
||||
start, end = int(start_str), int(end_str)
|
||||
return cls(restype, start, end, None)
|
||||
else:
|
||||
resid = int(resid_str)
|
||||
return cls(restype, resid, resid, None)
|
||||
|
||||
def __init__(self, restype: bytes, min_id: int, max_id: int, name: typing.Optional[bytes]) -> None:
|
||||
super().__init__()
|
||||
|
||||
if len(restype) != 4:
|
||||
raise ValueError(f"Invalid filter: Type code must be exactly 4 bytes long, not {len(restype)} bytes: {restype!r}")
|
||||
elif min_id < MIN_RESOURCE_ID:
|
||||
raise ValueError(f"Invalid filter: Resource ID lower bound ({min_id}) cannot be lower than {MIN_RESOURCE_ID}")
|
||||
elif max_id > MAX_RESOURCE_ID:
|
||||
raise ValueError(f"Invalid filter: Resource ID upper bound ({max_id}) cannot be greater than {MAX_RESOURCE_ID}")
|
||||
elif min_id > max_id:
|
||||
raise ValueError(f"Invalid filter: Resource ID lower bound ({min_id}) cannot be greater than upper bound ({max_id})")
|
||||
|
||||
self.type = restype
|
||||
self.min_id = min_id
|
||||
self.max_id = max_id
|
||||
self.name = name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{type(self).__name__}({self.type!r}, {self.min_id!r}, {self.max_id!r}, {self.name!r})"
|
||||
|
||||
def matches(self, res: api.Resource) -> bool:
|
||||
return res.type == self.type and self.min_id <= res.id <= self.max_id and (self.name is None or res.name == self.name)
|
||||
|
||||
|
||||
def filter_resources(rf: api.ResourceFile, filters: typing.Sequence[str]) -> typing.Iterable[api.Resource]:
|
||||
if not filters:
|
||||
# Special case: an empty list of filters matches all resources rather than none
|
||||
for reses in rf.values():
|
||||
yield from reses.values()
|
||||
else:
|
||||
filter_objs = [ResourceFilter.from_string(filter) for filter in filters]
|
||||
|
||||
for reses in rf.values():
|
||||
for res in reses.values():
|
||||
if any(filter_obj.matches(res) for filter_obj in filter_objs):
|
||||
yield res
|
||||
|
||||
|
||||
def hexdump_stream(stream: typing.BinaryIO) -> typing.Iterable[str]:
|
||||
last_line = None
|
||||
asterisk_shown = False
|
||||
line = stream.read(16)
|
||||
i = 0
|
||||
while line:
|
||||
# If the same 16-byte lines appear multiple times, print only the first one, and replace all further lines with a single line with an asterisk.
|
||||
# This is unambiguous - to find out how many lines were collapsed this way, the user can compare the addresses of the lines before and after the asterisk.
|
||||
if line == last_line:
|
||||
if not asterisk_shown:
|
||||
yield "*"
|
||||
asterisk_shown = True
|
||||
else:
|
||||
line_hex_left = " ".join(f"{byte:02x}" for byte in line[:8])
|
||||
line_hex_right = " ".join(f"{byte:02x}" for byte in line[8:])
|
||||
line_char = line.decode(_TEXT_ENCODING).translate(_TRANSLATE_NONPRINTABLES)
|
||||
yield f"{i:08x} {line_hex_left:<{8*2+7}} {line_hex_right:<{8*2+7}} |{line_char}|"
|
||||
asterisk_shown = False
|
||||
last_line = line
|
||||
i += len(line)
|
||||
line = stream.read(16)
|
||||
|
||||
if i:
|
||||
yield f"{i:08x}"
|
||||
|
||||
|
||||
def hexdump(data: bytes) -> typing.Iterable[str]:
|
||||
yield from hexdump_stream(io.BytesIO(data))
|
||||
|
||||
|
||||
def raw_hexdump_stream(stream: typing.BinaryIO) -> typing.Iterable[str]:
|
||||
line = stream.read(16)
|
||||
while line:
|
||||
yield " ".join(f"{byte:02x}" for byte in line)
|
||||
line = stream.read(16)
|
||||
|
||||
|
||||
def raw_hexdump(data: bytes) -> typing.Iterable[str]:
|
||||
yield from raw_hexdump_stream(io.BytesIO(data))
|
||||
|
||||
|
||||
def translate_text(data: bytes) -> str:
|
||||
return data.decode(_TEXT_ENCODING).replace("\r", "\n")
|
||||
|
||||
|
||||
def describe_resource(res: api.Resource, *, include_type: bool, decompress: bool) -> str:
|
||||
id_desc_parts = [f"{res.id}"]
|
||||
|
||||
if res.name is not None:
|
||||
id_desc_parts.append(bytes_quote(res.name, '"'))
|
||||
|
||||
id_desc = ", ".join(id_desc_parts)
|
||||
|
||||
content_desc_parts = []
|
||||
|
||||
if decompress and api.ResourceAttrs.resCompressed in res.attributes:
|
||||
try:
|
||||
res.compressed_info
|
||||
except compress.DecompressError:
|
||||
length_desc = f"unparseable compressed data header ({res.length_raw} bytes compressed)"
|
||||
else:
|
||||
assert res.compressed_info is not None
|
||||
length_desc = f"{res.length} bytes ({res.length_raw} bytes compressed)"
|
||||
else:
|
||||
length_desc = f"{res.length_raw} bytes"
|
||||
content_desc_parts.append(length_desc)
|
||||
|
||||
attrs = decompose_flags(res.attributes)
|
||||
if attrs:
|
||||
content_desc_parts.append(join_flag_names(attrs))
|
||||
|
||||
content_desc = ", ".join(content_desc_parts)
|
||||
|
||||
desc = f"({id_desc}): {content_desc}"
|
||||
if include_type:
|
||||
quoted_restype = bytes_quote(res.type, "'")
|
||||
desc = f"{quoted_restype} {desc}"
|
||||
return desc
|
||||
|
||||
|
||||
def show_filtered_resources(resources: typing.Sequence[api.Resource], format: str, decompress: bool) -> None:
|
||||
if not resources:
|
||||
if format in ("dump", "dump-text"):
|
||||
print("No resources matched the filter")
|
||||
elif format in ("hex", "raw"):
|
||||
print("No resources matched the filter", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif format == "derez":
|
||||
print("/* No resources matched the filter */")
|
||||
else:
|
||||
raise AssertionError(f"Unhandled output format: {format}")
|
||||
elif format in ("hex", "raw") and len(resources) != 1:
|
||||
print(f"Format {format} can only output a single resource, but the filter matched {len(resources)} resources", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
for res in resources:
|
||||
if decompress:
|
||||
open_func = res.open
|
||||
else:
|
||||
open_func = res.open_raw
|
||||
|
||||
with open_func() as f:
|
||||
if format in ("dump", "dump-text"):
|
||||
# Human-readable info and hex or text dump
|
||||
desc = describe_resource(res, include_type=True, decompress=decompress)
|
||||
print(f"Resource {desc}:")
|
||||
if format == "dump":
|
||||
for line in hexdump_stream(f):
|
||||
print(line)
|
||||
elif format == "dump-text":
|
||||
print(translate_text(f.read()))
|
||||
else:
|
||||
raise AssertionError(f"Unhandled format: {format!r}")
|
||||
print()
|
||||
elif format == "hex":
|
||||
# Data only as hex
|
||||
|
||||
for line in raw_hexdump_stream(f):
|
||||
print(line)
|
||||
elif format == "raw":
|
||||
# Data only as raw bytes
|
||||
|
||||
shutil.copyfileobj(f, sys.stdout.buffer)
|
||||
elif format == "derez":
|
||||
# Like DeRez with no resource definitions
|
||||
|
||||
attrs = list(decompose_flags(res.attributes))
|
||||
|
||||
if decompress and api.ResourceAttrs.resCompressed in attrs:
|
||||
attrs.remove(api.ResourceAttrs.resCompressed)
|
||||
attrs_comment = " /* was compressed */"
|
||||
else:
|
||||
attrs_comment = ""
|
||||
|
||||
attr_descs_with_none = [_REZ_ATTR_NAMES[attr] for attr in attrs]
|
||||
if None in attr_descs_with_none:
|
||||
attr_descs = [f"${res.attributes.value:02X}"]
|
||||
else:
|
||||
attr_descs = typing.cast(typing.List[str], attr_descs_with_none)
|
||||
|
||||
parts = [str(res.id)]
|
||||
|
||||
if res.name is not None:
|
||||
parts.append(bytes_quote(res.name, '"'))
|
||||
|
||||
parts += attr_descs
|
||||
|
||||
quoted_restype = bytes_quote(res.type, "'")
|
||||
print(f"data {quoted_restype} ({', '.join(parts)}{attrs_comment}) {{")
|
||||
|
||||
bytes_line = f.read(16)
|
||||
while bytes_line:
|
||||
# Two-byte grouping is really annoying to implement.
|
||||
groups = []
|
||||
for j in range(0, 16, 2):
|
||||
if j >= len(bytes_line):
|
||||
break
|
||||
elif j+1 >= len(bytes_line):
|
||||
groups.append(f"{bytes_line[j]:02X}")
|
||||
else:
|
||||
groups.append(f"{bytes_line[j]:02X}{bytes_line[j+1]:02X}")
|
||||
|
||||
s = f'$"{" ".join(groups)}"'
|
||||
comment = "/* " + bytes_line.decode(_TEXT_ENCODING).translate(_TRANSLATE_NONPRINTABLES) + " */"
|
||||
print(f"\t{s:<54s}{comment}")
|
||||
bytes_line = f.read(16)
|
||||
|
||||
print("};")
|
||||
print()
|
||||
else:
|
||||
raise ValueError(f"Unhandled output format: {format}")
|
||||
|
||||
|
||||
def list_resources(resources: typing.List[api.Resource], *, sort: bool, group: str, decompress: bool) -> None:
|
||||
if len(resources) == 0:
|
||||
print("No resources matched the filter")
|
||||
return
|
||||
|
||||
if group == "none":
|
||||
if sort:
|
||||
resources.sort(key=lambda res: (res.type, res.id))
|
||||
print(f"{len(resources)} resources:")
|
||||
for res in resources:
|
||||
print(describe_resource(res, include_type=True, decompress=decompress))
|
||||
elif group == "type":
|
||||
if sort:
|
||||
resources.sort(key=lambda res: res.type)
|
||||
resources_by_type = {restype: list(reses) for restype, reses in itertools.groupby(resources, key=lambda res: res.type)}
|
||||
print(f"{len(resources_by_type)} resource types:")
|
||||
for restype, restype_resources in resources_by_type.items():
|
||||
quoted_restype = bytes_quote(restype, "'")
|
||||
print(f"{quoted_restype}: {len(restype_resources)} resources:")
|
||||
if sort:
|
||||
restype_resources.sort(key=lambda res: res.id)
|
||||
for res in restype_resources:
|
||||
print(describe_resource(res, include_type=False, decompress=decompress))
|
||||
print()
|
||||
elif group == "id":
|
||||
resources.sort(key=lambda res: res.id)
|
||||
resources_by_id = {resid: list(reses) for resid, reses in itertools.groupby(resources, key=lambda res: res.id)}
|
||||
print(f"{len(resources_by_id)} resource IDs:")
|
||||
for resid, resid_resources in resources_by_id.items():
|
||||
print(f"({resid}): {len(resid_resources)} resources:")
|
||||
if sort:
|
||||
resid_resources.sort(key=lambda res: res.type)
|
||||
for res in resid_resources:
|
||||
print(describe_resource(res, include_type=True, decompress=decompress))
|
||||
print()
|
||||
else:
|
||||
raise AssertionError(f"Unhandled group mode: {group!r}")
|
||||
|
||||
|
||||
def format_compressed_header_info(header_info: compress.CompressedHeaderInfo) -> typing.Iterable[str]:
|
||||
yield f"Header length: {header_info.header_length} bytes"
|
||||
yield f"Compression type: 0x{header_info.compression_type:>04x}"
|
||||
yield f"Decompressed data length: {header_info.decompressed_length} bytes"
|
||||
yield f"'dcmp' resource ID: {header_info.dcmp_id}"
|
||||
|
||||
if isinstance(header_info, compress.CompressedType8HeaderInfo):
|
||||
yield f"Working buffer fractional size: {header_info.working_buffer_fractional_size} 256ths of compressed data length"
|
||||
yield f"Expansion buffer size: {header_info.expansion_buffer_size} bytes"
|
||||
elif isinstance(header_info, compress.CompressedType9HeaderInfo):
|
||||
yield f"Decompressor-specific parameters: {header_info.parameters!r}"
|
||||
else:
|
||||
raise AssertionError(f"Unhandled compressed header info type: {type(header_info)}")
|
||||
|
||||
|
||||
def make_subcommand_parser(subs: typing.Any, name: str, *, help: str, description: str, **kwargs: typing.Any) -> argparse.ArgumentParser:
|
||||
"""Add a subcommand parser with some slightly modified defaults to a subcommand set.
|
||||
|
||||
This function is used to ensure that all subcommands use the same base configuration for their ArgumentParser.
|
||||
"""
|
||||
|
||||
ap = subs.add_parser(
|
||||
name,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
help=help,
|
||||
description=description,
|
||||
allow_abbrev=False,
|
||||
add_help=False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
ap.add_argument("--help", action="help", help="Display this help message and exit.")
|
||||
|
||||
return ap
|
||||
|
||||
|
||||
def add_resource_file_args(ap: argparse.ArgumentParser) -> None:
|
||||
"""Define common options/arguments for specifying an input resource file.
|
||||
|
||||
This includes a positional argument for the resource file's path, and the ``--fork`` option to select which fork of the file to use.
|
||||
"""
|
||||
|
||||
ap.add_argument("--fork", choices=["auto", "data", "rsrc"], default="auto", help="The fork from which to read the resource file data, or auto to guess. Default: %(default)s")
|
||||
ap.add_argument("file", help="The file from which to read resources, or - for stdin.")
|
||||
|
||||
|
||||
RESOURCE_FILTER_HELP = """
|
||||
The resource filters use syntax similar to Rez (resource definition) files.
|
||||
Each filter can have one of the following forms:
|
||||
|
||||
An unquoted type name (without escapes): TYPE
|
||||
A quoted type name: 'TYPE'
|
||||
A quoted type name and an ID: 'TYPE' (42)
|
||||
A quoted type name and an ID range: 'TYPE' (24:42)
|
||||
A quoted type name and a resource name: 'TYPE' ("foobar")
|
||||
|
||||
Note that the resource filter syntax uses quotes, parentheses and spaces,
|
||||
which have special meanings in most shells. It is recommended to quote each
|
||||
resource filter (using double quotes) to ensure that it is not interpreted
|
||||
or rewritten by the shell.
|
||||
"""
|
||||
|
||||
|
||||
def add_resource_filter_args(ap: argparse.ArgumentParser) -> None:
|
||||
"""Define common options/arguments for specifying resource filters."""
|
||||
|
||||
ap.add_argument("filter", nargs="*", help="One or more filters to select resources. If no filters are specified, all resources are selected.")
|
||||
|
||||
|
||||
def open_resource_file(file: str, *, fork: str) -> api.ResourceFile:
|
||||
"""Open a resource file at the given path, using the specified fork."""
|
||||
|
||||
if file == "-":
|
||||
if fork != "auto":
|
||||
print("Cannot specify an explicit fork when reading from stdin", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return api.ResourceFile(sys.stdin.buffer)
|
||||
else:
|
||||
return api.ResourceFile.open(file, fork=fork)
|
||||
|
||||
|
||||
def do_read_header(ns: argparse.Namespace) -> typing.NoReturn:
|
||||
with open_resource_file(ns.file, fork=ns.fork) as rf:
|
||||
if ns.format in {"dump", "dump-text"}:
|
||||
if ns.format == "dump":
|
||||
dump_func = hexdump
|
||||
elif ns.format == "dump-text":
|
||||
def dump_func(data: bytes) -> typing.Iterable[str]:
|
||||
yield translate_text(data)
|
||||
else:
|
||||
raise AssertionError(f"Unhandled --format: {ns.format!r}")
|
||||
|
||||
if ns.part in {"system", "all"}:
|
||||
print("System-reserved header data:")
|
||||
for line in dump_func(rf.header_system_data):
|
||||
print(line)
|
||||
|
||||
if ns.part in {"application", "all"}:
|
||||
print("Application-specific header data:")
|
||||
for line in dump_func(rf.header_application_data):
|
||||
print(line)
|
||||
elif ns.format in {"hex", "raw"}:
|
||||
if ns.part == "system":
|
||||
data = rf.header_system_data
|
||||
elif ns.part == "application":
|
||||
data = rf.header_application_data
|
||||
elif ns.part == "all":
|
||||
data = rf.header_system_data + rf.header_application_data
|
||||
else:
|
||||
raise AssertionError(f"Unhandled --part: {ns.part!r}")
|
||||
|
||||
if ns.format == "hex":
|
||||
for line in raw_hexdump(data):
|
||||
print(line)
|
||||
elif ns.format == "raw":
|
||||
sys.stdout.buffer.write(data)
|
||||
else:
|
||||
raise AssertionError(f"Unhandled --format: {ns.format!r}")
|
||||
else:
|
||||
raise AssertionError(f"Unhandled --format: {ns.format!r}")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def do_info(ns: argparse.Namespace) -> typing.NoReturn:
|
||||
with open_resource_file(ns.file, fork=ns.fork) as rf:
|
||||
print("System-reserved header data:")
|
||||
for line in hexdump(rf.header_system_data):
|
||||
print(line)
|
||||
print()
|
||||
print("Application-specific header data:")
|
||||
for line in hexdump(rf.header_application_data):
|
||||
print(line)
|
||||
print()
|
||||
|
||||
print(f"Resource data starts at {rf.data_offset:#x} and is {rf.data_length:#x} bytes long")
|
||||
print(f"Resource map starts at {rf.map_offset:#x} and is {rf.map_length:#x} bytes long")
|
||||
attrs = decompose_flags(rf.file_attributes)
|
||||
if attrs:
|
||||
attrs_desc = join_flag_names(attrs)
|
||||
else:
|
||||
attrs_desc = "(none)"
|
||||
print(f"Resource map attributes: {attrs_desc}")
|
||||
print(f"Resource map type list starts at {rf.map_type_list_offset:#x} (relative to map start) and contains {len(rf)} types")
|
||||
print(f"Resource map name list starts at {rf.map_name_list_offset:#x} (relative to map start)")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def do_list(ns: argparse.Namespace) -> typing.NoReturn:
|
||||
with open_resource_file(ns.file, fork=ns.fork) as rf:
|
||||
if not rf:
|
||||
print("No resources (empty resource file)")
|
||||
else:
|
||||
resources = list(filter_resources(rf, ns.filter))
|
||||
list_resources(resources, sort=ns.sort, group=ns.group, decompress=ns.decompress)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def do_resource_info(ns: argparse.Namespace) -> typing.NoReturn:
|
||||
with open_resource_file(ns.file, fork=ns.fork) as rf:
|
||||
resources = list(filter_resources(rf, ns.filter))
|
||||
|
||||
if ns.sort:
|
||||
resources.sort(key=lambda res: (res.type, res.id))
|
||||
|
||||
if not resources:
|
||||
print("No resources matched the filter")
|
||||
sys.exit(0)
|
||||
|
||||
for res in resources:
|
||||
quoted_restype = bytes_quote(res.type, "'")
|
||||
print(f"Resource {quoted_restype} ({res.id}):")
|
||||
|
||||
if res.name is None:
|
||||
print("\tName: none (unnamed)")
|
||||
else:
|
||||
assert res.name_offset is not None
|
||||
quoted_name = bytes_quote(res.name, '"')
|
||||
print(f'\tName: {quoted_name} (at offset {res.name_offset} in name list)')
|
||||
|
||||
attrs = decompose_flags(res.attributes)
|
||||
if attrs:
|
||||
attrs_desc = join_flag_names(attrs)
|
||||
else:
|
||||
attrs_desc = "(none)"
|
||||
print(f"\tAttributes: {attrs_desc}")
|
||||
|
||||
print(f"\tData: {res.length_raw} bytes stored at offset {res.data_raw_offset} in resource file data")
|
||||
|
||||
if api.ResourceAttrs.resCompressed in res.attributes and ns.decompress:
|
||||
print()
|
||||
print("\tCompressed resource header info:")
|
||||
try:
|
||||
res.compressed_info
|
||||
except compress.DecompressError:
|
||||
print("\t\t(failed to parse compressed resource header)")
|
||||
else:
|
||||
assert res.compressed_info is not None
|
||||
for line in format_compressed_header_info(res.compressed_info):
|
||||
print(f"\t\t{line}")
|
||||
|
||||
print()
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def do_read(ns: argparse.Namespace) -> typing.NoReturn:
|
||||
with open_resource_file(ns.file, fork=ns.fork) as rf:
|
||||
resources = list(filter_resources(rf, ns.filter))
|
||||
|
||||
if ns.sort:
|
||||
resources.sort(key=lambda res: (res.type, res.id))
|
||||
|
||||
show_filtered_resources(resources, format=ns.format, decompress=ns.decompress)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def do_raw_compress_info(ns: argparse.Namespace) -> typing.NoReturn:
|
||||
if ns.input_file == "-":
|
||||
in_stream = sys.stdin.buffer
|
||||
close_in_stream = False
|
||||
else:
|
||||
in_stream = open(ns.input_file, "rb")
|
||||
close_in_stream = True
|
||||
|
||||
try:
|
||||
for line in format_compressed_header_info(compress.CompressedHeaderInfo.parse_stream(in_stream)):
|
||||
print(line)
|
||||
finally:
|
||||
if close_in_stream:
|
||||
in_stream.close()
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def do_raw_decompress(ns: argparse.Namespace) -> typing.NoReturn:
|
||||
if ns.input_file == "-":
|
||||
in_stream = sys.stdin.buffer
|
||||
close_in_stream = False
|
||||
else:
|
||||
in_stream = open(ns.input_file, "rb")
|
||||
close_in_stream = True
|
||||
|
||||
try:
|
||||
header_info = compress.CompressedHeaderInfo.parse_stream(in_stream)
|
||||
|
||||
# Open the output file only after parsing the header, so that the file is only created (or its existing contents deleted) if the input file is valid.
|
||||
if ns.output_file == "-":
|
||||
if ns.debug:
|
||||
print("Cannot use --debug if the decompression output file is - (stdout).", file=sys.stderr)
|
||||
print("The debug output goes to stdout and would conflict with the decompressed data.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
out_stream = sys.stdout.buffer
|
||||
close_out_stream = False
|
||||
else:
|
||||
out_stream = open(ns.output_file, "wb")
|
||||
close_out_stream = True
|
||||
|
||||
try:
|
||||
for chunk in compress.decompress_stream_parsed(header_info, in_stream, debug=ns.debug):
|
||||
out_stream.write(chunk)
|
||||
finally:
|
||||
if close_out_stream:
|
||||
out_stream.close()
|
||||
finally:
|
||||
if close_in_stream:
|
||||
in_stream.close()
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main() -> typing.NoReturn:
|
||||
"""Main function of the CLI.
|
||||
|
||||
This function is a valid setuptools entry point. Arguments are passed in sys.argv, and every execution path ends with a sys.exit call. (setuptools entry points are also permitted to return an integer, which will be treated as an exit code. We do not use this feature and instead always call sys.exit ourselves.)
|
||||
"""
|
||||
|
||||
ap = argparse.ArgumentParser(
|
||||
description="""
|
||||
%(prog)s is a tool for working with Classic Mac OS resource files.
|
||||
Currently this tool can only read resource files; modifying/writing resource
|
||||
files is not supported yet.
|
||||
|
||||
Note: This tool is intended for human users. The output format is not
|
||||
machine-readable and may change at any time. The command-line syntax usually
|
||||
does not change much across versions, but this should not be relied on.
|
||||
Automated scripts and programs should use the Python API provided by the
|
||||
rsrcfork library, which this tool is a part of.
|
||||
""",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
allow_abbrev=False,
|
||||
add_help=False,
|
||||
)
|
||||
|
||||
ap.add_argument("--help", action="help", help="Display this help message and exit.")
|
||||
ap.add_argument("--version", action="version", version=__version__, help="Display version information and exit.")
|
||||
|
||||
subs = ap.add_subparsers(
|
||||
dest="subcommand",
|
||||
# TODO Add required=True (added in Python 3.7) once we drop Python 3.6 compatibility.
|
||||
metavar="SUBCOMMAND",
|
||||
)
|
||||
|
||||
ap_read_header = make_subcommand_parser(
|
||||
subs,
|
||||
"read-header",
|
||||
help="Read the header data from a resource file.",
|
||||
description="""
|
||||
Read and output a resource file's header data.
|
||||
|
||||
The header data consists of two parts:
|
||||
|
||||
The system-reserved data is 112 bytes long and used by the Classic Mac OS
|
||||
Finder as temporary storage space. It usually contains parts of the
|
||||
file metadata (name, type/creator code, etc.).
|
||||
|
||||
The application-specific data is 128 bytes long and is available for use by
|
||||
applications. In practice it usually contains junk data that happened to be in
|
||||
memory when the resource file was written.
|
||||
|
||||
Mac OS X does not use the header data fields anymore. Resource files written
|
||||
on Mac OS X normally have both parts of the header data set to all zero bytes.
|
||||
""",
|
||||
)
|
||||
|
||||
ap_read_header.add_argument("--format", choices=["dump", "dump-text", "hex", "raw"], default="dump", help="How to output the header data: human-readable info with hex dump (dump) (default), human-readable info with newline-translated data (dump-text), data only as hex (hex), or data only as raw bytes (raw). Default: %(default)s")
|
||||
ap_read_header.add_argument("--part", choices=["system", "application", "all"], default="all", help="Which part of the header to read. Default: %(default)s")
|
||||
add_resource_file_args(ap_read_header)
|
||||
|
||||
ap_info = make_subcommand_parser(
|
||||
subs,
|
||||
"info",
|
||||
help="Display technical information about the resource file.",
|
||||
description="""
|
||||
Display technical information and stats about the resource file.
|
||||
""",
|
||||
)
|
||||
add_resource_file_args(ap_info)
|
||||
|
||||
ap_list = make_subcommand_parser(
|
||||
subs,
|
||||
"list",
|
||||
help="List the resources in a file.",
|
||||
description=f"""
|
||||
List the resources stored in a resource file.
|
||||
|
||||
Each resource's type, ID, name (if any), attributes (if any), and data length
|
||||
are displayed. For compressed resources, the compressed and decompressed data
|
||||
length are displayed, as well as the ID of the 'dcmp' resource used to
|
||||
decompress the resource data.
|
||||
|
||||
{RESOURCE_FILTER_HELP}
|
||||
""",
|
||||
)
|
||||
|
||||
ap_list.add_argument("--no-decompress", action="store_false", dest="decompress", help="Do not parse the data header of compressed resources and only output their compressed length.")
|
||||
ap_list.add_argument("--group", action="store", choices=["none", "type", "id"], default="type", help="Group resources by type or ID, or disable grouping. Default: %(default)s")
|
||||
ap_list.add_argument("--no-sort", action="store_false", dest="sort", help="Output resources in the order in which they are stored in the file, instead of sorting them by type and ID.")
|
||||
add_resource_file_args(ap_list)
|
||||
add_resource_filter_args(ap_list)
|
||||
|
||||
ap_resource_info = make_subcommand_parser(
|
||||
subs,
|
||||
"resource-info",
|
||||
help="Display technical information about resources.",
|
||||
description=f"""
|
||||
Display technical information about one or more resources.
|
||||
|
||||
{RESOURCE_FILTER_HELP}
|
||||
""",
|
||||
)
|
||||
|
||||
ap_resource_info.add_argument("--no-decompress", action="store_false", dest="decompress", help="Do not parse the contents of compressed resources, only output regular resource information.")
|
||||
ap_resource_info.add_argument("--no-sort", action="store_false", dest="sort", help="Output resources in the order in which they are stored in the file, instead of sorting them by type and ID.")
|
||||
add_resource_file_args(ap_resource_info)
|
||||
add_resource_filter_args(ap_resource_info)
|
||||
|
||||
ap_read = make_subcommand_parser(
|
||||
subs,
|
||||
"read",
|
||||
help="Read data from resources.",
|
||||
description=f"""
|
||||
Read the data of one or more resources.
|
||||
|
||||
{RESOURCE_FILTER_HELP}
|
||||
""",
|
||||
)
|
||||
|
||||
ap_read.add_argument("--no-decompress", action="store_false", dest="decompress", help="Do not decompress compressed resources, output the raw compressed resource data.")
|
||||
ap_read.add_argument("--format", choices=["dump", "dump-text", "hex", "raw", "derez"], default="dump", help="How to output the resources: human-readable info with hex dump (dump), human-readable info with newline-translated data (dump-text), data only as hex (hex), data only as raw bytes (raw), or like DeRez with no resource definitions (derez). Default: %(default)s")
|
||||
ap_read.add_argument("--no-sort", action="store_false", dest="sort", help="Output resources in the order in which they are stored in the file, instead of sorting them by type and ID.")
|
||||
add_resource_file_args(ap_read)
|
||||
add_resource_filter_args(ap_read)
|
||||
|
||||
ap_raw_compress_info = make_subcommand_parser(
|
||||
subs,
|
||||
"raw-compress-info",
|
||||
help="Display technical information about raw compressed resource data.",
|
||||
description="""
|
||||
Display technical information about raw compressed resource data that is stored
|
||||
in a standalone file and not as a resource in a resource file.
|
||||
""",
|
||||
)
|
||||
|
||||
ap_raw_compress_info.add_argument("input_file", help="The file from which to read the compressed resource data, or - for stdin.")
|
||||
|
||||
ap_raw_decompress = make_subcommand_parser(
|
||||
subs,
|
||||
"raw-decompress",
|
||||
help="Decompress raw compressed resource data.",
|
||||
description="""
|
||||
Decompress raw compressed resource data that is stored in a standalone file
|
||||
and not as a resource in a resource file.
|
||||
|
||||
This subcommand can be used in a shell pipeline by passing - as the input and
|
||||
output file name, i. e. "%(prog)s - -".
|
||||
|
||||
Note: All other rsrcfork subcommands natively support compressed resources and
|
||||
will automatically decompress them as needed. This subcommand is only needed
|
||||
to decompress resource data that has been read from a resource file in
|
||||
compressed form (e. g. using --no-decompress or another tool that does not
|
||||
handle resource compression).
|
||||
""",
|
||||
)
|
||||
|
||||
ap_raw_decompress.add_argument("--debug", action="store_true", help="Display debugging output from the decompressor on stdout. Cannot be used if the output file is - (stdout).")
|
||||
|
||||
ap_raw_decompress.add_argument("input_file", help="The file from which to read the compressed resource data, or - for stdin.")
|
||||
ap_raw_decompress.add_argument("output_file", help="The file to which to write the decompressed resource data, or - for stdout.")
|
||||
|
||||
ns = ap.parse_args()
|
||||
|
||||
if ns.subcommand is None:
|
||||
# TODO Remove this branch once we drop Python 3.6 compatibility, because this case will be handled by passing required=True to add_subparsers (see above).
|
||||
print("Missing subcommand", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
elif ns.subcommand == "read-header":
|
||||
do_read_header(ns)
|
||||
elif ns.subcommand == "info":
|
||||
do_info(ns)
|
||||
elif ns.subcommand == "list":
|
||||
do_list(ns)
|
||||
elif ns.subcommand == "resource-info":
|
||||
do_resource_info(ns)
|
||||
elif ns.subcommand == "read":
|
||||
do_read(ns)
|
||||
elif ns.subcommand == "raw-compress-info":
|
||||
do_raw_compress_info(ns)
|
||||
elif ns.subcommand == "raw-decompress":
|
||||
do_raw_decompress(ns)
|
||||
else:
|
||||
raise AssertionError(f"Subcommand not handled: {ns.subcommand!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
|
@ -0,0 +1,93 @@
|
|||
"""A collection of utility functions and classes related to IO streams. For internal use only."""
|
||||
|
||||
import io
|
||||
import typing
|
||||
|
||||
|
||||
def read_exact(stream: typing.BinaryIO, byte_count: int) -> bytes:
|
||||
"""Read byte_count bytes from the stream and raise an exception if too few bytes are read (i. e. if EOF was hit prematurely).
|
||||
|
||||
:param stream: The stream to read from.
|
||||
:param byte_count: The number of bytes to read.
|
||||
:return: The read data, which is exactly ``byte_count`` bytes long.
|
||||
:raise EOFError: If not enough data could be read from the stream.
|
||||
"""
|
||||
|
||||
data = stream.read(byte_count)
|
||||
if len(data) != byte_count:
|
||||
raise EOFError(f"Attempted to read {byte_count} bytes of data, but only got {len(data)} bytes")
|
||||
return data
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
class PeekableIO(typing.Protocol):
|
||||
"""Minimal protocol for binary IO streams that support the peek method.
|
||||
|
||||
The peek method is supported by various standard Python binary IO streams, such as io.BufferedReader. If a stream does not natively support the peek method, it may be wrapped using the custom helper function make_peekable.
|
||||
"""
|
||||
|
||||
def readable(self) -> bool:
|
||||
...
|
||||
|
||||
def read(self, size: typing.Optional[int] = ...) -> bytes:
|
||||
...
|
||||
|
||||
def peek(self, size: int = ...) -> bytes:
|
||||
...
|
||||
|
||||
|
||||
class _PeekableIOWrapper(object):
|
||||
"""Wrapper class to add peek support to an existing stream. Do not instantiate this class directly, use the make_peekable function instead.
|
||||
|
||||
Python provides a standard io.BufferedReader class, which supports the peek method. However, according to its documentation, it only supports wrapping io.RawIOBase subclasses, and not streams which are already otherwise buffered.
|
||||
|
||||
Warning: this class does not perform any buffering of its own, outside of what is required to make peek work. It is strongly recommended to only wrap streams that are already buffered or otherwise fast to read from. In particular, raw streams (io.RawIOBase subclasses) should be wrapped using io.BufferedReader instead.
|
||||
"""
|
||||
|
||||
_wrapped: typing.BinaryIO
|
||||
_readahead: bytes
|
||||
|
||||
def __init__(self, wrapped: typing.BinaryIO) -> None:
|
||||
super().__init__()
|
||||
|
||||
self._wrapped = wrapped
|
||||
self._readahead = b""
|
||||
|
||||
def readable(self) -> bool:
|
||||
return self._wrapped.readable()
|
||||
|
||||
def read(self, size: typing.Optional[int] = None) -> bytes:
|
||||
if size is None or size < 0:
|
||||
ret = self._readahead + self._wrapped.read()
|
||||
self._readahead = b""
|
||||
elif size <= len(self._readahead):
|
||||
ret = self._readahead[:size]
|
||||
self._readahead = self._readahead[size:]
|
||||
else:
|
||||
ret = self._readahead + self._wrapped.read(size - len(self._readahead))
|
||||
self._readahead = b""
|
||||
|
||||
return ret
|
||||
|
||||
def peek(self, size: int = -1) -> bytes:
|
||||
if not self._readahead:
|
||||
self._readahead = self._wrapped.read(io.DEFAULT_BUFFER_SIZE if size < 0 else size)
|
||||
return self._readahead
|
||||
|
||||
|
||||
def make_peekable(stream: typing.BinaryIO) -> "PeekableIO":
|
||||
"""Wrap an arbitrary binary IO stream so that it supports the peek method.
|
||||
|
||||
The stream is wrapped as efficiently as possible (or not at all if it already supports the peek method). However, in the worst case a custom wrapper class needs to be used, which may not be particularly efficient and only supports a very minimal interface. The only methods that are guaranteed to exist on the returned stream are readable, read, and peek.
|
||||
"""
|
||||
|
||||
if hasattr(stream, "peek"):
|
||||
# Stream is already peekable, nothing to be done.
|
||||
return typing.cast("PeekableIO", stream)
|
||||
elif not typing.TYPE_CHECKING and isinstance(stream, io.RawIOBase):
|
||||
# This branch is skipped when type checking - mypy incorrectly warns about this code being unreachable, because it thinks that a typing.BinaryIO cannot be an instance of io.RawIOBase.
|
||||
# Raw IO streams can be wrapped efficiently using BufferedReader.
|
||||
return io.BufferedReader(stream)
|
||||
else:
|
||||
# Other streams need to be wrapped using our custom wrapper class.
|
||||
return _PeekableIOWrapper(stream)
|
|
@ -8,6 +8,7 @@ import types
|
|||
import typing
|
||||
import warnings
|
||||
|
||||
from . import _io_utils
|
||||
from . import compress
|
||||
|
||||
# The formats of all following structures is as described in the Inside Macintosh book (see module docstring).
|
||||
|
@ -59,9 +60,11 @@ STRUCT_RESOURCE_REFERENCE = struct.Struct(">hHI4x")
|
|||
# 1 byte: Length of following resource name.
|
||||
STRUCT_RESOURCE_NAME_HEADER = struct.Struct(">B")
|
||||
|
||||
|
||||
class InvalidResourceFileError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ResourceFileAttrs(enum.Flag):
|
||||
"""Resource file attribute flags. The descriptions for these flags are taken from comments on the map*Bit and map* enum constants in <CarbonCore/Resources.h>."""
|
||||
|
||||
|
@ -82,6 +85,7 @@ class ResourceFileAttrs(enum.Flag):
|
|||
_BIT_1 = 1 << 1
|
||||
_BIT_0 = 1 << 0
|
||||
|
||||
|
||||
class ResourceAttrs(enum.Flag):
|
||||
"""Resource attribute flags. The descriptions for these flags are taken from comments on the res*Bit and res* enum constants in <CarbonCore/Resources.h>."""
|
||||
|
||||
|
@ -94,6 +98,7 @@ class ResourceAttrs(enum.Flag):
|
|||
resChanged = 1 << 1 # "Existing resource changed since last update", "Resource changed?"
|
||||
resCompressed = 1 << 0 # "indicates that the resource data is compressed" (only documented in https://github.com/kreativekorp/ksfl/wiki/Macintosh-Resource-File-Format)
|
||||
|
||||
|
||||
class Resource(object):
|
||||
"""A single resource from a resource file."""
|
||||
|
||||
|
@ -104,6 +109,7 @@ class Resource(object):
|
|||
_name: typing.Optional[bytes]
|
||||
attributes: ResourceAttrs
|
||||
data_raw_offset: int
|
||||
_length_raw: int
|
||||
_data_raw: bytes
|
||||
_compressed_info: compress.common.CompressedHeaderInfo
|
||||
_data_decompressed: bytes
|
||||
|
@ -125,31 +131,33 @@ class Resource(object):
|
|||
|
||||
def __repr__(self) -> str:
|
||||
try:
|
||||
data = self.data
|
||||
with self.open() as f:
|
||||
data = f.read(33)
|
||||
except compress.DecompressError:
|
||||
decompress_ok = False
|
||||
data = self.data_raw
|
||||
with self.open_raw() as f:
|
||||
data = f.read(33)
|
||||
else:
|
||||
decompress_ok = True
|
||||
|
||||
if len(data) > 32:
|
||||
data_repr = f"<{len(data)} bytes: {data[:32]}...>"
|
||||
data_repr = f"<{len(data)} bytes: {data[:32]!r}...>"
|
||||
else:
|
||||
data_repr = repr(data)
|
||||
|
||||
if not decompress_ok:
|
||||
data_repr = f"<decompression failed - compressed data: {data_repr}>"
|
||||
|
||||
return f"<{type(self).__qualname__} type {self.type}, id {self.id}, name {self.name}, attributes {self.attributes}, data {data_repr}>"
|
||||
return f"<{type(self).__qualname__} type {self.type!r}, id {self.id}, name {self.name!r}, attributes {self.attributes}, data {data_repr}>"
|
||||
|
||||
@property
|
||||
def resource_type(self) -> bytes:
|
||||
warnings.warn(DeprecationWarning("The resource_type attribute has been deprecated and will be removed in a future version. Please use the type attribute instead."))
|
||||
warnings.warn(DeprecationWarning("The resource_type attribute has been deprecated and will be removed in a future version. Please use the type attribute instead."), stacklevel=2)
|
||||
return self.type
|
||||
|
||||
@property
|
||||
def resource_id(self) -> int:
|
||||
warnings.warn(DeprecationWarning("The resource_id attribute has been deprecated and will be removed in a future version. Please use the id attribute instead."))
|
||||
warnings.warn(DeprecationWarning("The resource_id attribute has been deprecated and will be removed in a future version. Please use the id attribute instead."), stacklevel=2)
|
||||
return self.id
|
||||
|
||||
@property
|
||||
|
@ -171,11 +179,25 @@ class Resource(object):
|
|||
try:
|
||||
return self._data_raw
|
||||
except AttributeError:
|
||||
self._resfile._stream.seek(self._resfile.data_offset + self.data_raw_offset)
|
||||
(data_raw_length,) = self._resfile._stream_unpack(STRUCT_RESOURCE_DATA_HEADER)
|
||||
self._data_raw = self._resfile._read_exact(data_raw_length)
|
||||
self._resfile._stream.seek(self._resfile.data_offset + self.data_raw_offset + STRUCT_RESOURCE_DATA_HEADER.size)
|
||||
self._data_raw = _io_utils.read_exact(self._resfile._stream, self.length_raw)
|
||||
return self._data_raw
|
||||
|
||||
def open_raw(self) -> typing.BinaryIO:
|
||||
"""Create a binary file-like object that provides access to this resource's raw data, which may be compressed.
|
||||
|
||||
The returned stream is read-only and seekable.
|
||||
Multiple resource data streams can be opened at the same time for the same resource or for different resources in the same file,
|
||||
without interfering with each other.
|
||||
If a :class:`ResourceFile` is closed,
|
||||
all resource data streams for that file may become unusable.
|
||||
|
||||
This method is recommended over :attr:`data_raw` if the data is accessed incrementally or only partially,
|
||||
because the stream API does not require the entire resource data to be read in advance.
|
||||
"""
|
||||
|
||||
return io.BytesIO(self.data_raw)
|
||||
|
||||
@property
|
||||
def compressed_info(self) -> typing.Optional[compress.common.CompressedHeaderInfo]:
|
||||
"""The compressed resource header information, or None if this resource is not compressed.
|
||||
|
@ -187,7 +209,8 @@ class Resource(object):
|
|||
try:
|
||||
return self._compressed_info
|
||||
except AttributeError:
|
||||
self._compressed_info = compress.common.CompressedHeaderInfo.parse(self.data_raw)
|
||||
with self.open_raw() as f:
|
||||
self._compressed_info = compress.common.CompressedHeaderInfo.parse_stream(f)
|
||||
return self._compressed_info
|
||||
else:
|
||||
return None
|
||||
|
@ -199,7 +222,12 @@ class Resource(object):
|
|||
Accessing this attribute may be faster than computing len(self.data_raw) manually.
|
||||
"""
|
||||
|
||||
return len(self.data_raw)
|
||||
try:
|
||||
return self._length_raw
|
||||
except AttributeError:
|
||||
self._resfile._stream.seek(self._resfile.data_offset + self.data_raw_offset)
|
||||
(self._length_raw,) = self._resfile._stream_unpack(STRUCT_RESOURCE_DATA_HEADER)
|
||||
return self._length_raw
|
||||
|
||||
@property
|
||||
def length(self) -> int:
|
||||
|
@ -224,10 +252,28 @@ class Resource(object):
|
|||
try:
|
||||
return self._data_decompressed
|
||||
except AttributeError:
|
||||
self._data_decompressed = compress.decompress_parsed(self.compressed_info, self.data_raw[self.compressed_info.header_length:])
|
||||
with self.open_raw() as compressed_f:
|
||||
compressed_f.seek(self.compressed_info.header_length)
|
||||
self._data_decompressed = b"".join(compress.decompress_stream_parsed(self.compressed_info, compressed_f))
|
||||
return self._data_decompressed
|
||||
else:
|
||||
return self.data_raw
|
||||
|
||||
def open(self) -> typing.BinaryIO:
|
||||
"""Create a binary file-like object that provides access to this resource's data, decompressed if necessary.
|
||||
|
||||
The returned stream is read-only and seekable.
|
||||
Multiple resource data streams can be opened at the same time for the same resource or for different resources in the same file,
|
||||
without interfering with each other.
|
||||
If a :class:`ResourceFile` is closed,
|
||||
all resource data streams for that file may become unusable.
|
||||
|
||||
This method is recommended over :attr:`data` if the data is accessed incrementally or only partially,
|
||||
because the stream API does not require the entire resource data to be read (and possibly decompressed) in advance.
|
||||
"""
|
||||
|
||||
return io.BytesIO(self.data)
|
||||
|
||||
|
||||
class _LazyResourceMap(typing.Mapping[int, Resource]):
|
||||
"""Internal class: Read-only wrapper for a mapping of resource IDs to resource objects.
|
||||
|
@ -272,7 +318,8 @@ class _LazyResourceMap(typing.Mapping[int, Resource]):
|
|||
else:
|
||||
contents = f"{len(self)} resources with IDs {list(self)}"
|
||||
|
||||
return f"<Resource map for type {self.type}, containing {contents}>"
|
||||
return f"<Resource map for type {self.type!r}, containing {contents}>"
|
||||
|
||||
|
||||
class ResourceFile(typing.Mapping[bytes, typing.Mapping[int, Resource]], typing.ContextManager["ResourceFile"]):
|
||||
"""A resource file reader operating on a byte stream."""
|
||||
|
@ -295,7 +342,7 @@ class ResourceFile(typing.Mapping[bytes, typing.Mapping[int, Resource]], typing.
|
|||
_references: typing.MutableMapping[bytes, typing.MutableMapping[int, Resource]]
|
||||
|
||||
@classmethod
|
||||
def open(cls, filename: typing.Union[str, os.PathLike], *, fork: str="auto", **kwargs: typing.Any) -> "ResourceFile":
|
||||
def open(cls, filename: typing.Union[str, os.PathLike], *, fork: str = "auto", **kwargs: typing.Any) -> "ResourceFile":
|
||||
"""Open the file at the given path as a ResourceFile.
|
||||
|
||||
The fork parameter controls which fork of the file the resource data will be read from. It accepts the following values:
|
||||
|
@ -322,7 +369,7 @@ class ResourceFile(typing.Mapping[bytes, typing.Mapping[int, Resource]], typing.
|
|||
fork = "rsrc"
|
||||
else:
|
||||
fork = "data"
|
||||
warnings.warn(DeprecationWarning(f"The rsrcfork parameter has been deprecated and will be removed in a future version. Please use fork={fork!r} instead of rsrcfork={kwargs['rsrcfork']!r}."))
|
||||
warnings.warn(DeprecationWarning(f"The rsrcfork parameter has been deprecated and will be removed in a future version. Please use fork={fork!r} instead of rsrcfork={kwargs['rsrcfork']!r}."), stacklevel=2)
|
||||
del kwargs["rsrcfork"]
|
||||
|
||||
if fork == "auto":
|
||||
|
@ -354,7 +401,7 @@ class ResourceFile(typing.Mapping[bytes, typing.Mapping[int, Resource]], typing.
|
|||
else:
|
||||
raise ValueError(f"Unsupported value for the fork parameter: {fork!r}")
|
||||
|
||||
def __init__(self, stream: typing.BinaryIO, *, close: bool=False) -> None:
|
||||
def __init__(self, stream: typing.BinaryIO, *, close: bool = False) -> None:
|
||||
"""Create a ResourceFile wrapping the given byte stream.
|
||||
|
||||
To read resource file data from a bytes object, wrap it in an io.BytesIO.
|
||||
|
@ -387,10 +434,10 @@ class ResourceFile(typing.Mapping[bytes, typing.Mapping[int, Resource]], typing.
|
|||
def _read_exact(self, byte_count: int) -> bytes:
|
||||
"""Read byte_count bytes from the stream and raise an exception if too few bytes are read (i. e. if EOF was hit prematurely)."""
|
||||
|
||||
data = self._stream.read(byte_count)
|
||||
if len(data) != byte_count:
|
||||
raise InvalidResourceFileError(f"Attempted to read {byte_count} bytes of data, but only got {len(data)} bytes")
|
||||
return data
|
||||
try:
|
||||
return _io_utils.read_exact(self._stream, byte_count)
|
||||
except EOFError as e:
|
||||
raise InvalidResourceFileError(str(e))
|
||||
|
||||
def _stream_unpack(self, st: struct.Struct) -> tuple:
|
||||
"""Unpack data from the stream according to the struct st. The number of bytes to read is determined using st.size, so variable-sized structs cannot be used with this method."""
|
|
@ -5,10 +5,12 @@ from . import dcmp0
|
|||
from . import dcmp1
|
||||
from . import dcmp2
|
||||
|
||||
from .common import DecompressError, CompressedHeaderInfo
|
||||
from .common import DecompressError, CompressedHeaderInfo, CompressedType8HeaderInfo, CompressedType9HeaderInfo
|
||||
|
||||
__all__ = [
|
||||
"CompressedHeaderInfo",
|
||||
"CompressedType8HeaderInfo",
|
||||
"CompressedType9HeaderInfo",
|
||||
"DecompressError",
|
||||
"decompress",
|
||||
"decompress_parsed",
|
||||
|
@ -26,7 +28,7 @@ DECOMPRESSORS = {
|
|||
}
|
||||
|
||||
|
||||
def decompress_stream_parsed(header_info: CompressedHeaderInfo, stream: typing.BinaryIO, *, debug: bool=False) -> typing.Iterator[bytes]:
|
||||
def decompress_stream_parsed(header_info: CompressedHeaderInfo, stream: typing.BinaryIO, *, debug: bool = False) -> typing.Iterator[bytes]:
|
||||
"""Decompress compressed resource data from a stream, whose header has already been read and parsed into a CompressedHeaderInfo object."""
|
||||
|
||||
try:
|
||||
|
@ -42,12 +44,14 @@ def decompress_stream_parsed(header_info: CompressedHeaderInfo, stream: typing.B
|
|||
if decompressed_length != header_info.decompressed_length:
|
||||
raise DecompressError(f"Actual length of decompressed data ({decompressed_length}) does not match length stored in resource ({header_info.decompressed_length})")
|
||||
|
||||
def decompress_parsed(header_info: CompressedHeaderInfo, data: bytes, *, debug: bool=False) -> bytes:
|
||||
|
||||
def decompress_parsed(header_info: CompressedHeaderInfo, data: bytes, *, debug: bool = False) -> bytes:
|
||||
"""Decompress the given compressed resource data, whose header has already been removed and parsed into a CompressedHeaderInfo object."""
|
||||
|
||||
return b"".join(decompress_stream_parsed(header_info, io.BytesIO(data), debug=debug))
|
||||
|
||||
def decompress_stream(stream: typing.BinaryIO, *, debug: bool=False) -> typing.Iterator[bytes]:
|
||||
|
||||
def decompress_stream(stream: typing.BinaryIO, *, debug: bool = False) -> typing.Iterator[bytes]:
|
||||
"""Decompress compressed resource data from a stream."""
|
||||
|
||||
header_info = CompressedHeaderInfo.parse_stream(stream)
|
||||
|
@ -57,7 +61,8 @@ def decompress_stream(stream: typing.BinaryIO, *, debug: bool=False) -> typing.I
|
|||
|
||||
yield from decompress_stream_parsed(header_info, stream, debug=debug)
|
||||
|
||||
def decompress(data: bytes, *, debug: bool=False) -> bytes:
|
||||
|
||||
def decompress(data: bytes, *, debug: bool = False) -> bytes:
|
||||
"""Decompress the given compressed resource data."""
|
||||
|
||||
return b"".join(decompress_stream(io.BytesIO(data), debug=debug))
|
|
@ -2,6 +2,8 @@ import io
|
|||
import struct
|
||||
import typing
|
||||
|
||||
from .. import _io_utils
|
||||
|
||||
|
||||
class DecompressError(Exception):
|
||||
"""Raised when resource data decompression fails, because the data is invalid or the compression type is not supported."""
|
||||
|
@ -41,11 +43,11 @@ class CompressedHeaderInfo(object):
|
|||
try:
|
||||
signature, header_length, compression_type, decompressed_length, remainder = STRUCT_COMPRESSED_HEADER.unpack(stream.read(STRUCT_COMPRESSED_HEADER.size))
|
||||
except struct.error:
|
||||
raise DecompressError(f"Invalid header")
|
||||
raise DecompressError("Invalid header")
|
||||
if signature != COMPRESSED_SIGNATURE:
|
||||
raise DecompressError(f"Invalid signature: {signature!r}, expected {COMPRESSED_SIGNATURE}")
|
||||
if header_length != 0x12:
|
||||
raise DecompressError(f"Unsupported header length: 0x{header_length:>04x}, expected 0x12")
|
||||
raise DecompressError(f"Invalid signature: {signature!r}, expected {COMPRESSED_SIGNATURE!r}")
|
||||
if header_length not in {0, 0x12}:
|
||||
raise DecompressError(f"Unsupported header length value: 0x{header_length:>04x}, expected 0x12 or 0")
|
||||
|
||||
if compression_type == COMPRESSED_TYPE_8:
|
||||
working_buffer_fractional_size, expansion_buffer_size, dcmp_id, reserved = STRUCT_COMPRESSED_TYPE_8_HEADER.unpack(remainder)
|
||||
|
@ -105,81 +107,14 @@ class CompressedType9HeaderInfo(CompressedHeaderInfo):
|
|||
return f"{type(self).__qualname__}(header_length={self.header_length}, compression_type=0x{self.compression_type:>04x}, decompressed_length={self.decompressed_length}, dcmp_id={self.dcmp_id}, parameters={self.parameters!r})"
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
class PeekableIO(typing.Protocol):
|
||||
"""Minimal protocol for binary IO streams that support the peek method.
|
||||
|
||||
The peek method is supported by various standard Python binary IO streams, such as io.BufferedReader. If a stream does not natively support the peek method, it may be wrapped using the custom helper function make_peekable.
|
||||
"""
|
||||
|
||||
def readable(self) -> bool: ...
|
||||
def read(self, size: typing.Optional[int] = ...) -> bytes: ...
|
||||
def peek(self, size: int = ...) -> bytes: ...
|
||||
|
||||
|
||||
class _PeekableIOWrapper(object):
|
||||
"""Wrapper class to add peek support to an existing stream. Do not instantiate this class directly, use the make_peekable function instead.
|
||||
|
||||
Python provides a standard io.BufferedReader class, which supports the peek method. However, according to its documentation, it only supports wrapping io.RawIOBase subclasses, and not streams which are already otherwise buffered.
|
||||
|
||||
Warning: this class does not perform any buffering of its own, outside of what is required to make peek work. It is strongly recommended to only wrap streams that are already buffered or otherwise fast to read from. In particular, raw streams (io.RawIOBase subclasses) should be wrapped using io.BufferedReader instead.
|
||||
"""
|
||||
|
||||
_wrapped: typing.BinaryIO
|
||||
_readahead: bytes
|
||||
|
||||
def __init__(self, wrapped: typing.BinaryIO) -> None:
|
||||
super().__init__()
|
||||
|
||||
self._wrapped = wrapped
|
||||
self._readahead = b""
|
||||
|
||||
def readable(self) -> bool:
|
||||
return self._wrapped.readable()
|
||||
|
||||
def read(self, size: typing.Optional[int] = None) -> bytes:
|
||||
if size is None or size < 0:
|
||||
ret = self._readahead + self._wrapped.read()
|
||||
self._readahead = b""
|
||||
elif size <= len(self._readahead):
|
||||
ret = self._readahead[:size]
|
||||
self._readahead = self._readahead[size:]
|
||||
else:
|
||||
ret = self._readahead + self._wrapped.read(size - len(self._readahead))
|
||||
self._readahead = b""
|
||||
|
||||
return ret
|
||||
|
||||
def peek(self, size: int = -1) -> bytes:
|
||||
if not self._readahead:
|
||||
self._readahead = self._wrapped.read(io.DEFAULT_BUFFER_SIZE if size < 0 else size)
|
||||
return self._readahead
|
||||
|
||||
|
||||
def make_peekable(stream: typing.BinaryIO) -> "PeekableIO":
|
||||
"""Wrap an arbitrary binary IO stream so that it supports the peek method.
|
||||
|
||||
The stream is wrapped as efficiently as possible (or not at all if it already supports the peek method). However, in the worst case a custom wrapper class needs to be used, which may not be particularly efficient and only supports a very minimal interface. The only methods that are guaranteed to exist on the returned stream are readable, read, and peek.
|
||||
"""
|
||||
|
||||
if hasattr(stream, "peek"):
|
||||
# Stream is already peekable, nothing to be done.
|
||||
return typing.cast("PeekableIO", stream)
|
||||
elif isinstance(stream, io.RawIOBase):
|
||||
# Raw IO streams can be wrapped efficiently using BufferedReader.
|
||||
return io.BufferedReader(stream)
|
||||
else:
|
||||
# Other streams need to be wrapped using our custom wrapper class.
|
||||
return _PeekableIOWrapper(stream)
|
||||
|
||||
|
||||
def read_exact(stream: typing.BinaryIO, byte_count: int) -> bytes:
|
||||
"""Read byte_count bytes from the stream and raise an exception if too few bytes are read (i. e. if EOF was hit prematurely)."""
|
||||
|
||||
data = stream.read(byte_count)
|
||||
if len(data) != byte_count:
|
||||
raise DecompressError(f"Attempted to read {byte_count} bytes of data, but only got {len(data)} bytes")
|
||||
return data
|
||||
try:
|
||||
return _io_utils.read_exact(stream, byte_count)
|
||||
except EOFError as e:
|
||||
raise DecompressError(str(e))
|
||||
|
||||
|
||||
def read_variable_length_integer(stream: typing.BinaryIO) -> int:
|
||||
"""Read a variable-length integer from the stream.
|
|
@ -1,4 +1,3 @@
|
|||
import io
|
||||
import typing
|
||||
|
||||
from . import common
|
||||
|
@ -39,7 +38,7 @@ TABLE = [TABLE_DATA[i:i + 2] for i in range(0, len(TABLE_DATA), 2)]
|
|||
assert len(TABLE) == len(range(0x4b, 0xfe))
|
||||
|
||||
|
||||
def decompress_stream_inner(header_info: common.CompressedHeaderInfo, stream: typing.BinaryIO, *, debug: bool=False) -> typing.Iterator[bytes]:
|
||||
def decompress_stream_inner(header_info: common.CompressedHeaderInfo, stream: typing.BinaryIO, *, debug: bool = False) -> typing.Iterator[bytes]:
|
||||
"""Internal helper function, implements the main decompression algorithm. Only called from decompress_stream, which performs some extra checks and debug logging."""
|
||||
|
||||
if not isinstance(header_info, common.CompressedType8HeaderInfo):
|
||||
|
@ -111,7 +110,7 @@ def decompress_stream_inner(header_info: common.CompressedHeaderInfo, stream: ty
|
|||
# Compact representation of (part of) a segment loader jump table, as used in 'CODE' (0) resources.
|
||||
|
||||
if debug:
|
||||
print(f"Segment loader jump table entries")
|
||||
print("Segment loader jump table entries")
|
||||
|
||||
# All generated jump table entries have the same segment number.
|
||||
segment_number_int = common.read_variable_length_integer(stream)
|
||||
|
@ -169,13 +168,13 @@ def decompress_stream_inner(header_info: common.CompressedHeaderInfo, stream: ty
|
|||
raise common.DecompressError(f"Repeat count must be positive: {count}")
|
||||
|
||||
if debug:
|
||||
print(f"\t-> {to_repeat} * {count}")
|
||||
print(f"\t-> {to_repeat!r} * {count}")
|
||||
yield to_repeat * count
|
||||
elif kind == 0x04:
|
||||
# A sequence of 16-bit signed integers, with each integer encoded as a difference relative to the previous integer. The first integer is stored explicitly.
|
||||
|
||||
if debug:
|
||||
print(f"Difference-encoded 16-bit integers")
|
||||
print("Difference-encoded 16-bit integers")
|
||||
|
||||
# The first integer is stored explicitly, as a signed value.
|
||||
initial_int = common.read_variable_length_integer(stream)
|
||||
|
@ -207,7 +206,7 @@ def decompress_stream_inner(header_info: common.CompressedHeaderInfo, stream: ty
|
|||
# A sequence of 32-bit signed integers, with each integer encoded as a difference relative to the previous integer. The first integer is stored explicitly.
|
||||
|
||||
if debug:
|
||||
print(f"Difference-encoded 32-bit integers")
|
||||
print("Difference-encoded 32-bit integers")
|
||||
|
||||
# The first integer is stored explicitly, as a signed value.
|
||||
initial_int = common.read_variable_length_integer(stream)
|
||||
|
@ -243,18 +242,19 @@ def decompress_stream_inner(header_info: common.CompressedHeaderInfo, stream: ty
|
|||
# Check that there really is no more data left.
|
||||
extra = stream.read(1)
|
||||
if extra:
|
||||
raise common.DecompressError(f"Extra data encountered after end of data marker (first extra byte: {extra})")
|
||||
raise common.DecompressError(f"Extra data encountered after end of data marker (first extra byte: {extra!r})")
|
||||
break
|
||||
else:
|
||||
raise common.DecompressError(f"Unknown tag byte: 0x{byte:>02x}")
|
||||
|
||||
def decompress_stream(header_info: common.CompressedHeaderInfo, stream: typing.BinaryIO, *, debug: bool=False) -> typing.Iterator[bytes]:
|
||||
|
||||
def decompress_stream(header_info: common.CompressedHeaderInfo, stream: typing.BinaryIO, *, debug: bool = False) -> typing.Iterator[bytes]:
|
||||
"""Decompress compressed data in the format used by 'dcmp' (0)."""
|
||||
|
||||
|
||||
decompressed_length = 0
|
||||
for chunk in decompress_stream_inner(header_info, stream, debug=debug):
|
||||
if debug:
|
||||
print(f"\t-> {chunk}")
|
||||
print(f"\t-> {chunk!r}")
|
||||
|
||||
if header_info.decompressed_length % 2 != 0 and decompressed_length + len(chunk) == header_info.decompressed_length + 1:
|
||||
# Special case: if the decompressed data length stored in the header is odd and one less than the length of the actual decompressed data, drop the last byte.
|
|
@ -1,4 +1,3 @@
|
|||
import io
|
||||
import typing
|
||||
|
||||
from . import common
|
||||
|
@ -22,7 +21,7 @@ TABLE = [TABLE_DATA[i:i + 2] for i in range(0, len(TABLE_DATA), 2)]
|
|||
assert len(TABLE) == len(range(0xd5, 0xfe))
|
||||
|
||||
|
||||
def decompress_stream_inner(header_info: common.CompressedHeaderInfo, stream: typing.BinaryIO, *, debug: bool=False) -> typing.Iterator[bytes]:
|
||||
def decompress_stream_inner(header_info: common.CompressedHeaderInfo, stream: typing.BinaryIO, *, debug: bool = False) -> typing.Iterator[bytes]:
|
||||
"""Internal helper function, implements the main decompression algorithm. Only called from decompress_stream, which performs some extra checks and debug logging."""
|
||||
|
||||
if not isinstance(header_info, common.CompressedType8HeaderInfo):
|
||||
|
@ -112,7 +111,7 @@ def decompress_stream_inner(header_info: common.CompressedHeaderInfo, stream: ty
|
|||
raise common.DecompressError(f"Repeat count must be positive: {count}")
|
||||
|
||||
if debug:
|
||||
print(f"\t-> {to_repeat} * {count}")
|
||||
print(f"\t-> {to_repeat!r} * {count}")
|
||||
yield to_repeat * count
|
||||
else:
|
||||
raise common.DecompressError(f"Unknown extended code: 0x{kind:>02x}")
|
||||
|
@ -124,18 +123,19 @@ def decompress_stream_inner(header_info: common.CompressedHeaderInfo, stream: ty
|
|||
# Check that there really is no more data left.
|
||||
extra = stream.read(1)
|
||||
if extra:
|
||||
raise common.DecompressError(f"Extra data encountered after end of data marker (first extra byte: {extra})")
|
||||
raise common.DecompressError(f"Extra data encountered after end of data marker (first extra byte: {extra!r})")
|
||||
break
|
||||
else:
|
||||
raise common.DecompressError(f"Unknown tag byte: 0x{byte:>02x}")
|
||||
|
||||
def decompress_stream(header_info: common.CompressedHeaderInfo, stream: typing.BinaryIO, *, debug: bool=False) -> typing.Iterator[bytes]:
|
||||
|
||||
def decompress_stream(header_info: common.CompressedHeaderInfo, stream: typing.BinaryIO, *, debug: bool = False) -> typing.Iterator[bytes]:
|
||||
"""Decompress compressed data in the format used by 'dcmp' (1)."""
|
||||
|
||||
decompressed_length = 0
|
||||
for chunk in decompress_stream_inner(header_info, stream, debug=debug):
|
||||
if debug:
|
||||
print(f"\t-> {chunk}")
|
||||
print(f"\t-> {chunk!r}")
|
||||
|
||||
decompressed_length += len(chunk)
|
||||
yield chunk
|
|
@ -1,8 +1,8 @@
|
|||
import enum
|
||||
import io
|
||||
import struct
|
||||
import typing
|
||||
|
||||
from .. import _io_utils
|
||||
from . import common
|
||||
|
||||
|
||||
|
@ -74,7 +74,7 @@ def _split_bits(i: int) -> typing.Tuple[bool, bool, bool, bool, bool, bool, bool
|
|||
)
|
||||
|
||||
|
||||
def _decompress_untagged(stream: "common.PeekableIO", decompressed_length: int, table: typing.Sequence[bytes], *, debug: bool=False) -> typing.Iterator[bytes]:
|
||||
def _decompress_untagged(stream: "_io_utils.PeekableIO", decompressed_length: int, table: typing.Sequence[bytes], *, debug: bool = False) -> typing.Iterator[bytes]:
|
||||
while True: # Loop is terminated when EOF is reached.
|
||||
table_index_data = stream.read(1)
|
||||
if not table_index_data:
|
||||
|
@ -83,17 +83,18 @@ def _decompress_untagged(stream: "common.PeekableIO", decompressed_length: int,
|
|||
elif not stream.peek(1) and decompressed_length % 2 != 0:
|
||||
# Special case: if we are at the last byte of the compressed data, and the decompressed data has an odd length, the last byte is a single literal byte, and not a table reference.
|
||||
if debug:
|
||||
print(f"Last byte: {table_index_data}")
|
||||
print(f"Last byte: {table_index_data!r}")
|
||||
yield table_index_data
|
||||
break
|
||||
|
||||
# Compressed data is untagged, every byte is a table reference.
|
||||
(table_index,) = table_index_data
|
||||
if debug:
|
||||
print(f"Reference: {table_index} -> {table[table_index]}")
|
||||
print(f"Reference: {table_index} -> {table[table_index]!r}")
|
||||
yield table[table_index]
|
||||
|
||||
def _decompress_tagged(stream: "common.PeekableIO", decompressed_length: int, table: typing.Sequence[bytes], *, debug: bool=False) -> typing.Iterator[bytes]:
|
||||
|
||||
def _decompress_tagged(stream: "_io_utils.PeekableIO", decompressed_length: int, table: typing.Sequence[bytes], *, debug: bool = False) -> typing.Iterator[bytes]:
|
||||
while True: # Loop is terminated when EOF is reached.
|
||||
tag_data = stream.read(1)
|
||||
if not tag_data:
|
||||
|
@ -102,7 +103,7 @@ def _decompress_tagged(stream: "common.PeekableIO", decompressed_length: int, ta
|
|||
elif not stream.peek(1) and decompressed_length % 2 != 0:
|
||||
# Special case: if we are at the last byte of the compressed data, and the decompressed data has an odd length, the last byte is a single literal byte, and not a tag or a table reference.
|
||||
if debug:
|
||||
print(f"Last byte: {tag_data}")
|
||||
print(f"Last byte: {tag_data!r}")
|
||||
yield tag_data
|
||||
break
|
||||
|
||||
|
@ -119,7 +120,7 @@ def _decompress_tagged(stream: "common.PeekableIO", decompressed_length: int, ta
|
|||
break
|
||||
(table_index,) = table_index_data
|
||||
if debug:
|
||||
print(f"Reference: {table_index} -> {table[table_index]}")
|
||||
print(f"Reference: {table_index} -> {table[table_index]!r}")
|
||||
yield table[table_index]
|
||||
else:
|
||||
# This is a literal (two uncompressed bytes that are literally copied into the output).
|
||||
|
@ -129,11 +130,11 @@ def _decompress_tagged(stream: "common.PeekableIO", decompressed_length: int, ta
|
|||
break
|
||||
# Note: the literal may be only a single byte long if it is located exactly at EOF. This is intended and expected - the 1-byte literal is yielded normally, and on the next iteration, decompression is terminated as EOF is detected.
|
||||
if debug:
|
||||
print(f"Literal: {literal}")
|
||||
print(f"Literal: {literal!r}")
|
||||
yield literal
|
||||
|
||||
|
||||
def decompress_stream(header_info: common.CompressedHeaderInfo, stream: typing.BinaryIO, *, debug: bool=False) -> typing.Iterator[bytes]:
|
||||
def decompress_stream(header_info: common.CompressedHeaderInfo, stream: typing.BinaryIO, *, debug: bool = False) -> typing.Iterator[bytes]:
|
||||
"""Decompress compressed data in the format used by 'dcmp' (2)."""
|
||||
|
||||
if not isinstance(header_info, common.CompressedType9HeaderInfo):
|
||||
|
@ -174,4 +175,4 @@ def decompress_stream(header_info: common.CompressedHeaderInfo, stream: typing.B
|
|||
else:
|
||||
decompress_func = _decompress_untagged
|
||||
|
||||
yield from decompress_func(common.make_peekable(stream), header_info.decompressed_length, table, debug=debug)
|
||||
yield from decompress_func(_io_utils.make_peekable(stream), header_info.decompressed_length, table, debug=debug)
|
After Width: | Height: | Size: 35 KiB |
After Width: | Height: | Size: 355 KiB |
After Width: | Height: | Size: 127 KiB |
After Width: | Height: | Size: 884 KiB |
After Width: | Height: | Size: 51 KiB |
After Width: | Height: | Size: 478 KiB |
After Width: | Height: | Size: 159 KiB |
After Width: | Height: | Size: 1.1 MiB |
After Width: | Height: | Size: 286 B |
After Width: | Height: | Size: 558 B |
After Width: | Height: | Size: 602 B |
|
@ -0,0 +1,329 @@
|
|||
import collections
|
||||
import io
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import typing
|
||||
import unittest
|
||||
|
||||
import rsrcfork
|
||||
|
||||
RESOURCE_FORKS_SUPPORTED = sys.platform.startswith("darwin")
|
||||
RESOURCE_FORKS_NOT_SUPPORTED_MESSAGE = "Resource forks are only supported on Mac"
|
||||
|
||||
DATA_DIR = pathlib.Path(__file__).parent / "data"
|
||||
EMPTY_RSRC_FILE = DATA_DIR / "empty.rsrc"
|
||||
TEXTCLIPPING_RSRC_FILE = DATA_DIR / "unicode.textClipping.rsrc"
|
||||
TESTFILE_RSRC_FILE = DATA_DIR / "testfile.rsrc"
|
||||
|
||||
COMPRESS_DATA_DIR = DATA_DIR / "compress"
|
||||
COMPRESSED_DIR = COMPRESS_DATA_DIR / "compressed"
|
||||
UNCOMPRESSED_DIR = COMPRESS_DATA_DIR / "uncompressed"
|
||||
COMPRESS_RSRC_FILE_NAMES = [
|
||||
"Finder.rsrc",
|
||||
"Finder Help.rsrc",
|
||||
# "Install.rsrc", # Commented out for performance - this file contains a lot of small resources.
|
||||
"System.rsrc",
|
||||
]
|
||||
|
||||
|
||||
def make_pascal_string(s):
|
||||
return bytes([len(s)]) + s
|
||||
|
||||
|
||||
UNICODE_TEXT = "Here is some text, including Üñïçø∂é!"
|
||||
DRAG_DATA = (
|
||||
b"\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x03"
|
||||
b"utxt\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
b"utf8\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
b"TEXT\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
)
|
||||
TEXTCLIPPING_RESOURCES = collections.OrderedDict([
|
||||
(b"utxt", collections.OrderedDict([
|
||||
(256, UNICODE_TEXT.encode("utf-16-be")),
|
||||
])),
|
||||
(b"utf8", collections.OrderedDict([
|
||||
(256, UNICODE_TEXT.encode("utf-8")),
|
||||
])),
|
||||
(b"TEXT", collections.OrderedDict([
|
||||
(256, UNICODE_TEXT.encode("macroman")),
|
||||
])),
|
||||
(b"drag", collections.OrderedDict([
|
||||
(128, DRAG_DATA),
|
||||
]))
|
||||
])
|
||||
|
||||
TESTFILE_HEADER_SYSTEM_DATA = (
|
||||
b"\xa7F$\x08 <\x00\x00\xab\x03\xa7F <\x00\x00"
|
||||
b"\x01\x00\xb4\x88f\x06`\np\x00`\x06 <\x00\x00"
|
||||
b"\x08testfile\x00\x02\x00\x02\x00rs"
|
||||
b"rcRSED\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
b"\x02\x00rsrcRSED\x00\x00\x00\x00\x00\x00"
|
||||
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
b"\x00\x00\xdaIp~\x00\x00\x00\x00\x00\x00\x02.\xfe\x84"
|
||||
)
|
||||
TESTFILE_HEADER_APPLICATION_DATA = b"This is the application-specific header data section. Apparently I can write whatever nonsense I want here. A few more bytes...."
|
||||
TESTFILE_RESOURCES = collections.OrderedDict([
|
||||
(b"STR ", collections.OrderedDict([
|
||||
(128, (
|
||||
None, rsrcfork.ResourceAttrs(0),
|
||||
make_pascal_string(b"The String, without name or attributes"),
|
||||
)),
|
||||
(129, (
|
||||
b"The Name", rsrcfork.ResourceAttrs(0),
|
||||
make_pascal_string(b"The String, with name and no attributes"),
|
||||
)),
|
||||
(130, (
|
||||
None, rsrcfork.ResourceAttrs.resProtected | rsrcfork.ResourceAttrs.resPreload,
|
||||
make_pascal_string(b"The String, without name but with attributes"),
|
||||
)),
|
||||
(131, (
|
||||
b"The Name with Attributes", rsrcfork.ResourceAttrs.resSysHeap,
|
||||
make_pascal_string(b"The String, with both name and attributes"),
|
||||
)),
|
||||
])),
|
||||
])
|
||||
|
||||
|
||||
class UnseekableStreamWrapper(io.BufferedIOBase):
|
||||
_wrapped: typing.BinaryIO
|
||||
|
||||
def __init__(self, wrapped: typing.BinaryIO) -> None:
|
||||
super().__init__()
|
||||
|
||||
self._wrapped = wrapped
|
||||
|
||||
def read(self, size: typing.Optional[int] = -1) -> bytes:
|
||||
return self._wrapped.read(size)
|
||||
|
||||
|
||||
def open_resource_fork(path: pathlib.Path, mode: str) -> typing.BinaryIO:
|
||||
return (path / "..namedfork" / "rsrc").open(mode)
|
||||
|
||||
|
||||
class ResourceFileReadTests(unittest.TestCase):
|
||||
def test_empty(self) -> None:
|
||||
with rsrcfork.open(EMPTY_RSRC_FILE, fork="data") as rf:
|
||||
self.assertEqual(rf.header_system_data, bytes(112))
|
||||
self.assertEqual(rf.header_application_data, bytes(128))
|
||||
self.assertEqual(rf.file_attributes, rsrcfork.ResourceFileAttrs(0))
|
||||
self.assertEqual(list(rf), [])
|
||||
|
||||
def internal_test_textclipping(self, rf: rsrcfork.ResourceFile) -> None:
|
||||
self.assertEqual(rf.header_system_data, bytes(112))
|
||||
self.assertEqual(rf.header_application_data, bytes(128))
|
||||
self.assertEqual(rf.file_attributes, rsrcfork.ResourceFileAttrs(0))
|
||||
self.assertEqual(list(rf), list(TEXTCLIPPING_RESOURCES))
|
||||
|
||||
for (actual_type, actual_reses), (expected_type, expected_reses) in zip(rf.items(), TEXTCLIPPING_RESOURCES.items()):
|
||||
with self.subTest(type=expected_type):
|
||||
self.assertEqual(actual_type, expected_type)
|
||||
self.assertEqual(list(actual_reses), list(expected_reses))
|
||||
|
||||
for (actual_id, actual_res), (expected_id, expected_data) in zip(actual_reses.items(), expected_reses.items()):
|
||||
with self.subTest(id=expected_id):
|
||||
self.assertEqual(actual_res.type, expected_type)
|
||||
self.assertEqual(actual_id, expected_id)
|
||||
self.assertEqual(actual_res.id, expected_id)
|
||||
self.assertEqual(actual_res.name, None)
|
||||
self.assertEqual(actual_res.attributes, rsrcfork.ResourceAttrs(0))
|
||||
self.assertEqual(actual_res.data, expected_data)
|
||||
with actual_res.open() as f:
|
||||
self.assertEqual(f.read(10), expected_data[:10])
|
||||
self.assertEqual(f.read(5), expected_data[10:15])
|
||||
self.assertEqual(f.read(), expected_data[15:])
|
||||
f.seek(0)
|
||||
self.assertEqual(f.read(), expected_data)
|
||||
self.assertEqual(actual_res.compressed_info, None)
|
||||
|
||||
actual_res_1 = rf[b"TEXT"][256]
|
||||
expected_data_1 = TEXTCLIPPING_RESOURCES[b"TEXT"][256]
|
||||
actual_res_2 = rf[b"utxt"][256]
|
||||
expected_data_2 = TEXTCLIPPING_RESOURCES[b"utxt"][256]
|
||||
|
||||
with self.subTest(stream_test="multiple streams for the same resource"):
|
||||
with actual_res_1.open() as f1, actual_res_1.open() as f2:
|
||||
f1.seek(5)
|
||||
f2.seek(10)
|
||||
self.assertEqual(f1.read(10), expected_data_1[5:15])
|
||||
self.assertEqual(f2.read(10), expected_data_1[10:20])
|
||||
self.assertEqual(f1.read(), expected_data_1[15:])
|
||||
self.assertEqual(f2.read(), expected_data_1[20:])
|
||||
|
||||
with self.subTest(stream_test="multiple streams for different resources"):
|
||||
with actual_res_1.open() as f1, actual_res_2.open() as f2:
|
||||
f1.seek(5)
|
||||
f2.seek(10)
|
||||
self.assertEqual(f1.read(10), expected_data_1[5:15])
|
||||
self.assertEqual(f2.read(10), expected_data_2[10:20])
|
||||
self.assertEqual(f1.read(), expected_data_1[15:])
|
||||
self.assertEqual(f2.read(), expected_data_2[20:])
|
||||
|
||||
def test_textclipping_seekable_stream(self) -> None:
|
||||
with TEXTCLIPPING_RSRC_FILE.open("rb") as f:
|
||||
with rsrcfork.ResourceFile(f) as rf:
|
||||
self.internal_test_textclipping(rf)
|
||||
|
||||
def test_textclipping_unseekable_stream(self) -> None:
|
||||
with TEXTCLIPPING_RSRC_FILE.open("rb") as f:
|
||||
with UnseekableStreamWrapper(f) as usf:
|
||||
with rsrcfork.ResourceFile(usf) as rf:
|
||||
self.internal_test_textclipping(rf)
|
||||
|
||||
def test_textclipping_path_data_fork(self) -> None:
|
||||
with rsrcfork.open(TEXTCLIPPING_RSRC_FILE, fork="data") as rf:
|
||||
self.internal_test_textclipping(rf)
|
||||
|
||||
@unittest.skipUnless(RESOURCE_FORKS_SUPPORTED, RESOURCE_FORKS_NOT_SUPPORTED_MESSAGE)
|
||||
def test_textclipping_path_resource_fork(self) -> None:
|
||||
with tempfile.NamedTemporaryFile() as tempf:
|
||||
with TEXTCLIPPING_RSRC_FILE.open("rb") as dataf:
|
||||
with open_resource_fork(pathlib.Path(tempf.name), "wb") as rsrcf:
|
||||
shutil.copyfileobj(dataf, rsrcf)
|
||||
|
||||
with rsrcfork.open(tempf.name, fork="rsrc") as rf:
|
||||
self.internal_test_textclipping(rf)
|
||||
|
||||
@unittest.skipUnless(RESOURCE_FORKS_SUPPORTED, RESOURCE_FORKS_NOT_SUPPORTED_MESSAGE)
|
||||
def test_textclipping_path_auto_resource_fork(self) -> None:
|
||||
with tempfile.NamedTemporaryFile() as temp_data_fork:
|
||||
with TEXTCLIPPING_RSRC_FILE.open("rb") as source_file:
|
||||
with open_resource_fork(pathlib.Path(temp_data_fork.name), "wb") as temp_rsrc_fork:
|
||||
shutil.copyfileobj(source_file, temp_rsrc_fork)
|
||||
|
||||
with self.subTest(data_fork="empty"):
|
||||
# Resource fork is selected when data fork is empty.
|
||||
|
||||
with rsrcfork.open(temp_data_fork.name) as rf:
|
||||
self.internal_test_textclipping(rf)
|
||||
|
||||
with self.subTest(data_fork="non-resource data"):
|
||||
# Resource fork is selected when data fork contains non-resource data.
|
||||
|
||||
temp_data_fork.write(b"This is the file's data fork. It should not be read, as the file has a resource fork.")
|
||||
|
||||
with rsrcfork.open(temp_data_fork.name) as rf:
|
||||
self.internal_test_textclipping(rf)
|
||||
|
||||
with self.subTest(data_fork="valid resource data"):
|
||||
# Resource fork is selected even when data fork contains valid resource data.
|
||||
|
||||
with EMPTY_RSRC_FILE.open("rb") as source_file:
|
||||
shutil.copyfileobj(source_file, temp_data_fork)
|
||||
|
||||
with rsrcfork.open(temp_data_fork.name) as rf:
|
||||
self.internal_test_textclipping(rf)
|
||||
|
||||
@unittest.skipUnless(RESOURCE_FORKS_SUPPORTED, RESOURCE_FORKS_NOT_SUPPORTED_MESSAGE)
|
||||
def test_textclipping_path_auto_data_fork(self) -> None:
|
||||
with tempfile.NamedTemporaryFile() as temp_data_fork:
|
||||
with TEXTCLIPPING_RSRC_FILE.open("rb") as source_file:
|
||||
shutil.copyfileobj(source_file, temp_data_fork)
|
||||
# Have to flush the temporary file manually so that the data is visible to the other reads below.
|
||||
# Normally this happens automatically as part of the close method, but that would also delete the temporary file, which we don't want.
|
||||
temp_data_fork.flush()
|
||||
|
||||
with self.subTest(rsrc_fork="nonexistant"):
|
||||
# Data fork is selected when resource fork does not exist.
|
||||
|
||||
with rsrcfork.open(temp_data_fork.name) as rf:
|
||||
self.internal_test_textclipping(rf)
|
||||
|
||||
with self.subTest(rsrc_fork="empty"):
|
||||
# Data fork is selected when resource fork exists, but is empty.
|
||||
|
||||
with open_resource_fork(pathlib.Path(temp_data_fork.name), "wb") as temp_rsrc_fork:
|
||||
temp_rsrc_fork.write(b"")
|
||||
|
||||
with rsrcfork.open(temp_data_fork.name) as rf:
|
||||
self.internal_test_textclipping(rf)
|
||||
|
||||
with self.subTest(rsrc_fork="non-resource data"):
|
||||
# Data fork is selected when resource fork contains non-resource data.
|
||||
|
||||
with open_resource_fork(pathlib.Path(temp_data_fork.name), "wb") as temp_rsrc_fork:
|
||||
temp_rsrc_fork.write(b"This is the file's resource fork. It contains junk, so it should be ignored in favor of the data fork.")
|
||||
|
||||
with rsrcfork.open(temp_data_fork.name) as rf:
|
||||
self.internal_test_textclipping(rf)
|
||||
|
||||
def test_testfile(self) -> None:
|
||||
with rsrcfork.open(TESTFILE_RSRC_FILE, fork="data") as rf:
|
||||
self.assertEqual(rf.header_system_data, TESTFILE_HEADER_SYSTEM_DATA)
|
||||
self.assertEqual(rf.header_application_data, TESTFILE_HEADER_APPLICATION_DATA)
|
||||
self.assertEqual(rf.file_attributes, rsrcfork.ResourceFileAttrs.mapPrinterDriverMultiFinderCompatible | rsrcfork.ResourceFileAttrs.mapReadOnly)
|
||||
self.assertEqual(list(rf), list(TESTFILE_RESOURCES))
|
||||
|
||||
for (actual_type, actual_reses), (expected_type, expected_reses) in zip(rf.items(), TESTFILE_RESOURCES.items()):
|
||||
with self.subTest(type=expected_type):
|
||||
self.assertEqual(actual_type, expected_type)
|
||||
self.assertEqual(list(actual_reses), list(expected_reses))
|
||||
|
||||
for (actual_id, actual_res), (expected_id, (expected_name, expected_attrs, expected_data)) in zip(actual_reses.items(), expected_reses.items()):
|
||||
with self.subTest(id=expected_id):
|
||||
self.assertEqual(actual_res.type, expected_type)
|
||||
self.assertEqual(actual_id, expected_id)
|
||||
self.assertEqual(actual_res.id, expected_id)
|
||||
self.assertEqual(actual_res.name, expected_name)
|
||||
self.assertEqual(actual_res.attributes, expected_attrs)
|
||||
self.assertEqual(actual_res.data, expected_data)
|
||||
with actual_res.open() as f:
|
||||
self.assertEqual(f.read(), expected_data)
|
||||
self.assertEqual(actual_res.compressed_info, None)
|
||||
|
||||
def test_compress_compare(self) -> None:
|
||||
# This test goes through pairs of resource files: one original file with both compressed and uncompressed resources, and one modified file where all compressed resources have been decompressed (using ResEdit on System 7.5.5).
|
||||
# It checks that the rsrcfork library performs automatic decompression on the compressed resources, so that the compressed resource file appears to the user like the uncompressed resource file (ignoring resource order, which was lost during decompression using ResEdit).
|
||||
|
||||
for name in COMPRESS_RSRC_FILE_NAMES:
|
||||
with self.subTest(name=name):
|
||||
with rsrcfork.open(COMPRESSED_DIR / name, fork="data") as compressed_rf, rsrcfork.open(UNCOMPRESSED_DIR / name, fork="data") as uncompressed_rf:
|
||||
self.assertEqual(sorted(compressed_rf), sorted(uncompressed_rf))
|
||||
|
||||
for (compressed_type, compressed_reses), (uncompressed_type, uncompressed_reses) in zip(sorted(compressed_rf.items()), sorted(uncompressed_rf.items())):
|
||||
with self.subTest(type=compressed_type):
|
||||
self.assertEqual(compressed_type, uncompressed_type)
|
||||
self.assertEqual(sorted(compressed_reses), sorted(uncompressed_reses))
|
||||
|
||||
for (compressed_id, compressed_res), (uncompressed_id, uncompressed_res) in zip(sorted(compressed_reses.items()), sorted(uncompressed_reses.items())):
|
||||
with self.subTest(id=compressed_id):
|
||||
# The metadata of the compressed and uncompressed resources must match.
|
||||
self.assertEqual(compressed_res.type, uncompressed_res.type)
|
||||
self.assertEqual(compressed_id, uncompressed_id)
|
||||
self.assertEqual(compressed_res.id, compressed_id)
|
||||
self.assertEqual(compressed_res.id, uncompressed_res.id)
|
||||
self.assertEqual(compressed_res.name, uncompressed_res.name)
|
||||
self.assertEqual(compressed_res.attributes & ~rsrcfork.ResourceAttrs.resCompressed, uncompressed_res.attributes)
|
||||
|
||||
# The uncompressed resource really has to be not compressed.
|
||||
self.assertNotIn(rsrcfork.ResourceAttrs.resCompressed, uncompressed_res.attributes)
|
||||
self.assertEqual(uncompressed_res.compressed_info, None)
|
||||
self.assertEqual(uncompressed_res.data, uncompressed_res.data_raw)
|
||||
self.assertEqual(uncompressed_res.length, uncompressed_res.length_raw)
|
||||
|
||||
# The compressed resource's (automatically decompressed) data must match the uncompressed data.
|
||||
self.assertEqual(compressed_res.data, uncompressed_res.data)
|
||||
self.assertEqual(compressed_res.length, uncompressed_res.length)
|
||||
with compressed_res.open() as compressed_f, uncompressed_res.open() as uncompressed_f:
|
||||
compressed_f.seek(15)
|
||||
uncompressed_f.seek(15)
|
||||
self.assertEqual(compressed_f.read(10), uncompressed_f.read(10))
|
||||
self.assertEqual(compressed_f.read(), uncompressed_f.read())
|
||||
compressed_f.seek(0)
|
||||
uncompressed_f.seek(0)
|
||||
self.assertEqual(compressed_f.read(), uncompressed_f.read())
|
||||
|
||||
if rsrcfork.ResourceAttrs.resCompressed in compressed_res.attributes:
|
||||
# Resources with the compressed attribute must expose correct compression metadata.
|
||||
self.assertNotEqual(compressed_res.compressed_info, None)
|
||||
self.assertEqual(compressed_res.compressed_info.decompressed_length, compressed_res.length)
|
||||
else:
|
||||
# Some resources in the "compressed" files are not actually compressed, in which case there is no compression metadata.
|
||||
self.assertEqual(compressed_res.compressed_info, None)
|
||||
self.assertEqual(compressed_res.data, compressed_res.data_raw)
|
||||
self.assertEqual(compressed_res.length, compressed_res.length_raw)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
|
@ -0,0 +1,27 @@
|
|||
[tox]
|
||||
# When updating the Python versions here,
|
||||
# please also update the corresponding Python versions in the GitHub Actions workflow (.github/workflows/ci.yml).
|
||||
envlist = py{36,311},flake8,mypy,package
|
||||
|
||||
[testenv]
|
||||
commands = python -m unittest discover --start-directory ./tests
|
||||
|
||||
[testenv:flake8]
|
||||
deps =
|
||||
flake8 >= 3.8.0
|
||||
flake8-bugbear
|
||||
commands = flake8
|
||||
|
||||
[testenv:mypy]
|
||||
deps =
|
||||
mypy
|
||||
commands = mypy
|
||||
|
||||
[testenv:package]
|
||||
deps =
|
||||
twine
|
||||
wheel >= 0.32.0
|
||||
|
||||
commands =
|
||||
python setup.py sdist bdist_wheel
|
||||
twine check dist/*
|