Skip to content

API

Basic Usage

from projectcard.io import read_cards

# Read in cards from a directory with the tag "Baseline 2030"
project_cards = read_cards(directory, filter_tags=["Baseline2030"])

# Iterate through a deck of cards for validity
for project_name,card in project_cards.items():
    print(f"{project_name}: {card.valid}")

# Print out a summary of the card with the project name "4th Ave Busway"
print(project_cards["4th Ave Busway"])

Project Card class for project card data schema.

DictDotAccessMixin

Provides dictionary-like access to class instance attributes and dot-like access to dict attributes.

Source code in projectcard/projectcard.py
class DictDotAccessMixin:
    """Provides dictionary-like access to class instance attributes and dot-like access to __dict__ attributes."""

    def __getitem__(self, key):
        """Return value of attribute."""
        return getattr(self, key)

    def __getattr__(self, key):
        """Return value of attribute."""
        return self.__dict__[key]

__getattr__(key)

Return value of attribute.

Source code in projectcard/projectcard.py
def __getattr__(self, key):
    """Return value of attribute."""
    return self.__dict__[key]

__getitem__(key)

Return value of attribute.

Source code in projectcard/projectcard.py
def __getitem__(self, key):
    """Return value of attribute."""
    return getattr(self, key)

ProjectCard

Bases: DictDotAccessMixin

Representation of a Project Card.

Attributes:

Name Type Description
__dict__

Dictionary of project card attributes

project

Name of project

dependencies dict

Dependencies of project

tags list[str]

Tags of project

notes str

Notes about project

valid bool

Boolean indicating if data conforms to project card data schema

change_types list[str]

List of all project types in project card

change_type str

either singular project type in project card or the string “multiple”

sub_projects str

list of sub_project objects

Source code in projectcard/projectcard.py
class ProjectCard(DictDotAccessMixin):
    """Representation of a Project Card.

    Attributes:
        __dict__: Dictionary of project card attributes
        project: Name of project
        dependencies: Dependencies of project
        tags: Tags of project
        notes: Notes about project
        valid: Boolean indicating if data conforms to project card data schema
        change_types: List of all project types in project card
        change_type: either singular project type in project card or the string "multiple"
        sub_projects: list of sub_project objects
    """

    def __init__(self, attribute_dictonary: dict, use_defaults: bool = True):
        """Constructor for ProjectCard object.

        Args:
            attribute_dictonary: a nested dictionary of attributes
            use_defaults: if True, will use default values for missing required attributes,
                if exist in schema. Defaults to True.
        """
        # add these first so they are first on write out
        self.tags: list[str] = []
        self.dependencies: dict = {}
        self.notes: str = ""
        self._sub_projects: list[SubProject] = []
        if use_defaults:
            attribute_dictonary = update_dict_with_schema_defaults(attribute_dictonary)
        self.__dict__.update(attribute_dictonary)
        for sp in self.__dict__.get("changes", []):
            sp_obj = SubProject(sp, self)
            self._sub_projects.append(sp_obj)

    def __str__(self):
        """String representation of project card."""
        s = [f"{key}: {value}" for key, value in self.__dict__.items()]
        return "\n".join(s)

    def validate(self) -> bool:
        """Return True if project card is valid, False otherwise."""
        return validate_card(self.__dict__)

    @property
    def to_dict(self) -> dict:
        """Return dictionary of public project card attributes."""
        return {k: v for k, v in self.__dict__.items() if not k.startswith("_") and v is not None}

    @property
    def valid(self) -> bool:
        """Return True if project card is valid, False otherwise."""
        try:
            self.validate()
        except ProjectCardValidationError as e:
            CardLogger.error(f"Project {self.project} is not valid: {e}")
            return False
        return True

    @property
    def change_types(self) -> list[str]:
        """Returns list of all change types from project/subproject."""
        if self._sub_projects:
            return [sp.change_type for sp in self._sub_projects]

        type_keys = [k for k in self.__dict__ if k in CHANGE_TYPES]
        if not type_keys:
            msg = f"Couldn't find type of project card {self.project}"
            raise ProjectCardValidationError(msg)
        return type_keys

    @property
    def change_type(self) -> str:
        """Return single change type if single project or "multiple" if >1 subproject."""
        t = self.change_types
        if len(t) > 1:
            return "multiple"
        return t[0]

change_type: str property

Return single change type if single project or “multiple” if >1 subproject.

change_types: list[str] property

Returns list of all change types from project/subproject.

to_dict: dict property

Return dictionary of public project card attributes.

valid: bool property

Return True if project card is valid, False otherwise.

__init__(attribute_dictonary, use_defaults=True)

Constructor for ProjectCard object.

Parameters:

Name Type Description Default
attribute_dictonary dict

a nested dictionary of attributes

required
use_defaults bool

if True, will use default values for missing required attributes, if exist in schema. Defaults to True.

True
Source code in projectcard/projectcard.py
def __init__(self, attribute_dictonary: dict, use_defaults: bool = True):
    """Constructor for ProjectCard object.

    Args:
        attribute_dictonary: a nested dictionary of attributes
        use_defaults: if True, will use default values for missing required attributes,
            if exist in schema. Defaults to True.
    """
    # add these first so they are first on write out
    self.tags: list[str] = []
    self.dependencies: dict = {}
    self.notes: str = ""
    self._sub_projects: list[SubProject] = []
    if use_defaults:
        attribute_dictonary = update_dict_with_schema_defaults(attribute_dictonary)
    self.__dict__.update(attribute_dictonary)
    for sp in self.__dict__.get("changes", []):
        sp_obj = SubProject(sp, self)
        self._sub_projects.append(sp_obj)

__str__()

String representation of project card.

Source code in projectcard/projectcard.py
def __str__(self):
    """String representation of project card."""
    s = [f"{key}: {value}" for key, value in self.__dict__.items()]
    return "\n".join(s)

validate()

Return True if project card is valid, False otherwise.

Source code in projectcard/projectcard.py
def validate(self) -> bool:
    """Return True if project card is valid, False otherwise."""
    return validate_card(self.__dict__)

SubProject

Bases: DictDotAccessMixin

Representation of a SubProject within a ProjectCard.

Attributes:

Name Type Description
parent_project ProjectCard

reference to parent ProjectCard object

type ProjectCard

project type

tags list[str]

reference to parent project card tags

dependencies dict

reference to parent project card’s dependencies

project str

reference to the name of the parent project card’s name

Source code in projectcard/projectcard.py
class SubProject(DictDotAccessMixin):
    """Representation of a SubProject within a ProjectCard.

    Attributes:
        parent_project: reference to parent ProjectCard object
        type:  project type
        tags: reference to parent project card tags
        dependencies: reference to parent project card's dependencies
        project: reference to the name of the parent project card's name
    """

    def __init__(self, sp_dictionary: dict, parent_project: ProjectCard):
        """Constructor for SubProject object.

        Args:
            sp_dictionary (dict): dictionary of sub-project attributes contained within "changes"
                list of parent projet card
            parent_project (ProjectCard): ProjectCard object for parent project card

        """
        self._parent_project = parent_project

        if len(sp_dictionary) != 1:
            msg = f"Subproject of {parent_project.project} should only have one change. Found {len(sp_dictionary)} changes."
            CardLogger.error(
                msg
                + f"  Did you forget to indent the rest of this change?\nKeys: {sp_dictionary.keys()}"
            )
            raise SubprojectValidationError(msg)
        self._change_type = next(iter(sp_dictionary.keys()))
        self.__dict__.update(sp_dictionary)
        self._sub_projects: list[SubProject] = []

    @property
    def change_type(self) -> str:
        """Return change type from subproject."""
        return self._change_type

    @property
    def parent_project(self) -> ProjectCard:
        """Return parent project from parent project card."""
        return self._parent_project

    @property
    def project(self) -> str:
        """Return project name from parent project card."""
        return self._parent_project.project

    @property
    def dependencies(self) -> dict:
        """Return dependencies from parent project card."""
        return self._parent_project.dependencies

    @property
    def tags(self) -> list[str]:
        """Return tags from parent project card."""
        return self._parent_project.tags

    @property
    def valid(self) -> bool:
        """Check if subproject is valid."""
        return self._parent_project.valid

change_type: str property

Return change type from subproject.

dependencies: dict property

Return dependencies from parent project card.

parent_project: ProjectCard property

Return parent project from parent project card.

project: str property

Return project name from parent project card.

tags: list[str] property

Return tags from parent project card.

valid: bool property

Check if subproject is valid.

__init__(sp_dictionary, parent_project)

Constructor for SubProject object.

Parameters:

Name Type Description Default
sp_dictionary dict

dictionary of sub-project attributes contained within “changes” list of parent projet card

required
parent_project ProjectCard

ProjectCard object for parent project card

required
Source code in projectcard/projectcard.py
def __init__(self, sp_dictionary: dict, parent_project: ProjectCard):
    """Constructor for SubProject object.

    Args:
        sp_dictionary (dict): dictionary of sub-project attributes contained within "changes"
            list of parent projet card
        parent_project (ProjectCard): ProjectCard object for parent project card

    """
    self._parent_project = parent_project

    if len(sp_dictionary) != 1:
        msg = f"Subproject of {parent_project.project} should only have one change. Found {len(sp_dictionary)} changes."
        CardLogger.error(
            msg
            + f"  Did you forget to indent the rest of this change?\nKeys: {sp_dictionary.keys()}"
        )
        raise SubprojectValidationError(msg)
    self._change_type = next(iter(sp_dictionary.keys()))
    self.__dict__.update(sp_dictionary)
    self._sub_projects: list[SubProject] = []

Functions for reading and writing project cards.

dict_to_yaml_with_comments(d)

Converts a dictionary to a YAML string with comments.

Source code in projectcard/io.py
def dict_to_yaml_with_comments(d):
    """Converts a dictionary to a YAML string with comments."""
    yaml_str = yaml.dump(d, default_flow_style=False, sort_keys=False)
    yaml_lines = yaml_str.splitlines()
    final_yaml_lines = []

    for line in yaml_lines:
        if "#" in line:
            final_yaml_lines.append(f"#{line}")
        else:
            final_yaml_lines.append(line)

    return "\n".join(final_yaml_lines)

read_card(filepath, validate=True)

Read single project card from a path and return project card object.

Parameters:

Name Type Description Default
filepath ProjectCardFilepath

file where the project card is.

required
validate bool

if True, will validate the project card schema. Defaults to True.

True
Source code in projectcard/io.py
def read_card(filepath: ProjectCardFilepath, validate: bool = True):
    """Read single project card from a path and return project card object.

    Args:
        filepath: file where the project card is.
        validate: if True, will validate the project card schema. Defaults to True.
    """
    if not Path(filepath).is_file():
        msg = f"Cannot find project card file: {filepath}"
        raise FileNotFoundError(msg)
    card_dict = read_cards(filepath)
    card = next(iter(card_dict.values()))
    if validate:
        card.validate()
    return card

read_cards(filepath, filter_tags=None, recursive=False, base_path=DEFAULT_BASE_PATH, existing_projects=None)

Reads collection of project card files by inferring the file type.

Lowercases all keys, but then replaces any that need to be uppercased using the REPLACE_KEYS mapping. Needed to keep “A” and “B” uppercased.

If a path is given as a relative path, it will be resolved to an absolute path using the base_path.

Parameters:

Name Type Description Default
filepath ProjectCardFilepaths

where the project card is. A single path, list of paths, a directory, or a glob pattern.

required
filter_tags Optional[list[str]]

list of tags to filter by.

None
recursive bool

if True, will search recursively in subdirs.

False
base_path Path

base path to resolve relative paths from. Defaults to current working directory.

DEFAULT_BASE_PATH
existing_projects Optional[list[str]]

list of existing project names to check for uniqueness.

None
Source code in projectcard/io.py
def read_cards(
    filepath: ProjectCardFilepaths,
    filter_tags: Optional[list[str]] = None,
    recursive: bool = False,
    base_path: Path = DEFAULT_BASE_PATH,
    existing_projects: Optional[list[str]] = None,
) -> dict[str, ProjectCard]:
    """Reads collection of project card files by inferring the file type.

    Lowercases all keys, but then replaces any that need to be uppercased using the
    REPLACE_KEYS mapping.  Needed to keep "A" and "B" uppercased.

    If a path is given as a relative path, it will be resolved to an absolute path using
    the base_path.

    Args:
        filepath: where the project card is.  A single path, list of paths,
            a directory, or a glob pattern.
        filter_tags: list of tags to filter by.
        recursive: if True, will search recursively in subdirs.
        base_path: base path to resolve relative paths from. Defaults to current working directory.
        existing_projects: list of existing project names to check for uniqueness.

    Returns: dictionary of project cards by project name
    """
    CardLogger.debug(f"Reading cards from {filepath}.")
    filter_tags = filter_tags or []
    filter_tags = list(map(str.lower, filter_tags))
    cards = {}

    filepath = _resolve_rel_paths(filepath, base_path=base_path)
    if isinstance(filepath, list) or filepath.is_dir():
        card_paths = _get_cardpath_list(filepath, valid_ext=VALID_EXT, recursive=recursive)
        for p in card_paths:
            project_card = _read_card(
                p, filter_tags=filter_tags, existing_projects=existing_projects
            )
            if project_card is None:
                continue
            if project_card.project in cards:
                msg = f"Project names not unique from projects being read in together in `read_cards()`: {project_card.project}"
                raise ProjectCardReadError(msg)
            cards[project_card.project] = project_card
    else:
        project_card = _read_card(
            filepath, filter_tags=filter_tags, existing_projects=existing_projects
        )
        if project_card is not None:
            cards[project_card.project] = project_card
    if len(cards) == 0:
        CardLogger.warning("No project cards found with given parameters.")
    return cards

write_card(project_card, filename=None)

Writes project card dictionary to YAML file.

Source code in projectcard/io.py
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)

    if not project_card.valid:
        CardLogger.warning(f"{project_card.project} Project Card not valid.")
    out_dict: dict[str, Any] = {}

    # Writing these first manually so that they are at top of file
    out_dict["project"] = None
    if project_card.to_dict.get("tags"):
        out_dict["tags"] = None
    if project_card.to_dict.get("dependencies"):
        out_dict["dependencies"] = None
    out_dict.update(project_card.to_dict)
    for k in SKIP_WRITE:
        if k in out_dict:
            del out_dict[k]

    yaml_content = dict_to_yaml_with_comments(out_dict)

    with filename.open("w") as outfile:
        outfile.write(yaml_content)

    CardLogger.info(f"Wrote project card to: {filename}")

Validates ProjectCard JSON data against a JSON schema.

CRITICAL_ERRORS = ['E9', 'F821', 'F823', 'F405'] module-attribute

Errors in Ruff that will cause a code execution failure. E9: Syntax errors. F821: Undefined name. F823: Local variable referenced before assignment. F405: Name may be undefined, or defined from star imports.

package_schema(schema_path=PROJECTCARD_SCHEMA, outfile_path=None)

Consolidates referenced schemas into a single schema and writes it out.

Parameters:

Name Type Description Default
schema_path Union[Path, str]

Schema to read int and package. Defaults to PROJECTCARD_SCHEMA which is ROOTDIR / “schema” / “projectcard.json”.

PROJECTCARD_SCHEMA
outfile_path Optional[Union[Path, str]]

Where to write out packaged schema. Defaults to schema_path.basepath.packaged.json

None
Source code in projectcard/validate.py
def package_schema(
    schema_path: Union[Path, str] = PROJECTCARD_SCHEMA,
    outfile_path: Optional[Union[Path, str]] = None,
) -> None:
    """Consolidates referenced schemas into a single schema and writes it out.

    Args:
        schema_path: Schema to read int and package. Defaults to PROJECTCARD_SCHEMA which is
             ROOTDIR / "schema" / "projectcard.json".
        outfile_path: Where to write out packaged schema. Defaults
            to schema_path.basepath.packaged.json
    """
    schema_path = Path(schema_path)
    _s_data = _load_schema(schema_path)
    default_outfile_path = schema_path.parent / f"{schema_path.stem}packaged.{schema_path.suffix}"
    outfile_path = outfile_path or default_outfile_path
    outfile_path = Path(outfile_path)
    with outfile_path.open("w") as outfile:
        json.dump(_s_data, outfile, indent=4)
    CardLogger.info(f"Wrote {schema_path.stem} to {outfile_path.stem}")

update_dict_with_schema_defaults(data, schema=PROJECTCARD_SCHEMA)

Recursively update missing required properties with default values.

Parameters:

Name Type Description Default
data dict

The data dictionary to update.

required
schema Union[Path, dict]

The schema dictionary or path to the schema file.

PROJECTCARD_SCHEMA

Returns:

Type Description
dict

The updated data dictionary.

Source code in projectcard/validate.py
def update_dict_with_schema_defaults(
    data: dict, schema: Union[Path, dict] = PROJECTCARD_SCHEMA
) -> dict:
    """Recursively update missing required properties with default values.

    Args:
        data: The data dictionary to update.
        schema: The schema dictionary or path to the schema file.

    Returns:
        The updated data dictionary.
    """
    if isinstance(schema, (str, Path)):
        schema = _load_schema(schema)

    if "properties" in schema:
        for prop_name, schema_part in schema["properties"].items():
            # Only update if the property is required, has a default, and is not already there
            if (
                prop_name not in data
                and "default" in schema_part
                and prop_name in schema.get("required", [])
            ):
                CardLogger.debug(f"Adding default value for {prop_name}: {schema_part['default']}")
                data[prop_name] = schema_part["default"]
            elif (
                prop_name in data
                and isinstance(data[prop_name], dict)
                and "properties" in schema_part
            ):
                data[prop_name] = update_dict_with_schema_defaults(data[prop_name], schema_part)
            elif (
                prop_name in data and isinstance(data[prop_name], list) and "items" in schema_part
            ):
                for item in data[prop_name]:
                    if isinstance(item, dict):
                        update_dict_with_schema_defaults(item, schema_part["items"])
    return data

validate_card(jsondata, schema_path=PROJECTCARD_SCHEMA, parse_defaults=True)

Validates json-like data to specified schema.

If pycode key exists, will evaluate it for basic runtime errors using Flake8. Note: will not flag any invalid use of RoadwayNetwork or TransitNetwork APIs.

Parameters:

Name Type Description Default
jsondata dict

json-like data to validate.

required
schema_path Path

path to schema to validate to. Defaults to PROJECTCARD_SCHEMA which is ROOTDIR / “schema” / “projectcard.json”

PROJECTCARD_SCHEMA
parse_defaults bool

if True, will use default values for missing required attributes.

True

Raises:

Type Description
ValidationError

If jsondata doesn’t conform to specified schema.

SchemaError

If schema itself is not valid.

Source code in projectcard/validate.py
def validate_card(
    jsondata: dict, schema_path: Path = PROJECTCARD_SCHEMA, parse_defaults: bool = True
) -> bool:
    """Validates json-like data to specified schema.

    If `pycode` key exists, will evaluate it for basic runtime errors using Flake8.
    Note: will not flag any invalid use of RoadwayNetwork or TransitNetwork APIs.

    Args:
        jsondata: json-like data to validate.
        schema_path: path to schema to validate to.
            Defaults to PROJECTCARD_SCHEMA which is
            ROOTDIR / "schema" / "projectcard.json"
        parse_defaults: if True, will use default values for missing required attributes.

    Raises:
        ValidationError: If jsondata doesn't conform to specified schema.
        SchemaError: If schema itself is not valid.
    """
    if "project" in jsondata:
        CardLogger.debug(f"Validating: {jsondata['project']}")
    try:
        _schema_data = _load_schema(schema_path)
        if parse_defaults:
            jsondata = update_dict_with_schema_defaults(jsondata, _schema_data)
        validate(jsondata, schema=_schema_data)
    except ValidationError as e:
        CardLogger.error(f"---- Error validating {jsondata.get('project','unknown')} ----")
        msg = f"\nRelevant schema: {e.schema}\nValidator Value: {e.validator_value}\nValidator: {e.validator}"
        msg += f"\nabsolute_schema_path:{e.absolute_schema_path}\nabsolute_path:{e.absolute_path}"
        CardLogger.error(msg)
        msg = f"Validation error for project {jsondata.get('project','unknown')}"
        raise ProjectCardValidationError(msg) from e
    except SchemaError as e:
        CardLogger.error(e)
        msg = f"Schema error for projectcard schema."
        raise ProjectCardJSONSchemaError(msg) from e

    if "pycode" in jsondata:
        if "self." in jsondata["pycode"] and "self_obj_type" not in jsondata:
            msg = "If using self, must specify what `self` refers to in yml frontmatter using self_obj_type: <RoadwayNetwork|TransitNetwork>"
            raise PycodeError(msg)
        _validate_pycode(jsondata)

    return True

validate_schema_file(schema_path=PROJECTCARD_SCHEMA)

Validates that a schema file is a valid JSON-schema.

Parameters:

Name Type Description Default
schema_path Path

description. Defaults to PROJECTCARD_SCHEMA which is ROOTDIR / “schema” / “projectcard.json”.

PROJECTCARD_SCHEMA
Source code in projectcard/validate.py
def validate_schema_file(schema_path: Path = PROJECTCARD_SCHEMA) -> bool:
    """Validates that a schema file is a valid JSON-schema.

    Args:
        schema_path: _description_. Defaults to PROJECTCARD_SCHEMA which is
            ROOTDIR / "schema" / "projectcard.json".
    """
    try:
        _schema_data = _load_schema(schema_path)
        # _resolver = _ref_resolver(schema_path,_schema_data)
        validate({}, schema=_schema_data)  # ,resolver=_resolver)
    except ValidationError:
        pass
    except SchemaError as e:
        CardLogger.error(e)
        msg = f"Schema error for projectcard schema."
        raise ProjectCardJSONSchemaError(msg) from e

    return True