"""PYLABNOTEBOOK
This module is the main module where all the functions to run the cli of pylabnotebook are defined.
"""
import argparse
import os
from datetime import datetime
import subprocess
import json
import shutil
import sys
import re
from typing import Union
from .version import __version__
# Useful values
YELLOW: str = '\033[0;33m'
GREEN: str = '\033[0;32m'
NCOL: str = '\033[0m'
RED: str = '\033[0;31m'
[docs]
def create_labnotebook(name: str) -> None:
"""Create new labnotebook.
This function creates a new labnotebook by creating a new .labnotebook folder with all the
necessary files included.
:param name: Name of the project.
:type name: str
"""
# 1. Check if .git folder is present
if not os.path.exists(".git"):
print("Error: There is no .git folder in the current working directory.")
print("Please go to the folder where .git is to create a new notebook in the same folder or run 'git init'.") # pylint: disable=line-too-long
return
# 2. Create .labnotebook directory if it doesn't exist, otherwise return an error
try:
os.makedirs(".labnotebook")
except OSError:
print(".labnotebook folder is already present. If you want to create a new .labnotebook directory, you have to firstly delete it.") # pylint: disable=line-too-long
return
# 3. Get useful variables
today: str = datetime.now().strftime('%Y-%m-%d')
aut: str = subprocess.check_output(["git", "config", "--get", "user.name"],
universal_newlines = True).strip()
# 4. Create config file
create_config_json(name = name, aut = aut)
# 5. Create HEAD, BODY and FOOTER
create_head_html(name = name)
create_body_html(name, today, aut)
script_dir: str = os.path.dirname(os.path.abspath(__file__))
footer_template_path: str = os.path.join(script_dir, "templates", "footer.html")
new_footer_path: str = os.path.join(".labnotebook", "footer.html")
shutil.copy(footer_template_path, new_footer_path)
# 6. Copy style.css file
css_template_path: str = os.path.join(script_dir, "templates", "style.css")
new_css_path: str = os.path.join(".labnotebook", "labstyles.css")
shutil.copy(css_template_path, new_css_path)
# 7. Return messages
print(f"\n{GREEN}.labnotebook folder successfully created")
print(f"{YELLOW}Mandatory: when updating the notebook, make sure you are in {os.getcwd()}")
print("Never change the .labnotebook folder name or content")
print(NCOL)
def create_config_json(name: str, aut: str) -> None:
"""Create configuration file.
This function creates the config.json file of the notebook inside .labnotebook folder.
:param name: Name of the notebook.
:type name: str
:param aut: Author of the notebook.
:type aut: str
"""
config: dict = {"NOTEBOOK_NAME": f"{name}",
"LAB_AUTHOR": f"{aut}",
"LAST_COMMIT": None,
"LAST_DAY": None,
"SHOW_ANALYSIS_FILES": True,
"LAB_CSS": ".labnotebook/labstyles.css",
"ANALYSIS_EXT": ['.html']}
filename: str = '.labnotebook/config.json'
with open(filename, 'w', encoding = 'utf8') as file:
json.dump(config, file, indent = 4)
def create_head_html(name: str) -> None:
"""Create head html file.
This function creates the head.html file based on the head template, by changing the title meta.
:param name: Name of the notebook (or project). This will be added as part of the title tag in
head.
:type name: str
"""
# 1. Get the directory where the current script is located
script_dir: str = os.path.dirname(os.path.abspath(__file__))
# 2. Define the path to the 'templates' folder relative to the script's directory
head_template_path: str = os.path.join(script_dir, "templates", "head.html")
# 3. Perform the substitution
try:
# 3.1 Read the content of the template file
with open(head_template_path, "r", encoding = 'utf8') as template_file:
template_content: str = template_file.read()
# 3.2 Perform the substitution
head_content: str = template_content.replace("{name_placeholder}", name)
# 3.3 Define the path for the new .labnotebook/head.html file
new_head_path: str = os.path.join(".labnotebook", "head.html")
# 3.4 Write the modified content to the new file
with open(new_head_path, "w", encoding = 'utf8') as new_head_file:
new_head_file.write(head_content)
except FileNotFoundError:
print("Template file not found.")
def create_body_html(name: str, today: str, aut: str) -> None:
"""Create body html file.
This function creates the body.html file based on the body template, by changing the title,
the author and the creation date.
:param name: Name of the notebook (or project). This will be displayed has h1 in the body.
:type name: str
:param today: date of creation. This will be shown alongside "Created on:" in the top of the
body.
:type today: str
:param aut: author of the notebook. This will be shown alongside "Author:" in the top of the
body
:type aut: str
"""
# 1. Get the directory where the current script is located
script_dir: str = os.path.dirname(os.path.abspath(__file__))
# 2. Define the path to the 'templates' folder relative to the script's directory
body_template_path: str = os.path.join(script_dir, "templates", "body.html")
# 3. Perform the substitution
try:
# 3.1 Read the content of the template file
with open(body_template_path, "r", encoding = 'utf8') as template_file:
template_content: str = template_file.read()
# 3.2 Perform the substitution
body_content: str = (template_content.replace("{name_placeholder}", name).
replace("{today_placeholder}", today).
replace("{aut_placeholder}", aut))
# 3.3 Define the path for the new .labnotebook/head.html file
new_body_path: str = os.path.join(".labnotebook", "body.html")
# 3.4 Write the modified content to the new file
with open(new_body_path, "w", encoding = 'utf8') as new_body_file:
new_body_file.write(body_content)
except FileNotFoundError:
print("Template file not found.")
[docs]
def update_labnotebook(force_update: bool) -> None:
"""Update labnotebook files.
This function updates body.html and config.json files in .labonotebook folder by looping through
all commits not already inclded.
:param force_update: whether to force the update by starting from the beginning of the commit history. Mandatory if last commit in config.json is no more present in commit history (e.g. rebase, reset or any change in commit history) # pylint: disable=line-too-long
:type force_update: bool
"""
# 2. Check for .labnotebook folder and config.json files
if not os.path.exists(".labnotebook"):
print(f"{RED}Error: There is no .labnotebook folder in the current working directory. "
"Please go to the folder where .labnotebook is.")
sys.exit(2)
config_file: str = os.path.join(".labnotebook", "config.json")
try:
with open(config_file, "r", encoding = 'utf8') as config_file:
config: dict = json.load(config_file)
except FileNotFoundError:
print(f"{RED}Error: There is no config file in .labnotebook folder. Please provide the config file.") # pylint: disable=line-too-long
sys.exit(2)
# 3. Check for staged files
git_status: str = subprocess.run("git status", shell = True, stdout = subprocess.PIPE,
text = True, check = False)
if "Changes to be committed:" in git_status.stdout:
print(f"{RED}Error: You have staged files to be committed. This is incompatible with updatenotebook. " # pylint: disable=line-too-long
"Please commit those changes, restore the files, or stage them prior to running this function.") # pylint: disable=line-too-long
sys.exit(1)
# 4. Reset config and head if force_update
if force_update:
create_config_json(name = config.get('NOTEBOOK_NAME'),
aut = config.get('LAB_AUTHOR'))
with open(config_file, "r", encoding = 'utf8') as config_file:
config: dict = json.load(config_file)
create_body_html(name = config.get('NOTEBOOK_NAME'),
today = datetime.now().strftime('%Y-%m-%d'),
aut = config.get('LAB_AUTHOR'))
# 5. Get list of commits sha
last_commit: str = config.get('LAST_COMMIT')
sha_list: list[str] = get_sha_list(last_commit)
# 6. Remove main and body closing tags from body.html
with open(".labnotebook/body.html", "r", encoding = 'utf8') as body_file:
body_content: str = body_file.read()
body_content: str = (body_content.replace("</main>", "").
replace("</body>", ""))
# 7. Get info about each commit
analysis_ext: list[str] = config.get('ANALYSIS_EXT')
excluded_patterns: list[str] = get_excluded_patterns()
commits_info: dict = {sha: get_commit_info(sha, analysis_ext, excluded_patterns) for sha
in sha_list}
# 8. Write info into body.html and update config
write_update_files(commits_info, body_content, config)
def get_sha_list(last_commit: Union[str, None]) -> list[str]:
"""Get sha list since last commit in notebook
This functions returns a list of commits sha (from oldest to newest) that have not been already
included in the notebook.
:param last_commit: sha of the last commit in the notebook.
:type last_commit: str or None
:return: list of the commits not included in the notebook since last_commit.
:rtype: list[str]
"""
# 1. Get list of all commits
git_sha: list[str] = subprocess.run("git log --pretty=format:%h --reverse", shell = True,
stdout = subprocess.PIPE, text = True, check = False).stdout.split('\n') # pylint: disable=line-too-long
# 2. Subset for new commits
# 2.1 If git history is empty, return error
if git_sha == ['']:
print(f"{RED}Error: Git history is empty")
sys.exit(5)
# 2.2 Return all if last commit is None
if last_commit is None:
return git_sha
# 2.3 Raise error if last commit is not in git_sha list
if last_commit not in git_sha:
print(f"{RED}Error: Last commit used for the lab notebook ({last_commit}) is not in the current git log history." # pylint: disable=line-too-long
f"\nIt is possible that you have changed commit history. Please check your git log and insert the commit SHA to use in the config file or force the update to start again from the beginning of the git history using labnotebook update -f/--force.") # pylint: disable=line-too-long
sys.exit(5)
# 2.4 Perform the subset
index: int = git_sha.index(last_commit)
git_sha: str = git_sha[index + 1:]
# 2.5 Interrupt if last_commit is actually the last commit in history
if len(git_sha) == 0:
print(f"{YELLOW}Warning: LAST_COMMIT is already the last commit in history. Nothing to update.") # pylint: disable=line-too-long
sys.exit(5)
# 3. Return git_sha
return git_sha
def get_excluded_patterns() -> list[str]:
"""Get the exluded patterns for analysis files.
This functions returns a list of the patterns to exclude in analysis files. It reads the
.labignore file in root directory, if exists.
:return: list of excluded patterns.
:rtype: list[str]
"""
try:
with open('.labignore', 'r', encoding = 'utf8') as f:
excluded_patterns: list[str] = f.read().splitlines()
excluded_patterns: list[str] = [pattern.replace('*', '.*') for pattern
in excluded_patterns]
except FileNotFoundError:
excluded_patterns: list = []
return excluded_patterns
def get_commit_info(commit_sha: str, analysis_ext: list[str], excluded_patterns: list[str]) -> dict:
"""Get commit info.
This function returns a dictionary of the information about the commit specified in commit_sha.
These info are: date, author, title, message, changed files and analysis_files (based on both
analysis_ext and excluded_patterns).
:param commit_sha: sha of the commit of interest.
:type commit_sha: str
:param analysis_ext: list of the file extensions used as reference for analysis files.
:type analysis_ext: list[str]
:param excluded_patterns: list of the pattern to be excluded from the analysis files.
:type excluded_patterns: list[str]
:return: information about the commit specified in commit_sha: date, author, title, message,
changed files and analysis_files (based on both analysis_ext and excluded_patterns).
:rtype: dict
"""
date, author, title = subprocess.check_output(['git', 'log', '-n', '1', '--pretty=format:%cs%n%an%n%s', commit_sha], text = True).strip().split('\n') # pylint: disable=line-too-long
message: str = subprocess.check_output(['git', 'log', '-n', '1', '--pretty=format:%b', commit_sha], text=True).strip() # pylint: disable=line-too-long
pattern: str = r"(\.|:|!|\?)\n"
replacement: str = r"\1<br>\n"
message = re.sub(pattern, replacement, message).replace('\n\n', '\n<br>\n')
changed_files: str = subprocess.check_output(['git', 'show', '--pretty=%n', '--name-status', commit_sha], text=True).strip().split('\n') # pylint: disable=line-too-long
changed_files: dict = {file.split('\t')[1] : file.split('\t')[0] for file in changed_files}
analysis_files: list[str] = [key for key, _ in changed_files.items()
if any(ext in key for ext in analysis_ext) and not
any(re.search(pattern, key) for pattern in excluded_patterns)]
commit_info: dict = {'date': date,
'author': author,
'title': title,
'message': message,
'changed_files': changed_files,
'analysis_files': analysis_files}
return commit_info
def write_update_files(commits_info: dict, body_content: str, config: dict) -> None:
"""Write commits update in body.html.
This function writes the commit elements into body.html and updates the config.json file.
:param commits_info: dictionary with the info about the new commits to write in body.html.
:type commits_info: dict
:param body_content: content of the actual body.html.
:type body_content: str
:param config: configuration dictionary. This will be updated and saved in config.json.
:type config: dict
"""
# 1. Loop through commits
for sha, commit in commits_info.items():
# 1.1 Check last day
if config.get('LAST_DAY') != commit.get('date'):
body_content += f"<h2 class='day-el'>{commit.get('date')}</h2>\n\n"
config['LAST_DAY'] = commit.get('date')
# 1.2 Write commit div
body_content += f"<div class='commit-el' id='{sha}'>\n"
body_content += f"<h3 class='title-el'>{commit.get('title')}</h3>\n"
if commit.get('message') == '':
pass
else:
body_content += f"<p class='mess-el'>{commit.get('message')}</p>\n"
body_content += f"<p class='author-el'>Author: {commit.get('author')}</p>\n"
body_content += f"<p class='sha-el'>sha: {sha}</p>\n"
if len(commit.get('analysis_files')) == 0:
body_content += "<div class='analyses-el'>Analysis file/s: <code>none</code></div>\n"
else:
body_content += "<div class='analyses-el'>Analysis file/s:\n<ul class='analysis_list'>\n" # pylint: disable=line-too-long
for a_file in commit.get('analysis_files'):
body_content += f"<li><code><a href='{a_file}' target='_blank'>{a_file}</a></code></li>\n" # pylint: disable=line-too-long
body_content += "</ul>\n</div>\n"
body_content += "<details>\n<summary>Changed files</summary>\n<ul class='changed_list'>\n"
for c_file in commit.get('changed_files'):
body_content += f"<li>{c_file}</li>\n"
body_content += "</ul>\n</details>\n</div>\n"
# 1.3 Update last commit
config['LAST_COMMIT'] = f"{sha}"
# 2. Insert closing tags
body_content += "</main>\n</body>"
# 3. Write body.html
with open('.labnotebook/body.html', "w", encoding = 'utf8') as new_body_file:
new_body_file.write(body_content)
# 4. Write config.json
with open(".labnotebook/config.json", 'w', encoding = 'utf8') as file:
json.dump(config, file, indent = 4)
[docs]
def export_labnotebook(output_file: str, force: bool, link: bool) -> None:
"""Export labnotebook to html.
This function exports the labnotebook into a single html file ready to read and share.
:param output_file: path of the file to create
:type output_file: str
:param force: whether to force the overwriting of output_file if exists.
:type force: bool
:param link: whether to create links to analysis files in analysis files bullet list. These links can be used to open the analysis files directly from the notebook. # pylint: disable=line-too-long
:type link: bool
"""
# 2. Check for .labnotebook folder, config.json and .html files
if not os.path.exists(".labnotebook"):
print(f"{RED}Error: There is no .labnotebook folder in the current working directory. "
"Please go to the folder where .labnotebook is.")
sys.exit(2)
config_file: str = os.path.join(".labnotebook", "config.json")
try:
with open(config_file, "r", encoding = 'utf8') as config_file:
config: dict = json.load(config_file)
except FileNotFoundError:
print(f"{RED}Error: There is no config file in .labnotebook folder. Please provide the config file.") # pylint: disable=line-too-long
sys.exit(2)
required_files: list[str] = [
'.labnotebook/head.html',
'.labnotebook/body.html',
'.labnotebook/footer.html',
config.get('LAB_CSS')
]
for file in required_files:
if not os.path.exists(file):
print(f"Error: There is no {file} file.")
sys.exit(2)
# 3. Check if file already exists and force is False
if os.path.exists(output_file) and not force:
print(f"{RED}Error: {output_file} already exists. Use -f/--force to overwrite it.")
sys.exit(1)
# 4. Read head.html and edit it
with open('.labnotebook/head.html', 'r', encoding = 'utf8') as head_file:
output_content: str = head_file.read()
output_content: str = output_content.replace("</head>", "")
if link:
output_content += f"<link rel='stylesheet' href='{config.get('LAB_CSS')}'>\n"
else:
with open(config.get('LAB_CSS'), 'r', encoding = 'utf8') as style_file:
output_content += f"<style>\n{style_file.read()}\n</style>\n"
if not config.get('SHOW_ANALYSIS_FILES'):
output_content += "<style>\n.analyses-el {display: none;}\n</style>\n"
output_content += "</head>\n"
# 5. Read body.html and insert into output content
with open('.labnotebook/body.html', 'r', encoding = 'utf8') as body_file:
output_content += f"{body_file.read()}\n"
# 5. Read footer.html and insert into output content
with open('.labnotebook/footer.html', 'r', encoding = 'utf8') as footer_file:
output_content += f"{footer_file.read()}\n"
# 6. Write output file
with open(output_file, 'w', encoding = 'utf8') as of:
of.write(output_content)
def main():
"""Main cli function handler.
This function is the main function of the module, which handles command line input values to run
the different functions of the labnotebook.
"""
parser: argparse.ArgumentParser = argparse.ArgumentParser(description="Lab Notebook Tool")
parser.add_argument('--version', action = 'version', version = '%(prog)s ' + __version__,
help = "Show package version")
subparsers: argparse._SubParsersAction = parser.add_subparsers(dest = "command")
create_parser: argparse.ArgumentParser = subparsers.add_parser("create",
help = "Create a new lab notebook") # pylint: disable=line-too-long
create_parser.add_argument("-n", "--name", required = True,
help="Name of the lab notebook. If the name should contain more words, wrap them into quotes") # pylint: disable=line-too-long
update_parser: argparse.ArgumentParser = subparsers.add_parser("update",
help = "Update lab notebook")
update_parser.add_argument("-f", "--force", help = "Force the update",
default = False, action = "store_true")
export_parser: argparse.ArgumentParser = subparsers.add_parser("export", help = "Export lab notebook to an html file") # pylint: disable=line-too-long
export_parser.add_argument("-o", "--output", required = True,
help = "Path/name of the output HTML file")
export_parser.add_argument("-f", "--force",
help = "Force the overwriting of the output file if already present",
default = False, action = "store_true")
export_parser.add_argument("-l", "--link", default = False, action = "store_true",
help = "Link style file in head. By default style file is copied in <style></style> tags in head") # pylint: disable=line-too-long
args = parser.parse_args()
if args.command == "create":
if not args.name:
create_parser.error("-n/--name is required for the 'create' command. Please provide the name of the notebook.") # pylint: disable=line-too-long
create_labnotebook(args.name)
elif args.command == "update":
update_labnotebook(args.force)
elif args.command == "export":
if not args.output:
export_parser.error("-o/--output is required for the 'export' command. Please provide the name of the output file.") # pylint: disable=line-too-long
export_labnotebook(args.output, args.force, args.link)
else:
parser.print_help()
if __name__ == "__main__":
main()