# Copyright (c) 2013-2025, Andrea Zoppi
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
r"""MOS Technology format.
See Also:
`<https://srecord.sourceforge.net/man/man5/srec_mos_tech.5.html>`_
"""
import enum
import io
import re
from typing import IO
from typing import Any
from typing import Mapping
from typing import Type
from typing import TypeVar
from typing import Union # NOTE: type | operator unsupported for Python < 3.10
from typing import cast as _cast
from ..base import AnyBytes
from ..base import BaseFile
from ..base import BaseRecord
from ..base import BaseTag
from ..base import ByteString
from ..utils import hexlify
from ..utils import unhexlify
try:
from typing import Self
except ImportError: # pragma: no cover
Self = Any # Python < 3.11
__TYPING_HAS_SELF = Self is not Any
[docs]
class MosTag(BaseTag, enum.IntEnum):
r"""MOS Technology tag."""
DATA = 0
r"""Data."""
EOF = 1
r"""End Of File."""
_DATA = DATA # type: ignore
[docs]
def is_data(self) -> bool:
return self == self.DATA
[docs]
def is_eof(self) -> bool:
r"""Tells whether this is an End Of File record tag.
This method returns true if this record tag is used for *End Of File*
records.
Returns:
bool: This is an End Of File record tag.
Examples:
>>> from hexrec import MosFile
>>> MosTag = MosFile.Record.Tag
>>> MosTag.EOF.is_eof()
True
>>> MosTag.DATA.is_eof()
False
"""
return self == self.EOF
[docs]
def is_file_termination(self) -> bool:
return self.is_eof()
if not __TYPING_HAS_SELF: # pragma: no cover
del Self
Self = TypeVar('Self', bound='MosRecord')
[docs]
class MosRecord(BaseRecord):
r"""MOS Technology record object."""
Tag: Type[MosTag] = MosTag # type: ignore override
LINE_REGEX = re.compile(
b'^\0*(?P<before>[^;]*);'
b'(?P<count>[0-9A-Fa-f]{2})'
b'(?P<address>[0-9A-Fa-f]{4})'
b'(?P<data>([0-9A-Fa-f]{2}){,255})'
b'(?P<checksum>[0-9A-Fa-f]{4})'
b'(?P<after>[^\\r\\n]*)\\r?\\n?\0*$'
)
r"""Line parser regex."""
[docs]
def compute_checksum(self) -> int:
if self.count is None:
raise ValueError('missing count')
count = self.count & 0xFF
address = self.address & 0xFFFF
sum_address = (address >> 8) + (address & 0xFF)
sum_data = sum(iter(self.data))
checksum = (count + sum_address + sum_data) & 0xFFFF
return checksum
[docs]
def compute_count(self) -> int:
return len(self.data)
[docs]
@classmethod
def create_data(
cls,
address: int,
data: ByteString,
) -> Self: # type: ignore Self
address = address.__index__()
if not 0 <= address <= 0xFFFF:
raise ValueError('address overflow')
size = len(data)
if size > 0xFF:
raise ValueError('size overflow')
record = cls(cls.Tag.DATA, address=address, data=data)
return record
[docs]
@classmethod
def create_eof(
cls,
record_count: int,
) -> Self: # type: ignore Self
r"""Creates an End Of File record.
The End Of File record also carries the *record count*.
Args:
record_count (int):
Number of preceding records.
Returns:
:class:`MosRecord`: End Of File record object.
Examples:
>>> from hexrec import MosFile
>>> record = MosFile.Record.create_eof(123)
>>> str(record)
';00007B007B\r\n\x00\x00\x00\x00\x00\x00'
"""
record_count = record_count.__index__()
if not 0 <= record_count <= 0xFFFF:
raise ValueError('record count overflow')
record = cls(cls.Tag.EOF, address=record_count)
return record
[docs]
@classmethod
def parse( # type: ignore kwargs order
cls,
line: ByteString,
eof: bool = False,
validate: bool = True,
) -> Self: # type: ignore Self
r"""Parses a record from bytes.
Please refer to the actual implementation provided by the record
*format* for more details.
Args:
line (bytes):
String of bytes to parse.
eof (bool):
Parsing an *End Of File* record.
validate (bool):
Perform validation checks.
Returns:
:class:`BaseRecord`: Parsed record.
Raises:
ValueError: Syntax error.
Examples:
>>> from hexrec import MosFile
>>> record = MosFile.Record.parse(b';0000010001\r\n', eof=True)
>>> record.tag
<MosTag.EOF: 1>
>>> MosFile.Record.parse(b';;0000010001\r\n', eof=True)
Traceback (most recent call last):
...
ValueError: syntax error
"""
match = cls.LINE_REGEX.match(line)
if not match:
raise ValueError('syntax error')
groups = match.groupdict()
before = groups['before']
count = int(groups['count'], 16)
address = int(groups['address'], 16)
data = unhexlify(groups['data'])
checksum = int(groups['checksum'], 16)
after = groups['after']
record = cls(cls.Tag.EOF if eof else cls.Tag.DATA,
address=address,
data=data,
count=count,
checksum=checksum,
before=before,
after=after,
validate=validate)
return record
[docs]
def to_bytestr(
self,
end: AnyBytes = b'\r\n',
nuls: bool = True,
) -> bytes:
self.validate(checksum=False, count=False)
nulstr = b'\0\0\0\0\0\0' if nuls else b''
line = b'%s;%02X%04X%s%04X%s%s%s' % (
self.before,
(self.count or 0) & 0xFF,
self.address & 0xFFFF,
hexlify(self.data),
(self.checksum or 0) & 0xFFFF,
self.after,
end,
nulstr,
)
return line
[docs]
def to_tokens(
self,
end: ByteString = b'\r\n',
nuls: bool = True,
) -> Mapping[str, bytes]:
self.validate(checksum=False, count=False)
nulstr = b'\0\0\0\0\0\0' if nuls else b''
return {
'before': self.before,
'begin': b';',
'count': b'%02X' % ((self.count or 0) & 0xFF),
'address': b'%04X' % (self.address & 0xFFFF),
'data': hexlify(self.data),
'checksum': b'%04X' % ((self.checksum or 0) & 0xFFFF),
'after': self.after,
'end': end,
'nuls': nulstr,
}
[docs]
def validate(
self,
checksum: bool = True,
count: bool = True,
) -> Self: # type: ignore Self
super().validate(checksum=checksum, count=count)
if self.after and not self.after.isspace():
raise ValueError('junk after is not whitespace')
if b';' in self.before:
raise ValueError('junk before contains ";"')
if self.checksum is not None:
if not 0 <= self.checksum <= 0xFFFF:
raise ValueError('checksum overflow')
if self.count is not None:
if not 0 <= self.count <= 0xFF:
raise ValueError('count overflow')
data_size = len(self.data)
if data_size > 0xFF:
raise ValueError('data size overflow')
if not 0 <= self.address <= 0xFFFF:
raise ValueError('address overflow')
return self
if not __TYPING_HAS_SELF: # pragma: no cover
del Self
Self = TypeVar('Self', bound='MosFile')
[docs]
class MosFile(BaseFile):
r"""MOS Technology file object."""
DEFAULT_DATALEN: int = 24
Record: Type[MosRecord] = MosRecord # type: ignore override
[docs]
@classmethod
def _is_line_empty(cls, line: Union[bytes, bytearray]) -> bool:
if b'\0' in line:
line = line.replace(b'\0', b'')
return not line or line.isspace()
[docs]
@classmethod
def parse( # type: ignore kwargs order
cls,
stream: IO,
ignore_errors: bool = False,
ignore_after_termination: bool = True,
eof_record: bool = True,
) -> Self: # type: ignore Self
r"""Parses records from a byte stream.
It executes :meth:`MosRecord.parse` for each line of the incoming
`stream`, creating a new file object with the collected records calling
:meth:`from_records`.
Lines resulting empty by :meth:`_is_empty_line` are just discarded.
Notes:
Please refer to the actual implementation of each record file
*format*, because it may be more specialized.
Args:
stream (bytes IO):
Stream to serialize records onto.
ignore_errors (bool):
Ignore :class:`Exception` raised by :meth:`MosRecord.parse`.
ignore_after_termination (bool):
Ignore anything after the *End Of File* record was parsed.
eof_record (bool):
Interpret the last record as the *End Of File* record.
Returns:
:class:`MosFile`: *self*.
See Also:
:meth:`parse`
:meth:`MosRecord.parse`
:meth:`from_records`
:meth:`_is_empty_line`
Examples:
>>> from hexrec import MosFile
>>> buffer = b'''
... ;031234616263016F
... ;0000010001
... '''
>>> import io
>>> stream = io.BytesIO(buffer)
>>> file = MosFile.parse(stream)
>>> file.memory.to_blocks()
[(4660, b'abc')]
>>> file.get_meta()
{'maxdatalen': 3}
"""
data = stream.read()
data = _cast(bytes, data)
start = data.find(b';')
if start < 0:
start = len(data)
endex = data.find(b'\x13')
if endex < 0:
endex = len(data)
if start > endex:
start = endex
data = data[start:endex]
stream = io.BytesIO(data)
file = super().parse(stream, ignore_errors=ignore_errors,
ignore_after_termination=ignore_after_termination)
file = _cast(MosFile, file)
if eof_record:
if file._records:
file._records[-1].tag = cls.Record.Tag.EOF # patch
elif not ignore_errors:
raise ValueError('missing end of file record')
return file
[docs]
def serialize(
self,
stream: IO,
end: AnyBytes = b'\r\n',
nuls: bool = True,
xoff: bool = True,
) -> 'BaseFile':
r"""Serializes records onto a byte stream.
It executes :meth:`MosRecord.serialize` for each of the stored
:attr:`records`.
Args:
stream (bytes IO):
Stream to serialize records onto.
end (bytes):
Line ending suffix bytes.
nuls (bool):
Append six ASCII ``NUL`` (zero) bytes after each line, as
prescribed by the original specifications.
xoff (bool):
Append the ASCII ``XOFF`` byte at the end of the whole
serialization.
Returns:
:class:`MosFile`: *self*.
See Also:
:meth:`parse`
:meth:`MosRecord.serialize`
Examples:
>>> from hexrec import MosFile
>>> file = MosFile.from_blocks([(0xDA7A, b'abc')])
>>> import sys
>>> _ = file.serialize(sys.stdout.buffer, nuls=False, xoff=False)
;03DA7A616263027D
;0000010001
"""
for record in self.records:
record.serialize(stream, end=end, nuls=nuls)
if xoff:
stream.write(b'\x13')
return self
[docs]
def update_records(
self,
align: bool = False,
) -> Self: # type: ignore Self
r"""Applies memory and meta to records.
This method processes the stored :attr:`memory` and *meta* information
to generate the sequence of :attr:`records`.
This effectively converts the *memory role* into the *records role*
(keeping both).
The :attr:`records` is assigned upon return.
Any exceptions being raised should not alter the file object.
Args:
align (bool):
Aligns data record chunk address bounds to :attr:`maxdatalen`.
Returns:
:class:`MosFile`: *self*.
Raises:
ValueError: :attr:`memory` attribute not populated.
See Also:
:attr:`records`
:attr:`memory`
:meth:`get_meta`
:meth:`apply_records`
Examples:
>>> from hexrec import MosFile
>>> blocks = [(123, b'abc')]
>>> file = MosFile.from_blocks(blocks, maxdatalen=16)
>>> file.memory.to_blocks()
[(123, b'abc')]
>>> file.get_meta()
{'maxdatalen': 16}
>>> _ = file.update_records()
>>> len(file.records)
2
>>> _ = file.print(nuls=False)
;03007B61626301A4
;0000010001
"""
memory = self._memory
if memory is None:
raise ValueError('memory instance required')
records = []
Record = self.Record
chunk_views = []
try:
for chunk_start, chunk_view in memory.chop(self.maxdatalen, align=align):
chunk_views.append(chunk_view)
data = bytes(chunk_view)
record = Record.create_data(chunk_start, data)
records.append(record)
record = Record.create_eof(len(records))
records.append(record)
finally:
for chunk_view in chunk_views:
chunk_view.release()
self.discard_records()
self._records = records
return self
[docs]
def validate_records(
self,
data_ordering: bool = False,
eof_record_required: bool = True,
) -> Self: # type: ignore Self
r"""Validates records.
It performs consistency checks for the underlying :attr:`records`.
Args:
data_ordering (bool):
Checks that the *data* record sequence has monotonically
increasing addresses, without any overlapping.
eof_record_required (bool):
Requires the *End Of File* record be present.
Returns:
:class:`MosFile`: *self*.
Raises:
ValueError: Invalid record sequence.
Examples:
>>> from hexrec import MosFile
>>> records = [MosFile.Record.create_data(123, b'abc')]
>>> file = MosFile.from_records(records)
>>> _ = file.validate_records()
Traceback (most recent call last):
...
ValueError: missing end of file record
"""
records = self._records
if records is None:
raise ValueError('records required')
eof_record = None
last_data_endex = 0
for index, record in enumerate(records):
record.validate()
tag = _cast(MosTag, record.tag)
if data_ordering:
if tag == tag.DATA:
address = record.address
if address < last_data_endex:
raise ValueError('unordered data record')
last_data_endex = address + len(record.data)
if tag == tag.EOF:
eof_record = record
expected_eof_index = len(records) - 1
if index != expected_eof_index:
raise ValueError('end of file record not last')
if record.address != expected_eof_index:
raise ValueError('wrong record count as address')
if eof_record_required and eof_record is None:
raise ValueError('missing end of file record')
return self
if not __TYPING_HAS_SELF: # pragma: no cover
del Self