Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 |
|:---|:---|:---|
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
20 changes: 19 additions & 1 deletion python/update_readme/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
)
16 changes: 14 additions & 2 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,26 @@ use pyo3::prelude::*;
use std::path::Path;

#[pyfunction]
fn py_main(scripts_root: String, readme_path: String) -> PyResult<i8> {
fn py_main(
scripts_root: String,
readme_path: String,
table_fields: Vec<String>,
link_fields: Vec<String>,
) -> PyResult<i8> {
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),
}
}

Expand Down
159 changes: 125 additions & 34 deletions src/core/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -28,7 +50,7 @@ pub fn main(file_sys: &mut impl FileSystem, scripts_root: String, readme_path: &
}

let py_files: Vec<PyFile> = 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 {
Expand All @@ -50,6 +72,7 @@ pub enum RetCode {
NoPyFiles,
FailedParsingFile,
FailedToWriteReadme,
InvalidLinkFields,
}

#[derive(Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -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<String, TableValue>,
}

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::<Vec<_>>()
.join(" | ");
format!("| `{}` | {} |", basename, cols)
}
}

fn extract_docinfo(py_files: Vec<PyFile>) -> Vec<DocInfo> {
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<Ordering> {
Some(self.cmp(other))
}
}

fn extract_docinfo(
py_files: Vec<PyFile>,
table_fields: &[String],
link_fields: &[String],
) -> Vec<DocInfo> {
let mut doc_infos: Vec<DocInfo> = 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<DocInfo>) -> String {
[
"# Scripts",
"| Name | Description | Link |",
"|:---|:---|:---|",
]
.into_iter()
.map(str::to_string)
.chain(doc_infos.iter().map(|n| n.to_readme()).collect::<Vec<_>>())
.chain(std::iter::once("::".to_string()))
.collect::<Vec<_>>()
.join("\n")
fn create_readme(doc_infos: Vec<DocInfo>, 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::<Vec<_>>(),
)
.chain(std::iter::once("::".to_string()))
.collect::<Vec<_>>()
.join("\n")
}

fn generate_scripts_docs(py_files: Vec<PyFile>) -> String {
create_readme(extract_docinfo(py_files))
fn generate_scripts_docs(
py_files: Vec<PyFile>,
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 {
Expand Down Expand Up @@ -235,6 +319,13 @@ mod tests {
.collect::<Vec<String>>()
.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
);
}
}
Loading