From 02aa6bce0d1b99aa1d1768b81bf3312a67a57896 Mon Sep 17 00:00:00 2001 From: "Salvador E. Tropea" Date: Thu, 24 Nov 2022 11:32:47 -0300 Subject: [PATCH] [BoM][Added] Human readable text output format. Closes #334 --- README.md | 13 +++- docs/samples/generic_plot.kibot.yaml | 17 ++++- kibot/bom/bom_writer.py | 10 +-- kibot/bom/csv_writer.py | 106 +++++++++++++++++++++++---- kibot/out_bom.py | 41 ++++++++++- 5 files changed, 159 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 165c74c8..646e8e88 100644 --- a/README.md +++ b/README.md @@ -1443,13 +1443,22 @@ Notes: - `hide_header`: [boolean=false] Hide the header line (names of the columns). - `hide_pcb_info`: [boolean=false] Hide project information. - `hide_stats_info`: [boolean=false] Hide statistics information. - - **`format`**: [string=''] [HTML,CSV,TXT,TSV,XML,XLSX] format for the BoM. - Defaults to CSV or a guess according to the options.. + - **`format`**: [string=''] [HTML,CSV,TXT,TSV,XML,XLSX,HRTXT] format for the BoM. + Defaults to CSV or a guess according to the options. + HRTXT stands for Human Readable TeXT. - **`group_fields`**: [list(string)] List of fields used for sorting individual components into groups. Components which match (comparing *all* fields) will be grouped together. Field names are case-insensitive. If empty: ['Part', 'Part Lib', 'Value', 'Footprint', 'Footprint Lib', 'Voltage', 'Tolerance', 'Current', 'Power'] is used. + - **`hrtxt`**: [dict] Options for the HRTXT formats. + * Valid keys: + - **`separator`**: [string='I'] Column Separator. + - `header_sep`: [string='-'] Separator between the header and the data. + - `hide_header`: [boolean=false] Hide the header line (names of the columns). + - `hide_pcb_info`: [boolean=false] Hide project information. + - `hide_stats_info`: [boolean=false] Hide statistics information. + - `justify`: [string='left'] [left,right,center] Text justification. - **`html`**: [dict] Options for the HTML format. * Valid keys: - **`datasheet_as_link`**: [string=''] Column with links to the datasheet. diff --git a/docs/samples/generic_plot.kibot.yaml b/docs/samples/generic_plot.kibot.yaml index d6b30bcd..10522a20 100644 --- a/docs/samples/generic_plot.kibot.yaml +++ b/docs/samples/generic_plot.kibot.yaml @@ -244,8 +244,9 @@ outputs: footprint_populate_values: 'no,yes' # [string|list(string)='SMD,THT,VIRTUAL'] Values for the `Footprint Type` column footprint_type_values: 'SMD,THT,VIRTUAL' - # [string=''] [HTML,CSV,TXT,TSV,XML,XLSX] format for the BoM. + # [string=''] [HTML,CSV,TXT,TSV,XML,XLSX,HRTXT] format for the BoM. # Defaults to CSV or a guess according to the options. + # HRTXT stands for Human Readable TeXT format: 'CSV' # [boolean=true] Connectors with the same footprints will be grouped together, independent of the name of the connector group_connectors: true @@ -258,6 +259,20 @@ outputs: # [list(string)] List of fields to be used when the fields in `group_fields` are empty. # The first field in this list is the fallback for the first in `group_fields`, and so on group_fields_fallbacks: + # [dict] Options for the HRTXT formats + hrtxt: + # [string='-'] Separator between the header and the data + header_sep: '-' + # [boolean=false] Hide the header line (names of the columns) + hide_header: false + # [boolean=false] Hide project information + hide_pcb_info: false + # [boolean=false] Hide statistics information + hide_stats_info: false + # [string='left'] [left,right,center] Text justification + justify: 'left' + # [string='I'] Column Separator + separator: 'I' # [dict] Options for the HTML format html: # [boolean=true] Use colors to show the field type diff --git a/kibot/bom/bom_writer.py b/kibot/bom/bom_writer.py index 9e47d940..0b47ea9c 100644 --- a/kibot/bom/bom_writer.py +++ b/kibot/bom/bom_writer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2020 Salvador E. Tropea -# Copyright (c) 2020 Instituto Nacional de TecnologĂ­a Industrial +# Copyright (c) 2020-2022 Salvador E. Tropea +# Copyright (c) 2020-2022 Instituto Nacional de TecnologĂ­a Industrial # Copyright (c) 2016-2020 Oliver Henry Walters (@SchrodingersGat) # License: MIT # Project: KiBot (formerly KiPlot) @@ -35,13 +35,13 @@ def write_bom(filename, ext, groups, headings, cfg): headings = [h.lower() for h in headings] result = False # CSV file writing - if ext in ["csv", "tsv", "txt"]: + if ext in ["csv", "tsv", "txt", "hrtxt"]: result = write_csv(filename, ext, groups, headings, head_names, cfg) elif ext in ["htm", "html"]: result = write_html(filename, groups, headings, head_names, cfg) - elif ext in ["xml"]: + elif ext == "xml": result = write_xml(filename, groups, headings, head_names, cfg) - elif ext in ["xlsx"]: + elif ext == "xlsx": # We delay the module load to give out_bom the chance to install XLSXWriter dependencies from .xlsx_writer import write_xlsx result = write_xlsx(filename, groups, headings, head_names, cfg) diff --git a/kibot/bom/csv_writer.py b/kibot/bom/csv_writer.py index b38b73f4..7d3b0352 100644 --- a/kibot/bom/csv_writer.py +++ b/kibot/bom/csv_writer.py @@ -9,44 +9,101 @@ CSV Writer: Generates a CSV, TSV or TXT BoM file. """ import csv +ALIGN_CODE = {'right': '>', 'left': '<', 'center': '^'} -def write_stats(writer, cfg): +class HRTXT(object): + def __init__(self, fh, delimiter=',', hsep='-', align='left'): + self.f = fh + self.delimiter = delimiter + self.hsep = hsep + self.cols_w = [] + self.data = [] + self.align = ALIGN_CODE[align] + + def writerow(self, row): + self.data.append(row) + for c, d in enumerate(row): + d = str(d) + l_cell = len(d) + try: + self.cols_w[c] = max(self.cols_w[c], l_cell) + except IndexError: + self.cols_w.append(l_cell) + + def add_sep(self): + self.data.append(None) + + def flush(self): + self.col_fmt = [] + for ln in self.cols_w: + self.col_fmt.append("{:"+self.align+str(ln)+"s}") + prev = None + for r in self.data: + # Is a separator? + if r is None: + # Skip if we don't want separators + if not self.hsep: + continue + # Create a fake row using the separator + r = [] + for _, ln in zip(prev, self.cols_w): + r.append(self.hsep*ln) + if len(r): + self.f.write(self.delimiter) + for cell, fmt in zip(r, self.col_fmt): + cell = str(cell) + self.f.write(fmt.format(cell.replace("\n", self.delimiter))) + self.f.write(self.delimiter) + self.f.write("\n") + prev = r + + +def write_stats(writer, cfg, ops, write_sep): if len(cfg.aggregate) == 1: # Only one project - if not cfg.csv.hide_pcb_info: + if not ops.hide_pcb_info: prj = cfg.aggregate[0] writer.writerow(["Project info:"]) + write_sep() writer.writerow(["Schematic:", prj.name]) writer.writerow(["Variant:", cfg.variant.name]) writer.writerow(["Revision:", prj.sch.revision]) writer.writerow(["Date:", prj.sch.date]) writer.writerow(["KiCad Version:", cfg.kicad_version]) - if not cfg.csv.hide_stats_info: + write_sep() + if not ops.hide_stats_info: writer.writerow(["Statistics:"]) + write_sep() writer.writerow(["Component Groups:", cfg.n_groups]) writer.writerow(["Component Count:", cfg.total_str]) writer.writerow(["Fitted Components:", cfg.fitted_str]) writer.writerow(["Number of PCBs:", cfg.number]) writer.writerow(["Total Components:", cfg.n_build]) + write_sep() else: # Multiple projects - if not cfg.csv.hide_pcb_info: + if not ops.hide_pcb_info: prj = cfg.aggregate[0] writer.writerow(["Project info:"]) + write_sep() writer.writerow(["Variant:", cfg.variant.name]) writer.writerow(["KiCad Version:", cfg.kicad_version]) - if not cfg.csv.hide_stats_info: + write_sep() + if not ops.hide_stats_info: writer.writerow(["Global statistics:"]) + write_sep() writer.writerow(["Component Groups:", cfg.n_groups]) writer.writerow(["Component Count:", cfg.total_str]) writer.writerow(["Fitted Components:", cfg.fitted_str]) writer.writerow(["Number of PCBs:", cfg.number]) writer.writerow(["Total Components:", cfg.n_build]) + write_sep() # Individual stats for prj in cfg.aggregate: - if not cfg.csv.hide_pcb_info: + if not ops.hide_pcb_info: writer.writerow(["Project info:", prj.sch.title]) + write_sep() writer.writerow(["Schematic:", prj.name]) writer.writerow(["Revision:", prj.sch.revision]) writer.writerow(["Date:", prj.sch.date]) @@ -54,13 +111,20 @@ def write_stats(writer, cfg): writer.writerow(["Company:", prj.sch.company]) if prj.ref_id: writer.writerow(["ID", prj.ref_id]) - if not cfg.csv.hide_stats_info: + write_sep() + if not ops.hide_stats_info: writer.writerow(["Statistics:", prj.sch.title]) + write_sep() writer.writerow(["Component Groups:", prj.comp_groups]) writer.writerow(["Component Count:", prj.total_str]) writer.writerow(["Fitted Components:", prj.fitted_str]) writer.writerow(["Number of PCBs:", prj.number]) writer.writerow(["Total Components:", prj.comp_build]) + write_sep() + + +def dummy(): + pass def write_csv(filename, ext, groups, headings, head_names, cfg): @@ -72,37 +136,47 @@ def write_csv(filename, ext, groups, headings, head_names, cfg): head_names = [list of headings to display in the BoM file] cfg = BoMOptions object with all the configuration """ + is_hrtxt = ext == "hrtxt" + ops = cfg.hrtxt if is_hrtxt else cfg.csv # Delimiter is assumed from file extension # Override delimiter if separator specified - if ext == "csv" and cfg.csv.separator: - delimiter = cfg.csv.separator + if is_hrtxt or (ext == "csv" and ops.separator): + delimiter = ops.separator else: if ext == "csv": delimiter = "," elif ext == "tsv" or ext == "txt": delimiter = "\t" - if cfg.csv.quote_all: + quoting = csv.QUOTE_MINIMAL + if hasattr(ops, 'quote_all') and ops.quote_all: quoting = csv.QUOTE_ALL - else: - quoting = csv.QUOTE_MINIMAL + with open(filename, "wt") as f: - writer = csv.writer(f, delimiter=delimiter, lineterminator="\n", quoting=quoting) + if is_hrtxt: + writer = HRTXT(f, delimiter=delimiter, hsep=ops.header_sep, align=ops.justify) + else: + writer = csv.writer(f, delimiter=delimiter, lineterminator="\n", quoting=quoting) + write_sep = writer.add_sep if is_hrtxt else dummy # Headers - if not cfg.csv.hide_header: + if not ops.hide_header: writer.writerow(head_names) + write_sep() # Body for group in groups: if cfg.ignore_dnf and not group.is_fitted(): continue row = group.get_row(headings) writer.writerow(row) + write_sep() # PCB info - if not (cfg.csv.hide_pcb_info and cfg.csv.hide_stats_info): + if not (ops.hide_pcb_info and ops.hide_stats_info): # Add some blank rows for _ in range(5): writer.writerow([]) # The info - write_stats(writer, cfg) + write_stats(writer, cfg, ops, write_sep) + if is_hrtxt: + writer.flush() return True diff --git a/kibot/out_bom.py b/kibot/out_bom.py index 13d38845..c041033d 100644 --- a/kibot/out_bom.py +++ b/kibot/out_bom.py @@ -269,6 +269,33 @@ class BoMCSV(Optionable): raise KiPlotConfigurationError('The CSV separator must be one character (`{}`)'.format(self.separator)) +class BoMTXT(Optionable): + """ HRTXT options """ + def __init__(self): + super().__init__() + with document: + self.separator = 'I' + """ *Column Separator """ + self.header_sep = '-' + """ Separator between the header and the data """ + self.justify = 'left' + """ [left,right,center] Text justification """ + self.hide_header = False + """ Hide the header line (names of the columns) """ + self.hide_pcb_info = False + """ Hide project information """ + self.hide_stats_info = False + """ Hide statistics information """ + + def config(self, parent): + super().config(parent) + if self.separator: + self.separator = self.separator.replace(r'\t', '\t') + self.separator = self.separator.replace(r'\n', '\n') + self.separator = self.separator.replace(r'\r', '\r') + self.separator = self.separator.replace(r'\\', '\\') + + class BoMXLSX(BoMLinkable): """ XLSX options """ def __init__(self): @@ -411,8 +438,9 @@ class BoMOptions(BaseOptions): self.output = GS.def_global_output """ *filename for the output (%i=bom)""" self.format = '' - """ *[HTML,CSV,TXT,TSV,XML,XLSX] format for the BoM. - Defaults to CSV or a guess according to the options. """ + """ *[HTML,CSV,TXT,TSV,XML,XLSX,HRTXT] format for the BoM. + Defaults to CSV or a guess according to the options. + HRTXT stands for Human Readable TeXT """ # Equivalent to KiBoM INI: self.ignore_dnf = True """ *Exclude DNF (Do Not Fit) components """ @@ -438,6 +466,8 @@ class BoMOptions(BaseOptions): """ *[dict] Options for the XLSX format """ self.csv = BoMCSV """ *[dict] Options for the CSV, TXT and TSV formats """ + self.hrtxt = BoMTXT + """ *[dict] Options for the HRTXT formats """ # * Filters self.pre_transform = Optionable """ [string|list(string)='_none'] Name of the filter to transform fields before applying other filters. @@ -553,6 +583,9 @@ class BoMOptions(BaseOptions): # Same for XLSX if not isinstance(self.xlsx, type): return 'xlsx' + # Same for HRTXT + if not isinstance(self.hrtxt, type): + return 'hrtxt' # Default to a simple and common format: CSV return 'csv' # Explicit selection @@ -639,7 +672,7 @@ class BoMOptions(BaseOptions): super().config(parent) self.format = self._guess_format() self._expand_id = 'bom' - self._expand_ext = self.format.lower() + self._expand_ext = 'txt' if self.format.lower() == 'hrtxt' else self.format.lower() # HTML options if self.format == 'html' and isinstance(self.html, type): # If no options get the defaults @@ -1018,7 +1051,7 @@ class BoM(BaseOutput): # noqa: F821 if join_fields: logger.debug(' - Fields to join with Value: {}'.format(join_fields)) # Create a generic version - SIMP_FMT = ['HTML', 'CSV', 'TXT', 'TSV', 'XML'] + SIMP_FMT = ['HTML', 'CSV', 'HRTXT', 'TSV', 'XML'] XYRS_FMT = ['HTML'] if GS.check_tool(name, 'XLSXWriter') is not None: SIMP_FMT.append('XLSX')