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
« 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.
3Generates a clean, responsive HTML webpage using Jinja2 templates.
4"""
6import json
7import sys
8from datetime import datetime
9from os import getenv
10from pathlib import Path
12import requests
13import typer
14from jinja2 import Environment, FileSystemLoader
17def get_git_repo_url():
18 """Retrieve the GitHub repository URL.
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.
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}"
33def validate_url(url, timeout=5):
34 """Validate if a URL is accessible.
36 Args:
37 url (str): The URL to validate
38 timeout (int, optional): Timeout in seconds for the request
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
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)
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)
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}"
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}"
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.
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
80 Returns:
81 str: The path to the generated HTML file
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}")
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")
100 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
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 )
107 # Save the HTML to a file
108 with Path(output_file).open("w") as f:
109 f.write(html)
111 return output_file
114app = typer.Typer(help="Create a minibook from a list of links")
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)
138 typer.echo(f"Parsing links: {links}")
140 link_tuples = []
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()
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)}")
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))
169 typer.echo(f"Parsed JSON links: {link_tuples}")
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
176 # Validate links if requested
177 if validate_links:
178 typer.echo("Validating links...")
179 invalid_links = []
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))
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)
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!")
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
209 return 0
212if __name__ == "__main__":
213 app() # pragma: no cover