From 435b31a06358561b49ece92ace03aaf33e21b513 Mon Sep 17 00:00:00 2001 From: George Zhang Date: Thu, 3 Dec 2020 01:51:31 -0500 Subject: [PATCH 1/4] New ConfigInfo data class - Change open_config -> from_file, entries_from_info -> from_info, and config_lines_from_info -> to_config_lines and move into new ConfigInfo data class --- config.py | 102 ++++++++++++++++++++++++++++++++++++++++++++--------- convert.py | 31 ---------------- form.py | 19 +++++++--- 3 files changed, 100 insertions(+), 52 deletions(-) diff --git a/config.py b/config.py index 295768c..2ee6a11 100644 --- a/config.py +++ b/config.py @@ -3,12 +3,14 @@ # This module holds config related functions. This includes the EntryInfo data # class too. -from collections import namedtuple -from dataclasses import dataclass +from __future__ import annotations + +import dataclasses +import typing # Yeah... This is needed even with annotations from utils import to_form_url -@dataclass +@dataclasses.dataclass class EntryInfo: required: bool prompt: bool @@ -89,24 +91,92 @@ def __str__(self): f"-{self.key};{self.title}={self.value}" ) -ConfigInfo = namedtuple("ConfigInfo", "url entries") -def open_config(file): - """ - Open config file and return the URL and entries. - """ - if isinstance(file, str): - file = open(file) - with file: +@dataclasses.dataclass +class ConfigInfo: + url: str + entries: list[EntryInfo] + title: Optional[str] + description: Optional[str] + + @classmethod + def from_file(cls, file): + """ + Open config file and return the URL and entries. + """ + if isinstance(file, str): + with open(file) as file: + return cls.from_file(file) + url = to_form_url(file.readline()) entries = [] - for line in file: - line = line.strip() - if not line: - continue + title = None + description = [] + + fileiter = map(str.strip, iter(file)) + for line in fileiter: + + # Special code to detect autogenerated title / description if line.startswith("#"): + if title is not None: # Already defined + continue + if entries: # Title / description are before entries + continue + search = {"auto-generated", "info"} + if not any(word in line.lower() for word in search): + continue + try: + if (line := next(fileiter)).startswith("# "): + title = line.removeprefix("# ") + while (line := next(fileiter)).startswith("# "): + description.append(line.removeprefix("# ")) + except StopIteration: + break + else: + pass # Fall-through to other options + + # Normal processing + if not line: continue entries.append(EntryInfo.from_string(line)) - return ConfigInfo(url, entries) + + return cls(url, entries, title, "\n".join(description)) + + # Create entries from info + # `info` needs "types", "titles", "keys", "required", and "options" + @classmethod + def from_info(cls, info): + entries = [] + if info["takes_email"]: + args = (True, True, "extra", "emailAddress", "Email address", "") + entries.append(EntryInfo(*args)) + for type, title, key, required, options in zip( + info["types"], info["titles"], info["keys"], + info["required"], info["options"], + ): + if options: + title = f"{title} ({', '.join(options)})" + entries.append(EntryInfo(required, True, type, key, title, "")) + return cls( + info["form_url"], entries, + info.get("form_title"), info.get("form_description") + ) + + # Iterator of config lines from info + def to_config_lines(self): + # First line should be a link that you can paste into a browser + yield to_normal_form_url(self.url) + + # Note that the file was auto-generated + yield "# Auto-generated using form.py" + + if self.title: + yield f"# {self.title}" + if self.description: + for line in self.description.splitlines(): + yield f"# {line}" + + for entry in self.entries: + yield str(entry) # - Tests diff --git a/convert.py b/convert.py index ce4cb3e..00240fe 100644 --- a/convert.py +++ b/convert.py @@ -107,37 +107,6 @@ def get_options(question): def form_info(soup): return info_using_soup(soup) | info_using_json(form_json_data(soup)) -# Create entries from info -# `info` needs "types", "titles", "keys", "required", and "options" -def entries_from_info(info): - entries = [] - if info["takes_email"]: - args = (True, True, "extra", "emailAddress", "Email address", "") - entries.append(EntryInfo(*args)) - for type, title, key, required, options in zip( - info["types"], info["titles"], info["keys"], - info["required"], info["options"], - ): - if options: - title = f"{title} ({', '.join(options)})" - entries.append(EntryInfo(required, True, type, key, title, "")) - return entries - -# Iterator of config lines from info -def config_lines_from_info(info): - # First line should be a link that you can paste into a browser - yield to_normal_form_url(info["form_url"]) - - # Note that the file was auto-generated - yield f"# Auto-generated using form.py" - - yield f"# {info['form_title']}" - for line in info["form_description"].splitlines(): - yield f"# {line}" - - for entry in entries_from_info(info): - yield str(entry) - # - Tests # Test that the info from soup and from json match diff --git a/form.py b/form.py index 1d71eed..7bb7363 100644 --- a/form.py +++ b/form.py @@ -5,8 +5,8 @@ from configparser import Error as ConfigError from contextlib import suppress -from config import open_config -from convert import form_info, config_lines_from_info +from config import ConfigInfo +from convert import form_info from process import prompt_entry, parse_entries, format_entries from utils import to_form_url, url_from_shortcut @@ -121,9 +121,17 @@ def process(target="config.txt", *, command_line=False, should_submit=None): # Read and process the file with file: print_("Reading config entries...") - config = open_config(file) - print_(f"Form URL: {config.url}") + config = ConfigInfo.from_file(file) + # Print out config info + print_(f"URL: {config.url}") + if config.title: + print_(config.title) + if config.description: + for line in config.description.splitlines(): + print_(f" {line}") + + # Parse and format entries messages = parse_entries(config.entries, on_prompt=prompt_entry) data = format_entries(config.entries, messages) print_(f"Form data: {data}") @@ -204,11 +212,12 @@ def convert( print_("Converting form...") soup = BeautifulSoup(text, "html.parser") info = form_info(soup) + config = ConfigInfo.from_info(info) # Write the info to the config file print_(f"Writing to config file: {target}") with open(target, mode="w") as file: - for line in config_lines_from_info(info): + for line in config.to_config_lines(): file.write(line + "\n") print_(f"Form converted and written to file: {target}") From 54ee8e2fa172bbbbc325fab895b5b5a60667f99e Mon Sep 17 00:00:00 2001 From: George Zhang Date: Thu, 3 Dec 2020 12:46:42 -0500 Subject: [PATCH 2/4] Fix imports --- config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config.py b/config.py index 2ee6a11..2e4443e 100644 --- a/config.py +++ b/config.py @@ -6,9 +6,8 @@ from __future__ import annotations import dataclasses -import typing # Yeah... This is needed even with annotations -from utils import to_form_url +from utils import to_form_url, to_normal_form_url @dataclasses.dataclass class EntryInfo: From 33e3b05dd0f95ddaf435915d62b6c8fd46ff650c Mon Sep 17 00:00:00 2001 From: George Zhang Date: Fri, 4 Dec 2020 02:17:03 -0500 Subject: [PATCH 3/4] More comments --- config.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config.py b/config.py index 2e4443e..7cb9aed 100644 --- a/config.py +++ b/config.py @@ -48,16 +48,19 @@ def from_string(cls, string): """ string = string.strip() + # Check if the entry is required if not string: raise ValueError("Empty entry") required = (string[0] == "*") string = string.removeprefix("*").strip() + # Check if the entry will use a prompt if not string: raise ValueError("Missing type") prompt = (string[0] == "!") string = string.removeprefix("!").strip() + # Get the entry type type, split, string = map(str.strip, string.partition("-")) for name, aliases in cls.TYPES.items(): if type == name: @@ -70,12 +73,14 @@ def from_string(cls, string): if not split: raise ValueError("Missing type-key split '-'") + # Get the entry key key, split, string = map(str.strip, string.partition(";")) if not key: raise ValueError("Missing key") if not split: raise ValueError("Missing key-title split ';'") + # Get the entry title / value title, split, value = map(str.strip, string.partition("=")) if not title: title = key # Title defaults to the key if absent. @@ -138,6 +143,12 @@ def from_file(cls, file): continue entries.append(EntryInfo.from_string(line)) + # Make description an Optional[str] + if not description: + description = None + else: + description = "\n".join(description) + return cls(url, entries, title, "\n".join(description)) # Create entries from info From bd7d482417b5f1379f68e15bfc74f5c70ab5563a Mon Sep 17 00:00:00 2001 From: George Zhang Date: Fri, 4 Dec 2020 02:20:05 -0500 Subject: [PATCH 4/4] Fixed errors --- config.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config.py b/config.py index 7cb9aed..1f73432 100644 --- a/config.py +++ b/config.py @@ -141,6 +141,12 @@ def from_file(cls, file): # Normal processing if not line: continue + if line.startswith("#"): + # This is checked again because there could be another comment + # after the description. + continue + + # Parse and add to the entries list entries.append(EntryInfo.from_string(line)) # Make description an Optional[str] @@ -149,7 +155,7 @@ def from_file(cls, file): else: description = "\n".join(description) - return cls(url, entries, title, "\n".join(description)) + return cls(url, entries, title, description) # Create entries from info # `info` needs "types", "titles", "keys", "required", and "options"