diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d786845 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ + +Notable changes and version history. + +| Version | Date | Comment | +|---------|-------|-------| +| [v0.3.1](https://github.com/network-wrangler/projectcard/releases/tag/v0.3.1) | 2024-10-07 | Improved resilience of `read_cards()` including ability to handle relative paths. | +| [v0.3.0](https://github.com/network-wrangler/projectcard/releases/tag/v0.3.0) | 2024-09-27 | Added transit addition and deletion change types. | +| [v0.2.0](https://github.com/network-wrangler/projectcard/releases/tag/v0.2.0) | 2024-09-08 | Read cards from nested folders. | +| [v0.1.2](https://github.com/network-wrangler/projectcard/releases/tag/v0.1.2) | 2024-08-05 | - | +| [v0.1.1](https://github.com/network-wrangler/projectcard/releases/tag/v0.1.1) | 2024-06-20 | Initial release on PyPI | diff --git a/docs/development.md b/docs/development.md index f01eb87..01a5dbe 100644 --- a/docs/development.md +++ b/docs/development.md @@ -133,3 +133,10 @@ mkdocs serve !!! tip Releases must have a unique version number in order to be updated on pypi. + +## Changelog + +{! + include-markdown "../CHANGELOG.md" + heading-offset=1 +!} diff --git a/docs/index.md b/docs/index.md index a0df2ee..2e78f3b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -84,4 +84,11 @@ But we'd love to make it better! Please report bugs or incorrect/unclear/missing ## Who-dat? -ProjectCard was developed using resources from the [Metropolitan Transportation Commission](bayareametro.gov), [Metropolitan Council MN](https://metrocouncil.org/), and in-kind time from [UrbanLabs LLC](https://urbanlabs.io) and [WSP](www.wsp.com). It is currently maintained using in-kind time...so please be patient. +ProjectCard was developed using resources from the [Metropolitan Transportation Commission](https://www.bayareametro.gov), [Metropolitan Council MN](https://metrocouncil.org/), and in-kind time from [UrbanLabs LLC](https://urbanlabs.io) and [WSP](https://www.wsp.com). It is currently maintained using in-kind time...so please be patient. + +## Release History + +{! + include-markdown "../CHANGELOG.md" + heading-offset=1 +!} diff --git a/docs/json-schemas.md b/docs/json-schemas.md index 526d76a..288be1f 100644 --- a/docs/json-schemas.md +++ b/docs/json-schemas.md @@ -3,4 +3,4 @@ !!! tip "Find this documentation confusing?" Me too. That's why we spent a bunch more time documenting the [data models](datamodels.md), which read much more nicely - or you can check out the [examples](examples.md). -{{ document_schema(schema_filename="projectcard.json") }} +{{ document_schema() }} diff --git a/docs/requirements.txt b/docs/requirements.txt index db996d8..76358de 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,5 @@ fontawesome_markdown -json_schema_for_humans -jsonschema2md +jsonschema-markdown mike mkdocs mkdocs-autorefs diff --git a/main.py b/main.py index 3b75bab..e3516b8 100644 --- a/main.py +++ b/main.py @@ -15,42 +15,11 @@ def define_env(env): """ @env.macro - def document_schema(schema_filename: str) -> str: - from json_schema_for_humans.generate import generate_from_schema - from json_schema_for_humans.generation_configuration import ( - GenerationConfiguration, - ) - - _rel_schema_path = SCHEMA_DIR / schema_filename - _abs_schema_path = _rel_schema_path.absolute() - if not _abs_schema_path.exists(): - msg = f"Schema doesn't exist at: {_abs_schema_path}" - raise FileNotFoundError(msg) - - _config = GenerationConfiguration( - minify=False, - copy_css=False, - copy_js=False, - template_name="js", - expand_buttons=True, - ) - - content = generate_from_schema(_abs_schema_path, config=_config) - - # get content ready for mkdocs - _footer = _get_html_between_tags(content, tag="footer") - replace_strings = { - "": "", - '\ - Type: object
': "", - } - - for _orig, _new in replace_strings.items(): - content = content.replace(_orig, _new) - - content = _get_html_between_tags(content, tag="body") - content = _rm_html_between_tags(content, tag="footer") - return content + def document_schema(**kwargs) -> str: + """Generate Markdown documentation for a JSON schema.""" + from projectcard.docs import json_schema_to_md + + return json_schema_to_md(**kwargs) @env.macro def list_examples(data_dir: Path) -> str: diff --git a/mkdocs.yml b/mkdocs.yml index e73f5aa..bb7514a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,8 +15,12 @@ theme: features: - content.code.annotate - content.code.copy + - content.tabs.link + - navigation.indexes - navigation.tabs + - navigation.expand - toc.integrate + - toc.follow palette: - scheme: default primary: blue grey @@ -27,10 +31,12 @@ theme: plugins: - autorefs - awesome-pages - - include-markdown - - mike + - include-markdown: + opening_tag: "{!" + closing_tag: "!}" - macros - mermaid2 + - mike - mkdocs-jupyter: include_source: True - mkdocstrings: @@ -52,12 +58,10 @@ extra: default: latest extra_javascript: - - https://code.jquery.com/jquery-3.4.1.min.js - - https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js + - https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js - javascript/schema_doc.min.js extra_css: - - extra_css/extra.css markdown_extensions: @@ -65,11 +69,13 @@ markdown_extensions: - codehilite: linenums: true - meta - - pymdownx.inlinehilite + - pymdownx.details - pymdownx.highlight: anchor_linenums: true line_spans: __span pygments_lang_class: true + use_pygments: true + - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.tasklist: custom_checkbox: true diff --git a/projectcard/__init__.py b/projectcard/__init__.py index e3dd3ce..774f57e 100644 --- a/projectcard/__init__.py +++ b/projectcard/__init__.py @@ -1,8 +1,9 @@ """Project Card representation and validation.""" +from .errors import ProjectCardReadError, PycodeError, ValidationError from .io import read_card, read_cards, write_card from .logger import CardLogger, setup_logging from .projectcard import ProjectCard, SubProject -from .validate import PycodeError, ValidationError, validate_card, validate_schema_file +from .validate import validate_card, validate_schema_file __version__ = "0.3.0" diff --git a/projectcard/docs.py b/projectcard/docs.py index 780563f..db8f3ab 100644 --- a/projectcard/docs.py +++ b/projectcard/docs.py @@ -1,12 +1,17 @@ """Utilities to assist in documentation which may be useful for other purposes.""" + from __future__ import annotations +import json +from collections import defaultdict from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal, Union -from .utils import slug_to_str, make_slug -from .io import read_cards +import pandas as pd +from .io import read_cards +from .utils import make_slug, slug_to_str +from .validate import _open_json if TYPE_CHECKING: from .projectcard import ProjectCard @@ -22,7 +27,7 @@ def card_to_md(card: ProjectCard) -> str: return _card_md -def _categories_as_str(change_types: list[str] ) -> str: +def _categories_as_str(change_types: list[str]) -> str: if len(change_types) == 1: return slug_to_str(change_types[0]) @@ -32,15 +37,12 @@ def _categories_as_str(change_types: list[str] ) -> str: def _card_to_mdrow(card): - _md_row = ( - f"| [{card.project.title()}](#{make_slug(card.project).replace('_','-')}) | " - ) + _md_row = f"| [{card.project.title()}](#{make_slug(card.project).replace('_','-')}) | " _md_row += f" {_categories_as_str(card.change_types)} |" _md_row += f" {card.notes} |\n" return _md_row - def card_list_to_table(card_dir: Path) -> str: """Generates a table of all project cards in a directory followed by the cards.""" CARD_LIST_TABLE_FIELDS = ["Category", "Notes"] @@ -59,4 +61,307 @@ def card_list_to_table(card_dir: Path) -> str: md_table += _card_to_mdrow(card) md_examples += card_to_md(card) - return md_table + md_examples \ No newline at end of file + return md_table + md_examples + + +ROOTDIR = Path(__file__).resolve().parent +PROJECTCARD_SCHEMA = ROOTDIR / "schema" / "projectcard.json" + + +def json_schema_to_md(schema_path: Path = PROJECTCARD_SCHEMA) -> str: + """Generate Markdown documentation for a JSON schema.""" + if not schema_path.is_absolute(): + rel_schema_path = ROOTDIR / schema_path + schema_path = rel_schema_path.absolute() + schema_dir = schema_path.parent + schema_dict = _get_dict_of_schemas(schema_dir) + sorted_folders = sorted(schema_dict.keys()) + markdown_output = f"{schema_path.root}\n" + for rel_folder in sorted_folders: + for schema_file in schema_dict[rel_folder]: + markdown_output += _single_jsonschema_to_md(schema_file, rel_folder) + + return markdown_output + + +def _list_to_bullets(items: list, fmt: Literal["html", "md"] = "md") -> str: + """Convert a list of items to a bulleted list in markdown or html.""" + if fmt == "html": + return "" + if fmt == "md": + return "\n".join(f"- `{json.dumps(item)}`" for item in items) + msg = "fmt must be 'html' or 'md'" + raise ValueError(msg) + + +def _restrictions_to_md(item: dict, list_fmt: Literal["html", "md"] = "md") -> str: + required_md = "" + lb = "" if list_fmt == "html" else "\n\n" + if "required" in item: + required_md += ( + f"**Required:**{lb}" + + _fmt_array_or_obj_or_str(item["required"], list_fmt=list_fmt) + + f"{lb}" + ) + if "anyOf" in item: + required_md += ( + f"**Any Of:**{lb}" + + _fmt_array_or_obj_or_str(item["anyOf"], list_fmt=list_fmt) + + f"{lb}" + ) + if "oneOf" in item: + required_md += ( + f"**One Of:**{lb}" + + _fmt_array_or_obj_or_str(item["oneOf"], list_fmt=list_fmt) + + f"{lb}" + ) + if "enum" in item: + required_md += "**Enumeration:** `" + "`,`".join(list(map(str, item["enum"]))) + f"`{lb}" + return required_md + + +def _properties_to_md(properties: dict) -> str: + """Convert a dictionary of properties to a markdown table.""" + rows = [] + for prop, details in properties.items(): + prop_type = _get_type_txt(details) + # Handle anyOf and oneOf + row = { + "Property": f"`{prop}`", + "Type": prop_type, + "Description": details.get( + "description", details.get("title", details.get("name", "-")) + ), + "Restrictions": _restrictions_to_md(details), + } + rows.append(row) + properties_df = pd.DataFrame(rows) + properties_md = properties_df.to_markdown(index=False) + return properties_md + + +def _defs_to_md(defs: dict) -> str: + """Convert a dictionary of $defs to a markdown table with anchors.""" + rows = [] + defs_md = "" + for def_name, details in defs.items(): + row = { + "Definition": f"`{def_name}`", + "Type": _get_type_txt(details), + "Description": details.get("description", details.get("name", "-")), + "Restrictions": _restrictions_to_md(details, list_fmt="html"), + } + rows.append(row) + # anchor = def_name.replace('/', '_') + # defs_md += f'\n\n' + defs_df = pd.DataFrame(rows) + defs_md += defs_df.to_markdown(index=False) + return defs_md + + +def _examples_to_md(examples: list, schema_name: str) -> str: + """Convert a list of examples to markdown.""" + examples_md = "" + indent = " " + if examples: + for i, example in enumerate(examples, 1): + examples_md += f'??? example "{schema_name} Example {i}"\n' + examples_md += f"{indent}```json\n" + examples_md += indent + json.dumps(example, indent=4).replace("\n", f"\n{indent}") + examples_md += f"\n{indent}```\n" + return examples_md + + +def _raw_schema_to_md(schema: dict, schema_name: str) -> str: + """Convert a raw JSON schema to drop-down admonition.""" + raw_md = "" + indent = " " + raw_md += f'??? abstract "{schema_name} Contents"\n' + raw_md += f"{indent}```json\n" + raw_md += indent + json.dumps(schema, indent=4).replace("\n", f"\n{indent}") + raw_md += f"\n{indent}```\n" + return raw_md + + +def _object_to_md(object_schema: dict, schema_name: str) -> str: + """Convert json-schema object information to markdown.""" + defs = object_schema.get("$defs", {}) + properties = object_schema.get("properties", {}) + examples = object_schema.get("examples", []) + SKIP = ["properties", "required", "oneOf", "anyOf", "$defs", "$schema", "examples", "type"] + additional_info = {k: v for k, v in object_schema.items() if k not in SKIP} + + # Generate $defs table + defs_md = "" + if defs: + defs_md = "\n**Definitions**:\n\n" + defs_md += _defs_to_md(defs) + "\n\n" + + # Generate properties table + properties_md = "\n**Properties**:\n\n" + _properties_to_md(properties) + + # Generate required fields list + required_md = _restrictions_to_md(object_schema, list_fmt="html") + + # Generate examples + examples_md = _examples_to_md(examples, schema_name) + "\n\n" if examples else "" + + # Generate additional information + additional_md = "" + if additional_info: + additional_md = "**Additional Information**:\n\n" + additional_md += _fmt_array_or_obj_or_str(additional_info) + + # Combine all parts + object_md = f"\n*Type:* Object\n\n" + if additional_md: + object_md += additional_md + "\n\n" + if defs_md: + object_md += defs_md + "\n\n" + object_md += properties_md + "\n\n" + if required_md: + object_md += required_md + "\n\n" + if examples_md: + object_md += examples_md + "\n\n" + + return object_md + + +def _get_type_txt(item: dict) -> str: + if "items" in item: + t = item["items"].get("type", item.get("$ref", "Any")) + return f"`array` of `{t}` items." + if "$ref" in item: + return f"`{item['$ref']}`" + return f"`{item.get('type', 'Any')}`" + + +def _fmt_array_or_obj_or_str( + item: Union[dict, list, str], list_fmt: Literal["html", "md"] = "md" +) -> str: + lb = "" if list_fmt == "html" else "\n\n" + if isinstance(item, dict): + if len(item) == 0: + return "" + if len(item) == 1: + for k, v in item.items(): + if isinstance(v, list): + return f"{k}:{lb}{_list_to_bullets(v, fmt=list_fmt)}" + return f"{k}: {v}" + else: + md = "" + for k, v in item.items(): + if isinstance(v, list): + bullet_list = _list_to_bullets(v, fmt=list_fmt).replace("\n", "\n ") + md += f"{k}:{lb} {bullet_list}" + md += f"- {k}: {v}{lb}" + return md + elif isinstance(item, list): + return _list_to_bullets(item, fmt=list_fmt) + return str(item) + + +def _array_to_md(array_item: dict, schema_name: str) -> str: + """Convert json-schema array information to markdown.""" + if "type" not in array_item or array_item["type"] != "array": + return "Invalid array schema" + + array_md = f"\n*Type:* {_get_type_txt(array_item)}\n\n" + + SKIP = ["type", "$ref"] + restrictions = { + k: _fmt_array_or_obj_or_str(v) + for k, v in array_item.get("items", {}).items() + if k not in SKIP + } + if restrictions: + array_md += "| Property | Value |\n" + array_md += "|----------|-------|\n" + for key, value in restrictions.items(): + array_md += f"| **{key}** | {value} |\n" + array_md += "\n" + + # Handle $defs if present + defs = array_item.get("$defs", {}) + if defs: + array_md += f"\n**Definitions**:\n\n" + array_md += _defs_to_md(defs) + "\n\n" + + # Generate examples + examples = array_item.get("examples", []) + examples_md = _examples_to_md(examples, schema_name) + "\n\n" if examples else "" + if examples_md: + array_md += examples_md + "\n\n" + + return array_md + + +def _other_type_to_md(schema_data, schema_name): + content_md = f"Schema Type: {_get_type_txt(schema_data)}\n\n" + + # Generate additional information + SKIP = [ + "properties", + "required", + "oneOf", + "anyOf", + "$defs", + "$schema", + "examples", + "type", + "enum", + ] + additional_info = {k: v for k, v in schema_data.items() if k not in SKIP} + if additional_info: + content_md = "**Additional Information**:\n\n" + content_md += _fmt_array_or_obj_or_str(additional_info) + "\n\n" + + restrictions_md = _restrictions_to_md(schema_data, list_fmt="html") + if restrictions_md: + content_md += restrictions_md + "\n\n" + + # Handle $defs if present + defs = schema_data.get("$defs", {}) + if defs: + content_md += f"\n**Definitions**:\n\n" + content_md += _defs_to_md(defs) + "\n\n" + + # Generate examples + examples = schema_data.get("examples", []) + if examples: + examples_md = _examples_to_md(examples, schema_name) + "\n\n" if examples else "" + content_md += examples_md + "\n\n" + return content_md + + +def _single_jsonschema_to_md(schema_file: Path, rel_folder: Path) -> str: + heading_level = len(rel_folder.parts) + 1 # +1 because base directory doesn't count + header = "\n##" + if heading_level > 1: + header += f"{'#' * heading_level} {'.'.join(rel_folder.parts)}." + header += f"{schema_file.name}\n" + + schema_data = _open_json(schema_file) + schema_name = schema_data.get("title", f"`{schema_file.stem}`") + + # Determine the type of schema and call the appropriate function + schema_type = schema_data.get("type", "unknown") + if schema_type == "array": + content_md = _array_to_md(schema_data, schema_name) + elif schema_type == "object": + content_md = _object_to_md(schema_data, schema_name) + else: + content_md = _other_type_to_md(schema_data, schema_name) + + content_md += "\n" + _raw_schema_to_md(schema_data, schema_name) + "\n" + + return header + content_md + + +def _get_dict_of_schemas(base_dir: Path, extension: str = ".json") -> defaultdict: + base_dir = Path(base_dir) + + files_by_folder = defaultdict(list) + for schema_file in base_dir.rglob(f"*{extension}"): + files_by_folder[schema_file.parent.relative_to(base_dir)].append(schema_file) + return files_by_folder diff --git a/projectcard/errors.py b/projectcard/errors.py new file mode 100644 index 0000000..6140efd --- /dev/null +++ b/projectcard/errors.py @@ -0,0 +1,22 @@ +"""Custom errors for projectcard package.""" +from jsonschema.exceptions import SchemaError, ValidationError + + +class ProjectCardReadError(Exception): + """Error in reading project card.""" + + +class ProjectCardValidationError(ValidationError): + """Error in formatting of ProjectCard.""" + + +class SubprojectValidationError(ProjectCardValidationError): + """Error in formatting of Subproject.""" + + +class PycodeError(ProjectCardValidationError): + """Basic runtime error in python code.""" + + +class ProjectCardJSONSchemaError(SchemaError): + """Error in the ProjectCard json schema.""" \ No newline at end of file diff --git a/projectcard/io.py b/projectcard/io.py index 2b60035..8a57c6e 100644 --- a/projectcard/io.py +++ b/projectcard/io.py @@ -9,6 +9,7 @@ import toml import yaml +from .errors import ProjectCardReadError from .logger import CardLogger from .projectcard import REPLACE_KEYS, VALID_EXT, ProjectCard @@ -17,11 +18,6 @@ DEFAULT_BASE_PATH = Path.cwd() - -class ProjectCardReadError(Exception): - """Error in reading project card.""" - - SKIP_READ = ["valid"] SKIP_WRITE = ["valid"] @@ -98,6 +94,7 @@ def _read_wrangler(filepath: Path) -> dict: def write_card(project_card, filename: Optional[Path] = None): """Writes project card dictionary to YAML file.""" from .utils import make_slug + default_filename = make_slug(project_card.project) + ".yml" filename = filename or Path(default_filename) diff --git a/projectcard/models/changes.py b/projectcard/models/changes.py index 5c6c6e2..9525f8e 100644 --- a/projectcard/models/changes.py +++ b/projectcard/models/changes.py @@ -18,7 +18,7 @@ class RoadwayDeletion(BaseModel): """Requirements for describing roadway deletion project card (e.g. to delete). - Parameters: + Attributes: links (Optional[SelectRoadLinks]): Roadway links to delete. nodes (Optional[SelectRoadNodes]): Roadway nodes to delete. clean_shapes (bool): If True, will clean unused roadway shapes associated with the deleted links @@ -53,7 +53,7 @@ class RoadwayDeletion(BaseModel): class RoadwayAddition(BaseModel): """Requirements for describing roadway addition project card. - Parameters: + Attributes: links (Optional[list[RoadLink]]): Roadway links to add. Must have at least one link. nodes (Optional[list[RoadNode]]): Roadway nodes to add. Must have at least one node. @@ -93,7 +93,7 @@ class RoadwayAddition(BaseModel): class RoadwayPropertyChanges(BaseModel): """Value for setting property changes for a time of day and category. - Parameters: + Attributes: facility (SelectFacility): Selection of roadway links to change properties for. property_changes (dict[str, RoadwayPropertyChange]): Property changes to apply to the selection. Must have at least one property change. @@ -131,7 +131,7 @@ class RoadwayPropertyChanges(BaseModel): class TransitPropertyChange(BaseModel): """Value for setting property change for a time of day and category. - Parameters: + Attributes: service (SelectTransitTrips): Selection of transit trips to change properties for. property_changes (dict[str, TransitPropertyChange]): List of property changes to apply. @@ -161,7 +161,7 @@ class TransitPropertyChange(BaseModel): class TransitRoutingChange(BaseModel): """Value for setting routing change for transit. - Parameters: + Attributes: service (SelectTransitTrips): Selection of transit trips to change routing for. transit_routing_change (TransitRoutingChange): Existing and changed routing as denoted as a list of nodes with nodes where the route doesn't stop noted as negative integers. @@ -195,7 +195,7 @@ class TransitRoutingChange(BaseModel): class TransitServiceDeletion(BaseModel): """Requirements for describing transit service deletion project card (e.g. to delete). - Parameters: + Attributes: service (SelectTransitTrips): Selection of transit trips to delete. clean_shapes (Optional[bool]): If True, will clean unused transit shapes associated with the deleted trips if they are not otherwise being used. Defaults to False. @@ -225,7 +225,7 @@ class TransitServiceDeletion(BaseModel): class TransitRouteAddition(BaseModel): """Requirements for describing transit route addition project card. - Parameters: + Attributes: routes (list[TransitRoute]): List of transit routes to be added. Must have at least one route. !!! Example "Example Transit Route Addition" diff --git a/projectcard/models/project.py b/projectcard/models/project.py index 6d530d0..c8c3fdc 100644 --- a/projectcard/models/project.py +++ b/projectcard/models/project.py @@ -69,7 +69,7 @@ class ProjectModel(BaseModel): List of tools that support json-schema: - Parameters: + Attributes: project (str): The name of the project. This name must be unique within a set of projects being managed or applied to a network. notes (Optional[str]): Additional freeform notes about the project. diff --git a/projectcard/models/selections.py b/projectcard/models/selections.py index f6985d9..35e49c6 100644 --- a/projectcard/models/selections.py +++ b/projectcard/models/selections.py @@ -17,7 +17,7 @@ class SelectRoadNodes(BaseModel): """Requirements for describing multiple nodes of a project card (e.g. to delete). - Parameters: + Attributes: all (bool): If True, select all nodes. Must have either `all`, `osm_node_id` or `model_node_id`. osm_node_id (Optional[list[str]]): List of OSM node IDs to select. Must have either @@ -50,7 +50,7 @@ class SelectRoadLinks(BaseModel): Additional fields to select on may be provided and will be treated as an AND condition after the primary selection from `all`, `name`, `osm_link_id`, or `model_link_id`. - Parameters: + Attributes: all (bool): If True, select all links. name (Optional[list[str]]): List of names to select. If multiple provided will be treated as an OR condition. @@ -126,7 +126,7 @@ class SelectTransitTrips(BaseModel): Multiple requirements are treated as an AND condition. - Parameters: + Attributes: trip_properties (Optional[SelectTripProperties]): Selection based on trip properties. route_properties (Optional[SelectRouteProperties]): Selection based on route properties. timespans (List[Timespan]): List of timespans to select. Multiple timespans are treated @@ -180,7 +180,7 @@ class SelectFacility(BaseModel): continuous path - reulting in a final selection of links that may or may not connect the two nodes. - Parameters: + Attributes: links (Optional[SelectRoadLinks]): Selection of roadway links. nodes (Optional[SelectRoadNodes]): Selection of roadway nodes. from (Optional[SelectRoadNode]): Selection of the origin node. diff --git a/projectcard/projectcard.py b/projectcard/projectcard.py index 0f9c4f4..7c66991 100644 --- a/projectcard/projectcard.py +++ b/projectcard/projectcard.py @@ -4,11 +4,10 @@ from typing import Union +from .errors import ProjectCardValidationError, SubprojectValidationError from .logger import CardLogger from .utils import _findkeys from .validate import ( - ProjectCardValidationError, - SubprojectValidationError, update_dict_with_schema_defaults, validate_card, ) diff --git a/projectcard/validate.py b/projectcard/validate.py index a4868be..37df0ac 100644 --- a/projectcard/validate.py +++ b/projectcard/validate.py @@ -11,6 +11,7 @@ from jsonschema import validate from jsonschema.exceptions import SchemaError, ValidationError +from .errors import ProjectCardJSONSchemaError, ProjectCardValidationError, PycodeError from .logger import CardLogger ROOTDIR = Path(__file__).resolve().parent @@ -24,22 +25,6 @@ FLAKE8_ERRORS = ["E9", "F821", "F823", "F405"] -class ProjectCardValidationError(ValidationError): - """Error in formatting of ProjectCard.""" - - -class SubprojectValidationError(ProjectCardValidationError): - """Error in formatting of Subproject.""" - - -class PycodeError(ProjectCardValidationError): - """Basic runtime error in python code.""" - - -class ProjectCardJSONSchemaError(SchemaError): - """Error in the ProjectCard json schema.""" - - def _open_json(schema_path: Path) -> dict: try: with schema_path.open() as file: @@ -59,6 +44,11 @@ def _load_schema(schema_absolute_path: Path) -> dict: base_path = Path(schema_absolute_path).parent base_uri = f"file:///{base_path}/" + if not schema_absolute_path.exists(): + msg = f"Schema not found at {schema_absolute_path}" + CardLogger.error(msg) + raise FileNotFoundError(msg) + _s = jsonref.replace_refs( _open_json(schema_absolute_path), base_uri=base_uri, diff --git a/pyproject.toml b/pyproject.toml index 1f4758a..82b9bc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "jsonschema", "pydantic>=2.0", "pyyaml", + "tabulate", "toml", ] diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 0000000..e88ffd4 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,42 @@ +"""Test Documentation builds and utilities.""" + +import subprocess + +import markdown +import pytest + +from projectcard.docs import card_list_to_table, json_schema_to_md +from projectcard.logger import CardLogger + + +def test_mkdocs_build(request): + """Tests that the MkDocs documentation can be built without errors.""" + CardLogger.info(f"--Starting: {request.node.name}") + subprocess.run(["mkdocs", "build"], capture_output=True, text=True, check=True) + CardLogger.info(f"--Finished: {request.node.name}") + + +def test_jsonschema2md(request, test_out_dir): + CardLogger.info(f"--Starting: {request.node.name}") + md = json_schema_to_md() + out_md = test_out_dir / "schema.md" + with out_md.open("w") as f: + f.write(md) + try: + markdown.markdown(md) + except Exception as e: + pytest.fail(f"json_schema_to_md generated Invalid markdown: {e}") + CardLogger.info(f"--Finished: {request.node.name}") + + +def test_examples2md(request, example_dir, test_out_dir): + CardLogger.info(f"--Starting: {request.node.name}") + md = card_list_to_table(example_dir) + out_md = test_out_dir / "examples.md" + with out_md.open("w") as f: + f.write(md) + try: + markdown.markdown(md) + except Exception as e: + pytest.fail(f"card_list_to_table generated Invalid markdown: {e}") + CardLogger.info(f"--Finished: {request.node.name}") \ No newline at end of file