Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ISSUE 7] Automated diagram generation #11

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6f08e1c
build: Sets up poetry project for Milestone CLI
widal001 May 29, 2023
1a1e17d
feat: Adds pydantic schemas for milestones
widal001 May 30, 2023
6135b6d
build: Adds pyyaml to project dependencies
widal001 May 30, 2023
88628d1
feat: Adds loads_milestones_from_yaml_file()
widal001 May 30, 2023
998c8bd
build: Add jinja2 to project dependencies
widal001 May 31, 2023
eb1cd48
feat: Adds render_jinja_template() function
widal001 May 31, 2023
d0f214c
feat: Adds mermaid diagram template
widal001 May 31, 2023
ce945fe
feat(utils): Adds create_or_replace_file() function
widal001 May 31, 2023
fe7303a
fix: default status for milestones
widal001 May 31, 2023
58baaf5
build: Adds typer to project dependencies
widal001 May 31, 2023
f385a01
fix: How dependencies rendered in summary template
widal001 May 31, 2023
11ab465
feat: Add CLI entrypoints
widal001 May 31, 2023
4f439b3
feat: Creates a yaml version of the milestone summary
widal001 May 31, 2023
48a5cde
docs: Fills out README.md
widal001 May 31, 2023
07d7bb7
ci: Runs pre-commit on all files
widal001 May 31, 2023
a21182c
build: Adds .vscode/ to .gitingore
widal001 May 31, 2023
91b2d4f
feat: Finishes adding milestones to yaml file
widal001 Jun 4, 2023
c926724
build: Adds mypy to project dependencies
widal001 Jun 6, 2023
75ace40
fix: Type errors raised by mypy
widal001 Jun 6, 2023
2ec6b04
feat: Adds Makefile to simplify setup and dev commands
widal001 Jun 6, 2023
9e8a2ca
feat: Add tox file to orchestrate checks
widal001 Jun 6, 2023
abef00a
Merge branch 'main' into ISSUE-7-automated-diagram-generation
widal001 Jun 7, 2023
e2280e5
docs: Improves README and docstrings
widal001 Jun 7, 2023
b1533d7
Merge branch 'main' into ISSUE-7-automated-diagram-generation
widal001 Jun 9, 2023
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,7 @@ dmypy.json

# Pyre type checker
.pyre/


# VSCode settings
.vscode/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ A modernization effort for Grants.gov.
1. Visit [localhost:9001](https://localhost:9001) to view the server
-->

### Setting up development tools
### Setting up development tools

#### Configuring pre-commit hooks

Expand Down
1,063 changes: 1,063 additions & 0 deletions documentation/milestones/milestone-summaries.yaml

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions milestone-cli/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
diagram_path := ./diagram.mmd
summary_path := ./summary.md

install:
poetry install

lint:
poetry run black milestone_cli tests
poetry run ruff milestone_cli tests --fix
poetry run mypy milestone_cli tests

requirements:
poetry export -f requirements.txt -o requirements.txt --with dev --without-hashes
echo ". # installs project" >> requirements.txt

tox:
make requirements
poetry run tox

milestones-check:
poetry run milestones validate

milestones-diagram:
ifneq ($(output_path),)
poetry run milestones populate diagram $(output_path)
else
poetry run milestones populate diagram $(diagram_path)
endif

milestones-summary:
ifneq ($(output_path),)
poetry run milestones populate summary $(output_path)
else
poetry run milestones populate summary $(summary_path)
endif
48 changes: 48 additions & 0 deletions milestone-cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Milestone CLI Tool

The milestone CLI tool streamlines the process of validating and publishing changes to the milestone summary yaml file.

## Getting Started

### Prerequisites

The following items are pre-requisites for installing this CLI tool:

- Python version 3.10 or greater
- Poetry

Validate that these requirements are met with:
```shell
python --version
poetry --version
```

### Quickstart

1. Install the package: `make install`
2. Validate the milestone yaml file: `make milestones_check`
3. Populate the files:
- Diagram: `make milestones_diagram`
- Summary: `make milestones_summary`


## Made with

- Project Dependencies
- pydantic - Extends python dataclasses for data (de)serialization and validation
- typer - Enables building simple but powerful command-line interfaces
- Jinja2 - Templating engine used to populate documents with data
- PyYAML - (De)serializes data between yaml files and python objects
- Dev Dependencies
- poetry - Python build tool and dependency manager
- ruff - Fast python linter built in Rust
- black - Auto-formatting for python
- pytest - Unit test framework
- tox - Test and linting orchestrator


## Roadmap

- Simplify setup with Makefile
- Expand validations that are run on load
- Improve exception handling and error messages
1 change: 1 addition & 0 deletions milestone-cli/milestone_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "0.1.0"
96 changes: 96 additions & 0 deletions milestone-cli/milestone_cli/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Defines the command line entry points to load and populate milestone files"""
from pathlib import Path

import typer

from milestone_cli.utils import (
load_milestones_from_yaml_file,
render_milestone_template,
create_or_replace_file,
MilestoneSummary,
)

# path to root of the repository
REPO_ROOT_DIR = Path(__file__).absolute().parent.parent.parent

# path to project directories and files
PROJECT_ROOT = REPO_ROOT_DIR / "milestone-cli"
TEMPLATE_DIR = PROJECT_ROOT / "milestone_cli" / "templates"
DIAGRAM_TEMPLATE = TEMPLATE_DIR / "milestone-diagram.mmd"
SUMMARY_TEMPLATE = TEMPLATE_DIR / "milestone-summary.md"

# external directories and files
MILESTONE_DIR = REPO_ROOT_DIR / "documentation" / "milestones"
MILESTONE_FILE = MILESTONE_DIR / "milestone-summaries.yaml"


app = typer.Typer(name="Milestone CLI")


@app.command(name="hello")
def hello_world(name: str | None = None):
"""Say hello to a given name or Hello World by default"""
print(f"Hello {name if name else 'world'}")


@app.command(name="validate")
def validate_yaml_file_contents(
yaml_file: str | None = None,
) -> MilestoneSummary | None:
"""Loads MilestoneSummary from yaml file and validates contents

Args:
yaml_file: Pathlike string to the yaml file which contains milestone details

Returns:
An instance of MilestoneSummary when the details can be parsed from the
yaml file, None if the file doesn't exist or there are parsing errors
"""
if not yaml_file:
file_path = MILESTONE_FILE
else:
file_path = Path(yaml_file).absolute()
if not file_path.exists():
print(f"No file found at {file_path}")
return None
if file_path.suffix not in (".yaml", ".yml"):
print(f"{file_path} is not a path to a yaml file")
return None
milestones = load_milestones_from_yaml_file(file_path)
print("Everything looks good")
return milestones


@app.command(name="populate")
def populate_output_file(
kind: str,
output_file: str,
yaml_file: str | None = None,
) -> None:
"""Populate either the diagram or the summary file

Args:
kind: The type of document we are populating, must be "diagram" or "summary"
output_file: Pathlike string to the output file to populate
yaml_file: Pathlike string to yaml file from which to load milestone details
"""
if kind == "diagram":
template_path = DIAGRAM_TEMPLATE
elif kind == "summary":
template_path = SUMMARY_TEMPLATE
else:
print("Command must either be 'populate summary' or 'populate diagram'")
# load milestones and get output path
milestones = validate_yaml_file_contents(yaml_file)
if not milestones:
return
file_path = Path(output_file).absolute()
print(f"Writing {kind} to {file_path}")
# create or replace output file with rendered template
create_or_replace_file(
file_path=file_path,
new_contents=render_milestone_template(
template_path,
milestones.export_jinja_params(),
),
)
121 changes: 121 additions & 0 deletions milestone-cli/milestone_cli/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Defines the data interfaces for milestone details"""
from pydantic import BaseModel


class MilestoneBase(BaseModel):
"""Contains fields shared by Milestone and Section

Attributes:
heading: The heading as it will appear in the summary markdown doc
description: The description as it will appear in the summary markdown doc
diagram_name: The name as it will appear in the mermaid diagram
"""

heading: str
description: str
diagram_name: str


class Milestone(MilestoneBase):
"""Contains a summary of details about a project milestone

Attributes:
status: The status of the milestone, which appears in the summary doc and
determines the styling applied to the node in the mermaid diagram
dependencies: A list of the upstream dependencies for this milestone
"""

status: str | None = None
dependencies: list[str] | None = None


class MilestoneSection(MilestoneBase):
"""Represents a group of project milestones

Attributes:
milestones: The list of milestones that fall under this section
"""

milestones: dict[str, Milestone]


class Dependency(BaseModel):
"""Documents a (downstream) milestone's dependency on an upstream milestone

Attributes:
upstream: The milestone that the downstream milestone depends on
downstream: The downstream milestone that is blocked by the upstream milestone
"""

upstream: Milestone
downstream: Milestone

def jinja_export(self) -> dict:
"""Export just the diagram names for jinja templating

Returns:
A dictionary of the diagram names for upstream and downstream dependencies
"""
return {
"upstream": self.upstream.diagram_name,
"downstream": self.downstream.diagram_name,
}


class MilestoneSummary(BaseModel):
"""Stores the list of milestones and the sections they belong to

Attributes:
version: Version of the milestone summary pulled from the yaml file
sections: A dictionary that maps MilestoneSections to their keys
"""

version: str
sections: dict[str, MilestoneSection]

def map_dependencies(self, milestone: Milestone) -> list[Dependency] | None:
"""Split milestone dependencies into a list of Dependency objects

Args:
milestone: A Milestone object to map dependencies for

Returns:
A list of Dependency objects for the given Milestone if it has
upstream depdencies, None otherwise
"""
if not milestone.dependencies:
return None
deps = []
for parent in milestone.dependencies:
upstream = self.milestones.get(parent)
if not upstream:
# TODO: Change to custom error type
raise KeyError(f"'{parent}' not found in list of milestones")
deps.append(Dependency(upstream=upstream, downstream=milestone))
return deps

@property
def dependencies(self) -> list[Dependency]:
"""Returns a list of Dependency objects across all milestones"""
results = []
for milestone in self.milestones.values():
dependencies = self.map_dependencies(milestone)
if dependencies:
results.extend(dependencies)
return results

@property
def milestones(self) -> dict[str, Milestone]:
"""Returns a combined dictionary of Milestone objects mapped to their key"""
return {
milestone: details
for section in self.sections.values()
for milestone, details in section.milestones.items()
}

def export_jinja_params(self) -> dict:
"""Exports sections and milestones for use in jinja templates"""
return {
**self.dict(),
"dependencies": [dep.jinja_export() for dep in self.dependencies],
}
38 changes: 38 additions & 0 deletions milestone-cli/milestone_cli/templates/milestone-diagram.mmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
%% A note on syntax:
%% 1. Since node IDs cannot have spaces, prefer to give each milestone a short name with any spaces replaced by `-`. For instance, "Development Tools Implemented" becomes "Dev-Tools".

%% For unclear reasons, PyCharm's mermaid editor does not support title attributes. Comment on or off the title as needed.

%% ---
%% title: Grants.gov modernization milestones
%% ---

%% Diagram is oriented left-to-right ("LR") rather than top-to-bottom

flowchart LR

{% for section in params.sections.values() %}
subgraph {{ section.diagram_name -}}
{% for milestone in section.milestones.values() %}
{{ milestone.diagram_name }}:::{{ milestone.status if milestone.status else 'TODO' -}}
{% endfor %}
end
{% endfor %}

subgraph Legend
direction LR
a1[This milestone is finished]:::finished --> a2[This milestone is being executed]:::executing
a3[This milestone is being planned]:::planning --> a4[This milestone is not yet planned]
a5[This milestone is a 'north star' milestone]:::northStar
end

%% List relationships %%
{%- for dependency in params.dependencies %}
{{ dependency.upstream }} --> {{ dependency.downstream -}}
{% endfor %}

%% Define some styles
classDef planning fill:#9999FF
classDef executing fill:#FF6633
classDef finished fill:#99FF33
classDef northStar fill:#cc99ff
Loading