diff --git a/reqs/.doorstop.yml b/reqs/.doorstop.yml new file mode 100644 index 0000000..13192f8 --- /dev/null +++ b/reqs/.doorstop.yml @@ -0,0 +1,17 @@ +attributes: + defaults: + rationale: null + type: Requirement + verified-by: unverified + verified-comment: unverified + verified-date: unverified + publish: + - verified-by + - verified-date + - verified-comment + - type + - rationale +settings: + digits: 4 + prefix: REQ + sep: '-' diff --git a/reqs/.gitignore b/reqs/.gitignore new file mode 100644 index 0000000..2a1683c --- /dev/null +++ b/reqs/.gitignore @@ -0,0 +1,5 @@ +csv_files/ +*.csv +*.tsv +*.xlsx +public/ \ No newline at end of file diff --git a/reqs/README.md b/reqs/README.md new file mode 100644 index 0000000..a323eb4 --- /dev/null +++ b/reqs/README.md @@ -0,0 +1,213 @@ +# Requirements + +## Table of Contents + +- [Dependencies](#dependencies) +- [Layout](#layout) +- [Requirement Files](#requirement-files) +- [How to Validate the Requirements](#how-to-validate-the-requirements) +- [How to Export Requirements](#how-to-export-requirements) +- [How to Add a Requirement Category](#how-to-add-a-requirement-category) +- [How to Add a Requirement](#how-to-add-a-requirement) +- [How to View Requirements](#how-to-view-requirements) + +## Dependencies + +```python3 +pip3 install doorstop pyyaml +``` + +Requirements are managed with [doorstop](https://doorstop.readthedocs.io/en/latest/). + +The `doorstop` command can be run in any directory, but this README assumes the current directory is `reqs/`. + +## Layout + +The top level requirement directory is `reqs`. The requirements in this repository are the highest level platform requirements. They have the prefix `REQ`. + +Lower level requirement locations: +- Software-related: `SW/` +- Electronics-related: `HW/` +- Mechanical-related: `MECH` +- Project directives: `PROJ/` + +An example layout of requirements is below. +```tree +. +├── build +├── REQ-0001.yml +├── PROJ +│ └── PROJ-0001.yml +├── HW + └── HW-0001.yml +│ └── FLIGHT +│ └── FLIGHT-0001.yml +└── SW + └── SW-0001.yml + ├── CMD_SVC + │ ├── CMD_SVC-0001.yml + ├── HEALTH_SVC + │ ├── HEALTH_SVC-0001.yml + └── TLM_SVC + ├── TLM_SVC-0001.yml +``` + +## Requirement Files + +The requirements are individual `.yml` files. Each contains the requirement's primary text as well as metadata. + +```yml +# reqs/SW/HEALTH/HEALTH_SVC-0001.yml +active: true +derived: false +header: '' +level: 1.0 +links: +- SW-0001: bBkf6YMFHFULHe5eLcjSBipaMvJL7t5BdNTRJdS-4Ug= +normative: true +rationale: | + If a device is acting offnominally, the software manager in charge of that device may elect to mark it as SICK or DEAD + to prevent accidental further usage. +ref: '' +reviewed: 7Pi6ZnjoRSEqcevT_DevP6N0liXRQPDnPh1W4QYxSz4= +text: | + HEALTH_SVC shall provide an interface to mark a device as HEALTHY, SICK, or DEAD. +``` + +Important Field | Description +---- | --- +`text` | The primary text of the requirement +`rationale` | Why the requirement exists, explanation of values in requirement, etc. This is a required field and is enforced with the `build` script. +`links` | Links to parent requirements. In this case SW-0001 (*The platform shall maintain the health status of each device*) is the parent requirement. **All lower level requirements must have a parent.** This is enforced with the `build` script. +`active` | If the requirement is in draft and you don't want to cause build errors, set this to `false`. + + +## How to Validate the Requirements + +Use the `reqs/build` script. + +`reqs/build` will call: +- `doorstop_yml_formatter.yml` + - Enforces the existence of the following fields: `verified-by`, `verified-date`, `verified-comments`, `type`, `rationale` + - Sets all parent link signatures to `null` + - `doorstop_hooks.py` will regenerate all signatures + - This avoids common "suspect link" error +- `doorstop_hooks.py` + - A wrapper around `doorstop` with extra rules: + - `rationale` and `type` are required non-empty fields + - Will not check that all requirements have children (may be turned on later) + - Will not check for suspect links + - All parent link signatures were set to `null` in `doorstop_yml_formatter.py` + - Will regenerate link signatures + - Will reorder the `level` fields of documents to be consecutive +- `doorstop publish all public/` +- `doorstop export all ./csv_files/` + +An ideal build will look like so: + +```zsh +➜ reqs git:(main) ✗ ./build +building tree... +loading documents... +publishing tree to '/home/ams/gitprojects/arrowdrone-docs/reqs/REQ'... +published: /home/ams/gitprojects/arrowdrone-docs/reqs/REQ +building tree... +loading documents... +exporting tree to './csv_files'... +exported: ./csv_files +``` + +Common errors and their fixes: + +Common Error | Fix +--- | --- +`EXAMPLE-0001: Rationale is required!` | Populate a `rationale` field in the requirement file. +`EXAMPLE-0001: A parent is required for a lower level requirement!` | `doorstop link EXAMPLE-0001 PARENT-####`
Or manually update the `links` field in the requirement's `.yml`. +`EXAMPLE-0001: suspect link` | Set the suspect link to `null` in the requirement's `.yml` +`EXAMPLE-0001: unreviewed changes` | `doorstop review EXAMPLE-0001`.
Use `all` for resolving all reviews. +`WARNING: no item with UID: EXAMPLE-0001` | This can happen if `active:false` in `EXAMPLE-0001.yml`. + +## How to Export Requirements + +You may be more comfortable with viewing requirements in an Excel spreadsheet. + +The `build` script should produce a `reqs/csv_files` directory. + +You can also run `doorstop export REQ path/to/tst.xlsx` (also supports tsv, csv, or yml), or similarly call it for any other requirement category. + +## How to Add a Requirement Category + +A requirement category can be added with `doorstop create`: + +```bash +# doorstop create --parent REQ +$ doorstop create PWR ./HW/PWR --parent HW +building tree... +created document: PWR (@/reqs/HW/PWR) +``` + +You will see that a new directory was created and populated with a `.doorstop.yml` file: +```yaml +settings: + digits: 3 + parent: HW + prefix: PWR + sep: '' +``` + +We have specific fields for this project. All `.doorstop.yml` files not following the project standard will be rectified when the `build` script is called. + +Output after `./build`: + +```yaml +attributes: + publish: + - verified-by + - type + - rationale +settings: + digits: 4 + parent: HW + prefix: PWR + sep: '-' +``` + +If we build now, we will get a warning: `PWR: no items`. + +## How to Add a Requirement + +Requirements can be added with `doorstop add`: + +```bash +# doorstop add +$ doorstop add PWR +building tree... +added item: PWR-0001 (@/reqs/HW/PWR/PWR-0001.yml) +``` +The new file can be manually populated with the required fields. + +You must then link the requirement to a parent requirement before the build can succeed: + +`doorstop link PWR-0001 HW-####` + +You can add multiple requirements add the same time with the `-c` option: + +```bash +$ doorstop add PWR -c 3 +building tree... +added item: PWR-0002 (@/reqs/HW/PWR/PWR-0002.yml) +added item: PWR-0003 (@/reqs/HW/PWR/PWR-0003.yml) +added item: PWR-0004 (@/reqs/HW/PWR/PWR-0004.yml) +``` + +## How to View Requirements + +To view the requirements outside of source code, do one of the following: +1) `doorstop-server` + - Visit `localhost:7867` in your browser +2) `cd public/; python -m http.server PORT` + - Visit `localhost:PORT` in your browser +3) `doorstop-gui` + - tkinter application +4) TODO: Visit arrowair.com arrowdrone subdir +5) Open the csv file(s) in Excel. \ No newline at end of file diff --git a/reqs/build b/reqs/build new file mode 100755 index 0000000..25a5971 --- /dev/null +++ b/reqs/build @@ -0,0 +1,23 @@ +#!/bin/bash + +CSV=csv_files + +python3 doorstop_yml_formatter.py +if [ $? -ne 0 ]; then + echo "FAILURE, exiting build." + exit 1 +fi + +# doorstop --no-suspect-check --reorder --no-child-check +python3 doorstop_hooks.py +if [ $? -eq 0 ]; then + rm -rf public + doorstop publish all public/ # This may not scale + rm -rf ./$CSV + doorstop export all ./$CSV # This may not scale +else + echo "FAILURE, exiting build." + exit 1 +fi + +exit 0 \ No newline at end of file diff --git a/reqs/doorstop_hooks.py b/reqs/doorstop_hooks.py new file mode 100755 index 0000000..091379a --- /dev/null +++ b/reqs/doorstop_hooks.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python + + +import sys +from doorstop import build, settings, DoorstopInfo, DoorstopWarning, DoorstopError + +# Reorder document `level` fields automatically if a REQ is deleted +settings.REORDER = True + +# Parent links are set to null then regenerated every time with the ./build script +# Don't need to check +settings.CHECK_SUSPECT_LINKS = False + +# Not all requirements will have children +settings.CHECK_CHILD_LINKS = False + +def main(): + tree = build() + success = tree.validate(document_hook=check_document, item_hook=check_item) + sys.exit(0 if success else 1) + + +def check_document(document, tree): + if sum(1 for i in document if i.normative) < 10: + yield DoorstopInfo("fewer than 10 normative items") + +def check_item(item, tree, document): + if not item.get('rationale'): + yield DoorstopError("Rationale is required!") + + if not item.get('type'): + yield DoorstopError("Type is required!") + + uid = item.get('path').split('/')[-1] + if len(item.get('links')) == 0 and not uid.startswith('REQ'): + yield DoorstopError("A parent is required for a lower level requirement!") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/reqs/doorstop_yml_formatter.py b/reqs/doorstop_yml_formatter.py new file mode 100755 index 0000000..f62a0f6 --- /dev/null +++ b/reqs/doorstop_yml_formatter.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python + +import os +import sys +import re +import yaml + +# Constants +# To appear in every .doorstop.yml +DIGITS = 4 +SEPARATOR = '-' +TYPE = 'Requirement' + +## +## Helper +## +def failure(msg): + print("ERROR: %s. Failing %s." % ( + msg, __file__ + )) + sys.exit(1) + +def req_yaml_format(filename): + with open(filename, 'r') as f: + contents = yaml.safe_load(f) + if not contents: + failure("Unable to parse %s." % filename) + + contents['type'] = TYPE + if not contents.get('rationale'): + contents['rationale'] = None + + x = contents.get('verified-by') + if not x or x is None: + contents['verified-by'] = 'unverified' + + x = contents.get('verified-date') + if not x or x is None: + contents['verified-date'] = 'unverified' + + x = contents.get('verified-comment') + if not x or x is None: + contents['verified-comment'] = 'unverified' + + x = contents.get('links') + if x: + for count, item in enumerate(x): + for k, _ in item.items(): + contents['links'][count][k] = None + + with open(filename, 'w') as yml_file: + yaml.dump(contents, yml_file) + +def doorstop_yaml_format(filename): + with open(filename, 'r') as f: + contents = yaml.safe_load(f) + if not contents: + failure("Unable to parse %s." % filename) + + if not contents.get('settings'): + contents['settings'] = {} + + contents['settings']['sep'] = SEPARATOR + contents['settings']['digits'] = DIGITS + + if not contents.get('attributes'): + contents['attributes'] = {} + + contents['attributes']['publish'] = [ + 'verified-by', + 'verified-date', + 'verified-comment', + 'type', + 'rationale' + ] + + if not contents['attributes'].get('defaults'): + contents['attributes']['defaults'] = {} + + # Needs to be specific value + contents['attributes']['defaults']['type'] = TYPE + + # Default is None + if not contents['attributes']['defaults'].get('rationale'): + contents['attributes']['defaults']['rationale'] = None + + # Default is "unverified" + l = [ + 'verified-by', + 'verified-date', + 'verified-comment', + ] + for item in l: + x = contents['attributes']['defaults'].get(item) + if not x or x is None: + contents['attributes']['defaults'][item] = 'unverified' + + + with open(filename, 'w') as yml_file: + yaml.dump(contents, yml_file) + + +if __name__ == '__main__': + for root, subdirs, files in os.walk('.'): + for f in files: + if f == '.doorstop.yml': + doorstop_yaml_format(os.path.join(root, f)) + elif re.search('[A-Z]+-[0-9]+.yml', f): + req_yaml_format(os.path.join(root, f)) + + sys.exit(0) \ No newline at end of file