cucu 1.3.6__py3-none-any.whl → 1.3.8__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.
Potentially problematic release.
This version of cucu might be problematic. Click here for more details.
- cucu/cli/core.py +25 -21
- cucu/cli/run.py +5 -1
- cucu/db.py +144 -69
- cucu/environment.py +7 -69
- cucu/formatter/cucu.py +2 -2
- cucu/formatter/rundb.py +207 -0
- cucu/reporter/html.py +121 -25
- cucu/reporter/templates/index.html +7 -7
- cucu/reporter/templates/scenario.html +19 -11
- cucu/steps/flow_control_steps.py +4 -4
- {cucu-1.3.6.dist-info → cucu-1.3.8.dist-info}/METADATA +1 -1
- {cucu-1.3.6.dist-info → cucu-1.3.8.dist-info}/RECORD +14 -13
- {cucu-1.3.6.dist-info → cucu-1.3.8.dist-info}/WHEEL +0 -0
- {cucu-1.3.6.dist-info → cucu-1.3.8.dist-info}/entry_points.txt +0 -0
cucu/cli/core.py
CHANGED
|
@@ -33,7 +33,12 @@ from cucu.cli import thread_dumper
|
|
|
33
33
|
from cucu.cli.run import behave, behave_init, create_run
|
|
34
34
|
from cucu.cli.steps import print_human_readable_steps, print_json_steps
|
|
35
35
|
from cucu.config import CONFIG
|
|
36
|
-
from cucu.db import
|
|
36
|
+
from cucu.db import (
|
|
37
|
+
consolidate_database_files,
|
|
38
|
+
db,
|
|
39
|
+
finish_worker_record,
|
|
40
|
+
get_first_cucu_run_filepath,
|
|
41
|
+
)
|
|
37
42
|
from cucu.lint import linter
|
|
38
43
|
from cucu.utils import generate_short_id
|
|
39
44
|
|
|
@@ -259,12 +264,11 @@ def run(
|
|
|
259
264
|
os.environ["CUCU_LOGGING_LEVEL"] = logging_level.upper()
|
|
260
265
|
logger.init_logging(logging_level.upper())
|
|
261
266
|
|
|
262
|
-
if not
|
|
263
|
-
if
|
|
264
|
-
|
|
265
|
-
shutil.rmtree(results)
|
|
267
|
+
if not preserve_results:
|
|
268
|
+
if os.path.exists(results):
|
|
269
|
+
shutil.rmtree(results)
|
|
266
270
|
|
|
267
|
-
|
|
271
|
+
os.makedirs(results, exist_ok=True)
|
|
268
272
|
|
|
269
273
|
if selenium_remote_url is not None:
|
|
270
274
|
os.environ["CUCU_SELENIUM_REMOTE_URL"] = selenium_remote_url
|
|
@@ -299,8 +303,10 @@ def run(
|
|
|
299
303
|
os.environ["WORKER_PARENT_ID"] = CONFIG["WORKER_RUN_ID"] = (
|
|
300
304
|
generate_short_id(worker_id_seed)
|
|
301
305
|
)
|
|
302
|
-
|
|
303
|
-
|
|
306
|
+
|
|
307
|
+
os.environ["CUCU_FILEPATH"] = CONFIG["CUCU_FILEPATH"] = filepath
|
|
308
|
+
|
|
309
|
+
create_run(results, filepath)
|
|
304
310
|
|
|
305
311
|
try:
|
|
306
312
|
if workers is None or workers == 1:
|
|
@@ -491,7 +497,7 @@ def run(
|
|
|
491
497
|
if dumper is not None:
|
|
492
498
|
dumper.stop()
|
|
493
499
|
|
|
494
|
-
if
|
|
500
|
+
if os.path.exists(results):
|
|
495
501
|
finish_worker_record(worker_run_id=CONFIG.get("WORKER_PARENT_ID"))
|
|
496
502
|
consolidate_database_files(results)
|
|
497
503
|
|
|
@@ -510,18 +516,6 @@ def _generate_report(
|
|
|
510
516
|
only_failures: False,
|
|
511
517
|
junit: str | None = None,
|
|
512
518
|
):
|
|
513
|
-
"""
|
|
514
|
-
helper method to handle report generation so it can be used by the `cucu report`
|
|
515
|
-
command also the `cucu run` when told to generate a report. If junit is provided, it adds report
|
|
516
|
-
path to the JUnit files.
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
parameters:
|
|
520
|
-
results_dir(string): the results directory containing the previous test run
|
|
521
|
-
output(string): the directory where we'll generate the report
|
|
522
|
-
only_failures(bool, optional): if only report failures. The default is False.
|
|
523
|
-
junit(str|None, optional): the directory of the JUnit files. The default if None.
|
|
524
|
-
"""
|
|
525
519
|
if os.path.exists(output):
|
|
526
520
|
shutil.rmtree(output)
|
|
527
521
|
|
|
@@ -530,6 +524,16 @@ def _generate_report(
|
|
|
530
524
|
if os.path.exists(results_dir):
|
|
531
525
|
consolidate_database_files(results_dir)
|
|
532
526
|
|
|
527
|
+
db_path = os.path.join(results_dir, "run.db")
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
db.init(db_path)
|
|
531
|
+
db.connect(reuse_if_open=True)
|
|
532
|
+
filepath = get_first_cucu_run_filepath()
|
|
533
|
+
behave_init(filepath)
|
|
534
|
+
finally:
|
|
535
|
+
db.close()
|
|
536
|
+
|
|
533
537
|
report_location = reporter.generate(
|
|
534
538
|
results_dir, output, only_failures=only_failures
|
|
535
539
|
)
|
cucu/cli/run.py
CHANGED
|
@@ -110,8 +110,10 @@ def behave(
|
|
|
110
110
|
if dry_run:
|
|
111
111
|
args += [
|
|
112
112
|
"--dry-run",
|
|
113
|
-
# console
|
|
113
|
+
# console formatter
|
|
114
114
|
"--format=cucu.formatter.cucu:CucuFormatter",
|
|
115
|
+
# run.db formatter
|
|
116
|
+
"--format=cucu.formatter.rundb:RundbFormatter",
|
|
115
117
|
]
|
|
116
118
|
|
|
117
119
|
else:
|
|
@@ -128,6 +130,8 @@ def behave(
|
|
|
128
130
|
# disable behave's junit output in favor of our own formatter
|
|
129
131
|
"--no-junit",
|
|
130
132
|
"--format=cucu.formatter.junit:CucuJUnitFormatter",
|
|
133
|
+
# run.db formatter
|
|
134
|
+
"--format=cucu.formatter.rundb:RundbFormatter",
|
|
131
135
|
]
|
|
132
136
|
|
|
133
137
|
for tag in tags:
|
cucu/db.py
CHANGED
|
@@ -18,6 +18,7 @@ from peewee import (
|
|
|
18
18
|
TextField,
|
|
19
19
|
)
|
|
20
20
|
from playhouse.sqlite_ext import JSONField, SqliteExtDatabase
|
|
21
|
+
from tenacity import RetryError
|
|
21
22
|
|
|
22
23
|
from cucu.config import CONFIG
|
|
23
24
|
|
|
@@ -36,6 +37,7 @@ class BaseModel(Model):
|
|
|
36
37
|
class cucu_run(BaseModel):
|
|
37
38
|
cucu_run_id = TextField(primary_key=True)
|
|
38
39
|
full_arguments = JSONField()
|
|
40
|
+
filepath = TextField()
|
|
39
41
|
date = TextField()
|
|
40
42
|
start_at = DateTimeField()
|
|
41
43
|
end_at = DateTimeField(null=True)
|
|
@@ -71,12 +73,13 @@ class feature(BaseModel):
|
|
|
71
73
|
column_name="worker_run_id",
|
|
72
74
|
)
|
|
73
75
|
name = TextField()
|
|
74
|
-
|
|
75
|
-
description = TextField()
|
|
76
|
-
tags = TextField()
|
|
76
|
+
status = TextField(null=True)
|
|
77
77
|
start_at = DateTimeField()
|
|
78
78
|
end_at = DateTimeField(null=True)
|
|
79
79
|
custom_data = JSONField(null=True)
|
|
80
|
+
tags = JSONField()
|
|
81
|
+
filename = TextField()
|
|
82
|
+
description = TextField()
|
|
80
83
|
|
|
81
84
|
|
|
82
85
|
class scenario(BaseModel):
|
|
@@ -88,17 +91,17 @@ class scenario(BaseModel):
|
|
|
88
91
|
column_name="feature_run_id",
|
|
89
92
|
)
|
|
90
93
|
name = TextField()
|
|
91
|
-
|
|
92
|
-
seq = IntegerField()
|
|
93
|
-
tags = TextField()
|
|
94
|
+
seq = FloatField(null=True)
|
|
94
95
|
status = TextField(null=True)
|
|
95
96
|
duration = FloatField(null=True)
|
|
96
|
-
start_at = DateTimeField()
|
|
97
|
+
start_at = DateTimeField(null=True)
|
|
97
98
|
end_at = DateTimeField(null=True)
|
|
98
|
-
|
|
99
|
-
cucu_config = JSONField(null=True)
|
|
100
|
-
browser_info = JSONField(null=True)
|
|
99
|
+
tags = JSONField()
|
|
101
100
|
custom_data = JSONField(null=True)
|
|
101
|
+
browser_info = JSONField(null=True)
|
|
102
|
+
cucu_config = JSONField(null=True)
|
|
103
|
+
line_number = IntegerField()
|
|
104
|
+
log_files = JSONField(null=True)
|
|
102
105
|
|
|
103
106
|
|
|
104
107
|
class step(BaseModel):
|
|
@@ -109,27 +112,32 @@ class step(BaseModel):
|
|
|
109
112
|
backref="steps",
|
|
110
113
|
column_name="scenario_run_id",
|
|
111
114
|
)
|
|
112
|
-
seq = IntegerField()
|
|
113
115
|
section_level = IntegerField(null=True)
|
|
116
|
+
seq = IntegerField()
|
|
114
117
|
parent_seq = IntegerField(null=True)
|
|
115
|
-
keyword = TextField()
|
|
116
|
-
name = TextField()
|
|
117
|
-
text = TextField(null=True)
|
|
118
|
-
table_data = JSONField(null=True)
|
|
119
|
-
location = TextField()
|
|
120
118
|
is_substep = BooleanField(null=True) # info available after step ends
|
|
121
119
|
has_substeps = BooleanField()
|
|
120
|
+
keyword = TextField()
|
|
121
|
+
name = TextField()
|
|
122
122
|
status = TextField(null=True)
|
|
123
123
|
duration = FloatField(null=True)
|
|
124
|
-
start_at = DateTimeField()
|
|
124
|
+
start_at = DateTimeField(null=True)
|
|
125
125
|
end_at = DateTimeField(null=True)
|
|
126
|
+
stdout = JSONField(null=True)
|
|
127
|
+
stderr = JSONField(null=True)
|
|
128
|
+
error_message = JSONField(null=True)
|
|
129
|
+
exception = JSONField(null=True)
|
|
126
130
|
debug_output = TextField(null=True)
|
|
127
|
-
browser_logs = TextField(null=True)
|
|
128
131
|
browser_info = JSONField(null=True)
|
|
132
|
+
text = JSONField(null=True)
|
|
133
|
+
table_data = JSONField(null=True)
|
|
134
|
+
location = TextField()
|
|
135
|
+
browser_logs = TextField(null=True)
|
|
129
136
|
screenshots = JSONField(null=True)
|
|
130
137
|
|
|
131
138
|
|
|
132
139
|
def record_cucu_run():
|
|
140
|
+
filepath = CONFIG["CUCU_FILEPATH"]
|
|
133
141
|
cucu_run_id_val = CONFIG["CUCU_RUN_ID"]
|
|
134
142
|
worker_run_id = CONFIG["WORKER_RUN_ID"]
|
|
135
143
|
|
|
@@ -138,6 +146,7 @@ def record_cucu_run():
|
|
|
138
146
|
cucu_run.create(
|
|
139
147
|
cucu_run_id=cucu_run_id_val,
|
|
140
148
|
full_arguments=sys.argv,
|
|
149
|
+
filepath=filepath,
|
|
141
150
|
date=start_at,
|
|
142
151
|
start_at=start_at,
|
|
143
152
|
)
|
|
@@ -150,7 +159,6 @@ def record_cucu_run():
|
|
|
150
159
|
else None,
|
|
151
160
|
start_at=datetime.now().isoformat(),
|
|
152
161
|
)
|
|
153
|
-
db.close()
|
|
154
162
|
return str(db_filepath)
|
|
155
163
|
|
|
156
164
|
|
|
@@ -164,47 +172,48 @@ def record_feature(feature_obj):
|
|
|
164
172
|
description="\n".join(feature_obj.description)
|
|
165
173
|
if isinstance(feature_obj.description, list)
|
|
166
174
|
else str(feature_obj.description),
|
|
167
|
-
tags=
|
|
175
|
+
tags=feature_obj.tags,
|
|
168
176
|
start_at=datetime.now().isoformat(),
|
|
169
177
|
)
|
|
170
|
-
db.close()
|
|
171
178
|
|
|
172
179
|
|
|
173
|
-
def record_scenario(
|
|
180
|
+
def record_scenario(scenario_obj):
|
|
174
181
|
db.connect(reuse_if_open=True)
|
|
175
182
|
scenario.create(
|
|
176
|
-
scenario_run_id=
|
|
177
|
-
feature_run_id=
|
|
178
|
-
name=
|
|
179
|
-
line_number=
|
|
180
|
-
seq=
|
|
181
|
-
tags=
|
|
182
|
-
start_at=
|
|
183
|
+
scenario_run_id=scenario_obj.scenario_run_id,
|
|
184
|
+
feature_run_id=scenario_obj.feature.feature_run_id,
|
|
185
|
+
name=scenario_obj.name,
|
|
186
|
+
line_number=scenario_obj.line,
|
|
187
|
+
seq=scenario_obj.seq,
|
|
188
|
+
tags=scenario_obj.tags,
|
|
189
|
+
start_at=getattr(scenario_obj, "start_at", None),
|
|
183
190
|
)
|
|
184
|
-
db.close()
|
|
185
191
|
|
|
186
192
|
|
|
187
|
-
def start_step_record(
|
|
193
|
+
def start_step_record(step_obj, scenario_run_id):
|
|
188
194
|
db.connect(reuse_if_open=True)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
table =
|
|
193
|
-
|
|
195
|
+
|
|
196
|
+
table = None
|
|
197
|
+
if step_obj.table:
|
|
198
|
+
table = {
|
|
199
|
+
"headings": step_obj.table.headings,
|
|
200
|
+
"rows": [list(row) for row in step_obj.table.rows],
|
|
201
|
+
}
|
|
202
|
+
|
|
194
203
|
step.create(
|
|
195
204
|
step_run_id=step_obj.step_run_id,
|
|
196
|
-
scenario_run_id=
|
|
197
|
-
seq=step_obj
|
|
205
|
+
scenario_run_id=scenario_run_id,
|
|
206
|
+
seq=getattr(step_obj, "seq", -1),
|
|
198
207
|
keyword=step_obj.keyword,
|
|
199
208
|
name=step_obj.name,
|
|
200
|
-
|
|
201
|
-
|
|
209
|
+
status=step_obj.status.name,
|
|
210
|
+
text=step_obj.text.splitlines() if step_obj.text else [],
|
|
211
|
+
table_data=table,
|
|
202
212
|
location=str(step_obj.location),
|
|
203
|
-
|
|
213
|
+
is_substep=getattr(step_obj, "is_substep", False),
|
|
214
|
+
has_substeps=getattr(step_obj, "has_substeps", False),
|
|
204
215
|
section_level=getattr(step_obj, "section_level", None),
|
|
205
|
-
start_at=step_obj.start_at,
|
|
206
216
|
)
|
|
207
|
-
db.close()
|
|
208
217
|
|
|
209
218
|
|
|
210
219
|
def finish_step_record(step_obj, duration):
|
|
@@ -221,27 +230,60 @@ def finish_step_record(step_obj, duration):
|
|
|
221
230
|
}
|
|
222
231
|
screenshot_infos.append(screenshot_info)
|
|
223
232
|
|
|
233
|
+
error_message = None
|
|
234
|
+
exception = []
|
|
235
|
+
if step.error_message and step_obj.status.name == "failed":
|
|
236
|
+
error_message = CONFIG.hide_secrets(step_obj.error_message)
|
|
237
|
+
|
|
238
|
+
if error := step_obj.exception:
|
|
239
|
+
if isinstance(error, RetryError):
|
|
240
|
+
error = error.last_attempt.exception()
|
|
241
|
+
|
|
242
|
+
if len(error.args) > 0 and isinstance(error.args[0], str):
|
|
243
|
+
error_class_name = error.__class__.__name__
|
|
244
|
+
redacted_error_msg = CONFIG.hide_secrets(error.args[0])
|
|
245
|
+
error_lines = redacted_error_msg.splitlines()
|
|
246
|
+
error_lines[0] = f"{error_class_name}: {error_lines[0]}"
|
|
247
|
+
else:
|
|
248
|
+
error_lines = [repr(error)]
|
|
249
|
+
|
|
250
|
+
exception = error_lines
|
|
251
|
+
|
|
224
252
|
step.update(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
status=step_obj.status.name,
|
|
229
|
-
is_substep=step_obj.is_substep,
|
|
253
|
+
browser_info=getattr(step_obj, "browser_info", ""),
|
|
254
|
+
browser_logs=getattr(step_obj, "browser_logs", ""),
|
|
255
|
+
debug_output=getattr(step_obj, "debug_output", ""),
|
|
230
256
|
duration=duration,
|
|
231
|
-
end_at=step_obj
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
257
|
+
end_at=getattr(step_obj, "end_at", None),
|
|
258
|
+
error_message=error_message,
|
|
259
|
+
exception=exception,
|
|
260
|
+
has_substeps=getattr(step_obj, "has_substeps", False),
|
|
261
|
+
parent_seq=getattr(step_obj, "parent_seq", None),
|
|
235
262
|
screenshots=screenshot_infos,
|
|
263
|
+
section_level=getattr(step_obj, "section_level", None),
|
|
264
|
+
seq=step_obj.seq,
|
|
265
|
+
start_at=getattr(step_obj, "start_at", None),
|
|
266
|
+
status=step_obj.status.name,
|
|
267
|
+
stderr=getattr(step_obj, "stderr", []),
|
|
268
|
+
stdout=getattr(step_obj, "stdout", []),
|
|
236
269
|
).where(step.step_run_id == step_obj.step_run_id).execute()
|
|
237
|
-
db.close()
|
|
238
270
|
|
|
239
271
|
|
|
240
272
|
def finish_scenario_record(scenario_obj):
|
|
241
273
|
db.connect(reuse_if_open=True)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
274
|
+
if getattr(scenario_obj, "start_at", None):
|
|
275
|
+
start_at = datetime.fromisoformat(scenario_obj.start_at)
|
|
276
|
+
else:
|
|
277
|
+
start_at = None
|
|
278
|
+
if getattr(scenario_obj, "end_at", None):
|
|
279
|
+
end_at = datetime.fromisoformat(scenario_obj.end_at)
|
|
280
|
+
else:
|
|
281
|
+
end_at = None
|
|
282
|
+
if start_at and end_at:
|
|
283
|
+
duration = (end_at - start_at).total_seconds()
|
|
284
|
+
else:
|
|
285
|
+
duration = None
|
|
286
|
+
|
|
245
287
|
scenario_logs_dir = CONFIG.get("SCENARIO_LOGS_DIR")
|
|
246
288
|
if not scenario_logs_dir or not Path(scenario_logs_dir).exists():
|
|
247
289
|
log_files_json = "[]"
|
|
@@ -253,26 +295,30 @@ def finish_scenario_record(scenario_obj):
|
|
|
253
295
|
if file.is_file()
|
|
254
296
|
]
|
|
255
297
|
log_files_json = sorted(log_files)
|
|
256
|
-
|
|
298
|
+
|
|
299
|
+
if scenario_obj.hook_failed:
|
|
300
|
+
status = "errored"
|
|
301
|
+
else:
|
|
302
|
+
status = scenario_obj.status.name
|
|
303
|
+
|
|
257
304
|
scenario.update(
|
|
258
|
-
status=
|
|
305
|
+
status=status,
|
|
259
306
|
duration=duration,
|
|
260
|
-
end_at=
|
|
307
|
+
end_at=end_at,
|
|
261
308
|
log_files=log_files_json,
|
|
262
|
-
cucu_config=scenario_obj
|
|
263
|
-
browser_info=scenario_obj
|
|
264
|
-
custom_data=
|
|
309
|
+
cucu_config=getattr(scenario_obj, "cucu_config_json", dict()),
|
|
310
|
+
browser_info=getattr(scenario_obj, "browser_info", dict()),
|
|
311
|
+
custom_data=getattr(scenario_obj, "custom_data", dict()),
|
|
265
312
|
).where(scenario.scenario_run_id == scenario_obj.scenario_run_id).execute()
|
|
266
|
-
db.close()
|
|
267
313
|
|
|
268
314
|
|
|
269
315
|
def finish_feature_record(feature_obj):
|
|
270
316
|
db.connect(reuse_if_open=True)
|
|
271
317
|
feature.update(
|
|
318
|
+
status=feature_obj.status.name,
|
|
272
319
|
end_at=datetime.now().isoformat(),
|
|
273
320
|
custom_data=feature_obj.custom_data,
|
|
274
321
|
).where(feature.feature_run_id == feature_obj.feature_run_id).execute()
|
|
275
|
-
db.close()
|
|
276
322
|
|
|
277
323
|
|
|
278
324
|
def finish_worker_record(custom_data=None, worker_run_id=None):
|
|
@@ -282,7 +328,6 @@ def finish_worker_record(custom_data=None, worker_run_id=None):
|
|
|
282
328
|
end_at=datetime.now().isoformat(),
|
|
283
329
|
custom_data=custom_data,
|
|
284
330
|
).where(worker.worker_run_id == target_worker_run_id).execute()
|
|
285
|
-
db.close()
|
|
286
331
|
|
|
287
332
|
|
|
288
333
|
def finish_cucu_run_record():
|
|
@@ -290,7 +335,11 @@ def finish_cucu_run_record():
|
|
|
290
335
|
cucu_run.update(
|
|
291
336
|
end_at=datetime.now().isoformat(),
|
|
292
337
|
).where(cucu_run.cucu_run_id == CONFIG["CUCU_RUN_ID"]).execute()
|
|
293
|
-
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def close_db():
|
|
341
|
+
if not db.is_closed():
|
|
342
|
+
db.close()
|
|
294
343
|
|
|
295
344
|
|
|
296
345
|
def create_database_file(db_filepath):
|
|
@@ -305,7 +354,12 @@ def create_database_file(db_filepath):
|
|
|
305
354
|
s.duration,
|
|
306
355
|
f.name AS feature_name,
|
|
307
356
|
s.name AS scenario_name,
|
|
308
|
-
|
|
357
|
+
CASE
|
|
358
|
+
WHEN f.tags = '[]' AND s.tags = '[]' THEN JSON('[]')
|
|
359
|
+
WHEN f.tags = '[]' THEN s.tags
|
|
360
|
+
WHEN s.tags = '[]' THEN f.tags
|
|
361
|
+
ELSE JSON(REPLACE(f.tags, ']', '') || ',' || REPLACE(s.tags, '[', ''))
|
|
362
|
+
END as tags,
|
|
309
363
|
s.log_files
|
|
310
364
|
FROM scenario s
|
|
311
365
|
JOIN feature f ON s.feature_run_id = f.feature_run_id
|
|
@@ -317,7 +371,12 @@ def create_database_file(db_filepath):
|
|
|
317
371
|
s.scenario_run_id,
|
|
318
372
|
f.name AS feature_name,
|
|
319
373
|
s.name AS scenario_name,
|
|
320
|
-
|
|
374
|
+
CASE
|
|
375
|
+
WHEN f.tags = '[]' AND s.tags = '[]' THEN JSON('[]')
|
|
376
|
+
WHEN f.tags = '[]' THEN s.tags
|
|
377
|
+
WHEN s.tags = '[]' THEN f.tags
|
|
378
|
+
ELSE JSON(REPLACE(f.tags, ']', '') || ',' || REPLACE(s.tags, '[', ''))
|
|
379
|
+
END as tags,
|
|
321
380
|
f.filename || ':' || s.line_number AS feature_file_line,
|
|
322
381
|
s.status,
|
|
323
382
|
(
|
|
@@ -340,13 +399,20 @@ def create_database_file(db_filepath):
|
|
|
340
399
|
FROM scenario s
|
|
341
400
|
JOIN feature f ON s.feature_run_id = f.feature_run_id
|
|
342
401
|
""")
|
|
343
|
-
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def get_first_cucu_run_filepath():
|
|
405
|
+
run_record = cucu_run.select().first()
|
|
406
|
+
return run_record.filepath
|
|
344
407
|
|
|
345
408
|
|
|
346
409
|
def consolidate_database_files(results_dir):
|
|
347
410
|
# This function would need a more advanced approach with peewee, so for now, keep using sqlite3 for consolidation
|
|
348
411
|
results_path = Path(results_dir)
|
|
349
412
|
target_db_path = results_path / "run.db"
|
|
413
|
+
if not target_db_path.exists():
|
|
414
|
+
create_database_file(target_db_path)
|
|
415
|
+
|
|
350
416
|
db_files = [
|
|
351
417
|
db for db in results_path.glob("**/*.db") if db.name != "run.db"
|
|
352
418
|
]
|
|
@@ -368,3 +434,12 @@ def consolidate_database_files(results_dir):
|
|
|
368
434
|
)
|
|
369
435
|
target_conn.commit()
|
|
370
436
|
db_file.unlink()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def init_html_report_db(db_path):
|
|
440
|
+
db.init(db_path)
|
|
441
|
+
db.connect(reuse_if_open=True)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def close_html_report_db():
|
|
445
|
+
db.close()
|
cucu/environment.py
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import datetime
|
|
2
2
|
import json
|
|
3
|
-
import os
|
|
4
3
|
import sys
|
|
5
|
-
import time
|
|
6
4
|
import traceback
|
|
7
5
|
from functools import partial
|
|
8
6
|
from pathlib import Path
|
|
@@ -11,23 +9,10 @@ import yaml
|
|
|
11
9
|
|
|
12
10
|
from cucu import config, init_scenario_hook_variables, logger
|
|
13
11
|
from cucu.config import CONFIG
|
|
14
|
-
from cucu.db import (
|
|
15
|
-
create_database_file,
|
|
16
|
-
finish_cucu_run_record,
|
|
17
|
-
finish_feature_record,
|
|
18
|
-
finish_scenario_record,
|
|
19
|
-
finish_step_record,
|
|
20
|
-
finish_worker_record,
|
|
21
|
-
record_cucu_run,
|
|
22
|
-
record_feature,
|
|
23
|
-
record_scenario,
|
|
24
|
-
start_step_record,
|
|
25
|
-
)
|
|
26
12
|
from cucu.page_checks import init_page_checks
|
|
27
13
|
from cucu.utils import (
|
|
28
14
|
TeeStream,
|
|
29
15
|
ellipsize_filename,
|
|
30
|
-
generate_short_id,
|
|
31
16
|
take_screenshot,
|
|
32
17
|
)
|
|
33
18
|
|
|
@@ -61,27 +46,6 @@ def before_all(ctx):
|
|
|
61
46
|
ctx.check_browser_initialized = partial(check_browser_initialized, ctx)
|
|
62
47
|
ctx.worker_custom_data = {}
|
|
63
48
|
|
|
64
|
-
if CONFIG["WORKER_RUN_ID"] != CONFIG["WORKER_PARENT_ID"]:
|
|
65
|
-
logger.debug(
|
|
66
|
-
"Create a new worker db since this isn't the parent process"
|
|
67
|
-
)
|
|
68
|
-
# use seed unique enough for multiple cucu_runs to be combined but predictable within the same run
|
|
69
|
-
worker_id_seed = f"{CONFIG['WORKER_PARENT_ID']}_{os.getpid()}"
|
|
70
|
-
CONFIG["WORKER_RUN_ID"] = generate_short_id(worker_id_seed)
|
|
71
|
-
|
|
72
|
-
results_path = Path(CONFIG["CUCU_RESULTS_DIR"])
|
|
73
|
-
worker_run_id = CONFIG["WORKER_RUN_ID"]
|
|
74
|
-
cucu_run_id = CONFIG["CUCU_RUN_ID"]
|
|
75
|
-
CONFIG["RUN_DB_PATH"] = run_db_path = (
|
|
76
|
-
results_path / f"run_{cucu_run_id}_{worker_run_id}.db"
|
|
77
|
-
)
|
|
78
|
-
if not run_db_path.exists():
|
|
79
|
-
logger.debug(
|
|
80
|
-
f"Creating new run database file: {run_db_path} for {worker_id_seed}"
|
|
81
|
-
)
|
|
82
|
-
create_database_file(run_db_path)
|
|
83
|
-
record_cucu_run()
|
|
84
|
-
|
|
85
49
|
CONFIG.snapshot("before_all")
|
|
86
50
|
|
|
87
51
|
for hook in CONFIG["__CUCU_BEFORE_ALL_HOOKS"]:
|
|
@@ -93,29 +57,22 @@ def after_all(ctx):
|
|
|
93
57
|
for hook in CONFIG["__CUCU_AFTER_ALL_HOOKS"]:
|
|
94
58
|
hook(ctx)
|
|
95
59
|
|
|
96
|
-
finish_worker_record(ctx.worker_custom_data)
|
|
97
|
-
finish_cucu_run_record()
|
|
98
60
|
CONFIG.restore(with_pop=True)
|
|
99
61
|
|
|
100
62
|
|
|
101
63
|
def before_feature(ctx, feature):
|
|
102
|
-
feature_run_id_seed = f"{CONFIG['WORKER_RUN_ID']}_{time.perf_counter()}"
|
|
103
|
-
feature.feature_run_id = generate_short_id(feature_run_id_seed)
|
|
104
|
-
feature.custom_data = {}
|
|
105
|
-
record_feature(feature)
|
|
106
|
-
|
|
107
64
|
if config.CONFIG["CUCU_RESULTS_DIR"] is not None:
|
|
108
65
|
results_dir = Path(config.CONFIG["CUCU_RESULTS_DIR"])
|
|
109
66
|
ctx.feature_dir = results_dir / ellipsize_filename(feature.name)
|
|
110
67
|
|
|
111
68
|
|
|
112
69
|
def after_feature(ctx, feature):
|
|
113
|
-
|
|
70
|
+
pass
|
|
114
71
|
|
|
115
72
|
|
|
116
73
|
def before_scenario(ctx, scenario):
|
|
117
74
|
# we want every scenario to start with the exact same reinitialized config
|
|
118
|
-
# values and not
|
|
75
|
+
# values and not bleed values between scenario runs
|
|
119
76
|
CONFIG.restore()
|
|
120
77
|
|
|
121
78
|
# we should load any cucurc.yml files in the path to the feature file
|
|
@@ -125,10 +82,8 @@ def before_scenario(ctx, scenario):
|
|
|
125
82
|
|
|
126
83
|
init_scenario_hook_variables()
|
|
127
84
|
|
|
128
|
-
scenario.custom_data = {}
|
|
129
85
|
ctx.scenario = scenario
|
|
130
86
|
ctx.step_index = 0
|
|
131
|
-
ctx.scenario_index = ctx.feature.scenarios.index(scenario) + 1
|
|
132
87
|
ctx.browsers = []
|
|
133
88
|
ctx.browser = None
|
|
134
89
|
ctx.section_step_stack = []
|
|
@@ -172,14 +127,6 @@ def before_scenario(ctx, scenario):
|
|
|
172
127
|
)
|
|
173
128
|
ctx.browser_log_tee = TeeStream(ctx.browser_log_file)
|
|
174
129
|
|
|
175
|
-
scenario_run_id_seed = (
|
|
176
|
-
f"{ctx.feature.feature_run_id}_{time.perf_counter()}"
|
|
177
|
-
)
|
|
178
|
-
CONFIG["SCENARIO_RUN_ID"] = scenario.scenario_run_id = generate_short_id(
|
|
179
|
-
scenario_run_id_seed
|
|
180
|
-
)
|
|
181
|
-
record_scenario(ctx)
|
|
182
|
-
|
|
183
130
|
# run before all scenario hooks
|
|
184
131
|
for hook in CONFIG["__CUCU_BEFORE_SCENARIO_HOOKS"]:
|
|
185
132
|
try:
|
|
@@ -250,7 +197,6 @@ def after_scenario(ctx, scenario):
|
|
|
250
197
|
logger.debug("quitting browser between sessions")
|
|
251
198
|
run_after_scenario_hook(ctx, scenario, cleanup_browsers)
|
|
252
199
|
|
|
253
|
-
|
|
254
200
|
cucu_config_path = ctx.scenario_logs_dir / "cucu.config.yaml.txt"
|
|
255
201
|
with open(cucu_config_path, "w") as config_file:
|
|
256
202
|
config_file.write(CONFIG.to_yaml_without_secrets())
|
|
@@ -260,7 +206,6 @@ def after_scenario(ctx, scenario):
|
|
|
260
206
|
)
|
|
261
207
|
|
|
262
208
|
scenario.end_at = datetime.datetime.now().isoformat()[:-3]
|
|
263
|
-
finish_scenario_record(scenario)
|
|
264
209
|
|
|
265
210
|
|
|
266
211
|
def download_mht_data(ctx):
|
|
@@ -292,10 +237,6 @@ def cleanup_browsers(ctx):
|
|
|
292
237
|
|
|
293
238
|
|
|
294
239
|
def before_step(ctx, step):
|
|
295
|
-
step_run_id_seed = f"{ctx.scenario.scenario_run_id}_{ctx.step_index}_{time.perf_counter()}"
|
|
296
|
-
step.step_run_id = generate_short_id(
|
|
297
|
-
step_run_id_seed, length=10
|
|
298
|
-
) # up to 10 characters to give two orders of magnitude less chance of collision
|
|
299
240
|
step.start_at = datetime.datetime.now().isoformat()[:-3]
|
|
300
241
|
|
|
301
242
|
sys.stdout.captured()
|
|
@@ -306,7 +247,8 @@ def before_step(ctx, step):
|
|
|
306
247
|
ctx.scenario_debug_log_tee.clear()
|
|
307
248
|
|
|
308
249
|
ctx.current_step = step
|
|
309
|
-
|
|
250
|
+
step.is_substep = getattr(step, "is_substep", False)
|
|
251
|
+
step.has_substeps = getattr(step, "has_substeps", False)
|
|
310
252
|
ctx.section_level = None
|
|
311
253
|
step.seq = ctx.step_index + 1
|
|
312
254
|
step.parent_seq = (
|
|
@@ -315,8 +257,6 @@ def before_step(ctx, step):
|
|
|
315
257
|
|
|
316
258
|
CONFIG["__STEP_SCREENSHOT_COUNT"] = 0
|
|
317
259
|
|
|
318
|
-
start_step_record(ctx, step)
|
|
319
|
-
|
|
320
260
|
# run before all step hooks
|
|
321
261
|
for hook in CONFIG["__CUCU_BEFORE_STEP_HOOKS"]:
|
|
322
262
|
hook(ctx)
|
|
@@ -337,7 +277,7 @@ def after_step(ctx, step):
|
|
|
337
277
|
# calculate duration from ISO timestamps
|
|
338
278
|
start_at = datetime.datetime.fromisoformat(step.start_at)
|
|
339
279
|
end_at = datetime.datetime.fromisoformat(step.end_at)
|
|
340
|
-
ctx.previous_step_duration = (end_at - start_at).total_seconds()
|
|
280
|
+
ctx.scenario.previous_step_duration = (end_at - start_at).total_seconds()
|
|
341
281
|
|
|
342
282
|
# when set this means we're running in parallel mode using --workers and
|
|
343
283
|
# we want to see progress reported using simply dots
|
|
@@ -362,8 +302,8 @@ def after_step(ctx, step):
|
|
|
362
302
|
logger.debug(log_message)
|
|
363
303
|
|
|
364
304
|
# Add tab info to step.stdout so it shows up in the HTML report
|
|
365
|
-
step.stdout
|
|
366
|
-
f"
|
|
305
|
+
step.stdout.extend(
|
|
306
|
+
[f"tab({current_tab} of {total_tabs}): {title}", "url: {url}"]
|
|
367
307
|
)
|
|
368
308
|
|
|
369
309
|
# if the step has substeps from using `run_steps` then we already moved
|
|
@@ -407,5 +347,3 @@ def after_step(ctx, step):
|
|
|
407
347
|
}
|
|
408
348
|
|
|
409
349
|
step.browser_info = browser_info
|
|
410
|
-
|
|
411
|
-
finish_step_record(step, ctx.previous_step_duration)
|
cucu/formatter/cucu.py
CHANGED
|
@@ -101,8 +101,8 @@ class CucuFormatter(Formatter):
|
|
|
101
101
|
|
|
102
102
|
def insert_step(self, step, index=-1):
|
|
103
103
|
# used to determine how to better handle console output
|
|
104
|
-
step.has_substeps = False
|
|
105
|
-
step.is_substep = False
|
|
104
|
+
step.has_substeps = getattr(step, "has_substeps", False)
|
|
105
|
+
step.is_substep = getattr(step, "is_substep", False)
|
|
106
106
|
|
|
107
107
|
if index == -1:
|
|
108
108
|
self.steps.append(step)
|
cucu/formatter/rundb.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from __future__ import absolute_import
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from behave.formatter.base import Formatter
|
|
10
|
+
from behave.model import ScenarioOutline
|
|
11
|
+
|
|
12
|
+
from cucu import logger
|
|
13
|
+
from cucu.config import CONFIG
|
|
14
|
+
from cucu.db import (
|
|
15
|
+
close_db,
|
|
16
|
+
create_database_file,
|
|
17
|
+
finish_cucu_run_record,
|
|
18
|
+
finish_feature_record,
|
|
19
|
+
finish_scenario_record,
|
|
20
|
+
finish_step_record,
|
|
21
|
+
finish_worker_record,
|
|
22
|
+
record_cucu_run,
|
|
23
|
+
record_feature,
|
|
24
|
+
record_scenario,
|
|
25
|
+
start_step_record,
|
|
26
|
+
)
|
|
27
|
+
from cucu.utils import (
|
|
28
|
+
generate_short_id,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RundbFormatter(Formatter):
|
|
33
|
+
"""
|
|
34
|
+
Record to the database using the Formatter Behave API.
|
|
35
|
+
This is different from hooks as we don't have access to context (ctx) here.
|
|
36
|
+
Instead we use this class's data and add some data to the passed in objects: feature, scenario, step.
|
|
37
|
+
Another difference is that we can't rely on hooks to execute, as the step/scenario/feature may be skipped.
|
|
38
|
+
Also, use cucu's CONFIG for global data since we don't have context (ctx).
|
|
39
|
+
|
|
40
|
+
## Just for reference:
|
|
41
|
+
Processing Logic (simplified, without ScenarioOutline and skip logic)::
|
|
42
|
+
|
|
43
|
+
for feature in runner.features:
|
|
44
|
+
formatter = make_formatters(...)
|
|
45
|
+
formatter.uri(feature.filename)
|
|
46
|
+
formatter.feature(feature)
|
|
47
|
+
for scenario in feature.scenarios:
|
|
48
|
+
formatter.scenario(scenario)
|
|
49
|
+
for step in scenario.all_steps:
|
|
50
|
+
formatter.step(step)
|
|
51
|
+
step_match = step_registry.find_match(step)
|
|
52
|
+
formatter.match(step_match)
|
|
53
|
+
if step_match:
|
|
54
|
+
step_match.run()
|
|
55
|
+
else:
|
|
56
|
+
step.status = Status.undefined
|
|
57
|
+
formatter.result(step.status)
|
|
58
|
+
formatter.eof() # -- FEATURE-END
|
|
59
|
+
formatter.close()
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# -- FORMATTER API:
|
|
63
|
+
name = "rundb"
|
|
64
|
+
description = "records the results of the run to the run.db database"
|
|
65
|
+
|
|
66
|
+
def __init__(self, stream_opener, config):
|
|
67
|
+
super(RundbFormatter, self).__init__(stream_opener, config)
|
|
68
|
+
# We don't actually use the stream provided by Behave, so don't open it.
|
|
69
|
+
# self.stream = self.open()
|
|
70
|
+
self.config = config
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
not CONFIG["RUN_DB_PATH"]
|
|
74
|
+
or CONFIG["WORKER_RUN_ID"] != CONFIG["WORKER_PARENT_ID"]
|
|
75
|
+
):
|
|
76
|
+
logger.debug(
|
|
77
|
+
"Create a new worker db since this isn't the parent process"
|
|
78
|
+
)
|
|
79
|
+
# use seed unique enough for multiple cucu_runs to be combined but predictable within the same run
|
|
80
|
+
worker_id_seed = f"{CONFIG['WORKER_PARENT_ID']}_{os.getpid()}"
|
|
81
|
+
CONFIG["WORKER_RUN_ID"] = generate_short_id(worker_id_seed)
|
|
82
|
+
|
|
83
|
+
results_path = Path(CONFIG["CUCU_RESULTS_DIR"])
|
|
84
|
+
worker_run_id = CONFIG["WORKER_RUN_ID"]
|
|
85
|
+
cucu_run_id = CONFIG["CUCU_RUN_ID"]
|
|
86
|
+
CONFIG["RUN_DB_PATH"] = run_db_path = (
|
|
87
|
+
results_path / f"run_{cucu_run_id}_{worker_run_id}.db"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if not run_db_path.exists():
|
|
91
|
+
logger.debug(
|
|
92
|
+
f"Creating new run database file: {run_db_path} for {worker_id_seed}"
|
|
93
|
+
)
|
|
94
|
+
create_database_file(run_db_path)
|
|
95
|
+
record_cucu_run()
|
|
96
|
+
|
|
97
|
+
def uri(self, uri):
|
|
98
|
+
# nothing to do, but we need to implement the method for behave
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
def feature(self, feature):
|
|
102
|
+
"""Called before a feature is executed."""
|
|
103
|
+
self.this_feature = feature
|
|
104
|
+
self.this_scenario = None
|
|
105
|
+
self.this_background = None
|
|
106
|
+
self.next_start_at = None
|
|
107
|
+
|
|
108
|
+
feature_run_id_seed = (
|
|
109
|
+
f"{CONFIG['WORKER_RUN_ID']}_{time.perf_counter()}"
|
|
110
|
+
)
|
|
111
|
+
feature.feature_run_id = generate_short_id(feature_run_id_seed)
|
|
112
|
+
feature.custom_data = {}
|
|
113
|
+
|
|
114
|
+
record_feature(feature)
|
|
115
|
+
|
|
116
|
+
def background(self, background):
|
|
117
|
+
# nothing to do, but we need to implement the method for behave
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
def _finish_scenario(self):
|
|
121
|
+
if self.this_scenario is None:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# ensure non-executed steps have correct seq
|
|
125
|
+
for index, step in enumerate(self.this_steps):
|
|
126
|
+
if getattr(step, "seq", -1) == -1:
|
|
127
|
+
step.seq = index + 1 # 1-based sequence
|
|
128
|
+
finish_step_record(step, None)
|
|
129
|
+
|
|
130
|
+
finish_scenario_record(self.this_scenario)
|
|
131
|
+
|
|
132
|
+
def scenario(self, scenario):
|
|
133
|
+
"""Called before a scenario is executed (or ScenarioOutline scenarios)."""
|
|
134
|
+
self._finish_scenario()
|
|
135
|
+
|
|
136
|
+
self.this_scenario = scenario
|
|
137
|
+
self.this_steps = []
|
|
138
|
+
self.next_start_at = datetime.datetime.now().isoformat()[:-3]
|
|
139
|
+
scenario_run_id_seed = (
|
|
140
|
+
f"{scenario.feature.feature_run_id}_{time.perf_counter()}"
|
|
141
|
+
)
|
|
142
|
+
scenario.scenario_run_id = generate_short_id(scenario_run_id_seed)
|
|
143
|
+
scenario.custom_data = {}
|
|
144
|
+
|
|
145
|
+
# feature.scenarios is a mix of Scenario and ScenarioOutline objects with their own scenarios list
|
|
146
|
+
for index, feature_scenario in enumerate(scenario.feature.scenarios):
|
|
147
|
+
if feature_scenario == scenario:
|
|
148
|
+
scenario.seq = index + 1
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
# Scenarios belonging to a Scenario Outline are included under their Scenario Outline sequence.
|
|
152
|
+
# Add a suffix to preserve ordering within the Scenario Outline.
|
|
153
|
+
if isinstance(feature_scenario, ScenarioOutline):
|
|
154
|
+
for sub_index, sub_scenario in enumerate(
|
|
155
|
+
feature_scenario._scenarios
|
|
156
|
+
):
|
|
157
|
+
if sub_scenario == scenario:
|
|
158
|
+
scenario.seq = index + 1 + (sub_index + 1) / 10
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
record_scenario(scenario)
|
|
162
|
+
|
|
163
|
+
def step(self, step):
|
|
164
|
+
"""Called before a step is executed (and matched)."""
|
|
165
|
+
self.insert_step(step, index=-1)
|
|
166
|
+
|
|
167
|
+
def insert_step(self, step, index):
|
|
168
|
+
"""cucu specific step insertion method used to add steps here and dynamically"""
|
|
169
|
+
next_index = index if index != -1 else len(self.this_steps)
|
|
170
|
+
step_run_id_seed = f"{self.this_scenario.scenario_run_id}_{next_index}_{time.perf_counter()}"
|
|
171
|
+
step.step_run_id = generate_short_id(
|
|
172
|
+
step_run_id_seed, length=10
|
|
173
|
+
) # up to 10 characters to give two orders of magnitude less chance of collision
|
|
174
|
+
self.this_steps.insert(next_index, step)
|
|
175
|
+
start_step_record(step, self.this_scenario.scenario_run_id)
|
|
176
|
+
|
|
177
|
+
def match(self, match):
|
|
178
|
+
# nothing to do, but we need to implement the method for behave
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def result(self, step):
|
|
182
|
+
"""Called after processing a step result is known, applies to executed/skipped too."""
|
|
183
|
+
step.start_at = self.next_start_at
|
|
184
|
+
self.next_start_at = step.end_at = datetime.datetime.now().isoformat()[
|
|
185
|
+
:-3
|
|
186
|
+
]
|
|
187
|
+
previous_step_duration = getattr(
|
|
188
|
+
self.this_scenario, "previous_step_duration", 0
|
|
189
|
+
)
|
|
190
|
+
if step.status.name in ("untested", "undefined"):
|
|
191
|
+
step.seq = self.this_steps.index(step) + 1
|
|
192
|
+
|
|
193
|
+
finish_step_record(step, previous_step_duration)
|
|
194
|
+
|
|
195
|
+
def eof(self):
|
|
196
|
+
"""Called after processing a feature (or a feature file)."""
|
|
197
|
+
# need to finish the last scenario
|
|
198
|
+
self._finish_scenario()
|
|
199
|
+
finish_feature_record(self.this_feature)
|
|
200
|
+
|
|
201
|
+
def close(self):
|
|
202
|
+
"""Called before the formatter is no longer used
|
|
203
|
+
(stream/io compatibility).
|
|
204
|
+
"""
|
|
205
|
+
finish_worker_record(None)
|
|
206
|
+
finish_cucu_run_record()
|
|
207
|
+
close_db()
|
cucu/reporter/html.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import glob
|
|
2
|
-
import json
|
|
3
2
|
import os
|
|
4
3
|
import shutil
|
|
5
4
|
import sys
|
|
@@ -14,6 +13,10 @@ import jinja2
|
|
|
14
13
|
from cucu import format_gherkin_table, logger
|
|
15
14
|
from cucu.ansi_parser import parse_log_to_html
|
|
16
15
|
from cucu.config import CONFIG
|
|
16
|
+
from cucu.db import close_html_report_db, init_html_report_db
|
|
17
|
+
from cucu.db import feature as FeatureModel
|
|
18
|
+
from cucu.db import scenario as ScenarioModel
|
|
19
|
+
from cucu.db import step as StepModel
|
|
17
20
|
from cucu.utils import ellipsize_filename, get_step_image_dir
|
|
18
21
|
|
|
19
22
|
|
|
@@ -63,21 +66,108 @@ def generate(results, basepath, only_failures=False):
|
|
|
63
66
|
|
|
64
67
|
features = []
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
db_path = os.path.join(results, "run.db")
|
|
70
|
+
try:
|
|
71
|
+
init_html_report_db(db_path)
|
|
72
|
+
features = []
|
|
73
|
+
|
|
74
|
+
db_features = FeatureModel.select().order_by(FeatureModel.start_at)
|
|
75
|
+
logger.info(
|
|
76
|
+
f"Starting to process {len(db_features)} features for report"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
for db_feature in db_features:
|
|
80
|
+
feature_dict = {
|
|
81
|
+
"name": db_feature.name,
|
|
82
|
+
"filename": db_feature.filename,
|
|
83
|
+
"description": db_feature.description,
|
|
84
|
+
"tags": db_feature.tags if db_feature.tags else [],
|
|
85
|
+
"status": db_feature.status,
|
|
86
|
+
"elements": [],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
db_scenarios = (
|
|
90
|
+
ScenarioModel.select()
|
|
91
|
+
.where(
|
|
92
|
+
ScenarioModel.feature_run_id == db_feature.feature_run_id
|
|
93
|
+
)
|
|
94
|
+
.order_by(ScenarioModel.seq)
|
|
95
|
+
)
|
|
70
96
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
97
|
+
feature_has_failures = False
|
|
98
|
+
|
|
99
|
+
if len(db_scenarios) == 0:
|
|
100
|
+
logger.debug(f"Feature {db_feature.name} has no scenarios")
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
for db_scenario in db_scenarios:
|
|
104
|
+
scenario_dict = {
|
|
105
|
+
"name": db_scenario.name,
|
|
106
|
+
"line": db_scenario.line_number,
|
|
107
|
+
"tags": db_scenario.tags if db_scenario.tags else [],
|
|
108
|
+
"status": db_scenario.status or "passed",
|
|
109
|
+
"steps": [],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if db_scenario.status == "failed":
|
|
113
|
+
feature_has_failures = True
|
|
114
|
+
|
|
115
|
+
db_steps = (
|
|
116
|
+
StepModel.select()
|
|
117
|
+
.where(
|
|
118
|
+
StepModel.scenario_run_id
|
|
119
|
+
== db_scenario.scenario_run_id
|
|
120
|
+
)
|
|
121
|
+
.order_by(StepModel.seq)
|
|
78
122
|
)
|
|
79
123
|
|
|
80
|
-
|
|
124
|
+
for db_step in db_steps:
|
|
125
|
+
step_dict = {
|
|
126
|
+
"keyword": db_step.keyword,
|
|
127
|
+
"name": db_step.name,
|
|
128
|
+
"result": {
|
|
129
|
+
"status": db_step.status or "passed",
|
|
130
|
+
"duration": db_step.duration or 0,
|
|
131
|
+
"timestamp": db_step.end_at or "",
|
|
132
|
+
},
|
|
133
|
+
"substep": db_step.is_substep,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if db_step.text:
|
|
137
|
+
step_dict["text"] = db_step.text
|
|
138
|
+
|
|
139
|
+
if db_step.table_data:
|
|
140
|
+
step_dict["table"] = db_step.table_data
|
|
141
|
+
|
|
142
|
+
step_dict["result"]["error_message"] = (
|
|
143
|
+
db_step.error_message.splitlines()
|
|
144
|
+
if db_step.error_message
|
|
145
|
+
else []
|
|
146
|
+
)
|
|
147
|
+
step_dict["result"]["exception"] = db_step.exception
|
|
148
|
+
step_dict["result"]["stdout"] = db_step.stdout
|
|
149
|
+
step_dict["result"]["stderr"] = db_step.stderr
|
|
150
|
+
step_dict["result"]["browser_logs"] = (
|
|
151
|
+
db_step.browser_logs.splitlines()
|
|
152
|
+
)
|
|
153
|
+
step_dict["result"]["debug_output"] = (
|
|
154
|
+
db_step.debug_output.splitlines()
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
scenario_dict["steps"].append(step_dict)
|
|
158
|
+
|
|
159
|
+
feature_dict["elements"].append(scenario_dict)
|
|
160
|
+
|
|
161
|
+
if feature_has_failures:
|
|
162
|
+
feature_dict["status"] = "failed"
|
|
163
|
+
elif only_failures and not feature_has_failures:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
features.append(feature_dict)
|
|
167
|
+
|
|
168
|
+
finally:
|
|
169
|
+
close_html_report_db()
|
|
170
|
+
|
|
81
171
|
cucu_dir = os.path.dirname(sys.modules["cucu"].__file__)
|
|
82
172
|
external_dir = os.path.join(cucu_dir, "reporter", "external")
|
|
83
173
|
shutil.copytree(external_dir, os.path.join(basepath, "external"))
|
|
@@ -223,18 +313,24 @@ def generate(results, basepath, only_failures=False):
|
|
|
223
313
|
|
|
224
314
|
if "result" in step:
|
|
225
315
|
if step["result"]["status"] in ["failed", "passed"]:
|
|
226
|
-
timestamp
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
scenario_started_at
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
316
|
+
if step["result"]["timestamp"]:
|
|
317
|
+
timestamp = datetime.fromisoformat(
|
|
318
|
+
step["result"]["timestamp"]
|
|
319
|
+
)
|
|
320
|
+
step["result"]["timestamp"] = timestamp
|
|
321
|
+
|
|
322
|
+
if scenario_started_at is None:
|
|
323
|
+
scenario_started_at = timestamp
|
|
324
|
+
scenario["started_at"] = timestamp
|
|
325
|
+
time_offset = datetime.utcfromtimestamp(
|
|
326
|
+
(
|
|
327
|
+
timestamp - scenario_started_at
|
|
328
|
+
).total_seconds()
|
|
329
|
+
)
|
|
330
|
+
step["result"]["time_offset"] = time_offset
|
|
331
|
+
else:
|
|
332
|
+
step["result"]["timestamp"] = ""
|
|
333
|
+
step["result"]["time_offset"] = ""
|
|
238
334
|
|
|
239
335
|
scenario_duration += step["result"]["duration"]
|
|
240
336
|
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
<tr class="align-text-top">
|
|
20
20
|
<th class="text-center">Started at</th>
|
|
21
21
|
<th>Feature</th>
|
|
22
|
-
<th class="text-center">Total<br/>{{ grand_totals['total_scenarios'] }}</th>
|
|
23
|
-
<th class="text-center">Passed<br/>{{ grand_totals['total_scenarios_passed'] }}</th>
|
|
24
|
-
<th class="text-center">Failed<br/>{{ grand_totals['total_scenarios_failed'] }}</th>
|
|
25
|
-
<th class="text-center">Skipped<br/>{{ grand_totals['total_scenarios_skipped'] }}</th>
|
|
26
|
-
<th class="text-center">Errored<br/>{{ grand_totals['total_scenarios_errored'] }}</th>
|
|
22
|
+
<th class="text-center">Total<br/>{{ grand_totals['total_scenarios'] | int }}</th>
|
|
23
|
+
<th class="text-center">Passed<br/>{{ grand_totals['total_scenarios_passed'] | int}}</th>
|
|
24
|
+
<th class="text-center">Failed<br/>{{ grand_totals['total_scenarios_failed'] | int }}</th>
|
|
25
|
+
<th class="text-center">Skipped<br/>{{ grand_totals['total_scenarios_skipped'] | int }}</th>
|
|
26
|
+
<th class="text-center">Errored<br/>{{ grand_totals['total_scenarios_errored'] | int }}</th>
|
|
27
27
|
<th class="text-center">Status<br/> </th>
|
|
28
|
-
<th class="text-center">Duration (s)<br/>{{
|
|
28
|
+
<th class="text-center">Duration (s)<br/>{{ grand_totals['duration'] | int }}s</th>
|
|
29
29
|
</tr>
|
|
30
30
|
</thead>
|
|
31
31
|
{% for feature in features %}
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
<td class="text-center">{{ feature['total_scenarios_skipped'] }}</td>
|
|
39
39
|
<td class="text-center">{{ feature['total_scenarios_errored'] }}</td>
|
|
40
40
|
<td class="text-center"><span class="status-{{ feature['status'] }}">{{ feature['status'] }}</span></td>
|
|
41
|
-
<td class="text-center">{{ feature['duration'] }}</td>
|
|
41
|
+
<td class="text-center">{{ '{:.1f}'.format(feature['duration'] | float ) }}</td>
|
|
42
42
|
</tr>
|
|
43
43
|
{% endfor %}
|
|
44
44
|
</table>
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
{% endif %}
|
|
88
88
|
{% if step['result'] is defined %}
|
|
89
89
|
{% set step_status = step['result']['status'] %}
|
|
90
|
-
{% if step['result']['status']
|
|
90
|
+
{% if step['result']['status'] in ('failed', 'passed') and step["result"]["timestamp"] %}
|
|
91
91
|
{% set step_timing = "{} for {:.3f}s".format(step["result"]["timestamp"].strftime("%H:%M:%S"), step["result"]["duration"]) %}
|
|
92
92
|
{% set step_start = step["result"]["timestamp"] %}
|
|
93
93
|
{% endif %}
|
|
@@ -131,24 +131,32 @@
|
|
|
131
131
|
<tr class="row"><td style="min-width: 0;" class="col-12 collapse multi-collapse" id="collapsable-row-{{ loop.index }}" colspan="2">
|
|
132
132
|
|
|
133
133
|
{% if step['result']['stdout'] %}
|
|
134
|
+
<p>stdout ({{ step['result']['stdout']|length }} lines)</p>
|
|
134
135
|
<pre style="color: darkgray; margin: 0;">{{ escape("\n".join(step['result']['stdout'])) }}</pre>
|
|
135
136
|
{% endif %}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
{% if step['result']['stderr'] %}
|
|
138
|
+
<p>stderr ({{ step['result']['stderr']|length }} lines)</p>
|
|
139
|
+
<pre style="color: darkgray; margin: 0;">{{ escape("\n".join(step['result']['stderr'])) }}</pre>
|
|
140
|
+
{% endif %}
|
|
141
|
+
{% if step['images'] %}
|
|
142
|
+
<p>images ({{ step['images']|length }} images)</p>
|
|
141
143
|
{% for image in step['images'] %}
|
|
142
144
|
<img class="mx-auto d-block img-fluid shadow bg-white rounded" style="margin-bottom:15px" alt='{{ image["label"] }}' title='{{ image["label"] }}' src='{{ image["src"] }}'></img>
|
|
143
145
|
{% endfor %}
|
|
144
146
|
{% endif %}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
{% if step['image'] is defined %}
|
|
148
|
-
<br/>
|
|
149
|
-
{% endif %}
|
|
147
|
+
{% if step['result']['error_message'] %}
|
|
148
|
+
<p>error message ({{ step['result']['error_message']|length }} lines)</p>
|
|
150
149
|
<pre style="color: gray; margin: 0">{{ escape("\n".join(step['result']['error_message'])) }}</pre>
|
|
151
150
|
{% endif %}
|
|
151
|
+
{% if step['result']['browser_logs'] %}
|
|
152
|
+
<p>browser logs ({{ step['result']['browser_logs']|length }} lines)</p>
|
|
153
|
+
<pre style="color: gray; margin: 0;">{{ escape("\n".join(step['result']['browser_logs'])) }}</pre>
|
|
154
|
+
{% endif %}
|
|
155
|
+
{% if step['result']['debug_output'] %}
|
|
156
|
+
<p>debug output ({{ step['result']['debug_output']|length }} lines)</p>
|
|
157
|
+
<pre style="color: gray; margin: 0;">{{ escape("\n".join(step['result']['debug_output'])) }}</pre>
|
|
158
|
+
{% endif %}
|
|
159
|
+
|
|
152
160
|
</td></tr>
|
|
153
161
|
{% endif %}
|
|
154
162
|
|
cucu/steps/flow_control_steps.py
CHANGED
|
@@ -32,17 +32,17 @@ def expect_the_following_step_to_fail(ctx, message):
|
|
|
32
32
|
|
|
33
33
|
@step('I should see the previous step took less than "{seconds}" seconds')
|
|
34
34
|
def should_see_previous_step_took_less_than(ctx, seconds):
|
|
35
|
-
if ctx.previous_step_duration > float(seconds):
|
|
35
|
+
if ctx.scenario.previous_step_duration > float(seconds):
|
|
36
36
|
raise RuntimeError(
|
|
37
|
-
f"previous step took {ctx.previous_step_duration}, which is more than {seconds}"
|
|
37
|
+
f"previous step took {ctx.scenario.previous_step_duration}, which is more than {seconds}"
|
|
38
38
|
)
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
@step('I should see the previous step took more than "{seconds}" seconds')
|
|
42
42
|
def should_see_previous_step_took_more_than(ctx, seconds):
|
|
43
|
-
if ctx.previous_step_duration < float(seconds):
|
|
43
|
+
if ctx.scenario.previous_step_duration < float(seconds):
|
|
44
44
|
raise RuntimeError(
|
|
45
|
-
f"previous step took {ctx.previous_step_duration}, which is less than {seconds}"
|
|
45
|
+
f"previous step took {ctx.scenario.previous_step_duration}, which is less than {seconds}"
|
|
46
46
|
)
|
|
47
47
|
|
|
48
48
|
|
|
@@ -7,21 +7,22 @@ cucu/browser/frames.py,sha256=IW7kzRJn5PkbMaovIelAeCWO-T-2sOTwqaYBw-0-LKU,3545
|
|
|
7
7
|
cucu/browser/selenium.py,sha256=eUC2DZkhUIZi70sVTaNE_0AhanGTceyx_pCLIT7PN6o,13299
|
|
8
8
|
cucu/browser/selenium_tweaks.py,sha256=oUIhWVhBZbc9qsmQUJMpIr9uUWKxtgZBcnySWU6Yttk,879
|
|
9
9
|
cucu/cli/__init__.py,sha256=uXX5yVG1konJ_INdlrcfMg-Tt_5_cSx29Ed8R8v908A,62
|
|
10
|
-
cucu/cli/core.py,sha256=
|
|
11
|
-
cucu/cli/run.py,sha256=
|
|
10
|
+
cucu/cli/core.py,sha256=On0i9QzGoJ6ZFEYwt1Ae50i8YZFAAqeB__ULW8jcXc4,26950
|
|
11
|
+
cucu/cli/run.py,sha256=XIGIACPieywM5Mi3k_CycM9eiRElzGNZdHlV0_TDSVY,6118
|
|
12
12
|
cucu/cli/steps.py,sha256=lg5itVH_C-0_3RelWXv9X2qQUHggdxuxLCGwH5l1bf4,4210
|
|
13
13
|
cucu/cli/thread_dumper.py,sha256=Z3XnYSxidx6pqjlQ7zu-TKMIYZWk4z9c5YLdPkcemiU,1593
|
|
14
14
|
cucu/config.py,sha256=5AE6GrkqzjNhzzrB-eZrINgeztV7CCGuSdWJ-5GtWhk,14939
|
|
15
|
-
cucu/db.py,sha256=
|
|
15
|
+
cucu/db.py,sha256=iJ5NXECcqVbPdqozuQrdRGpCX1EgEENwDEGEsLUxpRs,14513
|
|
16
16
|
cucu/edgedriver_autoinstaller/README.md,sha256=tDkAWIqgRdCjt-oX1nYqikIC_FfiOEM2-pc5S5VbRLo,84
|
|
17
17
|
cucu/edgedriver_autoinstaller/__init__.py,sha256=fo6xJJPvcc5Xvni8epXfxDoPxJH5_b6Vk2jD9JTwfRs,969
|
|
18
18
|
cucu/edgedriver_autoinstaller/utils.py,sha256=iRKTww77CGaTAntt_QDvxlKPxpMU4otx95OeD97khcM,6802
|
|
19
|
-
cucu/environment.py,sha256=
|
|
19
|
+
cucu/environment.py,sha256=eeUERL4nUaFLlEv-UftQ99SfSSs7-3oaFkFM7YPqET0,11728
|
|
20
20
|
cucu/external/jquery/jquery-3.5.1.min.js,sha256=9_aliU8dGd2tb6OSsuzixeV4y_faTqgFtohetphbbj0,89476
|
|
21
21
|
cucu/formatter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
-
cucu/formatter/cucu.py,sha256=
|
|
22
|
+
cucu/formatter/cucu.py,sha256=NRLFsd6xl3uwUk45ihKN2OEioOeAqeTURrcQgscDGnU,9351
|
|
23
23
|
cucu/formatter/json.py,sha256=fJ1dZBGwYD4OkhQFDE49MRKGNzsrhDzQYq-dUfsYh94,10589
|
|
24
24
|
cucu/formatter/junit.py,sha256=aJ9dGLbamMH-wUi_msF66_-_c_YUq07-8_wCNEjUju4,10129
|
|
25
|
+
cucu/formatter/rundb.py,sha256=7gKVPbSg7bCuAt972XRs5vRjeowLzVZPaOHCIxUazrE,7668
|
|
25
26
|
cucu/fuzzy/__init__.py,sha256=ce4JRmaBF6oab6U99Qbpt7DrD3elhH32__-ND6fw5xc,104
|
|
26
27
|
cucu/fuzzy/core.py,sha256=tmQKX_Ni-2ohoxzctRUg2x7zMeEW8MlJJmpU3PfTmvQ,3153
|
|
27
28
|
cucu/fuzzy/fuzzy.js,sha256=ee-TytISLyUo7cMAkuVI5qbLXdt0eoFWczTsoU4zYhg,11618
|
|
@@ -55,12 +56,12 @@ cucu/reporter/external/jquery-3.5.1.min.js,sha256=9_aliU8dGd2tb6OSsuzixeV4y_faTq
|
|
|
55
56
|
cucu/reporter/external/jquery.dataTables.min.js,sha256=XNhaB1tBOSFMHu96BSAJpZOJzfZ4SZI1nwAbnwry2UY,90265
|
|
56
57
|
cucu/reporter/external/popper.min.js,sha256=pS96pU17yq-gVu4KBQJi38VpSuKN7otMrDQprzf_DWY,19188
|
|
57
58
|
cucu/reporter/favicon.png,sha256=9ikXLAmzfQzy2NQps_8CGaZog2FvQrOX8nnSZ0e1UmM,2161
|
|
58
|
-
cucu/reporter/html.py,sha256=
|
|
59
|
+
cucu/reporter/html.py,sha256=PhITO1Zxc3WVl1nU7maKpDeUvYmlkkv4cMNTFJnnTxM,19516
|
|
59
60
|
cucu/reporter/templates/feature.html,sha256=IBkwGiul-sRO5lT8q8VFXMUJx1owsAd1YbdDzziSjKw,3645
|
|
60
61
|
cucu/reporter/templates/flat.html,sha256=JGsMq-IWz6YUpJX9hcN65-15HxcX3NJclOmMDtW3HZE,2358
|
|
61
|
-
cucu/reporter/templates/index.html,sha256=
|
|
62
|
+
cucu/reporter/templates/index.html,sha256=xgPYNU-sozN-iOaEzyymoQ4LDRI75eHXngbAP0xDYls,2770
|
|
62
63
|
cucu/reporter/templates/layout.html,sha256=2iDRbm8atO8mgHWgijIvDCrBMKvcP6YHrmr95WtJiE4,4561
|
|
63
|
-
cucu/reporter/templates/scenario.html,sha256=
|
|
64
|
+
cucu/reporter/templates/scenario.html,sha256=Vo1s95qr4_cK1nPREp01lC0f1CHOhPDKAVorkuKiPJU,10974
|
|
64
65
|
cucu/steps/__init__.py,sha256=seSmASBlWu6-6wbFbvEbPwigBcRXiYP18C4X_2cW8Ng,753
|
|
65
66
|
cucu/steps/base_steps.py,sha256=0fPvdaKoan8lMAKrDnK0-zrALpxm11P1zVAY5CN7iXA,1893
|
|
66
67
|
cucu/steps/browser_steps.py,sha256=iTRl5ffpf2YrFk5qh655WFHAeSOwoE3HFhmXhjsZtao,12687
|
|
@@ -71,7 +72,7 @@ cucu/steps/draggable_steps.py,sha256=lnQLicp0GZJaxD_Qm2P13ruUZAsl3mptwaI5-SQ6XJ0
|
|
|
71
72
|
cucu/steps/dropdown_steps.py,sha256=abykG--m79kDQ4LU1tm73fNLFPmrKDavyFzJb2MYCu0,15601
|
|
72
73
|
cucu/steps/file_input_steps.py,sha256=LLMAozVpceLMD-kJOE-auKHAdWLbNprH8eCfVQuNoGg,5523
|
|
73
74
|
cucu/steps/filesystem_steps.py,sha256=8l37A-yPxT4Mzdi1JNSTShZkwyFxgSwnh6C0V-hM_RA,4741
|
|
74
|
-
cucu/steps/flow_control_steps.py,sha256=
|
|
75
|
+
cucu/steps/flow_control_steps.py,sha256=UV-rWKOF1OqLaDKhMfSAqAm5TF81T5_j_e3eYGpE6QE,6401
|
|
75
76
|
cucu/steps/image_steps.py,sha256=4X6bdumsIybcJBuao83TURxWAIshZyCvKi1uTJEoy1k,941
|
|
76
77
|
cucu/steps/input_steps.py,sha256=TYQhkmke-W5dJt4EsU-JHjRwnbd7zvXnhEjw5PQ4Wxs,9679
|
|
77
78
|
cucu/steps/link_steps.py,sha256=MQLxyjCbiF2dOsmj6IrKKlRYhaqJjxDUNTf5Cau4J0w,1625
|
|
@@ -87,7 +88,7 @@ cucu/steps/text_steps.py,sha256=Jj_GHoHeemNwVdUOdqcehArNp7WM-WMjljA4w0pLXuw,2576
|
|
|
87
88
|
cucu/steps/variable_steps.py,sha256=WSctH3_xcxjijGPYZlxp-foC_SIAAKtF__saNtgZJbk,2966
|
|
88
89
|
cucu/steps/webserver_steps.py,sha256=wWkpSvcSMdiskPkh4cqlepWx1nkvEpTU2tRXQmPDbyo,1410
|
|
89
90
|
cucu/utils.py,sha256=LCcs8sMzvdvH05N8P5QYO4lO6j-_PQC530mEAD96go8,10957
|
|
90
|
-
cucu-1.3.
|
|
91
|
-
cucu-1.3.
|
|
92
|
-
cucu-1.3.
|
|
93
|
-
cucu-1.3.
|
|
91
|
+
cucu-1.3.8.dist-info/WHEEL,sha256=-neZj6nU9KAMg2CnCY6T3w8J53nx1kFGw_9HfoSzM60,79
|
|
92
|
+
cucu-1.3.8.dist-info/entry_points.txt,sha256=11WRIhQM7LuUnQg1lAoZQoNvvBvYNN1maDgQS4djwJo,40
|
|
93
|
+
cucu-1.3.8.dist-info/METADATA,sha256=Q0no8PrSLllg8AYZP4lAVH10CmInJt2p2bEhwhDgUYw,16721
|
|
94
|
+
cucu-1.3.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|