-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathbinary.py
More file actions
2562 lines (2249 loc) · 82.5 KB
/
Copy pathbinary.py
File metadata and controls
2562 lines (2249 loc) · 82.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
#
# pylint: disable=too-many-lines
#
''' Facilities associated with binary data parsing and transcription.
The classes in this module support easy parsing of binary data
structures,
returning instances with the binary data decoded into attributes
and capable of transcribing themselves in binary form
(trivially via `bytes(instance)` and also otherwise).
See `cs.iso14496` for an ISO 14496 (eg MPEG4) parser
built using this module.
Note: this module requires Python 3.6+ because various default
behaviours rely on `dict`s preserving their insert order.
Terminology used below:
* buffer:
an instance of `cs.buffer.CornuCopyBuffer`,
which manages an iterable of bytes-like values
and has various useful methods for parsing.
* chunk:
a piece of binary data obeying the buffer protocol,
i.e. a `collections.abc.Buffer`;
almost always a `bytes` instance or a `memoryview`,
but in principle also things like `bytearray`.
The `CornuCopyBuffer` is the basis for all parsing, as it manages
a variety of input sources such as files, memory, sockets etc.
It also has a factory methods to make one from a variety of sources
such as bytes, iterables, binary files, `mmap`ped files,
TCP data streams, etc.
All the binary classes subclass `AbstractBinary`.
Amongst other things, this means that the binary transcription
can be had simply from `bytes(instance)`,
although there are more transcription methods provided
for when greater flexibility is desired.
It also means that all classes have `parse`* and `scan`* methods
for parsing binary data streams.
The `.parse(cls,bfr)` class method reads binary data from a
buffer and returns an instance.
The `.transcribe(self)` method may be a regular function or a
generator which returns or yields things which can be transcribed
as bytes via the `flatten` function.
See the `AbstractBinary.transcribe` docstring for specifics; this might:
- return a `bytes`
- return an ASCII string
- be a generator which yields various values such as bytes,
ASCII strings, other `AbstractBinary` instances such as each
field (which get transcribed in turn) or an iterable of these
things
There are 6 main ways an implementor might base their data structures:
* `BinaryStruct`: a factory for classes based
on a `struct.struct` format string with multiple values;
this also builds a `namedtuple` subclass
* `@binclass`: a dataclass-like specification of a binary structure
* `BinarySingleValue`: a base class for subclasses
parsing and transcribing a single value, such as `UInt8` or
`BinaryUTF8NUL`
* `BinaryMultiValue`: a factory for subclasses
parsing and transcribing multiple values
with no variation
* `SimpleBinary`: a base class for subclasses
with custom `.parse` and `.transcribe` methods,
for structures with variable fields;
this makes a `SimpleNamespace` subclass
These can all be mixed as appropriate to your needs.
You can also instantiate objects directly;
there's no requirement for the source information to be binary.
There are several presupplied subclasses for common basic types
such as `UInt32BE` (an unsigned 32 bit big endian integer).
## Some Examples
### A `BinaryStruct`, from `cs.iso14496`
A simple `struct` style definitiion for 9 longs:
Matrix9Long = BinaryStruct(
'Matrix9Long', '>lllllllll', 'v0 v1 v2 v3 v4 v5 v6 v7 v8'
)
Per the `struct.struct` format string, this parses 9 big endian longs
and returns a `namedtuple` with 9 fields.
Like all the `AbstractBinary` subclasses, parsing an instance from a
stream can be done like this:
m9 = Matrix9Long.parse(bfr)
print("m9.v3", m9.v3)
and writing its binary form to a file like this:
f.write(bytes(m9))
### A `@binclass`, also from `cs.iso14496`
For reasons to do with the larger MP4 parser this uses an extra
decorator `@boxbodyclass` which is just a shim for the `@binclass`
decorator with an addition step.
@boxbodyclass
class FullBoxBody2(BoxBody):
""" A common extension of a basic `BoxBody`, with a version and flags field.
ISO14496 section 4.2.
"""
version: UInt8
flags0: UInt8
flags1: UInt8
flags2: UInt8
@property
def flags(self):
""" The flags value, computed from the 3 flag bytes.
"""
return (self.flags0 << 16) | (self.flags1 << 8) | self.flags2
This has 4 fields, each an unsigned 8 bit value (one bytes),
and a property `.flags` which is the overall flags value for
the box header.
You should look at the source code for the `TKHDBoxBody` from
that module for an example of a `@binclass` with a variable
collection of fields based on an earlier `version` field value.
### A `BinarySingleValue`, the `BSUInt` from this module
The `BSUint` transcribes an unsigned integer of arbitrary size
as a big endian variable sizes sequence of bytes.
I understand this is the same scheme MIDI uses.
You can define a `BinarySingleValue` with conventional `.parse()`
and `.transribe()` methods but it is usually expedient to instead
provide `.parse_value()` and `transcribe_value()` methods, which
return or transcibe the core value (the unsigned integer in
this case).
class BSUInt(BinarySingleValue, value_type=int):
""" A binary serialised unsigned `int`.
This uses a big endian byte encoding where continuation octets
have their high bit set. The bits contributing to the value
are in the low order 7 bits.
"""
@staticmethod
def parse_value(bfr: CornuCopyBuffer) -> int:
""" Parse an extensible byte serialised unsigned `int` from a buffer.
Continuation octets have their high bit set.
The value is big-endian.
This is the go for reading from a stream. If you already have
a bare bytes instance then the `.decode_bytes` static method
is probably most efficient;
there is of course the usual `AbstractBinary.parse_bytes`
but that constructs a buffer to obtain the individual bytes.
"""
n = 0
b = 0x80
while b & 0x80:
b = bfr.byte0()
n = (n << 7) | (b & 0x7f)
return n
# pylint: disable=arguments-renamed
@staticmethod
def transcribe_value(n):
""" Encode an unsigned int as an entensible byte serialised octet
sequence for decode. Return the bytes object.
"""
bs = [n & 0x7f]
n >>= 7
while n > 0:
bs.append(0x80 | (n & 0x7f))
n >>= 7
return bytes(reversed(bs))
### A `BinaryMultiValue`
A `BinaryMultiValue` s a class factory for making a multi field
`AbstractBinary` from variable field descriptions.
You're probably better off using `@binclass` these days.
See the `BinaryMultiValue` docstring for details and an example.
An MP4 ELST box:
class ELSTBoxBody(FullBoxBody):
""" An 'elst' Edit List FullBoxBody - section 8.6.6.
"""
V0EditEntry = BinaryStruct(
'ELSTBoxBody_V0EditEntry', '>Llhh',
'segment_duration media_time media_rate_integer media_rate_fraction'
)
V1EditEntry = BinaryStruct(
'ELSTBoxBody_V1EditEntry', '>Qqhh',
'segment_duration media_time media_rate_integer media_rate_fraction'
)
@property
def entry_class(self):
""" The class representing each entry.
"""
return self.V1EditEntry if self.version == 1 else self.V0EditEntry
@property
def entry_count(self):
""" The number of entries.
"""
return len(self.entries)
def parse_fields(self, bfr: CornuCopyBuffer):
""" Parse the fields of an `ELSTBoxBody`.
"""
super().parse_fields(bfr)
assert self.version in (0, 1)
entry_count = UInt32BE.parse_value(bfr)
self.entries = list(self.entry_class.scan(bfr, count=entry_count))
def transcribe(self):
""" Transcribe an `ELSTBoxBody`.
"""
yield super().transcribe()
yield UInt32BE.transcribe_value(self.entry_count)
yield map(self.entry_class.transcribe, self.entries)
A Edit List box comes in a version 0 and version 1 form, differing
in the field sizes in the edit entries. This defines two
flavours of edit entry structure and a property to return the
suitable class based on the version field. The `parse_fields()`
method is called from the base `BoxBody` class' `parse()` method
to collect addition fields for any box. For this box it collects
a 32 bit `entry_count` and then a list of that many edit entries.
The transcription yields corresponding values.
'''
from abc import ABC, abstractmethod
from collections import namedtuple
from dataclasses import dataclass, fields
from inspect import signature, Signature
from struct import Struct # pylint: disable=no-name-in-module
import sys
from types import SimpleNamespace
from typing import (
get_args,
get_origin,
Any,
Callable,
Iterable,
List,
Mapping,
Optional,
Tuple,
Union,
)
from typeguard import check_type, typechecked
from cs.buffer import CornuCopyBuffer
from cs.deco import OBSOLETE, decorator, promote, Promotable, strable
from cs.gimmicks import Buffer, debug, warning
from cs.lex import cropped, cropped_repr, r, stripped_dedent, typed_str
from cs.pfx import Pfx, pfx, pfx_method, pfx_call
from cs.seq import Seq
__version__ = '20260531-post'
DISTINFO = {
'keywords': ["python3"],
'classifiers': [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Programming Language :: Python :: 3",
],
'install_requires': [
'cs.buffer',
'cs.deco',
'cs.gimmicks',
'cs.lex',
'cs.pfx',
'cs.seq',
'typeguard',
],
'python_requires':
'>=3.6',
}
if sys.version_info < (3, 6):
warning(
"module %r requires Python 3.6 for reliable field ordering but version_info=%s",
__name__, sys.version_info
)
def flatten(transcription) -> Iterable[bytes]:
''' Flatten `transcription` into an iterable of `Buffer`s.
None of the `Buffer`s will be empty.
This exists to allow subclass methods to easily return
transcribable things (having a `.transcribe` method), ASCII
strings or bytes or iterables or even `None`, in turn allowing
them simply to return their superclass' chunks iterators
directly instead of having to unpack them.
The supplied `transcription` may be any of the following:
- `None`: yield nothing
- an object with a `.transcribe` method: yield from
`flatten(transcription.transcribe())`
- a `Buffer`: yield the `Buffer` if it is not empty
- a `str`: yield `transcription.encode('ascii')`
- an iterable: yield from `flatten(item)` for each item in `transcription`
An example from the `cs.iso14496.METABoxBody` class:
def transcribe(self):
yield super().transcribe()
yield self.theHandler
yield self.boxes
The binary classes `flatten` the result of the `.transcribe`
method to obtain `bytes` instances for the object's binary
transcription.
'''
if transcription is None:
pass
elif hasattr(transcription, 'transcribe'):
# an object which can transcribe itself in a flattenable way
# includes all AbstractBinary instances
yield from flatten(transcription.transcribe())
elif isinstance(transcription, Buffer):
if len(transcription) > 0:
yield transcription
elif isinstance(transcription, str):
if len(transcription) > 0:
yield transcription.encode('ascii')
else:
for item in transcription:
yield from flatten(item)
@decorator
def parse_offsets(parse, report=False):
''' Decorate `parse` (usually an `AbstractBinary` class method)
to record the buffer starting offset as `self.offset`
and the buffer post parse offset as `self.end_offset`.
If the decorator parameter `report` is true,
call `bfr.report_offset()` with the starting offset at the end of the parse.
'''
def parse_wrapper(cls, bfr: CornuCopyBuffer, **parse_kw):
offset = bfr.offset
self = parse(cls, bfr, **parse_kw)
self.offset = offset
self.end_offset = bfr.offset
if report:
bfr.report_offset(offset)
return self
return parse_wrapper
_pt_spec_seq = Seq()
def pt_spec(pt, name=None, value_type=None, as_repr=None, as_str=None):
''' Convert a parse/transcribe specification `pt`
into an `AbstractBinary` subclass.
This is largely used to provide flexibility
in the specifications for the `BinaryMultiValue` factory
but can also be used as a factory for other simple classes.
If the specification `pt` is a subclass of `AbstractBinary`
this is returned directly.
If `pt` is a (str,str) 2-tuple
the values are presumed to be a format string for `struct.struct`
and field names separated by spaces;
a new `BinaryStruct` class is created from these and returned.
Otherwise two functions
`f_parse_value(bfr)` and `f_transcribe_value(value)`
are obtained and used to construct a new `BinarySingleValue` class
as follows:
If `pt` has `.parse_value` and `.transcribe_value` callable attributes,
use those for `f_parse_value` and `f_transcribe_value` respectively.
Otherwise, if `pt` is an `int`
define `f_parse_value` to obtain exactly that many bytes from a buffer
and `f_transcribe_value` to return those bytes directly.
Otherwise presume `pt` is a 2-tuple of `(f_parse_value,f_transcribe_value)`.
'''
# AbstractBinary subclasses are returned directly
try:
if issubclass(pt, AbstractBinary):
return pt
except TypeError:
# not a class at all, fall through
pass
# other specifications construct a class
try:
# an object with .parse_value and .transcribe_value attributes
f_parse_value = pt.parse_value
f_transcribe_value = pt.transcribe_value
except AttributeError:
# an int number of bytes
if isinstance(pt, int):
# pylint: disable=unnecessary-lambda-assignment
f_parse_value = lambda bfr: bfr.take(pt)
f_transcribe_value = lambda value: value
if value_type is None:
value_type = Buffer
elif not issubclass(value_type, Buffer):
raise TypeError(f'supplied {value_type=} is not a subclass of Buffer')
else:
struct_format, struct_fields = pt
if isinstance(struct_format, str) and isinstance(struct_fields, str):
# a struct (format,fields) 2-tuple
# struct format and field names
if name is None:
name = f'PTStruct_{next(_pt_spec_seq)}__{struct_fields.replace(" ", "__")}'
return BinaryStruct(name, struct_format, struct_fields)
# otherwise a parse/transcribe pair
f_parse_value, f_transcribe_value = pt
if value_type is None:
sig = signature(f_parse_value)
value_type = sig.return_annotation
if value_type is Signature.empty:
raise ValueError(f'no return type annotation on {f_parse_value=}')
class PTValue(BinarySingleValue, value_type=value_type): # pylint: disable=used-before-assignment
''' A `BinarySingleValue` subclass
made from `f_parse_value` and `f_transcribe_value`.
'''
if as_str:
__str__ = as_str
if as_repr:
__repr__ = as_repr
@staticmethod
def parse_value(bfr: CornuCopyBuffer) -> value_type:
''' Parse value from buffer.
'''
return f_parse_value(bfr)
@staticmethod
def transcribe_value(value):
''' Transcribe the value.
'''
return f_transcribe_value(value)
PTValue.__name__ = name or f'PTValue_{next(_pt_spec_seq)}'
PTValue.__doc__ = stripped_dedent(
f'''{name}, a `BinarySingleValue` subclass
made from {f_parse_value=} and {f_transcribe_value=}.
'''
)
return PTValue
class bs(bytes):
''' A `bytes` subclass with a compact `repr()`.
'''
def __repr__(self):
return cropped(super().__repr__())
def join(self, chunks):
''' `bytes.join` but returning a `bs`.
'''
return self.__class__(super().join(chunks))
@classmethod
def promote(cls, obj):
''' Promote a `Buffer` (eg `bytes` or `memoryview`) to a `bs`.
'''
if isinstance(obj, cls):
return obj
if isinstance(obj, Buffer):
return cls(obj)
raise TypeError(f'{cls.__name__}.promote({obj.__class__}): cannot promote')
class AbstractBinary(Promotable, ABC):
''' Abstract class for all `Binary`* implementations,
specifying the abstract `parse` and `transcribe` methods
and providing various helper methods.
Naming conventions:
- `parse`* methods parse a single instance from a buffer
- `scan`* methods are generators yielding successive instances from a buffer
'''
def __str__(self, attr_names=None, attr_choose=None, str_func=None):
''' The string summary of this object.
If called explicitly rather than via `str()` the following
optional parametsrs may be supplied:
* `attr_names`: an iterable of `str` naming the attributes to include;
the default if the keys of `self.__dict__`
* `attr_choose`: a callable to select amongst the attribute names names;
the default is to choose names which do not start with an underscore
* `str_func`: a callable returning the string form of an attribute value;
the default returns `cropped_repr(v)` where `v` is the value's `.value`
attribute for single value objects otherwise the object itself
'''
if attr_names is None:
attr_names = self._field_names
if attr_choose is None:
# pylint: disable=unnecessary-lambda-assignment
attr_choose = lambda attr: not attr.startswith('_')
elif attr_choose is True:
attr_choose = lambda: True
if str_func is None:
str_func = lambda obj: (
cropped_repr(obj.value)
if is_single_value(obj) else cropped_repr(obj)
)
attr_values = [
(attr, getattr(self, attr, None))
for attr in attr_names
if attr_choose(attr)
]
return "%s(%s)" % (
self.__class__.__name__,
','.join(f'{attr}={str_func(obj)}' for attr, obj in attr_values),
)
def __repr__(self):
return "%s(%s)" % (
self.__class__.__name__, ",".join(
f'{attr}={type(value).__name__}:{cropped_repr(value)}'
for attr, value in self.__dict__.items()
)
)
@property
def _field_names(self):
return self.__dict__.keys()
# pylint: disable=deprecated-decorator
@classmethod
@abstractmethod
def parse(cls, bfr: CornuCopyBuffer):
''' Parse an instance of `cls` from the buffer `bfr`.
'''
raise NotImplementedError("parse")
@abstractmethod
def transcribe(self):
''' Return or yield `bytes`, ASCII string, `None` or iterables
comprising the binary form of this instance.
This aims for maximum convenience when transcribing a data structure.
This may be implemented as a generator, yielding parts of the structure.
This may be implemented as a normal function, returning:
* `None`: no bytes of data,
for example for an omitted or empty structure
* a `bytes`-like object: the full data bytes for the structure
* an ASCII compatible string:
this will be encoded with the `'ascii'` encoding to make `bytes`
* an iterable:
the components of the structure,
including substranscriptions which themselves
adhere to this protocol - they may be `None`, `bytes`-like objects,
ASCII compatible strings or iterables.
This supports directly returning or yielding the result of a field's
`.transcribe` method.
'''
raise NotImplementedError("transcribe")
@pfx_method
def self_check(self, *, field_types=None):
''' Internal self check. Returns `True` if passed.
If the structure has a `FIELD_TYPES` attribute, normally a
class attribute, then check the fields against it.
The `FIELD_TYPES` attribute is a mapping of `field_name` to
a specification of `required` and `types`. The specification
may take one of 2 forms:
* a tuple of `(required,types)`
* a single `type`; this is equivalent to `(True,(type,))`
Their meanings are as follows:
* `required`: a Boolean. If true, the field must be present
in the packet `field_map`, otherwise it need not be present.
* `types`: a tuple of acceptable field types
There are some special semantics involved here.
An implementation of a structure may choose to make some
fields plain instance attributes instead of binary objects
in the `field_map` mapping, particularly variable structures
such as a `cs.iso14496.BoxHeader`, whose `.length` may be parsed
directly from its binary form or computed from other fields
depending on the `box_size` value. Therefore, checking for
a field is first done via the `field_map` mapping, then by
`getattr`, and as such the acceptable `types` may include
nonstructure types such as `int`.
Here is the `cs.iso14496` `Box.FIELD_TYPES` definition as an example:
FIELD_TYPES = {
'header': BoxHeader,
'body': BoxBody,
'unparsed': list,
'offset': int,
'unparsed_offset': int,
'end_offset': int,
}
Note that `length` includes some nonstructure types,
and that it is written as a tuple of `(True,types)` because
it has more than one acceptable type.
'''
ok = True
if field_types is None:
try:
field_types = self.FIELD_TYPES
except AttributeError:
warning("no FIELD_TYPES")
field_types = {}
# check fields against self.FIELD_TYPES
# TODO: call self_check on members with a .self_check() method
for field_name, field_spec in field_types.items():
with Pfx(".%s=%s", field_name, field_spec):
if isinstance(field_spec, tuple):
required, basetype = field_spec
else:
required, basetype = True, field_spec
try:
field = getattr(self, field_name)
except AttributeError:
if required:
warning(
"missing required field %s.%s: __dict__=%s",
type(self).__name__, field_name, cropped_repr(self.__dict__)
)
ok = False
else:
if not isinstance(field, basetype):
warning(
"should be an instance of %s:%s but is %s", (
'tuple'
if isinstance(basetype, tuple) else basetype.__name__
), basetype, typed_str(field, max_length=64)
)
ok = False
raise RuntimeError
return ok
def __bytes__(self):
''' The binary transcription as a single `bytes` object.
'''
return b''.join(flatten(self.transcribe()))
def transcribed_length(self):
''' Compute the length by running a transcription and measuring it.
'''
return sum(map(len, flatten(self.transcribe())))
# also available as len() by default
__len__ = transcribed_length
@classmethod
@promote
def scan(
cls,
bfr: CornuCopyBuffer,
count=None,
*,
min_count=None,
max_count=None,
with_offsets=False,
**parse_kw,
):
''' A generator to scan the buffer `bfr` for repeated instances of `cls`
until end of input, and yield them.
Note that if `bfr` is not already a `CornuCopyBuffer`
it is promoted to `CornuCopyBuffer` from several types
such as filenames etc; see `CornuCopyBuffer.promote`.
Parameters:
* `bfr`: the buffer to scan, or any object suitable for `CornuCopyBuffer.promote`
* `count`: the required number of instances to scan,
equivalent to setting `min_count=count` and `max_count=count`
* `min_count`: the minimum number of instances to scan
* `max_count`: the maximum number of instances to scan
* `with_offsets`: optional flag, default `False`;
if true yield `(pre_offset,obj,post_offset)`, otherwise just `obj`
It is in error to specify both `count` and one of `min_count` or `max_count`.
Other keyword arguments are passed to `self.parse()`.
Scanning stops after `max_count` instances (if specified).
If fewer than `min_count` instances (if specified) are scanned
a warning is issued.
This is to accomodate nonconformant streams without raising exceptions.
Callers wanting to validate `max_count` may want to probe `bfr.at_eof()`
after return.
Callers not wanting a warning over `min_count` should not specify it,
and instead check the number of instances returned themselves.
'''
if count is None:
if min_count is None:
min_count = 0
elif min_count < 0:
raise ValueError(f'{min_count=} must be >=0 if specified')
if max_count is not None:
if max_count < 0:
raise ValueError(f'{max_count=} must be >=0 if specified')
if max_count < min_count:
raise ValueError(f'{max_count=} must be >= {min_count=}')
else:
if min_count is not None or max_count is not None:
raise ValueError(
"scan_with_offsets: may not combine count with either min_count or max_count"
)
if count < 0:
raise ValueError(f'{count=} must be >=0 if specified')
min_count = max_count = count
scanned = 0
while (max_count is None or scanned < max_count) and not bfr.at_eof():
pre_offset = bfr.offset
obj = cls.parse(bfr, **parse_kw)
if with_offsets:
yield pre_offset, obj, bfr.offset
else:
yield obj
scanned += 1
if min_count is not None and scanned < min_count:
warning(
"fewer than min_count=%s instances scanned, only %d found",
min_count, scanned
)
@classmethod
@OBSOLETE(suggestion="AbstractBinary.scan")
def scan_with_offsets(
cls, bfr: CornuCopyBuffer, count=None, min_count=None, max_count=None
):
''' Wrapper for `scan()` which yields `(pre_offset,instance,post_offset)`
indicating the start and end offsets of the yielded instances.
All parameters are as for `scan()`.
*Deprecated; please just call `scan` with the `with_offsets=True` parameter.
'''
return cls.scan(
bfr,
count=count,
min_count=min_count,
max_count=max_count,
with_offsets=True
)
@classmethod
@OBSOLETE(suggestion="AbstractBinary.scan")
def scan_fspath(cls, fspath: str, *, with_offsets=False, **kw):
''' Open the file with filesystenm path `fspath` for read
and yield from `self.scan(..,**kw)` or
`self.scan_with_offsets(..,**kw)` according to the
`with_offsets` parameter.
*Deprecated; please just call `scan` with a filesystem pathname.*
Parameters:
* `fspath`: the filesystem path of the file to scan
* `with_offsets`: optional flag, default `False`;
if true then scan with `scan_with_offsets` instead of
with `scan`
Other keyword parameters are passed to `scan` or
`scan_with_offsets`.
'''
with open(fspath, 'rb') as f:
bfr = CornuCopyBuffer.from_file(f)
if with_offsets:
yield from cls.scan_with_offsets(bfr, **kw)
else:
yield from cls.scan(bfr, **kw)
def transcribe_flat(self):
''' Return a flat iterable of chunks transcribing this field.
'''
return flatten(self.transcribe())
@classmethod
def parse_bytes(cls, bs, offset=0, length=None, **parse_kw):
''' Factory to parse an instance from the
bytes `bs` starting at `offset`.
Returns `(instance,offset)` being the new instance and the post offset.
Raises `EOFError` if `bs` has insufficient data.
The parameters `offset` and `length` are passed to the
`CornuCopyBuffer.from_bytes` factory.
Other keyword parameters are passed to the `.parse` method.
This relies on the `cls.parse` method for the parse.
'''
bfr = CornuCopyBuffer.from_bytes(bs, offset=offset, length=length)
instance = cls.parse(bfr, **parse_kw)
return instance, bfr.offset
@classmethod
def from_bytes(cls, bs, **parse_bytes_kw):
''' Factory to parse an instance from the
bytes `bs` starting at `offset`.
Returns the new instance.
Raises `ValueError` if `bs` is not entirely consumed.
Raises `EOFError` if `bs` has insufficient data.
Keyword parameters are passed to the `.parse_bytes` method.
This relies on the `cls.parse` method for the parse.
'''
instance, offset = cls.parse_bytes(bs, **parse_bytes_kw)
if offset < len(bs):
raise ValueError(f'unparsed data at {offset=}: {bs[offset:]!r}')
return instance
@classmethod
def load(cls, f):
''' Load an instance from the file `f`
which may be a filename or an open file as for `AbstractBinary.scan`.
Return the instance or `None` if the file is empty.
'''
for instance in cls.scan(f):
return instance
return None
@strable(open_func=lambda fspath: pfx_call(open, fspath, 'wb'))
def save(self, f):
''' Save this instance to the file `f`
which may be a filename or an open file.
Return the length of the transcription.
'''
length = 0
for bs in self.transcribe_flat():
while bs:
written = f.write(bs)
length += written
if written < len(bs):
bs = bs[written:]
else:
break
return length
def write(self, file, *, flush=False):
''' Write this instance to `file`, a file-like object supporting
`.write(bytes)` and `.flush()`.
Return the number of bytes written.
'''
length = 0
for bs in self.transcribe_flat():
bslen = len(bs)
assert bslen > 0
offset = 0
while offset < bslen:
written = file.write(bs[offset:])
if written == 0:
raise RuntimeError(f'wrote 0 bytes to {file}')
offset += written
length += bslen
if flush:
file.flush()
return length
def is_single_value(obj):
''' Test whether `obj` is a single value binary object.
This currently recognises `BinarySingleValue` instances
and tuple based `AbstractBinary` instances of length 1.
'''
if isinstance(obj, BinarySingleValue):
return True
if isinstance(obj, AbstractBinary) and isinstance(obj, tuple):
return tuple.__len__(obj) == 1
return False
class SimpleBinary(SimpleNamespace, AbstractBinary):
''' Abstract binary class based on a `SimpleNamespace`,
thus providing a nice `__str__` and a keyword based `__init__`.
Implementors must still define `.parse` and `.transcribe`.
To constrain the arguments passed to `__init__`,
define an `__init__` which accepts specific keyword arguments
and pass through to `super().__init__()`. Example:
def __init__(self, *, field1=None, field2):
""" Accept only `field1` (optional)
and `field2` (mandatory).
"""
super().__init__(field1=field1, field2=field2)
'''
def __str__(self, attr_names=None):
if attr_names is None:
attr_names = sorted(
attr for attr in self.__dict__ if not attr.startswith('_')
)
fields_s = ",".join(
f'{attr}={getattr(self,attr,None)}' for attr in attr_names
)
return f'{self.__class__.__name__}({fields_s})'
class BinarySingleValue(AbstractBinary, Promotable):
''' A representation of a single value as the attribute `.value`.
Subclasses must implement:
* `parse` or `parse_value`
* `transcribe` or `transcribe_value`
'''
@classmethod
def __init_subclass__(cls, *, value_type, **isc_kw):
if not isinstance(value_type, type) and not get_origin(value_type):
raise TypeError(
f'{cls.__name__}.__init_subclass__: value_type={r(value_type)} is not a type'
)
super().__init_subclass__(**isc_kw)
cls.VALUE_TYPE = value_type
def __init__(self, value):
''' Initialise `self` with `value`.
'''
check_type(value, self.VALUE_TYPE)
self.value = value
def __repr__(self):
return "%s(%r)" % (
type(self).__name__,
getattr(self, 'value', None)
or f'<NO-{self.__class__.__name__}.value>',
)
def __str__(self):
return str(self.value)
def __int__(self):
return int(self.value)
def __float__(self):
return float(self.value)
def __eq__(self, other):
return self.value == other.value
@classmethod
def scan_values(cls, bfr: CornuCopyBuffer, **kw):
''' Scan `bfr`, yield values.
'''
return map(lambda instance: instance.value, cls.scan(bfr, **kw))
@classmethod
def parse(cls, bfr: CornuCopyBuffer):
''' Parse an instance from `bfr`.
Subclasses must implement this method or `parse_value`.
'''
value = cls.parse_value(bfr)
return cls(value)
@classmethod
def parse_value(cls, bfr: CornuCopyBuffer):
''' Parse a value from `bfr` based on this class.
Subclasses must implement this method or `parse`.
'''
return cls.parse(bfr).value
@classmethod
def parse_value_from_bytes(cls, bs, offset=0, length=None, **kw):
''' Parse a value from the bytes `bs` based on this class.
Return `(value,offset)`.
'''
instance, offset = cls.parse_bytes(bs, offset=offset, length=length, **kw)
return instance.value, offset
@classmethod
def value_from_bytes(cls, bs, **from_bytes_kw):
''' Decode an instance from `bs` using `.from_bytes`
and return the `.value` attribute.
Keyword arguments are passed to `cls.from_bytes`.
'''
instance = cls.from_bytes(bs, **from_bytes_kw)
return instance.value
def transcribe(self):
''' Transcribe this instance as bytes.