Coverage for src/minibook/main.py: 100%

97 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-28 05:58 +0000

1"""MiniBook - A tool to create a webpage from a list of links. 

2 

3Generates a clean, responsive HTML webpage using Jinja2 templates. 

4""" 

5 

6import json 

7import sys 

8from datetime import datetime 

9from os import getenv 

10from pathlib import Path 

11 

12import requests 

13import typer 

14from jinja2 import Environment, FileSystemLoader 

15 

16 

17def get_git_repo_url(): 

18 """Retrieve the GitHub repository URL. 

19 

20 This function generates the GitHub repository URL based on the repository name 

21 retrieved from the environment variable 'GITHUB_REPOSITORY'. If the environment 

22 variable is not set, it defaults to 'tschm/minibook'. This URL can then be used 

23 for interactions with the repository. 

24 

25 :return: The full URL for the GitHub repository. 

26 :rtype: str 

27 """ 

28 # Fallback to environment variable if git command fails 

29 github_repo = getenv("GITHUB_REPOSITORY", default="tschm/minibook") 

30 return f"https://github.com/{github_repo}" 

31 

32 

33def validate_url(url, timeout=5): 

34 """Validate if a URL is accessible. 

35 

36 Args: 

37 url (str): The URL to validate 

38 timeout (int, optional): Timeout in seconds for the request 

39 

40 Returns: 

41 tuple: (is_valid, error_message) where is_valid is a boolean and error_message is a string 

42 error_message is None if the URL is valid 

43 

44 """ 

45 try: 

46 # Make a HEAD request to check if the URL is accessible 

47 # HEAD is more efficient than GET as it doesn't download the full content 

48 response = requests.head(url, timeout=timeout, allow_redirects=True) 

49 

50 # If the HEAD request fails, try a GET request as some servers don't support HEAD 

51 if response.status_code >= 400: 

52 response = requests.get(url, timeout=timeout, allow_redirects=True) 

53 

54 # Check if the response status code indicates success 

55 if response.status_code < 400: 

56 return True, None 

57 else: 

58 return False, f"HTTP error: {response.status_code}" 

59 

60 except requests.exceptions.Timeout: 

61 return False, "Timeout error" 

62 except requests.exceptions.ConnectionError: 

63 return False, "Connection error" 

64 except requests.exceptions.RequestException as e: 

65 return False, f"Request error: {e!s}" 

66 except Exception as e: 

67 return False, f"Unexpected error: {e!s}" 

68 

69 

70def generate_html(title, links, subtitle=None, output_file="index.html", template_path=None): 

71 """Generate an HTML page with the given title and links using Jinja2. 

72 

73 Args: 

74 title (str): The title of the webpage 

75 links (list): A list of tuples with (name, url) 

76 subtitle (str, optional): A description to include on the page 

77 output_file (str, optional): The output HTML file 

78 template_path (str, optional): Path to a custom Jinja2 template file 

79 

80 Returns: 

81 str: The path to the generated HTML file 

82 

83 """ 

84 # Set up Jinja2 environment 

85 if template_path: 

86 # Use custom template if provided 

87 template_file = Path(template_path) 

88 if not template_file.exists(): 

89 raise FileNotFoundError(f"Template file not found: {template_path}") 

90 

91 template_dir = template_file.parent 

92 env = Environment(loader=FileSystemLoader(template_dir)) 

93 template = env.get_template(template_file.name) 

94 else: 

95 # Use default template 

96 template_dir = Path(__file__).parent / "templates" 

97 env = Environment(loader=FileSystemLoader(template_dir)) 

98 template = env.get_template("html.j2") 

99 

100 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 

101 

102 # Render the template with our data 

103 html = template.render( 

104 title=title, links=links, description=subtitle, timestamp=timestamp, repository_url=get_git_repo_url() 

105 ) 

106 

107 # Save the HTML to a file 

108 with Path(output_file).open("w") as f: 

109 f.write(html) 

110 

111 return output_file 

112 

113 

114app = typer.Typer(help="Create a minibook from a list of links") 

115 

116 

117@app.command() 

118def entrypoint( 

119 title: str = typer.Option("My Links", "--title", "-t", help="Title of the minibook"), 

120 subtitle: str | None = typer.Option(None, "--subtitle", help="Subtitle of the minibook"), 

121 output: str = typer.Option("artifacts", "--output", "-o", help="Output directory"), 

122 links: str = typer.Option( 

123 None, 

124 "--links", 

125 "-l", 

126 help="JSON formatted links: can be a list of objects with name/url keys, a list of arrays, or a dictionary", 

127 ), 

128 validate_links: bool = typer.Option(False, "--validate-links", help="Validate that all links are accessible"), 

129 template: str | None = typer.Option( 

130 None, "--template", help="Path to a custom Jinja2 template file for HTML output" 

131 ), 

132) -> int: 

133 """Create a minibook from a list of links.""" 

134 if links is None: 

135 typer.echo("No links provided. Exiting.", err=True) 

136 sys.exit(1) 

137 

138 typer.echo(f"Parsing links: {links}") 

139 

140 link_tuples = [] 

141 

142 # Try to parse as JSON first 

143 try: 

144 # Clean up the JSON string - remove leading/trailing whitespace and handle multi-line strings 

145 cleaned_links = links.strip() 

146 

147 # Parse the JSON string into a Python object 

148 json_data = json.loads(cleaned_links) 

149 typer.echo(f"Parsed JSON data: {json_data}") 

150 typer.echo(f"Instance of JSON data: {type(json_data)}") 

151 

152 # Handle different JSON formats 

153 if isinstance(json_data, list): 

154 # If it's a list of lists/arrays: [["name", "url"], ...] 

155 if all(isinstance(item, list) for item in json_data): 

156 for item in json_data: 

157 if len(item) >= 2: 

158 link_tuples.append((item[0], item[1])) 

159 # If it's a list of objects: [{"name": "...", "url": "..."}, ...] 

160 elif all(isinstance(item, dict) for item in json_data): 

161 for item in json_data: 

162 if "name" in item and "url" in item: 

163 link_tuples.append((item["name"], item["url"])) 

164 # If it's a dictionary: {"name1": "url1", "name2": "url2", ...} 

165 elif isinstance(json_data, dict): 

166 for name, url in json_data.items(): 

167 link_tuples.append((name, url)) 

168 

169 typer.echo(f"Parsed JSON links: {link_tuples}") 

170 

171 # Fall back to the original parsing logic for backward compatibility 

172 except (json.JSONDecodeError, TypeError): 

173 typer.echo("JSON parsing failed, falling back to legacy format") 

174 return 1 

175 

176 # Validate links if requested 

177 if validate_links: 

178 typer.echo("Validating links...") 

179 invalid_links = [] 

180 

181 with typer.progressbar(link_tuples) as progress: 

182 for name, url in progress: 

183 is_valid, error_message = validate_url(url) 

184 if not is_valid: 

185 invalid_links.append((name, url, error_message)) 

186 

187 # Report invalid links 

188 if invalid_links: 

189 typer.echo(f"\nFound {len(invalid_links)} invalid links:", err=True) 

190 for name, url, error in invalid_links: 

191 typer.echo(f" - {name} ({url}): {error}", err=True) 

192 

193 # Ask user if they want to continue 

194 if not typer.confirm("Do you want to continue with invalid links?"): 

195 typer.echo("Aborting due to invalid links.", err=True) 

196 return 1 

197 else: 

198 typer.echo("All links are valid!") 

199 

200 # Generate HTML using Jinja2 

201 output_file = Path(output) / "index.html" 

202 try: 

203 output_path = generate_html(title, link_tuples, subtitle, output_file, template) 

204 typer.echo(f"HTML minibook created successfully: {Path(output_path).absolute()}") 

205 except FileNotFoundError as e: 

206 typer.echo(f"Error: {e}", err=True) 

207 return 1 

208 

209 return 0 

210 

211 

212if __name__ == "__main__": 

213 app() # pragma: no cover