traffic-taffy 0.3.6__py3-none-any.whl → 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- traffic_taffy/cache_info.py +0 -6
- traffic_taffy/compare.py +154 -250
- traffic_taffy/comparison.py +26 -0
- traffic_taffy/dissection.py +383 -0
- traffic_taffy/dissectmany.py +20 -18
- traffic_taffy/dissector.py +128 -476
- traffic_taffy/dissector_engine/__init__.py +35 -0
- traffic_taffy/dissector_engine/dpkt.py +98 -0
- traffic_taffy/dissector_engine/scapy.py +98 -0
- traffic_taffy/graph.py +23 -90
- traffic_taffy/graphdata.py +35 -20
- traffic_taffy/output/__init__.py +118 -0
- traffic_taffy/output/console.py +72 -0
- traffic_taffy/output/fsdb.py +50 -0
- traffic_taffy/output/memory.py +51 -0
- traffic_taffy/pcap_splitter.py +17 -36
- traffic_taffy/tools/cache_info.py +65 -0
- traffic_taffy/tools/compare.py +110 -0
- traffic_taffy/tools/dissect.py +77 -0
- traffic_taffy/tools/explore.py +686 -0
- traffic_taffy/tools/graph.py +85 -0
- {traffic_taffy-0.3.6.dist-info → traffic_taffy-0.4.1.dist-info}/METADATA +1 -1
- traffic_taffy-0.4.1.dist-info/RECORD +29 -0
- traffic_taffy-0.4.1.dist-info/entry_points.txt +6 -0
- pcap_compare/cache_info.py +0 -46
- pcap_compare/compare.py +0 -288
- pcap_compare/dissectmany.py +0 -21
- pcap_compare/dissector.py +0 -512
- pcap_compare/dissectorresults.py +0 -21
- pcap_compare/graph.py +0 -210
- traffic_taffy/explore.py +0 -221
- traffic_taffy-0.3.6.dist-info/RECORD +0 -22
- traffic_taffy-0.3.6.dist-info/entry_points.txt +0 -5
- {pcap_compare → traffic_taffy/tools}/__init__.py +0 -0
- {traffic_taffy-0.3.6.dist-info → traffic_taffy-0.4.1.dist-info}/WHEEL +0 -0
- {traffic_taffy-0.3.6.dist-info → traffic_taffy-0.4.1.dist-info}/top_level.txt +0 -0
traffic_taffy/cache_info.py
CHANGED
@@ -47,12 +47,6 @@ def main():
|
|
47
47
|
for key in contents["parameters"]:
|
48
48
|
print(f" {key:<16} {contents['parameters'][key]}")
|
49
49
|
|
50
|
-
print("data info:")
|
51
|
-
timestamps = list(contents["dissection"].keys())
|
52
|
-
print(f" timestamps: {len(timestamps)}")
|
53
|
-
print(f" first: {timestamps[1]}") # skips 0 = global
|
54
|
-
print(f" last: {timestamps[-1]}")
|
55
|
-
|
56
50
|
|
57
51
|
if __name__ == "__main__":
|
58
52
|
main()
|
traffic_taffy/compare.py
CHANGED
@@ -1,18 +1,12 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
import logging
|
4
|
-
from logging import info, debug
|
5
|
-
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
|
1
|
+
from logging import debug
|
6
2
|
from typing import List
|
7
|
-
|
3
|
+
import datetime as dt
|
4
|
+
from datetime import datetime
|
5
|
+
|
6
|
+
from traffic_taffy.comparison import Comparison
|
8
7
|
from traffic_taffy.dissectmany import PCAPDissectMany
|
9
|
-
from traffic_taffy.dissector import
|
10
|
-
|
11
|
-
dissector_add_parseargs,
|
12
|
-
limitor_add_parseargs,
|
13
|
-
PCAPDissector,
|
14
|
-
check_dissector_level,
|
15
|
-
)
|
8
|
+
from traffic_taffy.dissector import PCAPDissectorLevel
|
9
|
+
from traffic_taffy.dissection import Dissection
|
16
10
|
|
17
11
|
|
18
12
|
class PcapCompare:
|
@@ -22,34 +16,35 @@ class PcapCompare:
|
|
22
16
|
|
23
17
|
def __init__(
|
24
18
|
self,
|
25
|
-
|
26
|
-
maximum_count: int
|
19
|
+
pcap_files: List[str],
|
20
|
+
maximum_count: int = 0, # where 0 == all
|
27
21
|
deep: bool = True,
|
28
|
-
print_threshold: float = 0.0,
|
29
|
-
print_minimum_count: int | None = None,
|
30
|
-
print_match_string: str | None = None,
|
31
22
|
pkt_filter: str | None = None,
|
32
|
-
only_positive: bool = False,
|
33
|
-
only_negative: bool = False,
|
34
23
|
cache_results: bool = False,
|
35
|
-
|
36
|
-
|
24
|
+
cache_file_suffix: str = "taffy",
|
25
|
+
bin_size: int | None = None,
|
26
|
+
dissection_level: PCAPDissectorLevel = PCAPDissectorLevel.COUNT_ONLY,
|
37
27
|
between_times: List[int] | None = None,
|
28
|
+
ignore_list: List[str] = [],
|
38
29
|
) -> None:
|
39
|
-
self.
|
30
|
+
self.pcap_files = pcap_files
|
40
31
|
self.deep = deep
|
41
32
|
self.maximum_count = maximum_count
|
42
|
-
self.print_threshold = print_threshold
|
43
|
-
self.print_minimum_count = print_minimum_count
|
44
|
-
self.print_match_string = print_match_string
|
45
33
|
self.pkt_filter = pkt_filter
|
46
|
-
self.only_positive = only_positive
|
47
|
-
self.only_negative = only_negative
|
48
34
|
self.cache_results = cache_results
|
49
35
|
self.dissection_level = dissection_level
|
50
36
|
self.between_times = between_times
|
51
37
|
self.bin_size = bin_size
|
52
|
-
self.
|
38
|
+
self.cache_file_suffix = cache_file_suffix
|
39
|
+
self.ignore_list = ignore_list
|
40
|
+
|
41
|
+
@property
|
42
|
+
def pcap_files(self):
|
43
|
+
return self._pcap_files
|
44
|
+
|
45
|
+
@pcap_files.setter
|
46
|
+
def pcap_files(self, new_pcap_files):
|
47
|
+
self._pcap_files = new_pcap_files
|
53
48
|
|
54
49
|
@property
|
55
50
|
def reports(self):
|
@@ -59,210 +54,135 @@ class PcapCompare:
|
|
59
54
|
def reports(self, newvalue):
|
60
55
|
self._reports = newvalue
|
61
56
|
|
62
|
-
def compare_dissections(self,
|
57
|
+
def compare_dissections(self, left_side: dict, right_side: dict) -> dict:
|
63
58
|
"compares the results from two reports"
|
64
59
|
|
65
60
|
report = {}
|
66
61
|
|
67
|
-
# TODO: missing key in
|
68
|
-
keys = set(
|
69
|
-
keys = keys.union(
|
62
|
+
# TODO: missing key in right_side (major items added)
|
63
|
+
keys = set(left_side.keys())
|
64
|
+
keys = keys.union(right_side.keys())
|
70
65
|
for key in keys:
|
71
|
-
dissection1_total = dissection1[key].total()
|
72
|
-
dissection2_total = dissection2[key].total()
|
73
66
|
report[key] = {}
|
74
67
|
|
75
|
-
|
76
|
-
|
68
|
+
if key not in left_side:
|
69
|
+
left_side[key] = {}
|
70
|
+
left_side_total = sum(left_side[key].values())
|
71
|
+
|
72
|
+
if key not in right_side:
|
73
|
+
right_side[key] = {}
|
74
|
+
right_side_total = sum(right_side[key].values())
|
75
|
+
|
76
|
+
new_left_count = 0
|
77
|
+
for subkey in left_side[key].keys():
|
78
|
+
delta_percentage = 0.0
|
77
79
|
total = 0
|
78
|
-
if subkey in
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
comp_count = dissection2[key][subkey]
|
80
|
+
if subkey in right_side[key]:
|
81
|
+
left_percentage = left_side[key][subkey] / left_side_total
|
82
|
+
right_percentage = right_side[key][subkey] / right_side_total
|
83
|
+
delta_percentage = right_percentage - left_percentage
|
84
|
+
total = right_side[key][subkey] + left_side[key][subkey]
|
85
|
+
left_count = left_side[key][subkey]
|
86
|
+
right_count = right_side[key][subkey]
|
86
87
|
else:
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
88
|
+
delta_percentage = -1.0
|
89
|
+
left_percentage = left_side[key][subkey] / left_side_total
|
90
|
+
right_percentage = 0.0
|
91
|
+
total = -left_side[key][subkey]
|
92
|
+
left_count = left_side[key][subkey]
|
93
|
+
right_count = 0
|
94
|
+
new_left_count += 1
|
95
|
+
|
96
|
+
delta_absolute = right_count - left_count
|
92
97
|
report[key][subkey] = {
|
93
|
-
"
|
98
|
+
"delta_percentage": delta_percentage,
|
99
|
+
"delta_absolute": delta_absolute,
|
94
100
|
"total": total,
|
95
|
-
"
|
96
|
-
"
|
101
|
+
"left_count": left_count,
|
102
|
+
"right_count": right_count,
|
103
|
+
"left_percentage": left_percentage,
|
104
|
+
"right_percentage": right_percentage,
|
97
105
|
}
|
98
106
|
|
99
|
-
|
107
|
+
new_right_count = 0
|
108
|
+
for subkey in right_side[key].keys():
|
100
109
|
if subkey not in report[key]:
|
101
|
-
|
102
|
-
total =
|
103
|
-
|
104
|
-
|
110
|
+
delta_percentage = 1.0
|
111
|
+
total = right_side[key][subkey]
|
112
|
+
left_count = 0
|
113
|
+
right_count = right_side[key][subkey]
|
114
|
+
left_percentage = 0.0
|
115
|
+
right_percentage = right_side[key][subkey] / right_side_total
|
116
|
+
new_right_count += 1 # this value wasn't in the left
|
105
117
|
|
106
118
|
report[key][subkey] = {
|
107
|
-
"
|
119
|
+
"delta_percentage": delta_percentage,
|
120
|
+
"delta_absolute": right_count,
|
108
121
|
"total": total,
|
109
|
-
"
|
110
|
-
"
|
122
|
+
"left_count": left_count,
|
123
|
+
"right_count": right_count,
|
124
|
+
"left_percentage": left_percentage,
|
125
|
+
"right_percentage": right_percentage,
|
111
126
|
}
|
112
127
|
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
return True
|
129
|
-
|
130
|
-
if self.print_threshold and not self.print_minimum_count:
|
131
|
-
# check print_threshold as a fraction
|
132
|
-
if abs(delta) > self.print_threshold:
|
133
|
-
return True
|
134
|
-
elif not self.print_threshold and self.print_minimum_count:
|
135
|
-
# just check print_minimum_count
|
136
|
-
if total > self.print_minimum_count:
|
137
|
-
return True
|
138
|
-
else:
|
139
|
-
# require both
|
140
|
-
if total > self.print_minimum_count and abs(delta) > self.print_threshold:
|
141
|
-
return True
|
142
|
-
|
143
|
-
return False
|
144
|
-
|
145
|
-
def init_console(self):
|
146
|
-
if not self.console:
|
147
|
-
self.console = Console()
|
148
|
-
|
149
|
-
def print_report(self, report: dict) -> None:
|
150
|
-
"prints a report to the console"
|
151
|
-
|
152
|
-
self.init_console()
|
153
|
-
for key in sorted(report):
|
154
|
-
reported: bool = False
|
155
|
-
|
156
|
-
if self.print_match_string and self.print_match_string not in key:
|
157
|
-
continue
|
158
|
-
|
159
|
-
for subkey, data in sorted(
|
160
|
-
report[key].items(), key=lambda x: x[1]["delta"], reverse=True
|
161
|
-
):
|
162
|
-
if not self.filter_check(data):
|
163
|
-
continue
|
164
|
-
|
165
|
-
# print the header
|
166
|
-
if not reported:
|
167
|
-
print(f"====== {key}")
|
168
|
-
reported = True
|
169
|
-
|
170
|
-
delta: float = data["delta"]
|
171
|
-
|
172
|
-
# apply some fancy styling
|
173
|
-
style = ""
|
174
|
-
if delta < -0.5:
|
175
|
-
style = "[bold red]"
|
176
|
-
elif delta < 0.0:
|
177
|
-
style = "[red]"
|
178
|
-
elif delta > 0.5:
|
179
|
-
style = "[bold green]"
|
180
|
-
elif delta > 0.0:
|
181
|
-
style = "[green]"
|
182
|
-
endstyle = style.replace("[", "[/")
|
183
|
-
|
184
|
-
# construct the output line with styling
|
185
|
-
subkey = PCAPDissector.make_printable(key, subkey)
|
186
|
-
line = f" {style}{subkey:<50}{endstyle}"
|
187
|
-
line += f"{100*delta:>7.2f} {data['total']:>8} "
|
188
|
-
line += f"{data['ref_count']:>8} {data['comp_count']:>8}"
|
189
|
-
|
190
|
-
# print it to the rich console
|
191
|
-
self.console.print(line)
|
192
|
-
|
193
|
-
def print_header(self):
|
194
|
-
# This should match the spacing in print_report()
|
195
|
-
self.init_console()
|
196
|
-
|
197
|
-
style = ""
|
198
|
-
subkey = "Value"
|
199
|
-
endstyle = ""
|
200
|
-
delta = "Delta %"
|
201
|
-
total = "Total"
|
202
|
-
ref_count = "Left"
|
203
|
-
comp_count = "Right"
|
204
|
-
|
205
|
-
line = f" {style}{subkey:<50}{endstyle}"
|
206
|
-
line += f"{delta:>7} {total:>8} "
|
207
|
-
line += f"{ref_count:>8} {comp_count:>8}"
|
208
|
-
|
209
|
-
self.console.print(line)
|
210
|
-
|
211
|
-
def print(self) -> None:
|
212
|
-
"outputs the results"
|
213
|
-
self.print_header()
|
214
|
-
for n, report in enumerate(self.reports):
|
215
|
-
title = report.get("title", f"report #{n}")
|
216
|
-
print(f"************ {title}")
|
217
|
-
self.print_report(report["report"])
|
128
|
+
if right_side_total == 0:
|
129
|
+
right_percent = 100
|
130
|
+
else:
|
131
|
+
right_percent = new_right_count / right_side_total
|
132
|
+
report[key][Dissection.NEW_RIGHT_SUBKEY] = {
|
133
|
+
"delta_absolute": new_right_count - new_left_count,
|
134
|
+
"total": new_left_count + new_right_count,
|
135
|
+
"left_count": new_left_count,
|
136
|
+
"right_count": new_right_count,
|
137
|
+
"left_percentage": new_left_count / left_side_total,
|
138
|
+
"right_percentage": right_percent,
|
139
|
+
"delta_percentage": (right_percent - new_left_count / left_side_total),
|
140
|
+
}
|
141
|
+
|
142
|
+
return Comparison(report)
|
218
143
|
|
219
144
|
def load_pcaps(self) -> None:
|
220
145
|
# load the first as a reference pcap
|
221
|
-
info(f"reading pcap files using level={self.dissection_level}")
|
222
146
|
pdm = PCAPDissectMany(
|
223
|
-
self.
|
147
|
+
self.pcap_files,
|
224
148
|
bin_size=self.bin_size,
|
225
149
|
maximum_count=self.maximum_count,
|
226
150
|
pcap_filter=self.pkt_filter,
|
227
151
|
cache_results=self.cache_results,
|
152
|
+
cache_file_suffix=self.cache_file_suffix,
|
228
153
|
dissector_level=self.dissection_level,
|
154
|
+
ignore_list=self.ignore_list,
|
229
155
|
)
|
230
156
|
results = pdm.load_all()
|
231
157
|
return results
|
232
158
|
|
233
|
-
def compare(self) ->
|
159
|
+
def compare(self) -> List[Comparison]:
|
234
160
|
"Compares each pcap against the original source"
|
235
161
|
|
236
|
-
|
237
|
-
self.compare_all(
|
162
|
+
dissections = self.load_pcaps()
|
163
|
+
self.compare_all(dissections)
|
164
|
+
return self.reports
|
238
165
|
|
239
|
-
def compare_all(self,
|
166
|
+
def compare_all(self, dissections) -> List[Comparison]:
|
240
167
|
reports = []
|
241
|
-
if len(self.
|
168
|
+
if len(self.pcap_files) > 1:
|
242
169
|
# multiple file comparison
|
243
|
-
reference = next(
|
244
|
-
for other in
|
170
|
+
reference = next(dissections)
|
171
|
+
for other in dissections:
|
245
172
|
# compare the two global summaries
|
246
|
-
reports.append(
|
247
|
-
{
|
248
|
-
"report": self.compare_dissections(
|
249
|
-
reference["data"][0], other["data"][0]
|
250
|
-
),
|
251
|
-
"title": f"{reference['file']} vs {other['file']}",
|
252
|
-
}
|
253
|
-
)
|
254
173
|
|
174
|
+
report = self.compare_dissections(reference.data[0], other.data[0])
|
175
|
+
report.title = f"{reference.pcap_file} vs {other.pcap_file}"
|
176
|
+
|
177
|
+
reports.append(report)
|
255
178
|
else:
|
256
179
|
# deal with timestamps within a single file
|
257
|
-
|
258
|
-
|
259
|
-
timestamps = list(reference["data"].keys())
|
180
|
+
reference = list(dissections)[0].data
|
181
|
+
timestamps = list(reference.keys())
|
260
182
|
debug(
|
261
183
|
f"found {len(timestamps)} timestamps from {timestamps[2]} to {timestamps[-1]}"
|
262
184
|
)
|
263
185
|
|
264
|
-
self.print_header()
|
265
|
-
|
266
186
|
for timestamp in range(
|
267
187
|
2, len(timestamps)
|
268
188
|
): # second real non-zero timestamp to last
|
@@ -280,12 +200,19 @@ class PcapCompare:
|
|
280
200
|
debug(f"comparing timestamps {time_left} and {time_right}")
|
281
201
|
|
282
202
|
report = self.compare_dissections(
|
283
|
-
reference[
|
203
|
+
reference[time_left],
|
204
|
+
reference[time_right],
|
205
|
+
)
|
206
|
+
|
207
|
+
title_left = datetime.fromtimestamp(time_left, dt.UTC).strftime(
|
208
|
+
"%Y-%m-%d %H:%M:%S"
|
209
|
+
)
|
210
|
+
title_right = datetime.fromtimestamp(time_right, dt.UTC).strftime(
|
211
|
+
"%Y-%m-%d %H:%M:%S"
|
284
212
|
)
|
285
213
|
|
286
|
-
title = f"time {
|
287
|
-
|
288
|
-
self.print_report(report)
|
214
|
+
report.title = f"time {title_left} vs time {title_right}"
|
215
|
+
reports.append(report)
|
289
216
|
|
290
217
|
continue
|
291
218
|
|
@@ -298,19 +225,14 @@ class PcapCompare:
|
|
298
225
|
# )
|
299
226
|
|
300
227
|
self.reports = reports
|
228
|
+
return reports
|
301
229
|
|
302
230
|
|
303
|
-
def
|
304
|
-
|
305
|
-
|
306
|
-
formatter_class=ArgumentDefaultsHelpFormatter,
|
307
|
-
description=__doc__,
|
308
|
-
epilog="Exmaple Usage: ",
|
309
|
-
)
|
310
|
-
|
311
|
-
limiting_parser = limitor_add_parseargs(parser)
|
231
|
+
def compare_add_parseargs(compare_parser, add_subgroup: bool = True):
|
232
|
+
if add_subgroup:
|
233
|
+
compare_parser = compare_parser.add_argument_group("Comparison result options")
|
312
234
|
|
313
|
-
|
235
|
+
compare_parser.add_argument(
|
314
236
|
"-t",
|
315
237
|
"--print-threshold",
|
316
238
|
default=0.0,
|
@@ -318,66 +240,48 @@ def parse_args():
|
|
318
240
|
help="Don't print results with abs(percent) less than this threshold",
|
319
241
|
)
|
320
242
|
|
321
|
-
|
243
|
+
compare_parser.add_argument(
|
322
244
|
"-P", "--only-positive", action="store_true", help="Only show positive entries"
|
323
245
|
)
|
324
246
|
|
325
|
-
|
247
|
+
compare_parser.add_argument(
|
326
248
|
"-N", "--only-negative", action="store_true", help="Only show negative entries"
|
327
249
|
)
|
328
250
|
|
329
|
-
|
330
|
-
"-
|
331
|
-
"--
|
332
|
-
|
251
|
+
compare_parser.add_argument(
|
252
|
+
"-x",
|
253
|
+
"--top-records",
|
254
|
+
default=None,
|
333
255
|
type=int,
|
334
|
-
help="
|
256
|
+
help="Show the top N records from each section.",
|
335
257
|
)
|
336
258
|
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
"--log-level",
|
343
|
-
"--ll",
|
344
|
-
default="info",
|
345
|
-
help="Define the logging verbosity level (debug, info, warning, error, ...).",
|
259
|
+
compare_parser.add_argument(
|
260
|
+
"-r",
|
261
|
+
"--reverse_sort",
|
262
|
+
action="store_true",
|
263
|
+
help="Reverse the sort order of reports",
|
346
264
|
)
|
347
265
|
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
check_dissector_level(args.dissection_level)
|
355
|
-
|
356
|
-
return args
|
357
|
-
|
358
|
-
|
359
|
-
def main():
|
360
|
-
args = parse_args()
|
361
|
-
pc = PcapCompare(
|
362
|
-
args.pcap_files,
|
363
|
-
maximum_count=args.packet_count,
|
364
|
-
print_threshold=float(args.print_threshold) / 100.0,
|
365
|
-
print_minimum_count=args.minimum_count,
|
366
|
-
print_match_string=args.match_string,
|
367
|
-
only_positive=args.only_positive,
|
368
|
-
only_negative=args.only_negative,
|
369
|
-
cache_results=args.cache_pcap_results,
|
370
|
-
dissection_level=args.dissection_level,
|
371
|
-
between_times=args.between_times,
|
372
|
-
bin_size=args.bin_size,
|
266
|
+
compare_parser.add_argument(
|
267
|
+
"-T",
|
268
|
+
"--between-times",
|
269
|
+
nargs=2,
|
270
|
+
type=int,
|
271
|
+
help="For single files, only display results between these timestamps",
|
373
272
|
)
|
374
273
|
|
375
|
-
|
376
|
-
pc.compare()
|
377
|
-
|
378
|
-
# print the results
|
379
|
-
pc.print()
|
274
|
+
return compare_parser
|
380
275
|
|
381
276
|
|
382
|
-
|
383
|
-
|
277
|
+
def get_comparison_args(args):
|
278
|
+
return {
|
279
|
+
"maximum_count": args.packet_count or 0,
|
280
|
+
"print_threshold": float(args.print_threshold) / 100.0,
|
281
|
+
"minimum_count": args.minimum_count,
|
282
|
+
"match_string": args.match_string,
|
283
|
+
"only_positive": args.only_positive,
|
284
|
+
"only_negative": args.only_negative,
|
285
|
+
"top_records": args.top_records,
|
286
|
+
"reverse_sort": args.reverse_sort,
|
287
|
+
}
|
@@ -0,0 +1,26 @@
|
|
1
|
+
from typing import Dict
|
2
|
+
|
3
|
+
|
4
|
+
class Comparison:
|
5
|
+
def __init__(self, contents: list, title: str = ""):
|
6
|
+
self.contents = contents
|
7
|
+
self.title: str = title
|
8
|
+
self.printing_arguments: Dict[str] = {}
|
9
|
+
|
10
|
+
# title
|
11
|
+
@property
|
12
|
+
def title(self) -> str:
|
13
|
+
return self._title
|
14
|
+
|
15
|
+
@title.setter
|
16
|
+
def title(self, new_title):
|
17
|
+
self._title = new_title
|
18
|
+
|
19
|
+
# report contents -- actual data
|
20
|
+
@property
|
21
|
+
def contents(self):
|
22
|
+
return self._contents
|
23
|
+
|
24
|
+
@contents.setter
|
25
|
+
def contents(self, new_contents):
|
26
|
+
self._contents = new_contents
|