def get_row(row_number: int, referencing_row: Row, referencing_column: Column) -> Row: """ Return the row at a row index. """ context = os.path.basename(referencing_row.data_path) from_row_index = referencing_row.row_index from_column_name = referencing_column.name # when looking at rows in a CSV they are not zero-based, and the first row # is always the headers, which makes the first row of actual data (that # you see) appear visually at row #2, like for example: # #1 'rank,title' # #2 '2,My Card' # #2 '4,My Other Card' # however, of course, when reading from the file, the first row is # actually at index 0, so we have to take this into account # this logic essentially makes '#0' and '#1' invalid row numbers line_number = row_number - 2 if line_number < 0: if row_number == 1: # special case- user probably meant the first data row (which is always #2) WarningDisplay.referencing_row_header( WarningContext(context, row_index=from_row_index, column=from_column_name)) else: WarningDisplay.referencing_row_out_of_bounds( WarningContext(context, row_index=from_row_index, column=from_column_name), referenced_row_number=row_number) return None with open(referencing_row.data_path) as data_file_raw: data_file = FileWrapper(data_file_raw) # read data appropriately data = csv.DictReader(lower_first_row(data_file)) try: # then read rows until reaching the target row_number row_data = next(itertools.islice(data, line_number, None)) except StopIteration: WarningDisplay.referencing_row_out_of_bounds( WarningContext(context, row_index=from_row_index, column=from_column_name), referenced_row_number=row_number) else: if Row.is_excluded(data_file.raw_line): WarningDisplay.referencing_excluded_row( WarningContext(context, row_index=from_row_index, column=from_column_name), referenced_row_number=row_number) else: # create a new row with the data at the referenced row return Row(row_data, referencing_row.data_path)
def make(data_paths: list, header_path: str=None, definitions_path: str=None, output_path: str=None, output_filename: str=None, force_page_breaks: bool=False, should_disable_backs: bool=False, should_disable_page_sections: bool=False, default_card_size_identifier: str='standard', is_preview: bool=False, clean_unused_resources: bool=False): """ Build cards for all specified datasources. """ time_started_make = datetime.datetime.now() datasource_count = len(data_paths) exclude_datasource_named = (os.path.basename(definitions_path) if definitions_path is not None else None) if datasource_count == 0: # attempt finding any datasources in current working directory data_paths = discover_datasources(in_directory='.', except_datasource_name=exclude_datasource_named) datasource_count = len(data_paths) elif datasource_count > 0: # determine whether any datasources point to a directory for i, datasource_path in enumerate(data_paths): if os.path.isdir(datasource_path): # discover any datasources within the specified directory discovered_datasource_paths = discover_datasources( datasource_path, except_datasource_name=exclude_datasource_named) # replace the datasource directory with any datasources discovered within data_paths = data_paths[:i] + discovered_datasource_paths + data_paths[i + 1:] datasource_count = len(data_paths) if datasource_count > 0: data_path_names, duplicates_count = get_data_path_names(data_paths) duplicates = (' ({0} {1})'.format( duplicates_count, 'duplicate' if duplicates_count == 1 else 'duplicates') if duplicates_count > 0 else '') print('Generating cards from {0} {1}{2}:\n {3}'.format( datasource_count, 'datasources' if datasource_count > 1 else 'datasource', duplicates, data_path_names)) print() else: WarningDisplay.no_datasources() # just quit- there's nothing to do return disable_auto_templating = False if definitions_path is None: # no definitions file has been explicitly specified, so try looking for it automatically found, potential_definitions_path = find_file_path('definitions.csv', data_paths) if found and potential_definitions_path is not None: definitions_path = potential_definitions_path WarningDisplay.using_automatically_found_definitions_info( definitions_path) definitions = get_definitions_from_file(definitions_path) if is_preview: WarningDisplay.preview_enabled_info() # dict of all image paths discovered for each context during card generation context_image_paths = {} # some definitions are always guaranteed to be referenced, # if not by cards, then by the final page output all_referenced_definitions = {TemplateFields.TITLE, TemplateFields.DESCRIPTION, TemplateFields.COPYRIGHT, TemplateFields.AUTHOR, TemplateFields.VERSION} # resolve any image fields found in definitions image_paths_from_definitions = [] for definition, content in definitions.items(): # build a temporary template with the definition content template = Template(content) # fill any partial definitions, as this might reveal other stuff all_referenced_definitions |= fill_definitions(definitions, template) # fill any image fields within image_paths_in_definition = fill_image_fields(template) # store every path found image_paths_from_definitions.extend(image_paths_in_definition) # update definition with resolved content (note that we only pre-resolve image fields here # and any complex/partially defined image fields will not be resolved at this point) definitions[definition] = template.content if definitions_path is not None: image_paths_from_definitions = transformed_image_paths(image_paths_from_definitions, definitions_path) context_image_paths[definitions_path] = list(set(image_paths_from_definitions)) base_path = get_base_path() card_template_path = os.path.join(base_path, 'templates/base/card.html') card, filled_image_paths = get_template(card_template_path) if len(filled_image_paths) > 0: context_image_paths[card_template_path] = list(set(filled_image_paths)) page_template_path = os.path.join(base_path, 'templates/base/page.html') page, filled_image_paths = get_template(page_template_path) if len(filled_image_paths) > 0: context_image_paths[page_template_path] = list(set(filled_image_paths)) page_filler_template_path = os.path.join(base_path, 'templates/base/page_filler.html') page_filler, filled_image_paths = get_template(page_filler_template_path) if len(filled_image_paths) > 0: context_image_paths[page_filler_template_path] = list(set(filled_image_paths)) section_template_path = os.path.join(base_path, 'templates/base/section.html') section, filled_image_paths = get_template(section_template_path) if len(filled_image_paths) > 0: context_image_paths[section_template_path] = list(set(filled_image_paths)) index_template_path = os.path.join(base_path, 'templates/base/index.html') index, filled_image_paths = get_template(index_template_path) if len(filled_image_paths) > 0: context_image_paths[index_template_path] = list(set(filled_image_paths)) not_found_template_path = os.path.join(base_path, 'templates/base/error/could_not_open.html') with open(not_found_template_path) as error_template: template_not_opened = error_template.read() no_front_template_path = os.path.join(base_path, 'templates/base/error/not_provided.html') with open(no_front_template_path) as error_template: template_not_provided = error_template.read() no_back_template_path = os.path.join(base_path, 'templates/base/error/back_not_provided.html') with open(no_back_template_path) as error_template: template_back_not_provided = error_template.read() default_card_size = CardSizes.get_card_size(default_card_size_identifier) if default_card_size is None: default_card_size = CardSizes.get_default_card_size() WarningDisplay.bad_card_size( WarningContext(), size_identifier=default_card_size_identifier) # buffer that will contain at most MAX_CARDS_PER_PAGE amount of cards cards = '' # buffer that will contain at most MAX_CARDS_PER_PAGE amount of card backs backs = '' # buffer of a row of backs that is filled in reverse to support double-sided printing backs_row = '' # buffer for all generated pages pages = '' embedded_styles = {} # incremented each time a card is generated, but reset to 0 for each page cards_on_page = 0 # incremented each time a card is generated cards_total = 0 # incremented each time a page is generated pages_total = 0 # incremented for each unique card (i.e. not incremented for copies/duplicates) cards_total_unique = 0 # holds total card counts per datasource cards_total_per_context = {} previous_card_size = None page_size = CardSizes.get_page_size() pages_contain_backs = False if not should_disable_backs: # if pages should render card backs, we need to figure out if any datasources # actually *do* contain specifications for card back templates # if any do, we need to know this beforehand to handle the synchronization issue # with mixing non-back and back datasources for double-sided printing for data_path in data_paths: if not os.path.isfile(data_path): continue with open(data_path) as data_file: # read the first line which should contain the column names header_line = data_file.readline() if Columns.TEMPLATE_BACK in header_line: pages_contain_backs = True # we don't need to continue; we figured out that at least one datasource # should render card backs break previous_context = None contexts_per_page = [] disable_backs = should_disable_backs for data_path_index, data_path in enumerate(data_paths): # define the context as the base filename of the current data- useful when troubleshooting context = os.path.basename(data_path) card_size = default_card_size image_paths_from_datasource = [] # determine whether this path leads to anything if not os.path.isfile(data_path): # if it doesn't, warn that the path to the datasource is not right WarningDisplay.bad_data_path_error(WarningContext(context), data_path) # and skip this datasource continue cards_total_per_context[context] = 0 with open(data_path) as data_file_raw: # wrap the file stream to retain access to unparsed lines data_file = FileWrapper(data_file_raw) # read the csv as a dict, so that we can access each column by name data = csv.DictReader(lower_first_row(data_file)) # make a list of all column names as they are (but stripped of excess whitespace) column_names = [column_name.strip() for column_name in data.fieldnames] # then determine the size identifier (if any; e.g. '@template:jumbo') size_identifier, stripped_column_names = size_identifier_from_columns(column_names) # determine whether this datasource contains invalid columns invalid_column_names = get_invalid_columns(stripped_column_names) if len(invalid_column_names) > 0: # warn that this datasource will be skipped WarningDisplay.invalid_columns_error( WarningContext(context), invalid_column_names) continue # replace the column keys with stripped/parsed representations # (e.g. '@template:jumbo' becomes just '@template') data.fieldnames = stripped_column_names if size_identifier is not None: new_card_size = CardSizes.get_card_size(size_identifier) if new_card_size is not None: card_size = new_card_size else: WarningDisplay.bad_card_size( WarningContext(context), size_identifier) if card_size != previous_card_size: if cards_on_page > 0: # card sizing is different for this datasource, so any remaining cards # must be added to a new page at this point pages += get_page(pages_total + 1, cards, page, section, contexts_per_page, exclude_section=should_disable_page_sections) pages_total += 1 if not disable_backs: # using the last value of cards_per_row cards_on_last_row = cards_on_page % cards_per_row if cards_on_last_row is not 0: # less than MAX_CARDS_PER_ROW cards were added to the current line, so # we have to add additional blank filler cards to ensure correct layout remaining_backs = cards_per_row - cards_on_last_row while remaining_backs > 0: # keep adding empty filler card backs until we've filled a row backs_row = empty_back + backs_row remaining_backs -= 1 backs += backs_row backs_row = '' # fill another page with the backs pages += get_page(pages_total + 1, backs, page, section, contexts_per_page, is_card_backs=True, exclude_section=should_disable_page_sections) pages_total += 1 backs = '' if pages_contain_backs and disable_backs: # we know some pages with backs have been added, and we know that this # datasource does not contain any card backs, so in order to keep # two-sided printing in sync, we need to add a filler page # the filler page counts as a page full of backs, but contains content # that will not be printed (not even a footer) pages += get_page(pages_total + 1, '', page_filler, section, contexts_per_page, is_card_backs=True, is_filler=True, exclude_section=should_disable_page_sections) pages_total += 1 WarningDisplay.datasource_contains_filler_pages( WarningContext(previous_context)) # reset to prepare for the next page cards_on_page = 0 cards = '' # we're finished with the current datasource, and we'll be starting a new page # so we reset any saved contexts contexts_per_page = [] contexts_per_page.append(context) disable_backs = should_disable_backs contains_filler_pages = False card_width, card_height = card_size.size_in_inches page_width, page_height = page_size.size_in_inches cards_per_row = math.floor(page_width / card_width) cards_per_column = math.floor(page_height / card_height) max_cards_per_page = cards_per_column * cards_per_row if disable_auto_templating: default_template_content = None else: # get a fitting template by analyzing the content of the data default_template_content = template_from_data(data) # reset the iterator # (note how this is done directly on the file stream; i.e. not on the wrapper) data_file_raw.seek(0) # and start over data = csv.DictReader( lower_first_row(data_file), fieldnames=stripped_column_names) # setting fieldnames explicitly causes the first row # to be treated as data, so skip it next(data) if default_template_content is None and Columns.TEMPLATE not in data.fieldnames: WarningDisplay.missing_default_template( WarningContext(context)) if not disable_backs and Columns.TEMPLATE_BACK in data.fieldnames: WarningDisplay.assume_backs_info( WarningContext(context)) else: # there's no back templates specified; so we can't render any if not disable_backs: WarningDisplay.no_backs_info( WarningContext(context)) # so disable them completely disable_backs = True if not disable_backs: # empty backs may be necessary to fill in empty spots on a page to ensure # that the layout remains correct # note that we're using a completely empty template, except for the size class field empty_back = get_sized_card( '<div class="card {0}"></div>'.format( str(TemplateField(name=TemplateFields.CARD_SIZE))), size_class=card_size.style, content='') ambiguous_references = determine_ambiguous_references( set(stripped_column_names), set(definitions.keys())) if len(ambiguous_references) > 0: WarningDisplay.potential_ambiguous_references( WarningContext(context), list(ambiguous_references)) previous_template_path = None previous_template_path_back = None row_index = 1 for row_data in data: # since the column names counts as a row, and most editors # do not use a zero-based row index, the first row == 2 row_index += 1 if Row.is_excluded(data_file.raw_line): # this row should be ignored - so skip and continue # note that we still need to increment the row_index; # otherwise row references will be offset incorrectly continue row = Row(row_data, data_path, row_index) if row.is_prototype(): # prototype rows should be skipped, but since the skip is intentional, # we should not warn about it count = 0 else: count, indeterminable_count = row.determine_count() if indeterminable_count: WarningDisplay.indeterminable_count( WarningContext(context, row_index)) elif count == 0: # the count was explicitly set to 0, but as this might be a temporary thing, # we should warn about skipping this card WarningDisplay.card_was_skipped_intentionally_info( WarningContext(context, row_index)) if count > 100: # the count was unusually high; ask whether it's an error or not if WarningDisplay.abort_unusually_high_count( WarningContext(context, row_index), count): # it was an error, so break out and continue with the next card continue if count > 0 and is_preview: # only render 1 card unless it should be skipped count = 1 # determine which template to use for this card, if any template_path = row_data.get(Columns.TEMPLATE, None) template_path = previous_or_current_path( template_path, previous_template_path) previous_template_path = template_path if not disable_backs: template_path_back = row_data.get(Columns.TEMPLATE_BACK, None) template_path_back = previous_or_current_path( template_path_back, previous_template_path_back) previous_template_path_back = template_path_back if count == 0: # might as well move on to the next card- # this card should not count towards number of unique cards either # note, however, that we *do* want to register the template paths continue resolved_template_path = None if template_path is not None and len(template_path) > 0: template_content, not_found, resolved_template_path = template_from_path( template_path, relative_to_path=data_path) if not_found: template_content = template_not_opened WarningDisplay.bad_template_path_error( WarningContext(context, row_index), resolved_template_path, cards_affected=count) elif len(template_content) == 0: template_content = default_template_content WarningDisplay.empty_template( WarningContext(context, row_index), resolved_template_path, cards_affected=count) else: template_content = default_template_content if template_content is not None: WarningDisplay.using_auto_template( WarningContext(context, row_index), cards_affected=count) if template_content is None: template_content = template_not_provided WarningDisplay.missing_template_error( WarningContext(context, row_index), cards_affected=count) # build a template object # note that we apply the path *as is*; i.e. not the resolved path- this is done to # let any warning show the path to the template as it was defined in the data template_front = Template(template_content, template_path) embedded_styles[template_front.path] = strip_styles(template_front) stripped_template_content = template_front.content resolved_template_path_back = None if not disable_backs: template_back_content = None if template_path_back is not None and len(template_path_back) > 0: template_back_content, not_found, resolved_template_path_back = template_from_path( template_path_back, relative_to_path=data_path) if not_found: template_back_content = template_not_opened WarningDisplay.bad_template_path_error( WarningContext(context, row_index), resolved_template_path_back, is_back=True, cards_affected=count) elif len(template_back_content) == 0: WarningDisplay.empty_template( WarningContext(context, row_index), resolved_template_path_back, is_back_template=True, cards_affected=count) if template_back_content is None: template_back_content = template_back_not_provided template_back = Template(template_back_content, template_path_back) embedded_styles[template_back.path] = strip_styles(template_back) stripped_template_back_content = template_back.content # this is also the shared index for any instance of this card cards_total_unique += 1 for i in range(count): card_index = cards_total + 1 # since we're mutating the template for each card, we need to make a new one template_front = Template( stripped_template_content, resolved_template_path) card_content, render_data = fill_card( template_front, row.front_row(), card_index, cards_total_unique, definitions) if (template_front.content is not template_not_provided and template_front.content is not template_not_opened): if len(render_data.unused_fields) > 0: WarningDisplay.missing_fields_in_template( WarningContext(context, row_index), list(render_data.unused_fields), cards_affected=count) if len(render_data.unknown_fields) > 0: WarningDisplay.unknown_fields_in_template( WarningContext(context, row_index), list(render_data.unknown_fields), template_path, cards_affected=count) all_referenced_definitions |= render_data.referenced_definitions embedded_styles.update(render_data.embedded_styles) image_paths_from_datasource.extend(render_data.image_paths) current_card = get_sized_card( card, size_class=card_size.style, content=card_content) cards += current_card cards_on_page += 1 cards_total += 1 cards_total_per_context[context] += 1 if not disable_backs: template_back = Template( stripped_template_back_content, resolved_template_path_back) back_content, render_data = fill_card( template_back, row.back_row(), card_index, cards_total_unique, definitions) if (template_back.content is not template_back_not_provided and template_back.content is not template_not_opened): if len(render_data.unused_fields) > 0: WarningDisplay.missing_fields_in_template( WarningContext(context, row_index), list(render_data.unused_fields), is_back_template=True, cards_affected=count) if len(render_data.unknown_fields) > 0: WarningDisplay.unknown_fields_in_template( WarningContext(context, row_index), list(render_data.unknown_fields), template_path_back, is_back_template=True, cards_affected=count) all_referenced_definitions |= render_data.referenced_definitions embedded_styles.update(render_data.embedded_styles) image_paths_from_datasource.extend(render_data.image_paths) current_card_back = get_sized_card( card, size_class=card_size.style, content=back_content) # prepend this card back to the current line of backs backs_row = current_card_back + backs_row # card backs are prepended rather than appended to # ensure correct layout when printing doublesided if cards_on_page % cards_per_row is 0: # a line has been filled- append the 3 card backs # to the page in the right order backs += backs_row # reset to prepare for the next line backs_row = '' if cards_on_page == max_cards_per_page: # add another page full of cards pages += get_page(pages_total + 1, cards, page, section, contexts_per_page, exclude_section=should_disable_page_sections) pages_total += 1 if not disable_backs: # and one full of backs pages += get_page(pages_total + 1, backs, page, section, contexts_per_page, is_card_backs=True, exclude_section=should_disable_page_sections) pages_total += 1 # reset to prepare for the next page backs = '' if pages_contain_backs and disable_backs: pages += get_page(pages_total + 1, '', page_filler, section, contexts_per_page, is_card_backs=True, is_filler=True, exclude_section=should_disable_page_sections) pages_total += 1 contains_filler_pages = True # reset to prepare for the next page cards_on_page = 0 cards = '' # we're not necesarilly done with the current context, but any other context # should be cleared at this point contexts_per_page = [context] if (force_page_breaks or data_path is data_paths[-1]) and cards_on_page > 0: # in case we're forcing pagebreaks for each datasource, or we're on the last datasource # and there's still cards remaining, then do a pagebreak and fill those into a new page pages += get_page(pages_total + 1, cards, page, section, contexts_per_page, exclude_section=should_disable_page_sections) pages_total += 1 if not disable_backs: cards_on_last_row = cards_on_page % cards_per_row if cards_on_last_row is not 0: # less than MAX_CARDS_PER_ROW cards were added to the current line, # so we have to add additional blank filler cards to ensure a correct layout remaining_backs = cards_per_row - cards_on_last_row while remaining_backs > 0: # keep adding empty filler card backs until we've filled a row backs_row = empty_back + backs_row remaining_backs -= 1 backs += backs_row backs_row = '' # fill another page with the backs pages += get_page(pages_total + 1, backs, page, section, contexts_per_page, is_card_backs=True, exclude_section=should_disable_page_sections) pages_total += 1 backs = '' if pages_contain_backs and disable_backs: pages += get_page(pages_total + 1, '', page_filler, section, contexts_per_page, is_card_backs=True, is_filler=True, exclude_section=should_disable_page_sections) pages_total += 1 contains_filler_pages = True # reset to prepare for the next page cards_on_page = 0 cards = '' # we're finished with this context contexts_per_page = [] if contains_filler_pages: WarningDisplay.datasource_contains_filler_pages( WarningContext(context)) # temporary solution involving creating new Template object only used for fill_each, # could be prettier; refactor as part of #34 pages_template = Template(pages) fill_each(TemplateFields.CARDS_TOTAL_IN_CONTEXT, str(cards_total_per_context[context]), pages_template) pages = pages_template.content # store the card size that was just used, so we can determine # whether or not the size changes for the next datasource previous_card_size = card_size # ensure there are no duplicate image paths, since that would just # cause unnecessary copy operations context_image_paths[data_path] = list(set(image_paths_from_datasource)) previous_context = context # determine unused definitions, if any unused_definitions = list(set(definitions.keys()) - all_referenced_definitions) if len(unused_definitions) > 0: WarningDisplay.unused_definitions(unused_definitions) if output_path is None: # output to current working directory unless otherwise specified output_path = '' output_directory_name = 'generated' # construct the final output path output_path = os.path.join(output_path, output_directory_name) # ensure all directories exist or created if missing create_directories_if_necessary(output_path) output_filepath = os.path.join(output_path, output_filename) # begin writing pages to the output file (overwriting any existing file) with open(output_filepath, 'w') as result: styles = '' for template_path, style in embedded_styles.items(): styles = styles + '\n' + style if len(styles) > 0 else style header = '' if header_path is not None: try: with open(header_path) as header_file: header = header_file.read().strip() except IOError: WarningDisplay.bad_header_file_error(header_path) index, render_data = fill_index( index, styles, pages, header, pages_total, cards_total, definitions) if len(render_data.image_paths) > 0: image_paths_from_index = transformed_image_paths(render_data.image_paths, index_template_path) # we assume that any leftover images would have been from a definition context_image_paths[index_template_path] = list(set(image_paths_from_index)) result.write(index) css_path = os.path.join(output_path, 'css') js_path = os.path.join(output_path, 'js') resources_path = os.path.join(output_path, get_resources_path()) create_directories_if_necessary(css_path) create_directories_if_necessary(js_path) create_directories_if_necessary(resources_path) copy_file_if_necessary(os.path.join(base_path, 'templates/base/css/cards.css'), os.path.join(css_path, 'cards.css')) copy_file_if_necessary(os.path.join(base_path, 'templates/base/css/index.css'), os.path.join(css_path, 'index.css')) copy_file_if_necessary(os.path.join(base_path, 'templates/base/js/index.js'), os.path.join(js_path, 'index.js')) all_copied_image_filenames = [] # additionally, copy all referenced images to the output directory for context in context_image_paths: image_paths = context_image_paths[context] image_filenames = [os.path.basename(image_path) for image_path in image_paths] copy_images_to_output_directory( image_paths, context, output_path) all_copied_image_filenames.extend(image_filenames) unused_resources, unused_resource_paths = get_unused_resources( output_path, all_copied_image_filenames) if len(unused_resources) > 0: if clean_unused_resources: for unused_resource_path in unused_resource_paths: os.remove(unused_resource_path) WarningDisplay.unused_resources_were_cleaned( unused_resources, in_resource_dir=resources_path) else: WarningDisplay.unused_resources( unused_resources, in_resource_dir=resources_path) output_location_message = (' -> \033[4m\'{0}\'\033[0m'.format(output_filepath) if terminal_supports_color() else ' -> \'{0}\''.format(output_filepath)) # get the grammar right errors_or_error = 'error' if WarningDisplay.error_count == 1 else 'errors' warnings_or_warning = 'warning' if WarningDisplay.warning_count == 1 else 'warnings' warnings_and_errors_message = (' ({0} {1}, {2} {3}{4})' .format(WarningDisplay.error_count, errors_or_error, WarningDisplay.warning_count, warnings_or_warning, ('; set --verbose for more' if not WarningDisplay.is_verbose else '')) if WarningDisplay.has_encountered_errors() or WarningDisplay.has_encountered_warnings() else '') now = datetime.datetime.now() time_difference = now - time_started_make time_difference_in_seconds = time_difference / timedelta(seconds=1) if WarningDisplay.has_displayed_messages(): # break line to separate next output print() print('[{0}] Finished in {1:.3f} seconds{2}'.format( '-' if not WarningDisplay.has_encountered_errors() else '!', time_difference_in_seconds, warnings_and_errors_message)) print() # find the total size of the generated directory generated_directory_size = pretty_size(directory_size(output_path)) if cards_total > 0: # get the grammar right pages_or_page = 'pages' if pages_total > 1 else 'page' cards_or_card = 'cards' if cards_total > 1 else 'card' if cards_total > cards_total_unique: print('Generated {0} ({1} unique) {2} on {3} {4} ({5})\n{6}' .format(cards_total, cards_total_unique, cards_or_card, pages_total, pages_or_page, generated_directory_size, output_location_message)) else: print('Generated {0} {1} on {2} {3} ({4})\n{5}' .format(cards_total, cards_or_card, pages_total, pages_or_page, generated_directory_size, output_location_message)) else: print('Generated 0 cards ({0})\n{1}' .format(generated_directory_size, output_location_message)) print() open_path(output_path)
def make( data_paths: list, header_path: str = None, definitions_path: str = None, output_path: str = None, output_filename: str = None, force_page_breaks: bool = False, should_disable_backs: bool = False, default_card_size_identifier: str = "standard", is_preview: bool = False, discover_datasources: bool = False, ): """ Build cards for all specified datasources. """ time_started_make = datetime.datetime.now() if discover_datasources: # todo: discover any CSV files in current working directory and append those to data_paths # todo: then clear duplicates by doing data_paths = list(set(data_paths)) pass datasource_count = len(data_paths) if datasource_count > 0: data_path_names, duplicates_count = get_data_path_names(data_paths) duplicates = ( " ({0} {1})".format(duplicates_count, "duplicate" if duplicates_count == 1 else "duplicates") if duplicates_count > 0 else "" ) print( "Generating cards from {0} {1}{2}:\n {3}".format( datasource_count, "datasources" if datasource_count > 1 else "datasource", duplicates, data_path_names ) ) print() else: print("No datasources.") if datasource_count == 0: print("Generated 0 cards.") return disable_auto_templating = False if definitions_path is None and discover_datasources: # no definitions file has been explicitly specified, so try looking for it automatically found, potential_definitions_path = find_file_path("definitions.csv", data_paths) if found and potential_definitions_path is not None: definitions_path = potential_definitions_path WarningDisplay.using_automatically_found_definitions_info(definitions_path) definitions = get_definitions_from_file(definitions_path) if is_preview: WarningDisplay.preview_enabled_info() # dict of all image paths discovered for each context during card generation context_image_paths = {} base_path = get_base_path() card_template_path = os.path.join(base_path, "templates/base/card.html") card, filled_image_paths = get_template(card_template_path) if len(filled_image_paths) > 0: context_image_paths[card_template_path] = list(set(filled_image_paths)) page_template_path = os.path.join(base_path, "templates/base/page.html") page, filled_image_paths = get_template(page_template_path) if len(filled_image_paths) > 0: context_image_paths[page_template_path] = list(set(filled_image_paths)) section_template_path = os.path.join(base_path, "templates/base/section.html") section, filled_image_paths = get_template(section_template_path) if len(filled_image_paths) > 0: context_image_paths[section_template_path] = list(set(filled_image_paths)) index_template_path = os.path.join(base_path, "templates/base/index.html") index, filled_image_paths = get_template(index_template_path) if len(filled_image_paths) > 0: context_image_paths[index_template_path] = list(set(filled_image_paths)) not_found_template_path = os.path.join(base_path, "templates/base/error/could_not_open.html") with open(not_found_template_path) as error_template: template_not_opened = error_template.read() no_front_template_path = os.path.join(base_path, "templates/base/error/not_provided.html") with open(no_front_template_path) as error_template: template_not_provided = error_template.read() no_back_template_path = os.path.join(base_path, "templates/base/error/back_not_provided.html") with open(no_back_template_path) as error_template: template_back_not_provided = error_template.read() default_card_size = CardSizes.get_card_size(default_card_size_identifier) if default_card_size is None: default_card_size = CardSizes.get_default_card_size() WarningDisplay.bad_card_size(WarningContext(), size_identifier=default_card_size_identifier) # buffer that will contain at most MAX_CARDS_PER_PAGE amount of cards cards = "" # buffer that will contain at most MAX_CARDS_PER_PAGE amount of card backs backs = "" # buffer of a row of backs that is filled in reverse to support double-sided printing backs_row = "" # buffer for all generated pages pages = "" embedded_styles = {} # incremented each time a card is generated, but reset to 0 for each page cards_on_page = 0 # incremented each time a card is generated cards_total = 0 # incremented each time a page is generated pages_total = 0 cards_total_unique = 0 cards_total_per_context = {} previous_card_size = None page_size = CardSizes.get_page_size() # some definitions are always guaranteed to be referenced, # if not by cards, then by the final page output all_referenced_definitions = { TemplateFields.TITLE, TemplateFields.DESCRIPTION, TemplateFields.COPYRIGHT, TemplateFields.AUTHOR, TemplateFields.VERSION, } for data_path_index, data_path in enumerate(data_paths): # define the context as the base filename of the current data- useful when troubleshooting context = os.path.basename(data_path) card_size = default_card_size image_paths = [] # determine whether this path leads to anything if not os.path.isfile(data_path): # if it doesn't, warn that the path to the datasource is not right WarningDisplay.bad_data_path_error(WarningContext(context), data_path) # and skip this datasource continue cards_total_per_context[context] = 0 with open(data_path) as data_file_raw: # wrap the file stream to retain access to unparsed lines data_file = FileWrapper(data_file_raw) # read the csv as a dict, so that we can access each column by name data = csv.DictReader(lower_first_row(data_file)) # make a list of all column names as they are (but stripped of excess whitespace) column_names = [column_name.strip() for column_name in data.fieldnames] # then determine the size identifier (if any; e.g. '@template:jumbo') size_identifier, stripped_column_names = size_identifier_from_columns(column_names) # determine whether this datasource contains invalid columns invalid_column_names = get_invalid_columns(stripped_column_names) if len(invalid_column_names) > 0: # warn that this datasource will be skipped WarningDisplay.invalid_columns_error(WarningContext(context), invalid_column_names) continue # replace the column keys with stripped/parsed representations # (e.g. '@template:jumbo' becomes just '@template') data.fieldnames = stripped_column_names if size_identifier is not None: new_card_size = CardSizes.get_card_size(size_identifier) if new_card_size is not None: card_size = new_card_size else: WarningDisplay.bad_card_size(WarningContext(context), size_identifier) disable_backs = should_disable_backs if card_size != previous_card_size and cards_on_page > 0: # card sizing is different for this datasource, so any remaining cards # must be added to a new page at this point pages += get_page(pages_total + 1, cards, page) pages_total += 1 if not disable_backs: # using the last value of cards_per_row cards_on_last_row = cards_on_page % cards_per_row if cards_on_last_row is not 0: # less than MAX_CARDS_PER_ROW cards were added to the current line, # so we have to add additional blank filler cards to ensure a correct layout remaining_backs = cards_per_row - cards_on_last_row while remaining_backs > 0: # keep adding empty filler card backs until we've filled a row backs_row = empty_back + backs_row remaining_backs -= 1 backs += backs_row backs_row = "" # fill another page with the backs pages += get_page(pages_total + 1, backs, page, is_card_backs=True) pages_total += 1 backs = "" # reset to prepare for the next page cards_on_page = 0 cards = "" if force_page_breaks: pages += get_section(os.path.splitext(context)[0], data_path_index + 1, section) card_width, card_height = card_size.size_in_inches page_width, page_height = page_size.size_in_inches cards_per_column = math.floor(page_width / card_width) cards_per_row = math.floor(page_height / card_height) max_cards_per_page = cards_per_column * cards_per_row if disable_auto_templating: default_template_content = None else: # get a fitting template by analyzing the content of the data default_template_content = template_from_data(data) # reset the iterator # (note how this is done directly on the file stream; i.e. not on the wrapper) data_file_raw.seek(0) # and start over data = csv.DictReader(lower_first_row(data_file), fieldnames=stripped_column_names) # setting fieldnames explicitly causes the first row # to be treated as data, so skip it next(data) if default_template_content is None and Columns.TEMPLATE not in data.fieldnames: WarningDisplay.missing_default_template(WarningContext(context)) if not disable_backs and Columns.TEMPLATE_BACK in data.fieldnames: WarningDisplay.assume_backs_info(WarningContext(context)) else: if not disable_backs: WarningDisplay.no_backs_info(WarningContext(context)) disable_backs = True if not disable_backs: # empty backs may be necessary to fill in empty spots on a page to ensure # that the layout remains correct # note that we're using a completely empty template, except for the size class field empty_back = get_sized_card( '<div class="card {0}"></div>'.format(str(TemplateField(name=TemplateFields.CARD_SIZE))), size_class=card_size.style, content="", ) ambiguous_references = determine_ambiguous_references(set(stripped_column_names), set(definitions.keys())) if len(ambiguous_references) > 0: WarningDisplay.potential_ambiguous_references(WarningContext(context), list(ambiguous_references)) previous_template_path = None previous_template_path_back = None row_index = 1 for row_data in data: # since the column names counts as a row, and most editors # do not use a zero-based row index, the first row == 2 row_index += 1 if Row.is_excluded(data_file.raw_line): # this row should be ignored - so skip and continue # note that we still need to increment the row_index; # otherwise row references will be offset incorrectly continue row = Row(row_data, data_path, row_index) if row.is_prototype(): # prototype rows should be skipped, but since the skip is intentional, # we should not warn about it count = 0 else: count, indeterminable_count = determine_count(row_data) if indeterminable_count: WarningDisplay.indeterminable_count(WarningContext(context, row_index)) elif count == 0: # the count was explicitly set to 0, but as this might be a temporary thing, # we should warn about skipping this card WarningDisplay.card_was_skipped_intentionally_info(WarningContext(context, row_index)) if count > 100: # the count was unusually high; ask whether it's an error or not if WarningDisplay.abort_unusually_high_count(WarningContext(context, row_index), count): # it was an error, so break out and continue with the next card continue if count > 0 and is_preview: # only render 1 card unless it should be skipped count = 1 # determine which template to use for this card, if any template_path = row_data.get(Columns.TEMPLATE, None) template_path = previous_or_current_path(template_path, previous_template_path) previous_template_path = template_path if not disable_backs: template_path_back = row_data.get(Columns.TEMPLATE_BACK, None) template_path_back = previous_or_current_path(template_path_back, previous_template_path_back) previous_template_path_back = template_path_back if count == 0: # might as well move on to the next card- # this card should not count towards number of unique cards either # note, however, that we *do* want to register the template paths continue resolved_template_path = None if template_path is not None and len(template_path) > 0: template_content, not_found, resolved_template_path = template_from_path( template_path, relative_to_path=data_path ) if not_found: template_content = template_not_opened WarningDisplay.bad_template_path_error( WarningContext(context, row_index), resolved_template_path, cards_affected=count ) elif len(template_content) == 0: template_content = default_template_content WarningDisplay.empty_template( WarningContext(context, row_index), resolved_template_path, cards_affected=count ) else: template_content = default_template_content if template_content is not None: WarningDisplay.using_auto_template(WarningContext(context, row_index), cards_affected=count) if template_content is None: template_content = template_not_provided WarningDisplay.missing_template_error(WarningContext(context, row_index), cards_affected=count) # build a template object # note that we apply the path *as is*; i.e. not the resolved path- this is done to # let any warning show the path to the template as it was defined in the data template_front = Template(template_content, template_path) embedded_styles[template_front.path] = strip_styles(template_front) stripped_template_content = template_front.content resolved_template_path_back = None if not disable_backs: template_back_content = None if template_path_back is not None and len(template_path_back) > 0: template_back_content, not_found, resolved_template_path_back = template_from_path( template_path_back, relative_to_path=data_path ) if not_found: template_back_content = template_not_opened WarningDisplay.bad_template_path_error( WarningContext(context, row_index), resolved_template_path_back, is_back=True, cards_affected=count, ) elif len(template_back_content) == 0: WarningDisplay.empty_template( WarningContext(context, row_index), resolved_template_path_back, is_back_template=True, cards_affected=count, ) if template_back_content is None: template_back_content = template_back_not_provided template_back = Template(template_back_content, template_path_back) embedded_styles[template_back.path] = strip_styles(template_back) stripped_template_back_content = template_back.content # this is also the shared index for any instance of this card cards_total_unique += 1 for i in range(count): card_index = cards_total + 1 # since we're mutating the template for each card, we need to make a new one template_front = Template(stripped_template_content, resolved_template_path) card_content, render_data = fill_card( template_front, row.front_row(), card_index, cards_total_unique, definitions ) if ( template_front.content is not template_not_provided and template_front.content is not template_not_opened ): if len(render_data.unused_fields) > 0: WarningDisplay.missing_fields_in_template( WarningContext(context, row_index), list(render_data.unused_fields), cards_affected=count, ) if len(render_data.unknown_fields) > 0: WarningDisplay.unknown_fields_in_template( WarningContext(context, row_index), list(render_data.unknown_fields), cards_affected=count, ) all_referenced_definitions |= render_data.referenced_definitions image_paths.extend(render_data.image_paths) current_card = get_sized_card(card, size_class=card_size.style, content=card_content) cards += current_card cards_on_page += 1 cards_total += 1 cards_total_per_context[context] += 1 if not disable_backs: template_back = Template(stripped_template_back_content, resolved_template_path_back) back_content, render_data = fill_card( template_back, row.back_row(), card_index, cards_total_unique, definitions ) if ( template_back.content is not template_back_not_provided and template_back.content is not template_not_opened ): if len(render_data.unused_fields) > 0: WarningDisplay.missing_fields_in_template( WarningContext(context, row_index), list(render_data.unused_fields), is_back_template=True, cards_affected=count, ) if len(render_data.unknown_fields) > 0: WarningDisplay.unknown_fields_in_template( WarningContext(context, row_index), list(render_data.unknown_fields), is_back_template=True, cards_affected=count, ) all_referenced_definitions |= render_data.referenced_definitions image_paths.extend(render_data.image_paths) current_card_back = get_sized_card(card, size_class=card_size.style, content=back_content) # prepend this card back to the current line of backs backs_row = current_card_back + backs_row # card backs are prepended rather than appended to # ensure correct layout when printing doublesided if cards_on_page % cards_per_row is 0: # a line has been filled- append the 3 card backs # to the page in the right order backs += backs_row # reset to prepare for the next line backs_row = "" if cards_on_page == max_cards_per_page: # add another page full of cards pages += get_page(pages_total + 1, cards, page) pages_total += 1 if not disable_backs: # and one full of backs pages += get_page(pages_total + 1, backs, page, is_card_backs=True) pages_total += 1 # reset to prepare for the next page backs = "" # reset to prepare for the next page cards_on_page = 0 cards = "" if (force_page_breaks or data_path is data_paths[-1]) and cards_on_page > 0: # in case we're forcing pagebreaks for each datasource, or we're on the last datasource # and there's still cards remaining, then do a pagebreak and fill those into a new page pages += get_page(pages_total + 1, cards, page) pages_total += 1 if not disable_backs: cards_on_last_row = cards_on_page % cards_per_row if cards_on_last_row is not 0: # less than MAX_CARDS_PER_ROW cards were added to the current line, # so we have to add additional blank filler cards to ensure a correct layout remaining_backs = cards_per_row - cards_on_last_row while remaining_backs > 0: # keep adding empty filler card backs until we've filled a row backs_row = empty_back + backs_row remaining_backs -= 1 backs += backs_row backs_row = "" # fill another page with the backs pages += get_page(pages_total + 1, backs, page, is_card_backs=True) pages_total += 1 backs = "" # reset to prepare for the next page cards_on_page = 0 cards = "" # temporary solution involving creating new Template object only used for fill_each, # could be prettier; refactor as part of #34 pages_template = Template(pages) fill_each(TemplateFields.CARDS_TOTAL_IN_CONTEXT, str(cards_total_per_context[context]), pages_template) pages = pages_template.content # store the card size that was just used, so we can determine # whether or not the size changes for the next datasource previous_card_size = card_size # ensure there are no duplicate image paths, since that would just # cause unnecessary copy operations context_image_paths[data_path] = list(set(image_paths)) # determine unused definitions, if any unused_definitions = list(set(definitions.keys()) - all_referenced_definitions) if len(unused_definitions) > 0: WarningDisplay.unused_definitions(unused_definitions) if output_path is None: # output to current working directory unless otherwise specified output_path = "" output_directory_name = "generated" # construct the final output path output_path = os.path.join(output_path, output_directory_name) # ensure all directories exist or created if missing create_directories_if_necessary(output_path) output_filepath = os.path.join(output_path, output_filename) # begin writing pages to the output file (overwriting any existing file) with open(output_filepath, "w") as result: styles = "" for template_path, style in embedded_styles.items(): styles = styles + "\n" + style if len(styles) > 0 else style header = "" if header_path is not None: with open(header_path) as header_file: header = header_file.read().strip() index, render_data = fill_index(index, styles, pages, header, pages_total, cards_total, definitions) if len(render_data.image_paths) > 0: # we assume that any leftover images would have been from a definition context_image_paths[definitions_path] = list(set(render_data.image_paths)) result.write(index) css_path = os.path.join(output_path, "css") js_path = os.path.join(output_path, "js") resources_path = os.path.join(output_path, get_resources_path()) create_directories_if_necessary(css_path) create_directories_if_necessary(js_path) create_directories_if_necessary(resources_path) copy_file_if_necessary(os.path.join(base_path, "templates/base/css/cards.css"), os.path.join(css_path, "cards.css")) copy_file_if_necessary(os.path.join(base_path, "templates/base/css/index.css"), os.path.join(css_path, "index.css")) copy_file_if_necessary(os.path.join(base_path, "templates/base/js/index.js"), os.path.join(js_path, "index.js")) all_copied_image_filenames = [] # additionally, copy all referenced images to the output directory for context in context_image_paths: image_paths = context_image_paths[context] image_filenames = [os.path.basename(image_path) for image_path in image_paths] copy_images_to_output_directory(image_paths, context, output_path) all_copied_image_filenames.extend(image_filenames) unused_resources = get_unused_resources(output_path, all_copied_image_filenames) if len(unused_resources) > 0: WarningDisplay.unused_resources(unused_resources, in_resource_dir=get_resources_path()) output_location_message = ( " → \033[4m'{0}'\033[0m".format(output_filepath) if terminal_supports_color() else " → '{0}'".format(output_filepath) ) # get the grammar right errors_or_error = "error" if WarningDisplay.error_count == 1 else "errors" warnings_or_warning = "warning" if WarningDisplay.warning_count == 1 else "warnings" warnings_and_errors_message = ( " ({0} {1}, {2} {3}{4})".format( WarningDisplay.error_count, errors_or_error, WarningDisplay.warning_count, warnings_or_warning, ("; set --verbose for more" if not WarningDisplay.is_verbose else ""), ) if WarningDisplay.has_encountered_errors() or WarningDisplay.has_encountered_warnings() else "" ) now = datetime.datetime.now() time_difference = now - time_started_make time_difference_in_seconds = time_difference / timedelta(seconds=1) if WarningDisplay.has_displayed_messages(): # break line to separate next output print() print( "[{0}] Finished in {1:.3f} seconds{2}".format( "✔" if not WarningDisplay.has_encountered_errors() else "✖", time_difference_in_seconds, warnings_and_errors_message, ) ) print() # find the total size of the generated directory generated_directory_size = pretty_size(directory_size(output_path)) if cards_total > 0: # get the grammar right pages_or_page = "pages" if pages_total > 1 else "page" cards_or_card = "cards" if cards_total > 1 else "card" if cards_total > cards_total_unique: print( "Generated {0} ({1} unique) {2} on {3} {4} ({5})\n{6}".format( cards_total, cards_total_unique, cards_or_card, pages_total, pages_or_page, generated_directory_size, output_location_message, ) ) else: print( "Generated {0} {1} on {2} {3} ({4})\n{5}".format( cards_total, cards_or_card, pages_total, pages_or_page, generated_directory_size, output_location_message, ) ) else: print("Generated 0 cards ({0})\n{1}".format(generated_directory_size, output_location_message)) print() open_path(output_path)