From 4d86906de3958b1609b5e74ab8c446505bc7e0ff Mon Sep 17 00:00:00 2001 From: Peter Vegh Date: Tue, 22 Sep 2020 12:40:30 +0100 Subject: [PATCH 01/10] License fix in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1e7cf88..d2be687 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ url="https://github.com/Edinburgh-Genome-Foundry/DnaCauldron", description="Cloning simulation for DNA assembly (Golden Gate, Gibson...)", long_description=open("pypi-readme.rst").read(), - license="see LICENSE.txt", + license="MIT", keywords="DNA assembly cloning simulator synthetic biology", scripts=["scripts/dnacauldron"], packages=find_packages(exclude="docs"), From 98dd3ceac8fdc850ca81ff1e2250a5ca5979c557 Mon Sep 17 00:00:00 2001 From: Peter Vegh Date: Thu, 1 Oct 2020 13:52:47 +0100 Subject: [PATCH 02/10] Black & docstrings --- .../AssemblyReportWriter.py | 54 +++++++------------ 1 file changed, 19 insertions(+), 35 deletions(-) diff --git a/dnacauldron/Assembly/AssemblyReportWriter/AssemblyReportWriter.py b/dnacauldron/Assembly/AssemblyReportWriter/AssemblyReportWriter.py index 520f77f..82c4414 100644 --- a/dnacauldron/Assembly/AssemblyReportWriter/AssemblyReportWriter.py +++ b/dnacauldron/Assembly/AssemblyReportWriter/AssemblyReportWriter.py @@ -6,9 +6,9 @@ class AssemblyReportWriter(AssemblyReportPlotsMixin): - """Class to configure assembly simulation reports writing. + """Class to configure assembly simulation report writing. - Responsible to write the final sequence(s) of the assembly in Genbank + Responsible for writing the final sequence(s) of the assembly in Genbank format as well as a .csv report on all assemblies produced and PDF figures to allow a quick overview or diagnostic. @@ -28,32 +28,31 @@ class AssemblyReportWriter(AssemblyReportPlotsMixin): include_part_plots Either True/False/"on_error" to plot schemas of the parts used, possibly with restriction sites relevant to the AssemblyMix. + include_mix_graphs - Either True/False/"on_error" to plot representations of fragments + Either True/False/"on_error" to plot representations of fragment connectivity in the AssemblyMix created during the simulation. include_part_records True/False to include the parts records in the simulation results (makes - for larger folders and zips, but is better for traceability) + for larger folders and zips, but is better for traceability). include_assembly_plots True/False to include assembly schemas in the reports (makes the report generation slower, but makes it easier to check assemblies at a - glance) - + glance). + show_overhangs_in_graph If true, the AssemblyMix graph representations will display the sequence - of all fragments overhangs. - + of all fragment overhangs. + include_errors_spreadsheet If true and there are errors, an errors spreadsheet will be added to the - report - + report. + include_warnings_spreadsheet If true and there are warnings, a warnings spreadsheet will be added to - the report - - + the report. """ def __init__( @@ -114,20 +113,12 @@ def _write_records_plots(self, assembly_simulation, report_root): construct_record = construct_record.as_biopython_record() self.plot_construct(construct_record, plots_dir) - def _write_errors_spreadsheet( - self, simulation, report_root, error_type="error" - ): - errors = ( - simulation.errors if error_type == "error" else simulation.warnings - ) + def _write_errors_spreadsheet(self, simulation, report_root, error_type="error"): + errors = simulation.errors if error_type == "error" else simulation.warnings if len(errors) > 0: - columns = ";".join( - ["assembly_name", "message", "suggestion", "data"] - ) + columns = ";".join(["assembly_name", "message", "suggestion", "data"]) all_error_rows = [ - ";".join( - [err.assembly.name, err.message, err.data_as_string(),] - ) + ";".join([err.assembly.name, err.message, err.data_as_string(),]) for err in errors ] filename = "%s.csv" % error_type @@ -153,9 +144,7 @@ def write_report(self, assembly_simulation, target): self._write_records(assembly_simulation, report_root) if self.include_part_records: - self._write_part_records( - assembly_simulation, part_records, report_root - ) + self._write_part_records(assembly_simulation, part_records, report_root) if self.include_assembly_plots: self._write_records_plots(assembly_simulation, report_root) @@ -165,9 +154,7 @@ def write_report(self, assembly_simulation, target): if plot_options["parts_plots"]: enzymes = assembly.enzymes if hasattr(assembly, "enzymes") else [] self.plot_provided_parts( - report_root=report_root, - parts_records=part_records, - enzymes=enzymes, + report_root=report_root, parts_records=part_records, enzymes=enzymes, ) if plot_options["fragment_plots"]: for mix in assembly_simulation.mixes: @@ -180,9 +167,7 @@ def write_report(self, assembly_simulation, target): with_overhangs=self.show_overhangs_in_graph, ) if len(assembly_simulation.construct_records): - self._write_constructs_spreadsheet( - assembly_simulation, report_root - ) + self._write_constructs_spreadsheet(assembly_simulation, report_root) if self.include_errors_spreadsheet: self._write_errors_spreadsheet( assembly_simulation, report_root, error_type="error" @@ -191,7 +176,6 @@ def write_report(self, assembly_simulation, target): self._write_errors_spreadsheet( assembly_simulation, report_root, error_type="warnings" ) - if target == "@memory": return report_root._close() From 6d08ddaab015a2b62f7355e4b1a633653972a48e Mon Sep 17 00:00:00 2001 From: Peter Vegh Date: Thu, 1 Oct 2020 13:58:09 +0100 Subject: [PATCH 03/10] Black & docstrings --- .../AssemblyPlan/AssemblyPlanSimulation.py | 44 +++++++------------ 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py b/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py index 521189d..462c4e3 100644 --- a/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py +++ b/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py @@ -1,9 +1,7 @@ from flametree import file_tree import proglog import pandas -from ..tools import ( - format_data_dicts_records_for_spreadsheet -) +from ..tools import format_data_dicts_records_for_spreadsheet from ..biotools import write_record from ..Assembly.AssemblyReportWriter import AssemblyReportWriter from .plot_leveled_graph import plot_leveled_graph @@ -73,7 +71,7 @@ def compute_summary_dataframe(self): return pandas.DataFrame(data, columns=columns) def compute_stats(self): - """Return a dictionnary of stats. + """Return a dictionary of stats. For instance {"cancelled_assemblies": 2, "errored_assemblies": 1, "valid_assemblies": 5}. @@ -94,7 +92,7 @@ def write_report( logger="bar", include_original_parts_records=True, ): - """Write a comprehensive report to a folder or zip file + """Write a comprehensive report to a folder or zip file. Parameters ---------- @@ -102,33 +100,29 @@ def write_report( target Either a path to a folder, to a zip file, or ``"@memory"`` to write into a virtual zip file whose raw data is then returned. - + folder_name Name of the folder created inside the target to host the report (yes, it is a folder inside a folder, which can be very practical). - + assembly_report_writer Either the "default" or any AssemblyReportWriter instance. - + logger Either "bar" for a progress bar, or None, or any Proglog logger. include_original_parts_records If true, the original provided part records will be included in the - report (creates larger file sizes, but better for traceability). - """ + report (creates larger file sizes, but better for traceability). + """ if assembly_report_writer == "default": # We'll write all records into one folder for the whole plan - assembly_report_writer = AssemblyReportWriter( - include_part_records=False - ) + assembly_report_writer = AssemblyReportWriter(include_part_records=False) logger = proglog.default_bar_logger(logger) if folder_name == "auto": folder_name = self.assembly_plan.name + "_simulation" report_root = file_tree(target)._dir(folder_name, replace=True) - self._write_assembly_reports( - report_root, assembly_report_writer, logger=logger - ) + self._write_assembly_reports(report_root, assembly_report_writer, logger=logger) self._write_errors_spreadsheet(report_root, error_type="error") self._write_errors_spreadsheet(report_root, error_type="warning") @@ -164,8 +158,7 @@ def _write_cancelled_assemblies(self, report_root): filename = self._get_file_name("cancelled_assemblies.csv") columns = ",".join(["cancelled_assembly", "failed_parent_assembly"]) cancelled = [ - ",".join([c.assembly_name, c.failed_dependency]) - for c in self.cancelled + ",".join([c.assembly_name, c.failed_dependency]) for c in self.cancelled ] report_root._file(filename).write("\n".join([columns] + cancelled)) @@ -179,9 +172,7 @@ def parts_sort_key(name): return 1000000 return indices[0] - all_parts = ( - self.list_all_original_parts_used() + self.assembly_plan.all_parts - ) + all_parts = self.list_all_original_parts_used() + self.assembly_plan.all_parts all_parts = sorted(set(all_parts), key=parts_sort_key) def sort_key(name): @@ -199,9 +190,7 @@ def draw_node(x, y, node, ax): text = node.replace("_", " ") ax.text(x, y, text, bbox={"facecolor": "white"}) - _, ax = plot_leveled_graph( - levels=levels, edges=edges, draw_node=draw_node - ) + _, ax = plot_leveled_graph(levels=levels, edges=edges, draw_node=draw_node) target = report_root._file("assembly_plan_graph.pdf") ax.figure.savefig(target.open("wb"), format="pdf") plt.close(ax.figure) @@ -211,9 +200,7 @@ def _write_errors_spreadsheet(self, report_root, error_type="error"): error for simulation in self.assembly_simulations for error in ( - simulation.errors - if error_type == "error" - else simulation.warnings + simulation.errors if error_type == "error" else simulation.warnings ) ] if len(all_errors) > 0: @@ -260,8 +247,7 @@ def list_all_original_parts_used(self): for part in simulation.list_all_parts_used() ] assemblies = [ - simulation.assembly.name - for simulation in self.assembly_simulations + simulation.assembly.name for simulation in self.assembly_simulations ] parts_that_arent_assembled = set(all_parts).difference(set(assemblies)) return sorted(parts_that_arent_assembled) From 7bc87cc9db1924e593c7ec27978f4ae9653aedd6 Mon Sep 17 00:00:00 2001 From: Peter Vegh Date: Thu, 1 Oct 2020 16:10:13 +0100 Subject: [PATCH 04/10] PDF report generation draft `AssemblyReportWriter()` parameter `include_pdf_report=False` added. --- .../AssemblyReportWriter.py | 5 ++ .../AssemblyPlan/AssemblyPlanSimulation.py | 18 +++++++ dnacauldron/__init__.py | 3 ++ .../report_assets/domestication_report.pug | 34 ++++++++++++++ dnacauldron/report_assets/imgs/logo.png | Bin 0 -> 19541 bytes dnacauldron/report_assets/report_style.css | 29 ++++++++++++ dnacauldron/reports.py | 44 ++++++++++++++++++ 7 files changed, 133 insertions(+) create mode 100644 dnacauldron/report_assets/domestication_report.pug create mode 100644 dnacauldron/report_assets/imgs/logo.png create mode 100644 dnacauldron/report_assets/report_style.css create mode 100644 dnacauldron/reports.py diff --git a/dnacauldron/Assembly/AssemblyReportWriter/AssemblyReportWriter.py b/dnacauldron/Assembly/AssemblyReportWriter/AssemblyReportWriter.py index 82c4414..0210047 100644 --- a/dnacauldron/Assembly/AssemblyReportWriter/AssemblyReportWriter.py +++ b/dnacauldron/Assembly/AssemblyReportWriter/AssemblyReportWriter.py @@ -53,6 +53,9 @@ class AssemblyReportWriter(AssemblyReportPlotsMixin): include_warnings_spreadsheet If true and there are warnings, a warnings spreadsheet will be added to the report. + + include_pdf_report + If true, a PDF report file is also generated. """ def __init__( @@ -66,6 +69,7 @@ def __init__( annotate_parts_homologies=True, include_errors_spreadsheet=True, include_warnings_spreadsheet=True, + include_pdf_report=False, ): self.include_fragment_plots = include_fragment_plots self.include_part_plots = include_part_plots @@ -76,6 +80,7 @@ def __init__( self.annotate_parts_homologies = annotate_parts_homologies self.include_errors_spreadsheet = include_errors_spreadsheet self.include_warnings_spreadsheet = include_warnings_spreadsheet + self.include_pdf_report = include_pdf_report def _write_constructs_spreadsheet(self, simulation, report_root): dataframe = simulation.compute_summary_dataframe() diff --git a/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py b/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py index 462c4e3..13583d9 100644 --- a/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py +++ b/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py @@ -7,6 +7,14 @@ from .plot_leveled_graph import plot_leveled_graph import matplotlib.pyplot as plt +try: + import pdf_reports + + PDF_REPORTS_AVAILABLE = True +except ImportError: + PDF_REPORTS_AVAILABLE = False +from ..reports import write_pdf_domestication_report + class AssemblyPlanSimulation: def __init__( @@ -136,6 +144,16 @@ def write_report( self._write_all_required_parts_records(report_root) if not self.has_single_level: self._plot_assembly_graph(report_root) + + if assembly_report_writer.include_pdf_report: + if not PDF_REPORTS_AVAILABLE: + raise ImportError( + "Could not load PDF Reports. Install with `pip install pdf_reports`" + "to generate a PDF report." + ) + print("PDF record will be included.") + write_pdf_domestication_report(report_root._file("Report.pdf")) + if target == "@memory": return report_root._close() diff --git a/dnacauldron/__init__.py b/dnacauldron/__init__.py index 2b9a805..a6097cf 100644 --- a/dnacauldron/__init__.py +++ b/dnacauldron/__init__.py @@ -46,6 +46,9 @@ write_record, autoselect_enzyme, ) + +from .reports import write_pdf_domestication_report + from .utils import ( swap_donor_vector_part, insert_parts_on_backbones, diff --git a/dnacauldron/report_assets/domestication_report.pug b/dnacauldron/report_assets/domestication_report.pug new file mode 100644 index 0000000..bc97304 --- /dev/null +++ b/dnacauldron/report_assets/domestication_report.pug @@ -0,0 +1,34 @@ +#sidebar: p {{sidebar_text}} + +.logos + img(src="file:///{{ dc_logo_url }}") + img(src="file:///{{ egf_logo_url }}") + +hr +h1 DNA assembly simulation report +hr + +p. + The 'all_construct_records' folder contains the final assemblies and 'part_records' + (if generated) contains the original input Genbank files of all parts provided for + the assembly. There is one folder for each assembly, which contains: +ul + li The Genbank file of the assembly (.gb) + li A CSV file about the assembly + li PDF files with schematic views of how the parts assemble together + li Genbank files of the parts ('provided_parts_records' folder) + +p. + In addition, various summary text/csv files are provided about the simulation. + + +h2 Summary table + +{{ summary_table }} + +//- h2 Domesticators + +//- each domesticator in domesticators +//- .ui.segment.raised +//- .ui.title.ribbon.label.teal {{domesticator.name}} +//- .description {{ domesticator.html_details() }} diff --git a/dnacauldron/report_assets/imgs/logo.png b/dnacauldron/report_assets/imgs/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..28207d6e9a0d11ff07d09bfd40a8bdf5cf6a5714 GIT binary patch literal 19541 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 Q1=t^!7i`Td%zWbi2XTtsy#N3J literal 0 HcmV?d00001 diff --git a/dnacauldron/report_assets/report_style.css b/dnacauldron/report_assets/report_style.css new file mode 100644 index 0000000..2186452 --- /dev/null +++ b/dnacauldron/report_assets/report_style.css @@ -0,0 +1,29 @@ +.logos { + margin: 0 auto; +} + +h1.appendix { + page-break-before: always; +} + +h1 { + text-align: center; +} + +.ribbon { + margin-left: -2.3em !important; +} + +.description { + margin-top: 1em; +} + +table { + font-size: 0.6em !important; +} + +table img { + width: 1.5em; + /* height: 1em; */ + margin-right: 1em +} diff --git a/dnacauldron/reports.py b/dnacauldron/reports.py new file mode 100644 index 0000000..1ba1cbf --- /dev/null +++ b/dnacauldron/reports.py @@ -0,0 +1,44 @@ +from datetime import datetime +import os +import hashlib +from copy import deepcopy + +from Bio import SeqIO +import matplotlib.pyplot as plt +import pandas +import jinja2 +import weasyprint + +import flametree +from pdf_reports import ( + write_report, + pug_to_html, + dataframe_to_html, + style_table_rows, + add_css_class, +) + +from .version import __version__ + +THIS_PATH = os.path.dirname(os.path.realpath(__file__)) +ASSETS_PATH = os.path.join(THIS_PATH, "report_assets") +DOMESTICATION_REPORT_TEMPLATE = os.path.join(ASSETS_PATH, "domestication_report.pug") +STYLESHEET = os.path.join(ASSETS_PATH, "report_style.css") + + +def dnacauldron_pug_to_html(template, **context): + now = datetime.now().strftime("%Y-%m-%d") + defaults = { + "sidebar_text": "Generated on %s by DNA Cauldron version %s" + % (now, __version__), + "dc_logo_url": os.path.join(ASSETS_PATH, "imgs", "logo.png"), + } + for k in defaults: + if k not in context: + context[k] = defaults[k] + return pug_to_html(template, **context) + + +def write_pdf_domestication_report(target): + html = dnacauldron_pug_to_html(DOMESTICATION_REPORT_TEMPLATE,) + write_report(html, target, extra_stylesheets=(STYLESHEET,)) From 35fde2e1fb0be5ae2dfe3578d7762547ce7e6a11 Mon Sep 17 00:00:00 2001 From: Peter Vegh Date: Fri, 2 Oct 2020 14:50:01 +0100 Subject: [PATCH 05/10] Fix Travis CI Removed unused imports Install pdf_reports --- .travis.yml | 2 +- dnacauldron/reports.py | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index e425cd7..d8dfd98 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ python: - "3.6" # command to install dependencies install: - - pip install coveralls geneblocks pytest-cov==2.6 pytest==3.2.3 + - pip install coveralls geneblocks pdf_reports pytest-cov==2.6 pytest==3.2.3 - pip install -e . - sudo apt-get install ncbi-blast+ # command to run tests diff --git a/dnacauldron/reports.py b/dnacauldron/reports.py index 1ba1cbf..c1df653 100644 --- a/dnacauldron/reports.py +++ b/dnacauldron/reports.py @@ -1,21 +1,9 @@ from datetime import datetime import os -import hashlib -from copy import deepcopy -from Bio import SeqIO -import matplotlib.pyplot as plt -import pandas -import jinja2 -import weasyprint - -import flametree from pdf_reports import ( write_report, pug_to_html, - dataframe_to_html, - style_table_rows, - add_css_class, ) from .version import __version__ From ad33addd9114af0cb9a84f09ff3f6b1932d57a9c Mon Sep 17 00:00:00 2001 From: Peter Vegh Date: Fri, 2 Oct 2020 15:53:50 +0100 Subject: [PATCH 06/10] Test pdf report generation --- tests/test_reports.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/test_reports.py diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 0000000..214ad23 --- /dev/null +++ b/tests/test_reports.py @@ -0,0 +1,26 @@ +import os +import dnacauldron as dc + +this_directory = os.path.join("tests", "test_hierarchical_type2s") +parts_folder = os.path.join(this_directory, "parts") + + +def test_single_assembly(tmpdir): + repository = dc.SequenceRepository() + repository.import_records(folder=parts_folder, use_file_names_as_ids=True) + assembly_plan = dc.AssemblyPlan.from_spreadsheet( + assembly_class=dc.Type2sRestrictionAssembly, + path=os.path.join(this_directory, "type2s_two-level.csv"), + ) + plan_simulation = assembly_plan.simulate(sequence_repository=repository) + stats = plan_simulation.compute_stats() + report_writer = dc.AssemblyReportWriter( + include_fragment_plots=False, + include_part_plots=False, + include_mix_graphs=False, + include_assembly_plots=False, + show_overhangs_in_graph=False, + annotate_parts_homologies=False, + include_pdf_report=True, + ) + plan_simulation.write_report(target="@memory", assembly_report_writer=report_writer) From 049e70692fc8aff7b64f6980750f476b699313c5 Mon Sep 17 00:00:00 2001 From: Peter Vegh Date: Fri, 2 Oct 2020 16:51:36 +0100 Subject: [PATCH 07/10] Added summary table to pdf report (draft) Added AssemblyPlanSimulation()._calculate_simulation_info() Also renamed write_pdf_domestication_report() to write_simulation_pdf_report() --- .../AssemblyPlan/AssemblyPlanSimulation.py | 29 +++++++++++++++++-- dnacauldron/__init__.py | 2 +- dnacauldron/reports.py | 10 +++++-- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py b/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py index 13583d9..f69f9a5 100644 --- a/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py +++ b/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py @@ -13,7 +13,7 @@ PDF_REPORTS_AVAILABLE = True except ImportError: PDF_REPORTS_AVAILABLE = False -from ..reports import write_pdf_domestication_report +from ..reports import write_simulation_pdf_report class AssemblyPlanSimulation: @@ -149,10 +149,14 @@ def write_report( if not PDF_REPORTS_AVAILABLE: raise ImportError( "Could not load PDF Reports. Install with `pip install pdf_reports`" - "to generate a PDF report." + " to generate a PDF report." ) print("PDF record will be included.") - write_pdf_domestication_report(report_root._file("Report.pdf")) + + simulation_info = self._calculate_simulation_info() + write_simulation_pdf_report( + report_root._file("Report.pdf"), simulation_info + ) if target == "@memory": return report_root._close() @@ -299,3 +303,22 @@ def _write_assembly_plan_spreadsheets(self, report_root): f = report_root._file(file_name) lines = [",".join([c] + parts) for c, parts in construct_parts] f.write("\n".join(["construct, parts"] + lines)) + + def _calculate_simulation_info(self): + stats_dict = self.compute_stats() + stats_dict_series = { + "Outcome": pandas.Series( + ["Valid assemblies", "Cancelled assemblies", "Errored assemblies"] + ), + "Number": pandas.Series( + [ + stats_dict["valid_assemblies"], + stats_dict["errored_assemblies"], + stats_dict["errored_assemblies"], + ] + ), + } + + stats_df = pandas.DataFrame(stats_dict_series) + + return stats_df diff --git a/dnacauldron/__init__.py b/dnacauldron/__init__.py index a6097cf..b1b4453 100644 --- a/dnacauldron/__init__.py +++ b/dnacauldron/__init__.py @@ -47,7 +47,7 @@ autoselect_enzyme, ) -from .reports import write_pdf_domestication_report +from .reports import write_simulation_pdf_report from .utils import ( swap_donor_vector_part, diff --git a/dnacauldron/reports.py b/dnacauldron/reports.py index c1df653..1213c46 100644 --- a/dnacauldron/reports.py +++ b/dnacauldron/reports.py @@ -2,8 +2,9 @@ import os from pdf_reports import ( - write_report, + dataframe_to_html, pug_to_html, + write_report, ) from .version import __version__ @@ -27,6 +28,9 @@ def dnacauldron_pug_to_html(template, **context): return pug_to_html(template, **context) -def write_pdf_domestication_report(target): - html = dnacauldron_pug_to_html(DOMESTICATION_REPORT_TEMPLATE,) +def write_simulation_pdf_report(target, simulation_info): + summary_table = dataframe_to_html(simulation_info, extra_classes=("definition",)) + html = dnacauldron_pug_to_html( + DOMESTICATION_REPORT_TEMPLATE, summary_table=summary_table + ) write_report(html, target, extra_stylesheets=(STYLESHEET,)) From 7e27ac0b09cd06237802c670959d62dd39b5f804 Mon Sep 17 00:00:00 2001 From: Peter Vegh Date: Fri, 16 Oct 2020 12:24:26 +0100 Subject: [PATCH 08/10] pdf_reports dependency added --- README.rst | 8 +++++--- setup.py | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index a25fd4f..3bdfeed 100644 --- a/README.rst +++ b/README.rst @@ -142,22 +142,24 @@ The simulation and reporting on an assembly plan is very similar to that of a si # Write a detailed report on each assembly and on the plan as a whole plan_simulation.write_report("my_assembly_simulation.zip") + Installation ------------- -You can install DnaCauldron through PIP - +You can install DnaCauldron through PIP: .. code:: shell sudo pip install dnacauldron -Alternatively, you can unzip the sources in a folder and type +The full installation using `dnacauldron[reports]` is required for report generation. +Alternatively, you can unzip the sources in a folder and type: .. code:: shell sudo python setup.py install + How it works ------------ diff --git a/setup.py b/setup.py index d2be687..b91c084 100644 --- a/setup.py +++ b/setup.py @@ -34,4 +34,5 @@ "python-Levenshtein", "xlrd", ], + extras_require={"reports": ["pdf_reports"]}, ) From 4e3689d68508a08708c4391261cf7ec100fddb58 Mon Sep 17 00:00:00 2001 From: Peter Vegh Date: Fri, 16 Oct 2020 16:16:21 +0100 Subject: [PATCH 09/10] Finalise report formatting --- .../AssemblyPlan/AssemblyPlanSimulation.py | 9 +++------ .../report_assets/domestication_report.pug | 6 +++--- dnacauldron/reports.py | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py b/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py index f69f9a5..d39e22f 100644 --- a/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py +++ b/dnacauldron/AssemblyPlan/AssemblyPlanSimulation.py @@ -151,7 +151,6 @@ def write_report( "Could not load PDF Reports. Install with `pip install pdf_reports`" " to generate a PDF report." ) - print("PDF record will be included.") simulation_info = self._calculate_simulation_info() write_simulation_pdf_report( @@ -307,13 +306,11 @@ def _write_assembly_plan_spreadsheets(self, report_root): def _calculate_simulation_info(self): stats_dict = self.compute_stats() stats_dict_series = { - "Outcome": pandas.Series( - ["Valid assemblies", "Cancelled assemblies", "Errored assemblies"] - ), - "Number": pandas.Series( + "Outcome": pandas.Series(["Valid", "Cancelled", "Errored"]), + "Number of assemblies": pandas.Series( [ stats_dict["valid_assemblies"], - stats_dict["errored_assemblies"], + stats_dict["cancelled_assemblies"], stats_dict["errored_assemblies"], ] ), diff --git a/dnacauldron/report_assets/domestication_report.pug b/dnacauldron/report_assets/domestication_report.pug index bc97304..a83584f 100644 --- a/dnacauldron/report_assets/domestication_report.pug +++ b/dnacauldron/report_assets/domestication_report.pug @@ -15,11 +15,11 @@ p. ul li The Genbank file of the assembly (.gb) li A CSV file about the assembly - li PDF files with schematic views of how the parts assemble together - li Genbank files of the parts ('provided_parts_records' folder) + li PDF files with schematic views of how the parts assemble together (if generated) + li Genbank files of the parts, in the 'provided_parts_records' folder p. - In addition, various summary text/csv files are provided about the simulation. + In addition, various summary text and csv files are provided about the simulation. h2 Summary table diff --git a/dnacauldron/reports.py b/dnacauldron/reports.py index 1213c46..57463a9 100644 --- a/dnacauldron/reports.py +++ b/dnacauldron/reports.py @@ -3,6 +3,8 @@ from pdf_reports import ( dataframe_to_html, + style_table_rows, + add_css_class, pug_to_html, write_report, ) @@ -30,6 +32,21 @@ def dnacauldron_pug_to_html(template, **context): def write_simulation_pdf_report(target, simulation_info): summary_table = dataframe_to_html(simulation_info, extra_classes=("definition",)) + + def tr_modifier(tr): + tds = list(tr.find_all("td")) + if len(tds) == 0: + return + outcome, number = tds + if outcome.text == "Valid": + if number.text == "0": + add_css_class(tr, "negative") + else: + add_css_class(tr, "positive") + elif number.text != "0": + add_css_class(tr, "negative") + + summary_table = style_table_rows(summary_table, tr_modifier) html = dnacauldron_pug_to_html( DOMESTICATION_REPORT_TEMPLATE, summary_table=summary_table ) From 8f45aa4a1bbdd548d4137db14402f5ab267fefb6 Mon Sep 17 00:00:00 2001 From: Peter Vegh Date: Fri, 16 Oct 2020 16:17:29 +0100 Subject: [PATCH 10/10] v2.0.3 Added `AssemblyReportWriter()` parameter `include_pdf_report=False`. If `True`, then a PDF summary is created. --- dnacauldron/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnacauldron/version.py b/dnacauldron/version.py index 0309ae2..5fa9130 100644 --- a/dnacauldron/version.py +++ b/dnacauldron/version.py @@ -1 +1 @@ -__version__ = "2.0.2" +__version__ = "2.0.3"