diff --git a/Cargo.lock b/Cargo.lock index 6513216..cd57242 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,7 +210,7 @@ dependencies = [ [[package]] name = "readme-update" -version = "0.2.1" +version = "0.3.0" dependencies = [ "colored", "pyo3", diff --git a/Cargo.toml b/Cargo.toml index 46f3cb4..4ea4af2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "readme-update" -version = "0.2.1" +version = "0.3.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index 73f8725..cb3f439 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ ## Tired of updating documentation? This tool updates your `README.md` with a one line description for each of the python scripts in a directory you point it to (and recursively). It adds the text for lines that start with `"Description: "` and `"Link: "`. It ignores any that don't have the description. +You can supply alternative `--table-fields` to create a table with columns for the provided fields. The `--link-fields` argument is used to signal which of the `--table-fields` should be rendered as links like this `[Link](this/is/the/link)`. + The idea is that links should link to higher level documentation (if it exists). This can be used as a `pre-commit` for python projects with standalone scripts for specific processes. @@ -12,6 +14,8 @@ It will update in place if the `# Scripts` block exists or else it will append i `example_usage.py` shows how to call the script from python. +# Here is an example output from the tool: + # Scripts | Name | Description | Link | |:---|:---|:---| @@ -50,6 +54,8 @@ uv run -m update_readme \ | ------------------ | --------------------- | -------- | ------- | ---------------------------------------------------- | | `--scripts-root` | `str` | ✅ | | Path to the root of the scripts to scan | | `--readme-path` | `str` | ❌ | `'./README.md'` | Path to the README file that will be modified | +| `--table-fields` | `list` | ❌ | `["Description", "Link"]` | Fields to dynamically add to the README.md table. | +| `--link-fields` | `list` | ❌ | `["Link"]` | Which of the provided table fields should be rendered as links. | # Ret codes @@ -60,6 +66,7 @@ uv run -m update_readme \ | `NoPyFiles` | 2 | No python files found at the `scripts-root` location. | | `FailedParsingFile` | 3 | Failed to read README file | | `FailedToWriteReadme` | 4 | The given `README.md` path does not match the expected basename. | +| `InvalidLinkFields` | 5 | The given `link_fields` are not a subset of the given `table_fields`. | # Repo map diff --git a/pyproject.toml b/pyproject.toml index 88b418c..6399a46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "readme-update" -version = "0.2.1" +version = "0.3.0" readme = "README.md" requires-python = ">=3.10" classifiers = [ diff --git a/python/update_readme/__main__.py b/python/update_readme/__main__.py index 60a5a3f..34f7fdf 100644 --- a/python/update_readme/__main__.py +++ b/python/update_readme/__main__.py @@ -18,5 +18,23 @@ default="./README.md", help="Path to the readme file.", ) + parser.add_argument( + "--table-fields", + type=list, + default=["Description", "Link"], + help="Fields to dynamically add to the README.md table.", + ) + parser.add_argument( + "--link-fields", + type=list, + default=["Link"], + help="Which of the provided table fields should be rendered as links.", + ) args = parser.parse_args() - sys.exit(int(readme_update.py_main(args.scripts_root, args.readme_path))) + sys.exit( + int( + readme_update.py_main( + args.scripts_root, args.readme_path, args.table_fields, args.link_fields + ) + ) + ) diff --git a/src/api.rs b/src/api.rs index 6e342ce..5f3c5cc 100644 --- a/src/api.rs +++ b/src/api.rs @@ -4,14 +4,26 @@ use pyo3::prelude::*; use std::path::Path; #[pyfunction] -fn py_main(scripts_root: String, readme_path: String) -> PyResult { +fn py_main( + scripts_root: String, + readme_path: String, + table_fields: Vec, + link_fields: Vec, +) -> PyResult { let mut file_sys = RealFileSystem; - match main(&mut file_sys, scripts_root, Path::new(&readme_path)) { + match main( + &mut file_sys, + scripts_root, + Path::new(&readme_path), + &table_fields, + &link_fields, + ) { RetCode::NoModification => Ok(0), RetCode::ModifiedReadme => Ok(1), RetCode::NoPyFiles => Ok(2), RetCode::FailedParsingFile => Ok(3), RetCode::FailedToWriteReadme => Ok(4), + RetCode::InvalidLinkFields => Ok(5), } } diff --git a/src/core/domain.rs b/src/core/domain.rs index 6234bd8..4297436 100644 --- a/src/core/domain.rs +++ b/src/core/domain.rs @@ -3,12 +3,34 @@ use colored::Colorize; use rayon::prelude::*; use regex::Regex; use std::{ + cmp::Ordering, + collections::{HashMap, HashSet}, ffi::OsStr, io, path::{Path, PathBuf}, }; -pub fn main(file_sys: &mut impl FileSystem, scripts_root: String, readme_path: &Path) -> RetCode { +pub fn main( + file_sys: &mut impl FileSystem, + scripts_root: String, + readme_path: &Path, + table_fields: &[String], + link_fields: &[String], +) -> RetCode { + let table_set: HashSet<_> = table_fields.iter().collect(); + if !link_fields.iter().all(|f| table_set.contains(f)) { + eprintln!( + "{} {:?} {} {:?}", + "Not all link fields are present in table fields. Link fields: " + .red() + .bold(), + link_fields, + "Table fields: ".red().bold(), + table_fields + ); + return RetCode::InvalidLinkFields; + } + let readme = match ReadMe::parse(file_sys, readme_path) { Ok(r) => r, Err(e) => { @@ -28,7 +50,7 @@ pub fn main(file_sys: &mut impl FileSystem, scripts_root: String, readme_path: & } let py_files: Vec = extract_pyfiles(file_sys, paths); - let scripts_docs = generate_scripts_docs(py_files); + let scripts_docs = generate_scripts_docs(py_files, table_fields, link_fields); let modified_readme = update_readme(&readme, scripts_docs); if modified_readme != readme { @@ -50,6 +72,7 @@ pub enum RetCode { NoPyFiles, FailedParsingFile, FailedToWriteReadme, + InvalidLinkFields, } #[derive(Debug, Eq, PartialEq)] @@ -128,63 +151,124 @@ fn extract_module_docstring(code: &str) -> String { .unwrap_or_default() } -#[derive(Debug, Eq, Ord, PartialEq, PartialOrd)] +#[derive(Debug, Eq, PartialEq, Clone, Default)] +pub struct TableValue { + value: String, + is_link: bool, +} + +impl TableValue { + pub fn new(value: &str, is_link: bool) -> Self { + Self { + value: value.to_string(), + is_link, + } + } + + pub fn to_readme_entry(&self) -> String { + if self.is_link { + format!("[Link]({})", self.value) + } else { + self.value.clone() + } + } +} + +#[derive(Debug, Eq, PartialEq)] pub struct DocInfo { path: PathBuf, - desc: String, - link: String, + table_fields: HashMap, } impl DocInfo { - pub fn to_readme(&self) -> String { + pub fn to_readme(&self, table_fields: &[String]) -> String { let basename: String = self.path.file_name().unwrap().to_string_lossy().to_string(); - format!("| `{}` | {} | {} |", basename, self.desc, self.link) + let cols = table_fields + .iter() + .map(|k| { + self.table_fields + .get(k) + .cloned() + .unwrap_or_default() + .to_readme_entry() + }) + .collect::>() + .join(" | "); + format!("| `{}` | {} |", basename, cols) } } -fn extract_docinfo(py_files: Vec) -> Vec { +impl Ord for DocInfo { + fn cmp(&self, other: &Self) -> Ordering { + self.path.cmp(&other.path) + } +} + +impl PartialOrd for DocInfo { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +fn extract_docinfo( + py_files: Vec, + table_fields: &[String], + link_fields: &[String], +) -> Vec { let mut doc_infos: Vec = py_files .into_par_iter() .map(|py_file| { - let mut desc = String::new(); - let mut link = String::new(); + let mut doc_fields = HashMap::new(); for line in py_file.docstring.lines() { - let trimmed_line = line.trim_start(); - - if let Some(rest) = trimmed_line.strip_prefix("Description: ") { - desc = rest.to_string(); - } else if let Some(rest) = trimmed_line.strip_prefix("Link: ") { - link = format!("[Link]({})", rest); + let trimmed_line = line.trim(); + for field in table_fields.iter() { + let prefix = format!("{field}: "); + if let Some(rest) = trimmed_line.strip_prefix(&prefix) { + doc_fields.insert( + field.clone(), + TableValue::new(rest, link_fields.contains(field)), + ); + } } } DocInfo { path: py_file.path, - desc, - link, + table_fields: doc_fields, } }) .collect(); - doc_infos.par_sort_by_key(|s| s.path.clone()); + doc_infos.par_sort(); doc_infos } -fn create_readme(doc_infos: Vec) -> String { - [ - "# Scripts", - "| Name | Description | Link |", - "|:---|:---|:---|", - ] - .into_iter() - .map(str::to_string) - .chain(doc_infos.iter().map(|n| n.to_readme()).collect::>()) - .chain(std::iter::once("::".to_string())) - .collect::>() - .join("\n") +fn create_readme(doc_infos: Vec, table_fields: &[String]) -> String { + let header = format!("| Name | {} |", table_fields.join(" | ")); + let separator = format!("|{}|", vec![":---"; table_fields.len() + 1].join("|")); + + std::iter::once("# Scripts".to_string()) + .chain(std::iter::once(header)) + .chain(std::iter::once(separator)) + .chain( + doc_infos + .iter() + .map(|n| n.to_readme(table_fields)) + .collect::>(), + ) + .chain(std::iter::once("::".to_string())) + .collect::>() + .join("\n") } -fn generate_scripts_docs(py_files: Vec) -> String { - create_readme(extract_docinfo(py_files)) +fn generate_scripts_docs( + py_files: Vec, + table_fields: &[String], + link_fields: &[String], +) -> String { + create_readme( + extract_docinfo(py_files, table_fields, link_fields), + table_fields, + ) } fn update_readme(readme: &ReadMe, scripts_docs: String) -> ReadMe { @@ -235,6 +319,13 @@ mod tests { .collect::>() .join("\n"); - assert_eq!(generate_scripts_docs(py_files), expected_readme); + assert_eq!( + generate_scripts_docs( + py_files, + &["Description".to_string(), "Link".to_string()], + &["Link".to_string()] + ), + expected_readme + ); } } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 5753e9f..3be9bdc 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -8,18 +8,32 @@ use test_case::test_case; #[test_case( "repo/root/scripts", "repo/root/README.md", "# Some readme\n\n# Scripts\n| Name | Description | Link |\n|:---|:---|:---|\n| `file1.py` | some desc | [Link](some-link.com) |\n::", + vec!["Description".to_string(), "Link".to_string()], + vec!["Link".to_string()], RetCode::NoModification, "# Some readme\n\n# Scripts\n| Name | Description | Link |\n|:---|:---|:---|\n| `file1.py` | some desc | [Link](some-link.com) |\n::" )] +#[test_case( + "repo/root/scripts", "repo/root/README.md", + "# Some readme", + vec!["Description".to_string(), "Link".to_string()], + vec!["Invalid".to_string()], + RetCode::InvalidLinkFields, + "# Some readme" +)] #[test_case( "repo/root/scripts", "repo/root/README.md", "# Some readme", + vec!["Some field".to_string()], + Vec::new(), RetCode::ModifiedReadme, - "# Some readme\n\n# Scripts\n| Name | Description | Link |\n|:---|:---|:---|\n| `file1.py` | some desc | [Link](some-link.com) |\n::" + "# Some readme\n\n# Scripts\n| Name | Some field |\n|:---|:---|\n| `file1.py` | Some random field. |\n::" )] #[test_case( "repo/root/scripts", "repo/root/INVALID.md", "# Some readme", + vec!["Description".to_string(), "Link".to_string()], + vec!["Link".to_string()], RetCode::FailedParsingFile, "# Some readme" )] @@ -27,6 +41,8 @@ fn test_readme_update( scripts_root: &str, readme_path: &str, readme_str: &str, + table_fields: Vec, + link_fields: Vec, expected_ret_code: RetCode, expected_readme: &str, ) { @@ -35,7 +51,7 @@ fn test_readme_update( let files: HashMap = vec![ ( "repo/root/scripts/file1.py", - "\"\"\"Description: some desc\n\nLink: some-link.com\"\"\"", + "\"\"\"Description: some desc\n\nLink: some-link.com\n\nSome field: Some random field.\n\n\"\"\"", ), (readme_path, readme_str), ] @@ -45,7 +61,13 @@ fn test_readme_update( let mut file_sys = FakeFileSystem::new(files); assert_eq!( - main(&mut file_sys, scripts_root, &PathBuf::from(readme_path)), + main( + &mut file_sys, + scripts_root, + &PathBuf::from(readme_path), + &table_fields, + &link_fields, + ), expected_ret_code ); diff --git a/uv.lock b/uv.lock index 98b6b94..2042086 100644 --- a/uv.lock +++ b/uv.lock @@ -117,7 +117,7 @@ wheels = [ [[package]] name = "readme-update" -version = "0.2.1" +version = "0.3.0" source = { editable = "." } [package.dev-dependencies]