diff --git a/.coveragerc b/.coveragerc index 5033e7f33..00d4bbde2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,3 +5,20 @@ branch = true omit = .venv/* */tests/* + */__main__.py + +[report] + +omit = + */gui/main.py + */gui/application.py + */gui/utilTkinter.py + */gui/widget.py + +exclude_lines = + pragma: no cover + raise NotImplementedError + except DistributionNotFound + if __name__ == '__main__' + +skip_covered = true diff --git a/.editorconfig b/.editorconfig index e78485e91..242407d9d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -21,5 +21,6 @@ indent_size = 4 [*.yml] indent_style = space -[makefile] +[Makefile] indent_style = tab +indent_size = 4 diff --git a/.gitignore b/.gitignore index 67f80a201..6a379fba4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,12 +3,13 @@ *.egg-info __pycache__ .ipynb_checkpoints +setup.py # Temporary OS files Icon* # Temporary virtual environment files -/.*cache/ +/.cache/ /.venv/ # Temporary server files @@ -20,7 +21,6 @@ Icon* /docs/apidocs/ /site/ /*.html -/*.rst /docs/*.png # Google Drive @@ -42,6 +42,7 @@ Icon* /build/ /dist/ *.spec +**/MathJax # Sublime Text *.sublime-workspace diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 000000000..9511bc777 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,9 @@ +[settings] +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 + +known_first_party = doorstop +known_third_party = bottle,openpyxl diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 000000000..264105fe9 --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,7 @@ +[mypy] + +ignore_missing_imports = true +no_implicit_optional = true +check_untyped_defs = true + +cache_dir = .cache/mypy/ diff --git a/.pycodestyle.ini b/.pycodestyle.ini deleted file mode 100644 index 5682c9f29..000000000 --- a/.pycodestyle.ini +++ /dev/null @@ -1,9 +0,0 @@ -[pycodestyle] - -# W504: line break after binary operator -# E401 multiple imports on one line (checked by PyLint) -# E402 module level import not at top of file (checked by PyLint) -# E501: line too long (checked by PyLint) -# E711: comparison to None (used to improve test style) -# E712: comparison to True (used to improve test style) -ignore = W504,E401,E402,E501,E711,E712 diff --git a/.pylint.ini b/.pylint.ini index cd5c77527..c1752825d 100644 --- a/.pylint.ini +++ b/.pylint.ini @@ -124,6 +124,9 @@ disable= logging-format-interpolation, duplicate-code, unpacking-non-sequence, + ungrouped-imports, + bad-continuation, + superfluous-parens, # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option @@ -267,7 +270,7 @@ indent-after-paren=4 indent-string=' ' # Maximum number of characters on a single line. -max-line-length=79 +max-line-length=88 # Maximum number of lines in a module max-module-lines=1000 diff --git a/.travis.yml b/.travis.yml index 978be4212..894775dae 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,28 +1,23 @@ +dist: xenial + language: python python: - - 3.4 - 3.5 - 3.6 -matrix: - include: - - python: 3.7 - dist: xenial - sudo: true + - 3.7 cache: pip: true directories: - - .venv + - ${VIRTUAL_ENV} env: global: - RANDOM_SEED=0 - - PIPENV_NOSPIN=true - # Encrypted GH_TOKEN token: http://benlimmer.com/2013/12/26/automatically-publish-javadoc-to-gh-pages-with-travis-ci - - secure: JfVl6zxzhRIEG1VvLqMEPMGOecrzDyb2U0HPyQ4Z4pfKJgFJOdQ1orT9Aztpfr4edXqbCZnEy4WH8+FLBQagkXjT3Ladopv+9j1IkP8v1Gu1O8H2tHQc7wfGxnkQF+h0pFFS6adNAsMliQ5dxTojFYVoWebvZQbiVGYkew+QZ0c= before_install: - - pip install pipenv + - curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python + - source $HOME/.poetry/env - make doctor install: @@ -35,7 +30,7 @@ script: after_script: > echo $TRAVIS_BRANCH; echo $TRAVIS_PULL_REQUEST; echo $TRAVIS_PYTHON_VERSION; - if [[ $TRAVIS_BRANCH == 'develop' && $TRAVIS_PULL_REQUEST == 'false' && $TRAVIS_PYTHON_VERSION == '3.3' ]]; then + if [[ $TRAVIS_BRANCH == 'develop' && $TRAVIS_PULL_REQUEST == 'false' && $TRAVIS_PYTHON_VERSION == '3.7' ]]; then # Generate Doorstop HTML pages make reqs ; @@ -66,4 +61,4 @@ after_success: notifications: email: on_success: never - on_failure: change + on_failure: never diff --git a/.verchew.ini b/.verchew.ini index ceddf6132..5f38f07d6 100644 --- a/.verchew.ini +++ b/.verchew.ini @@ -6,24 +6,17 @@ version = GNU Make [Python] cli = python -versions = Python 3.4. | Python 3.5. | Python 3.6. | Python 3.7. +versions = 3.5 || 3.6 || 3.7 -[pipenv] +[Poetry] -cli = pipenv -versions = 2018.7. - -[pandoc] - -cli = pandoc -version = 1. -optional = true -message = This is only needed to generate the README for PyPI. +cli = poetry +version = 0.12 [Graphviz] cli = dot cli_version_arg = -V -version = 2. +version = 2 optional = true message = This is only needed to generate UML diagrams for documentation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d075e9d..3f026a6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ -# Revision History +# 1.6 (2019-08-10) -## 1.5 (2018-09-19) +- Updated `edit` and `reorder` commands to use `$EDITOR`. +- Promoted support for item headers out of beta. +- Fixed bug with Doorstop attempting to import formulas from XLSX. +- Added support for `!include` statements in YAML to share configuration. +- Added `--default` option to `add` to specify a file with defaults values. + +# 1.5 (2018-09-19) - Added preliminary support for item headers ([@rickeywang](https://github.com/jacebrowning/doorstop/pull/285) - Added major enhancements to the Desktop GUI. ([@elarivie](https://github.com/jacebrowning/doorstop/pull/290)) @@ -10,24 +16,24 @@ - Fixed duplicate headings when publishing. ([@guille-r](https://github.com/jacebrowning/doorstop/pull/302)) - Added Python 3.7 support. -## 1.4 (2017-10-22) +# 1.4 (2017-10-22) - Fixed issue running doorstop on CI. ([@ojohnny](https://github.com/jacebrowning/doorstop/pull/281)) -## 1.3.1 (2017-08-26) +# 1.3.1 (2017-08-26) - Fixed templates location for installation issue. -## 1.3 (2017-07-29) +# 1.3 (2017-07-29) - Updated HTML templates. - Bug fixes. -## 1.2.1 (2017-02-25) +# 1.2.1 (2017-02-25) - Fixed issue where could `doorstop create` deleting the whole project. -## 1.2 (2017-02-11) +# 1.2 (2017-02-11) - Disabled excessive text cleanup in items. ([@michaelnt](https://github.com/michaelnt)) + Running `doorstop review all` will be required due to whitespace changes. @@ -35,39 +41,39 @@ - Removed unnecessary line breaks (`
`) in generated HTML. ([@michaelnt](https://github.com/michaelnt)) - **DEPRECATION WARNING:** `--no-body-levels` will not be supported in a future release. -## 1.1 (2017-01-09) +# 1.1 (2017-01-09) -- Added '--strict-child-check' option to ensure links from every child document. +- Added '--strict-child-check' option to ensure links from every child document. -## 1.0.2 (2016-06-08) +# 1.0.2 (2016-06-08) - Moved the documentation to [ReadTheDocs](http://doorstop.readthedocs.io). -## 1.0 (2016-04-17) +# 1.0 (2016-04-17) - Fixed a bug checking levels across inactive items. - Added error message for all IO errors. - Added '--skip' options to disable documents during validation. - Added Mercurial support. ([@tjasz](https://github.com/tjasz)) -## 0.8.4 (2015-03-12) +# 0.8.4 (2015-03-12) - Restrict `openpyxl < 2.2` (there appears to be a breaking change). -## 0.8.3 (2014-10-10) +# 0.8.3 (2014-10-10) - Fixed a bug running VCS commands in subdirectories. - Excluded `openpyxl == 2.1.0` as a dependency version. -## 0.8.2 (2014-09-29) +# 0.8.2 (2014-09-29) - Limit the maximum version of `openpyxl` to 2.1.0 due to deprecation bug. -## 0.8.1 (2014-09-04) +# 0.8.1 (2014-09-04) - Fixed a bug requesting new item numbers from the server. -## 0.8 (2014-08-28) +# 0.8 (2014-08-28) - Added `doorstop clear ...` to absolve items of their suspect link status. - Added `doorstop review ...` to absolve items of their unreviewed status. @@ -80,11 +86,11 @@ - Added '--server' argument to `doorstop add` to specify the server address. - Added '--warn-all' and '--error-all' options promote warnings to errors. -## 0.7.1 (2014-08-18) +# 0.7.1 (2014-08-18) - Fixed bug importing items with empty attributes. -## 0.7 (2014-07-08) +# 0.7 (2014-07-08) - Added `doorstop delete ...` to delete document directories. - Added `doorstop export ...` to export content for external tools. @@ -98,7 +104,7 @@ - Renamed `doorstop new ...` to `doorstop create ...`. - Made 'all' a reserved word, which cannot be used as a prefix. -## 0.6 (2014-05-15) +# 0.6 (2014-05-15) - Refactored `Item` levels into a `Level` class. - Refactored `Item` identifiers into an `ID` class. @@ -114,7 +120,7 @@ - Added '--no-level-check' to disable document level validation. - Added '--reorder' option to `doorstop` to enable reordering. -## 0.5 (2014-04-25) +# 0.5 (2014-04-25) - Converted `Item.issues()` to a property and added `Item.get_issues()`. - Added '--level' option to `doorstop add` to force an item level. @@ -125,143 +131,143 @@ - Renamed `Tree` methods: new -> new_document, add -> add_item, remove -> remove_item, link -> link_items, unlink -> unlink_items, edit -> edit_item, valid -> validate. - Added `doorstop.importer` functions to add exiting documents and items. -## 0.4.3 (2014-03-18) +# 0.4.3 (2014-03-18) - Fixed storage of 2-part levels ending in a multiple of 10. -## 0.4.2 (2014-03-17) +# 0.4.2 (2014-03-17) - Fixed a case where `Item.root` was not set. -## 0.4.1 (2014-03-16) +# 0.4.1 (2014-03-16) - Fixed auto save/load decorator order. -## 0.4 (2014-03-16) +# 0.4 (2014-03-16) - Added `Tree.delete()` to delete all document directories and item files. - Added `doorstop publish all ` to publish trees and `index.html`. -## 0.3 (2014-03-12) +# 0.3 (2014-03-12) - Added find_document and find_item convenience functions. - Added `Document.delete()` to delete a document directory and its item files. -## 0.2 (2014-03-05) +# 0.2 (2014-03-05) - All `Item` text attributes are now be split by sentences and line-wrapped. - Added `Tree.load()` for cases when lazy loading is too slow. - Added caching to `Tree.find_item()` and `Tree.find_document()`. -## 0.1 (2014-02-17) +# 0.1 (2014-02-17) - Top-level items are no longer required to have a level ending in zero. - Added `Item/Document.extended` to get a list of extended attribute names. -## 0.0.21 (2014-02-14) +# 0.0.21 (2014-02-14) - Documents can now have item files in sub-folders. -## 0.0.20 (2014-02-13) +# 0.0.20 (2014-02-13) - Updated `doorstop.core.report` to support lists of items. -## 0.0.19 (2014-02-13) +# 0.0.19 (2014-02-13) - Updated doorstop.core.report to support items or documents. - Removed the 'iter\_' prefix from all generators. -## 0.0.18 (2014-02-12) +# 0.0.18 (2014-02-12) - Fixed CSS bullets indent. -## 0.0.17 (2014-01-31) +# 0.0.17 (2014-01-31) - Added caching of `Item` in the `Document` class. - Added `Document.remove()` to delete an item by its ID. - `Item.find_rlinks()` will now search the entire tree for links. -## 0.0.16 (2014-01-28) +# 0.0.16 (2014-01-28) - Added `Item.find_rlinks()` to return reverse links and child documents. - Changed the logging format. - Added a '--project' argument to provide a path to the root of the project. -## 0.0.15 (2014-01-27) +# 0.0.15 (2014-01-27) - Fixed a mutable default argument bug in `Item` creation. -## 0.0.14 (2014-01-27) +# 0.0.14 (2014-01-27) - Added `Tree/Document/Item.iter_issues()` method to yield all issues. - `Tree/Document/Item.check()` now logs all issues rather than failing fast. - Renamed `Tree/Document/Item.check()` to `valid()`. -## 0.0.13 (2014-01-25) +# 0.0.13 (2014-01-25) - Added `Document.sep` to separate prefix and item numbers. -## 0.0.12 (2014-01-24) +# 0.0.12 (2014-01-24) - Fixed missing package data. -## 0.0.11 (2014-01-23) +# 0.0.11 (2014-01-23) - Added `Item.active` property to disable certain items. - Added `Item.derived` property to disable link checking on certain items. -## 0.0.10 (2014-01-22) +# 0.0.10 (2014-01-22) - Switched to embedded CSS in generated HTML. - Shortened default `Item` and `Document` string formatting. -## 0.0.9 (2014-01-21) +# 0.0.9 (2014-01-21) - Added top-down link checking. - Non-normative items with a zero-ended level are now headings. - Added a CSS for generated HTML. - The 'publish' command now accepts an output file path. -## 0.0.8 (2014-01-16) +# 0.0.8 (2014-01-16) - Searching for 'ref' will now also find filenames. - Item files can now contain arbitrary fields. - Document prefixes can now contain numbers, dashes, and periods. - Added a 'normative' attribute to the Item class. -## 0.0.7 (2013-12-09) +# 0.0.7 (2013-12-09) - Always showing 'ref' in items. - Reloading item attributes after a save. - Inserting lines breaks after sentences in item 'text'. -## 0.0.6 (2013-12-04) +# 0.0.6 (2013-12-04) - Added basic report creation via `doorstop publish ...`. -## 0.0.5 (2013-11-20) +# 0.0.5 (2013-11-20) - Added item link and reference validation. - Added cached of loaded items. - Added preliminary VCS support for Git and Veracity. -## 0.0.4 (2013-11-04) +# 0.0.4 (2013-11-04) - Implemented `add`, `remove`, `link`, and `unlink` commands. - Added basic tree validation. -## 0.0.3 (2013-10-17) +# 0.0.3 (2013-10-17) - Added the initial `Document` class. - Items can now be ordered by 'level' in a document. - Initial tutorial created. -## 0.0.2 (2013-09-25) +# 0.0.2 (2013-09-25) - Changed `doorstop init` to `doorstop new`. - Added the initial `Item` class. - Added stubs for the `Document` class. -## 0.0.1 (2013-09-11) +# 0.0.1 (2013-09-11) - Initial release of Doorstop. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19feb0deb..00bd68e7c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,16 +1,17 @@ -# For Contributors +# Setup -## Setup - -### Requirements +## Requirements * Make: - * Windows: http://mingw.org/download/installer - * Mac: http://developer.apple.com/xcode - * Linux: http://www.gnu.org/software/make -* pipenv: http://docs.pipenv.org -* Pandoc: http://johnmacfarlane.net/pandoc/installing.html -* Graphviz: http://www.graphviz.org/Download.php + * macOS: `$ xcode-select --install` + * Linux: [https://www.gnu.org/software/make](https://www.gnu.org/software/make) + * Windows: [https://mingw.org/download/installer](https://mingw.org/download/installer) +* Python: `$ pyenv install` +* Poetry: [https://poetry.eustace.io/docs/#installation](https://poetry.eustace.io/docs/#installation) +* Graphviz: + * macOS: `$ brew install graphviz` + * Linux: [https://graphviz.org/download](https://graphviz.org/download/) + * Windows: [https://graphviz.org/download](https://graphviz.org/download/) To confirm these system dependencies are configured correctly: @@ -18,7 +19,7 @@ To confirm these system dependencies are configured correctly: $ make doctor ``` -### Installation +## Installation Install project dependencies into a virtual environment: @@ -26,44 +27,39 @@ Install project dependencies into a virtual environment: $ make install ``` -## Development Tasks +# Development Tasks -### Testing +## Manual -Manually run the tests: +Run the tests: ```sh $ make test ``` -or keep them running on change: +Run static analysis: ```sh -$ make watch +$ make check ``` -> In order to have OS X notifications, `brew install terminal-notifier`. - -### Documentation - Build the documentation: ```sh $ make docs ``` -### Static Analysis +## Automatic -Run linters and static analyzers: +Keep all of the above tasks running on change: ```sh -$ make pylint -$ make pycodestyle -$ make pydocstyle -$ make check # includes all checks +$ make watch ``` -## Continuous Integration +> In order to have OS X notifications, `brew install terminal-notifier`. + +# Continuous Integration The CI server will report overall build status: @@ -71,7 +67,7 @@ The CI server will report overall build status: $ make ci ``` -## Release Tasks +# Release Tasks Release to PyPI: diff --git a/LICENSE.md b/LICENSE.md index 4ae064e0a..ecac79fbb 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,4 @@ -# License - -``` +```text GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 diff --git a/Makefile b/Makefile index d1424d085..4f86aa01f 100644 --- a/Makefile +++ b/Makefile @@ -9,32 +9,27 @@ CONFIG := $(wildcard *.py) MODULES := $(wildcard $(PACKAGE)/*.py) # Virtual environment paths -export PIPENV_VENV_IN_PROJECT=true -export PIPENV_IGNORE_VIRTUALENVS=true -VENV := .venv +VIRTUAL_ENV ?= .venv # MAIN TASKS ################################################################## -SNIFFER := pipenv run sniffer - .PHONY: all all: install .PHONY: ci -ci: check test demo ## Run all tasks that determine CI status +ci: format check test mkdocs demo ## Run all tasks that determine CI status .PHONY: watch watch: install .clean-test ## Continuously run all CI tasks when files chanage - $(SNIFFER) + poetry run sniffer .PHONY: run ## Start the program run: install - pipenv run python $(PACKAGE)/gui/main.py + poetry run python $(PACKAGE)/gui/main.py .PHONY: demo demo: install - pipenv run python setup.py develop - pipenv run python $(PACKAGE)/cli/tests/test_tutorial.py + poetry run python $(PACKAGE)/cli/tests/tutorial.py # SYSTEM DEPENDENCIES ######################################################### @@ -44,43 +39,43 @@ doctor: ## Confirm system dependencies are available # PROJECT DEPENDENCIES ######################################################## -DEPENDENCIES := $(VENV)/.pipenv-$(shell bin/checksum Pipfile* setup.py) +DEPENDENCIES := $(VIRTUAL_ENV)/.poetry-$(shell bin/checksum pyproject.toml poetry.lock) .PHONY: install -install: $(DEPENDENCIES) +install: $(DEPENDENCIES) .cache -$(DEPENDENCIES): - pipenv run python setup.py develop - pipenv install --dev +$(DEPENDENCIES): poetry.lock + @ poetry config settings.virtualenvs.in-project true + poetry install @ touch $@ -# CHECKS ###################################################################### - -PYLINT := pipenv run pylint -PYCODESTYLE := pipenv run pycodestyle -PYDOCSTYLE := pipenv run pydocstyle +poetry.lock: pyproject.toml + poetry lock + @ touch $@ -.PHONY: check -check: pylint pycodestyle pydocstyle ## Run linters and static analysis +.cache: + @ mkdir -p .cache -.PHONY: pylint -pylint: install - $(PYLINT) $(PACKAGES) $(CONFIG) --rcfile=.pylint.ini +# CHECKS ###################################################################### -.PHONY: pycodestyle -pycodestyle: install - $(PYCODESTYLE) $(PACKAGES) $(CONFIG) --config=.pycodestyle.ini +.PHONY: format +format: install + poetry run isort $(PACKAGES) --recursive --apply + poetry run black $(PACKAGES) || echo "black requires Python 3.6+" + @ echo -.PHONY: pydocstyle -pydocstyle: install - $(PYDOCSTYLE) $(PACKAGES) $(CONFIG) +.PHONY: check +check: install format ## Run formaters, linters, and static analysis +ifdef CI + git diff --exit-code +endif + # TODO: Enable mypy for type checking + # poetry run mypy $(PACKAGES) --config-file=.mypy.ini + poetry run pylint $(PACKAGES) --rcfile=.pylint.ini + poetry run pydocstyle $(PACKAGES) $(CONFIG) # TESTS ####################################################################### -NOSE := pipenv run nosetests -COVERAGE := pipenv run coverage -COVERAGE_SPACE := pipenv run coverage.space - RANDOM_SEED ?= $(shell date +%s) NOSE_OPTIONS := --with-doctest @@ -92,17 +87,17 @@ endif test: test-all ## Run unit and integration tests .PHONY: test-unit -test-unit: install .clean-test - $(NOSE) $(PACKAGE) $(NOSE_OPTIONS) - $(COVERAGE_SPACE) $(REPOSITORY) unit +test-unit: install + poetry run nosetests $(PACKAGE) $(NOSE_OPTIONS) + poetry run coveragespace $(REPOSITORY) unit .PHONY: test-int test-int: test-all .PHONY: test-all -test-all: install .clean-test - TEST_INTEGRATION=true $(NOSE) $(PACKAGES) $(NOSE_OPTIONS) --show-skipped - $(COVERAGE_SPACE) $(REPOSITORY) overall +test-all: install + TEST_INTEGRATION=true poetry run nosetests $(PACKAGES) $(NOSE_OPTIONS) --show-skipped + poetry run coveragespace $(REPOSITORY) overall .PHONY: read-coverage read-coverage: @@ -110,38 +105,40 @@ read-coverage: # DOCUMENTATION ############################################################### -PYREVERSE := pipenv run pyreverse -MKDOCS := pipenv run mkdocs - MKDOCS_INDEX := site/index.html .PHONY: docs -docs: uml mkdocs ## Generate documentation +docs: mkdocs uml ## Generate documentation and UML + +.PHONY: mkdocs +mkdocs: install $(MKDOCS_INDEX) +$(MKDOCS_INDEX): docs/requirements.txt mkdocs.yml docs/*.md + @ mkdir -p docs/about + @ cd docs && ln -sf ../README.md index.md + @ cd docs/about && ln -sf ../../CHANGELOG.md changelog.md + @ cd docs/about && ln -sf ../../CONTRIBUTING.md contributing.md + @ cd docs/about && ln -sf ../../LICENSE.md license.md + poetry run mkdocs build --clean --strict + +# Workaround: https://github.com/rtfd/readthedocs.org/issues/5090 +docs/requirements.txt: poetry.lock + @ poetry run pip freeze -qqq | grep mkdocs > $@ .PHONY: uml uml: install docs/*.png docs/*.png: $(MODULES) - $(PYREVERSE) $(PACKAGE) -p $(PACKAGE) -a 1 -f ALL -o png --ignore tests + poetry run pyreverse $(PACKAGE) -p $(PACKAGE) -a 1 -f ALL -o png --ignore tests - mv -f classes_$(PACKAGE).png docs/classes.png - mv -f packages_$(PACKAGE).png docs/packages.png -.PHONY: mkdocs -mkdocs: install $(MKDOCS_INDEX) -$(MKDOCS_INDEX): mkdocs.yml docs/*.md - ln -sf `realpath README.md --relative-to=docs` docs/index.md - ln -sf `realpath CHANGELOG.md --relative-to=docs/about` docs/about/changelog.md - ln -sf `realpath CONTRIBUTING.md --relative-to=docs/about` docs/about/contributing.md - ln -sf `realpath LICENSE.md --relative-to=docs/about` docs/about/license.md - $(MKDOCS) build --clean - -.PHONY: mkdocs-live -mkdocs-live: mkdocs +.PHONY: mkdocs-serve +mkdocs-serve: mkdocs eval "sleep 3; bin/open http://127.0.0.1:8000" & - $(MKDOCS) serve + poetry run mkdocs serve # REQUIREMENTS ################################################################ -DOORSTOP := pipenv run doorstop +DOORSTOP := poetry run doorstop YAML := $(wildcard */*.yml */*/*.yml */*/*/*/*.yml) @@ -165,43 +162,30 @@ docs/gen/*.txt: $(YAML) # BUILD ####################################################################### -PYINSTALLER := pipenv run pyinstaller -PYINSTALLER_MAKESPEC := pipenv run pyi-makespec - DIST_FILES := dist/*.tar.gz dist/*.whl EXE_FILES := dist/$(PROJECT).* -.PHONY: build -build: dist - .PHONY: dist dist: install $(DIST_FILES) -$(DIST_FILES): $(MODULES) README.rst CHANGELOG.rst +$(DIST_FILES): $(MODULES) pyproject.toml rm -f $(DIST_FILES) - pipenv run python setup.py check --restructuredtext --strict --metadata - pipenv run python setup.py sdist - pipenv run python setup.py bdist_wheel - -%.rst: %.md - pandoc -f markdown_github -t rst -o $@ $< + poetry build .PHONY: exe exe: install $(EXE_FILES) $(EXE_FILES): $(MODULES) $(PROJECT).spec # For framework/shared support: https://github.com/yyuu/pyenv/wiki - $(PYINSTALLER) $(PROJECT).spec --noconfirm --clean + poetry run pyinstaller $(PROJECT).spec --noconfirm --clean $(PROJECT).spec: - $(PYINSTALLER_MAKESPEC) $(PACKAGE)/__main__.py --onefile --windowed --name=$(PROJECT) + poetry run pyi-makespec $(PACKAGE)/__main__.py --onefile --windowed --name=$(PROJECT) # RELEASE ##################################################################### -TWINE := pipenv run twine - .PHONY: upload upload: dist ## Upload the current version to PyPI git diff --name-only --exit-code - $(TWINE) upload dist/*.* + poetry publish bin/open https://pypi.org/project/$(PROJECT) # CLEANUP ##################################################################### @@ -211,21 +195,20 @@ clean: .clean-build .clean-docs .clean-test .clean-install ## Delete all generat .PHONY: clean-all clean-all: clean - rm -rf $(VENV) + rm -rf $(VIRTUAL_ENV) .PHONY: .clean-install .clean-install: - find $(PACKAGES) -name '*.pyc' -delete find $(PACKAGES) -name '__pycache__' -delete rm -rf *.egg-info .PHONY: .clean-test .clean-test: - rm -rf .cache .pytest .coverage htmlcov xmlreport + rm -rf .cache .pytest .coverage htmlcov .PHONY: .clean-docs .clean-docs: - rm -rf *.rst docs/apidocs *.html docs/*.png site + rm -rf docs/*.png site .PHONY: .clean-build .clean-build: diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 1edfa84fe..000000000 --- a/Pipfile +++ /dev/null @@ -1,44 +0,0 @@ -[[source]] - -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] - -sappy = "*" - -[dev-packages] - -# Linters -pylint = "*" -typing = "*" # pylint dependency for Python less than 3.5 -pycodestyle = "*" -pydocstyle = "*" - -# Testing -nose = "~=1.3" -nose-cov = "*" -nose-capturestderr = "*" -nose-show-skipped = "*" -expecter = "*" - -# Reports -coverage-space = "*" - -# Documentation -mkdocs = "*" -docutils = "*" -pygments = "*" - -# Build -wheel = "*" -pyinstaller = "*" - -# Release -twine = "*" - -# Tooling -sniffer = "*" -pync = { version = "<2.0", sys_platform = "== 'darwin'" } -MacFSEvents = { version = "*", sys_platform = "== 'darwin'" } diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index cc85ae098..000000000 --- a/Pipfile.lock +++ /dev/null @@ -1,491 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "cb5b200ec4be939a2c43ff4d09d3904090e52be59ad3d8b287ee51579b376cb4" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "click": { - "hashes": [ - "sha256:3972ee95a32181e9069040414dd7c77001e9404c3c4d295300cdca06a8db026d", - "sha256:561a954a8740f1fc9c101679f43f3b75499192de1c44fbc05a5c27877047a76f" - ], - "version": "==6.0" - }, - "sappy": { - "hashes": [ - "sha256:98211c3e538fa5c4f4a3afba6100c66c29bbd0e337c96b75c8211a3ced267c27", - "sha256:c35e4e21cc618d5a3b19722ff421e9c827a60d1d1752f257777338063733a29f" - ], - "index": "pypi", - "version": "==1.1" - } - }, - "develop": { - "altgraph": { - "hashes": [ - "sha256:d6814989f242b2b43025cba7161fc1b8fb487a62cd49c49245d6fd01c18ac997", - "sha256:ddf5320017147ba7b810198e0b6619bd7b5563aa034da388cea8546b877f9b0c" - ], - "version": "==0.16.1" - }, - "astroid": { - "hashes": [ - "sha256:292fa429e69d60e4161e7612cb7cc8fa3609e2e309f80c224d93a76d5e7b58be", - "sha256:c7013d119ec95eb626f7a2011f0b63d0c9a095df9ad06d8507b37084eada1a8d" - ], - "version": "==2.0.4" - }, - "backports.shutil-get-terminal-size": { - "hashes": [ - "sha256:0975ba55054c15e346944b38956a4c9cbee9009391e41b86c68990effb8c1f64", - "sha256:713e7a8228ae80341c70586d1cc0a8caa5207346927e23d09dcbcaf18eadec80" - ], - "version": "==1.0.0" - }, - "certifi": { - "hashes": [ - "sha256:4c1d68a1408dd090d2f3a869aa94c3947cc1d967821d1ed303208c9f41f0f2f4", - "sha256:b6e8b28b2b7e771a41ecdd12d4d43262ecab52adebbafa42c77d6b57fb6ad3a4" - ], - "version": "==2018.8.13" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "click": { - "hashes": [ - "sha256:3972ee95a32181e9069040414dd7c77001e9404c3c4d295300cdca06a8db026d", - "sha256:561a954a8740f1fc9c101679f43f3b75499192de1c44fbc05a5c27877047a76f" - ], - "version": "==6.0" - }, - "colorama": { - "hashes": [ - "sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda", - "sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1" - ], - "version": "==0.3.9" - }, - "cov-core": { - "hashes": [ - "sha256:4a14c67d520fda9d42b0da6134638578caae1d374b9bb462d8de00587dba764c" - ], - "markers": "python_version < '4' and python_version != '3.2.*' and python_version >= '2.6' and python_version != '3.1.*' and python_version != '3.0.*'", - "version": "==1.15.0" - }, - "coverage": { - "hashes": [ - "sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba", - "sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed", - "sha256:10a46017fef60e16694a30627319f38a2b9b52e90182dddb6e37dcdab0f4bf95", - "sha256:198626739a79b09fa0a2f06e083ffd12eb55449b5f8bfdbeed1df4910b2ca640", - "sha256:23d341cdd4a0371820eb2b0bd6b88f5003a7438bbedb33688cd33b8eae59affd", - "sha256:28b2191e7283f4f3568962e373b47ef7f0392993bb6660d079c62bd50fe9d162", - "sha256:2a5b73210bad5279ddb558d9a2bfedc7f4bf6ad7f3c988641d83c40293deaec1", - "sha256:2eb564bbf7816a9d68dd3369a510be3327f1c618d2357fa6b1216994c2e3d508", - "sha256:337ded681dd2ef9ca04ef5d93cfc87e52e09db2594c296b4a0a3662cb1b41249", - "sha256:3a2184c6d797a125dca8367878d3b9a178b6fdd05fdc2d35d758c3006a1cd694", - "sha256:3c79a6f7b95751cdebcd9037e4d06f8d5a9b60e4ed0cd231342aa8ad7124882a", - "sha256:3d72c20bd105022d29b14a7d628462ebdc61de2f303322c0212a054352f3b287", - "sha256:3eb42bf89a6be7deb64116dd1cc4b08171734d721e7a7e57ad64cc4ef29ed2f1", - "sha256:4635a184d0bbe537aa185a34193898eee409332a8ccb27eea36f262566585000", - "sha256:56e448f051a201c5ebbaa86a5efd0ca90d327204d8b059ab25ad0f35fbfd79f1", - "sha256:5a13ea7911ff5e1796b6d5e4fbbf6952381a611209b736d48e675c2756f3f74e", - "sha256:69bf008a06b76619d3c3f3b1983f5145c75a305a0fea513aca094cae5c40a8f5", - "sha256:6bc583dc18d5979dc0f6cec26a8603129de0304d5ae1f17e57a12834e7235062", - "sha256:701cd6093d63e6b8ad7009d8a92425428bc4d6e7ab8d75efbb665c806c1d79ba", - "sha256:7608a3dd5d73cb06c531b8925e0ef8d3de31fed2544a7de6c63960a1e73ea4bc", - "sha256:76ecd006d1d8f739430ec50cc872889af1f9c1b6b8f48e29941814b09b0fd3cc", - "sha256:7aa36d2b844a3e4a4b356708d79fd2c260281a7390d678a10b91ca595ddc9e99", - "sha256:7d3f553904b0c5c016d1dad058a7554c7ac4c91a789fca496e7d8347ad040653", - "sha256:7e1fe19bd6dce69d9fd159d8e4a80a8f52101380d5d3a4d374b6d3eae0e5de9c", - "sha256:8c3cb8c35ec4d9506979b4cf90ee9918bc2e49f84189d9bf5c36c0c1119c6558", - "sha256:9d6dd10d49e01571bf6e147d3b505141ffc093a06756c60b053a859cb2128b1f", - "sha256:be6cfcd8053d13f5f5eeb284aa8a814220c3da1b0078fa859011c7fffd86dab9", - "sha256:c1bb572fab8208c400adaf06a8133ac0712179a334c09224fb11393e920abcdd", - "sha256:de4418dadaa1c01d497e539210cb6baa015965526ff5afc078c57ca69160108d", - "sha256:e05cb4d9aad6233d67e0541caa7e511fa4047ed7750ec2510d466e806e0255d6", - "sha256:f3f501f345f24383c0000395b26b726e46758b71393267aeae0bd36f8b3ade80" - ], - "markers": "python_version >= '2.6' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*' and python_version < '4'", - "version": "==4.5.1" - }, - "coverage-space": { - "hashes": [ - "sha256:ab48b9729e54972708a6430321a0e552c10ece7c4561010d669484f453fa4e03", - "sha256:e47459028a0580d916ac3f3ccfe2cf03d1d073b3284da05c4a09f5b05114ee74" - ], - "index": "pypi", - "version": "==1.0.2" - }, - "docopt": { - "hashes": [ - "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" - ], - "version": "==0.6.2" - }, - "docutils": { - "hashes": [ - "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", - "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", - "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6" - ], - "index": "pypi", - "version": "==0.14" - }, - "expecter": { - "hashes": [ - "sha256:4d2cab9d9c80620456231106b989c9a6c70f8f7f3a9725a6644097bd3017705a" - ], - "index": "pypi", - "version": "==0.3.0" - }, - "future": { - "hashes": [ - "sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb" - ], - "version": "==0.16.0" - }, - "idna": { - "hashes": [ - "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", - "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" - ], - "version": "==2.7" - }, - "isort": { - "hashes": [ - "sha256:1153601da39a25b14ddc54955dbbacbb6b2d19135386699e2ad58517953b34af", - "sha256:b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8", - "sha256:ec9ef8f4a9bc6f71eec99e1806bfa2de401650d996c59330782b89a5555c1497" - ], - "markers": "python_version != '3.0.*' and python_version != '3.1.*' and python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.2.*'", - "version": "==4.3.4" - }, - "jinja2": { - "hashes": [ - "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", - "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" - ], - "version": "==2.10" - }, - "lazy-object-proxy": { - "hashes": [ - "sha256:0ce34342b419bd8f018e6666bfef729aec3edf62345a53b537a4dcc115746a33", - "sha256:1b668120716eb7ee21d8a38815e5eb3bb8211117d9a90b0f8e21722c0758cc39", - "sha256:209615b0fe4624d79e50220ce3310ca1a9445fd8e6d3572a896e7f9146bbf019", - "sha256:27bf62cb2b1a2068d443ff7097ee33393f8483b570b475db8ebf7e1cba64f088", - "sha256:27ea6fd1c02dcc78172a82fc37fcc0992a94e4cecf53cb6d73f11749825bd98b", - "sha256:2c1b21b44ac9beb0fc848d3993924147ba45c4ebc24be19825e57aabbe74a99e", - "sha256:2df72ab12046a3496a92476020a1a0abf78b2a7db9ff4dc2036b8dd980203ae6", - "sha256:320ffd3de9699d3892048baee45ebfbbf9388a7d65d832d7e580243ade426d2b", - "sha256:50e3b9a464d5d08cc5227413db0d1c4707b6172e4d4d915c1c70e4de0bbff1f5", - "sha256:5276db7ff62bb7b52f77f1f51ed58850e315154249aceb42e7f4c611f0f847ff", - "sha256:61a6cf00dcb1a7f0c773ed4acc509cb636af2d6337a08f362413c76b2b47a8dd", - "sha256:6ae6c4cb59f199d8827c5a07546b2ab7e85d262acaccaacd49b62f53f7c456f7", - "sha256:7661d401d60d8bf15bb5da39e4dd72f5d764c5aff5a86ef52a042506e3e970ff", - "sha256:7bd527f36a605c914efca5d3d014170b2cb184723e423d26b1fb2fd9108e264d", - "sha256:7cb54db3535c8686ea12e9535eb087d32421184eacc6939ef15ef50f83a5e7e2", - "sha256:7f3a2d740291f7f2c111d86a1c4851b70fb000a6c8883a59660d95ad57b9df35", - "sha256:81304b7d8e9c824d058087dcb89144842c8e0dea6d281c031f59f0acf66963d4", - "sha256:933947e8b4fbe617a51528b09851685138b49d511af0b6c0da2539115d6d4514", - "sha256:94223d7f060301b3a8c09c9b3bc3294b56b2188e7d8179c762a1cda72c979252", - "sha256:ab3ca49afcb47058393b0122428358d2fbe0408cf99f1b58b295cfeb4ed39109", - "sha256:bd6292f565ca46dee4e737ebcc20742e3b5be2b01556dafe169f6c65d088875f", - "sha256:cb924aa3e4a3fb644d0c463cad5bc2572649a6a3f68a7f8e4fbe44aaa6d77e4c", - "sha256:d0fc7a286feac9077ec52a927fc9fe8fe2fabab95426722be4c953c9a8bede92", - "sha256:ddc34786490a6e4ec0a855d401034cbd1242ef186c20d79d2166d6a4bd449577", - "sha256:e34b155e36fa9da7e1b7c738ed7767fc9491a62ec6af70fe9da4a057759edc2d", - "sha256:e5b9e8f6bda48460b7b143c3821b21b452cb3a835e6bbd5dd33aa0c8d3f5137d", - "sha256:e81ebf6c5ee9684be8f2c87563880f93eedd56dd2b6146d8a725b50b7e5adb0f", - "sha256:eb91be369f945f10d3a49f5f9be8b3d0b93a4c2be8f8a5b83b0571b8123e0a7a", - "sha256:f460d1ceb0e4a5dcb2a652db0904224f367c9b3c1470d5a7683c0480e582468b" - ], - "version": "==1.3.1" - }, - "livereload": { - "hashes": [ - "sha256:583179dc8d49b040a9da79bd33de59e160d2a8802b939e304eb359a4419f6498", - "sha256:dd4469a8f5a6833576e9f5433f1439c306de15dbbfeceabd32479b1123380fa5" - ], - "markers": "python_version != '3.2.*' and python_version != '3.1.*' and python_version != '3.3.*' and python_version != '3.0.*' and python_version >= '2.7'", - "version": "==2.5.2" - }, - "macfsevents": { - "hashes": [ - "sha256:1324b66b356051de662ba87d84f73ada062acd42b047ed1246e60a449f833e10" - ], - "index": "pypi", - "markers": "sys_platform == 'darwin'", - "version": "==0.8.1" - }, - "macholib": { - "hashes": [ - "sha256:9485686b89d357f4a99fda5a12349b261ecf2baedd526d2c9b6397167a507e90", - "sha256:a8941ff2cf2519859ed64d1248782442c98f5e1e28a1c331911d5501f74b7b37" - ], - "version": "==1.10" - }, - "markdown": { - "hashes": [ - "sha256:9ba587db9daee7ec761cfc656272be6aabe2ed300fece21208e4aab2e457bc8f", - "sha256:a856869c7ff079ad84a3e19cd87a64998350c2b94e9e08e44270faef33400f81" - ], - "version": "==2.6.11" - }, - "markupsafe": { - "hashes": [ - "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" - ], - "version": "==1.0" - }, - "mccabe": { - "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" - ], - "version": "==0.6.1" - }, - "mkdocs": { - "hashes": [ - "sha256:2548dbefb5537f65fbdade6b1038c7b269d244b6b6db302231d2bdf6d80526de", - "sha256:88aca8afda97535112554ed1baacdd9ca669ce144f028f05cc16b18d6b596491" - ], - "index": "pypi", - "version": "==1.0.1" - }, - "nose": { - "hashes": [ - "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", - "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", - "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" - ], - "index": "pypi", - "version": "==1.3.7" - }, - "nose-capturestderr": { - "hashes": [ - "sha256:3a9d3986f44490a1286d9eacd66879dbb059b575f6660a228b2051a8617a89ab" - ], - "index": "pypi", - "version": "==1.2" - }, - "nose-cov": { - "hashes": [ - "sha256:8bec0335598f1cc69e3262cc50d7678c1a6010fa44625ce343c4ec1500774412" - ], - "index": "pypi", - "version": "==1.6" - }, - "nose-show-skipped": { - "hashes": [ - "sha256:a202f9c4b35107e9e1d6d8438eff4a930cb31a7e17517a69b319448f136815ce" - ], - "index": "pypi", - "version": "==0.1" - }, - "pefile": { - "hashes": [ - "sha256:4c5b7e2de0c8cb6c504592167acf83115cbbde01fe4a507c16a1422850e86cd6" - ], - "version": "==2018.8.8" - }, - "pkginfo": { - "hashes": [ - "sha256:5878d542a4b3f237e359926384f1dde4e099c9f5525d236b1840cf704fa8d474", - "sha256:a39076cb3eb34c333a0dd390b568e9e1e881c7bf2cc0aee12120636816f55aee" - ], - "version": "==1.4.2" - }, - "pycodestyle": { - "hashes": [ - "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83", - "sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a" - ], - "index": "pypi", - "version": "==2.4.0" - }, - "pydocstyle": { - "hashes": [ - "sha256:08a870edc94508264ed90510db466c6357c7192e0e866561d740624a8fc7d90c", - "sha256:4d5bcde961107873bae621f3d580c3e35a426d3687ffc6f8fb356f6628da5a97", - "sha256:af9fcccb303899b83bec82dc9a1d56c60fc369973223a5e80c3dfa9bdf984405" - ], - "index": "pypi", - "version": "==2.1.1" - }, - "pygments": { - "hashes": [ - "sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d", - "sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc" - ], - "index": "pypi", - "version": "==2.2.0" - }, - "pyinstaller": { - "hashes": [ - "sha256:715f81f24b1ef0e5fe3b3c71e7540551838e46e9de30882aa7c0a521147fd1ce" - ], - "index": "pypi", - "version": "==3.3.1" - }, - "pylint": { - "hashes": [ - "sha256:1d6d3622c94b4887115fe5204982eee66fdd8a951cf98635ee5caee6ec98c3ec", - "sha256:31142f764d2a7cd41df5196f9933b12b7ee55e73ef12204b648ad7e556c119fb" - ], - "index": "pypi", - "version": "==2.1.1" - }, - "pync": { - "hashes": [ - "sha256:85737aab9fc69cf59dc9fe831adbe94ac224944c05e297c98de3c2413f253530" - ], - "index": "pypi", - "markers": "sys_platform == 'darwin'", - "version": "==1.6.1" - }, - "python-dateutil": { - "hashes": [ - "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0", - "sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8" - ], - "version": "==2.7.3" - }, - "python-termstyle": { - "hashes": [ - "sha256:6faf42ba42f2826c38cf70dacb3ac51f248a418e48afc0e36593df11cf3ab1d2", - "sha256:f42a6bb16fbfc5e2c66d553e7ad46524ea833872f75ee5d827c15115fafc94e2" - ], - "markers": "python_version != '3.2.*' and python_version != '3.1.*' and python_version != '3.3.*' and python_version != '3.0.*' and python_version >= '2.7'", - "version": "==0.1.10" - }, - "pyyaml": { - "hashes": [ - "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", - "sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf", - "sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a", - "sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3", - "sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1", - "sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1", - "sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613", - "sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04", - "sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f", - "sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537", - "sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531" - ], - "version": "==3.13" - }, - "requests": { - "hashes": [ - "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1", - "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a" - ], - "version": "==2.19.1" - }, - "requests-toolbelt": { - "hashes": [ - "sha256:42c9c170abc2cacb78b8ab23ac957945c7716249206f90874651971a4acff237", - "sha256:f6a531936c6fa4c6cfce1b9c10d5c4f498d16528d2a54a22ca00011205a187b5" - ], - "markers": "python_version < '4' and python_version != '3.2.*' and python_version >= '2.6' and python_version != '3.1.*' and python_version != '3.3.*' and python_version != '3.0.*'", - "version": "==0.8.0" - }, - "six": { - "hashes": [ - "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", - "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" - ], - "version": "==1.11.0" - }, - "sniffer": { - "hashes": [ - "sha256:e8a0daa4c51dff3d00482b45dc9b978159100a8d5a7df327c28ed96586559970", - "sha256:e90c1ad4bd3c31a5fad8e03d45dfc83377b31420aa0779f17280c817ce0c9dd8" - ], - "index": "pypi", - "version": "==0.4.0" - }, - "snowballstemmer": { - "hashes": [ - "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", - "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89" - ], - "version": "==1.2.1" - }, - "tornado": { - "hashes": [ - "sha256:1c0816fc32b7d31b98781bd8ebc7a9726d7dce67407dc353a2e66e697e138448", - "sha256:4f66a2172cb947387193ca4c2c3e19131f1c70fa8be470ddbbd9317fd0801582", - "sha256:5327ba1a6c694e0149e7d9126426b3704b1d9d520852a3e4aa9fc8fe989e4046", - "sha256:6a7e8657618268bb007646b9eae7661d0b57f13efc94faa33cd2588eae5912c9", - "sha256:a9b14804783a1d77c0bd6c66f7a9b1196cbddfbdf8bceb64683c5ae60bd1ec6f", - "sha256:c58757e37c4a3172949c99099d4d5106e4d7b63aa0617f9bb24bfbff712c7866", - "sha256:d8984742ce86c0855cccecd5c6f54a9f7532c983947cff06f3a0e2115b47f85c" - ], - "markers": "python_version != '3.2.*' and python_version != '3.1.*' and python_version != '3.3.*' and python_version != '3.0.*' and python_version >= '2.7'", - "version": "==5.1" - }, - "tqdm": { - "hashes": [ - "sha256:5ef526702c0d265d5a960a3b27f3971fac13c26cf0fb819294bfa71fc6026c88", - "sha256:a3364bd83ce4777320b862e3c8a93d7da91e20a95f06ef79bed7dd71c654cafa" - ], - "markers": "python_version != '3.0.*' and python_version >= '2.6' and python_version != '3.1.*'", - "version": "==4.25.0" - }, - "twine": { - "hashes": [ - "sha256:08eb132bbaec40c6d25b358f546ec1dc96ebd2638a86eea68769d9e67fe2b129", - "sha256:2fd9a4d9ff0bcacf41fdc40c8cb0cfaef1f1859457c9653fd1b92237cc4e9f25" - ], - "index": "pypi", - "version": "==1.11.0" - }, - "typing": { - "hashes": [ - "sha256:3a887b021a77b292e151afb75323dea88a7bc1b3dfa92176cff8e44c8b68bddf", - "sha256:b2c689d54e1144bbcfd191b0832980a21c2dbcf7b5ff7a66248a60c90e951eb8", - "sha256:d400a9344254803a2368533e4533a4200d21eb7b6b729c173bc38201a74db3f2" - ], - "index": "pypi", - "version": "==3.6.4" - }, - "urllib3": { - "hashes": [ - "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", - "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" - ], - "markers": "python_version >= '2.6' and python_version != '3.1.*' and python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.2.*' and python_version < '4'", - "version": "==1.23" - }, - "wheel": { - "hashes": [ - "sha256:0a2e54558a0628f2145d2fc822137e322412115173e8a2ddbe1c9024338ae83c", - "sha256:80044e51ec5bbf6c894ba0bc48d26a8c20a9ba629f4ca19ea26ecfcf87685f5f" - ], - "index": "pypi", - "version": "==0.31.1" - }, - "wrapt": { - "hashes": [ - "sha256:d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6" - ], - "version": "==1.10.11" - } - } -} diff --git a/README.md b/README.md index 815fbb0d9..e9d99eee9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Coverage Status](http://img.shields.io/coveralls/jacebrowning/doorstop/master.svg)](https://coveralls.io/r/jacebrowning/doorstop) [![Scrutinizer Code Quality](http://img.shields.io/scrutinizer/g/jacebrowning/doorstop.svg)](https://scrutinizer-ci.com/g/jacebrowning/doorstop/?branch=master) [![PyPI Version](http://img.shields.io/pypi/v/Doorstop.svg)](https://pypi.org/project/Doorstop) -[![Best Practices](https://bestpractices.coreinfrastructure.org/projects/754/badge)](https://bestpractices.coreinfrastructure.org/projects/754) +[![Best Practices](https://bestpractices.coreinfrastructure.org/projects/754/badge)](https://bestpractices.coreinfrastructure.org/projects/754) [![Gitter](https://badges.gitter.im/jacebrowning/doorstop.svg)](https://gitter.im/jacebrowning/doorstop?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) # Overview @@ -11,15 +11,12 @@ Doorstop is a [requirements management](http://alternativeto.net/software/doorst -When a project utilizes this tool, each linkable item (requirement, test case, etc.) is stored as a YAML file in a designated directory. The items in each directory form a document. The relationship between documents forms a tree hierarchy. Doorstop provides mechanisms for modifying this tree, validating item traceability, and publishing documents in several formats. +When a project leverages this tool, each linkable item (requirement, test case, etc.) is stored as a YAML file in a designated directory. The items in each directory form a document. The relationship between documents forms a tree hierarchy. Doorstop provides mechanisms for modifying this tree, validating item traceability, and publishing documents in several formats. Doorstop is under active development and we welcome contributions. - -The project is licensed as GPLv3. - +The project is licensed as [LGPLv3](https://github.com/jacebrowning/doorstop/blob/develop/LICENSE.md). To report a problem or a security vulnerability please [raise an issue](https://github.com/jacebrowning/doorstop/issues). - -Additional reading: +Additional references: - publication: [JSEA Paper](http://www.scirp.org/journal/PaperInformation.aspx?PaperID=44268#.UzYtfWRdXEZ) - talks: [GRDevDay](https://speakerdeck.com/jacebrowning/doorstop-requirements-management-using-python-and-version-control), [BarCamp](https://speakerdeck.com/jacebrowning/strip-searched-a-rough-introduction-to-requirements-management) @@ -30,7 +27,7 @@ Additional reading: ## Requirements -* Python 3.4+ +* Python 3.5+ * A version control system for requirements storage ## Installation @@ -41,12 +38,10 @@ Install Doorstop with pip: $ pip install doorstop ``` -or directly from source: +or add it to your [Poetry](https://poetry.eustace.io/) project: ```sh -$ git clone https://github.com/jacebrowning/doorstop.git -$ cd doorstop -$ python setup.py install +$ poetry add doorstop ``` After installation, Doorstop is available on the command-line: diff --git a/bin/checksum b/bin/checksum index ce48931ef..1036e2381 100755 --- a/bin/checksum +++ b/bin/checksum @@ -1,17 +1,20 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -import sys import hashlib +import sys def run(paths): hash_md5 = hashlib.md5() for path in paths: - with open(path, 'rb') as f: - for chunk in iter(lambda: f.read(4096), b''): - hash_md5.update(chunk) + try: + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(4096), b''): + hash_md5.update(chunk) + except IOError: + hash_md5.update(path.encode()) print(hash_md5.hexdigest()) diff --git a/bin/post_compile b/bin/post_compile index ebd3aedbe..aa4c16953 100755 --- a/bin/post_compile +++ b/bin/post_compile @@ -10,12 +10,9 @@ puts-step() { echo "-----> $@" } -puts-step "Installing setuptools" -pip install setuptools | indent - -puts-step "Installing Doorstop" -python setup.py install | indent +puts-step "Installing dependencies with poetry" +poetry install | indent puts-step "Generating HTML from requirements" touch .mockvcs -doorstop publish all public -b header | indent +poetry run doorstop publish all public | indent diff --git a/bin/verchew b/bin/verchew index 3cd8c5c14..df487dc98 100755 --- a/bin/verchew +++ b/bin/verchew @@ -29,26 +29,31 @@ from __future__ import unicode_literals +import argparse +import logging import os +import re import sys -import argparse +from collections import OrderedDict +from subprocess import PIPE, STDOUT, Popen + + try: import configparser # Python 3 except ImportError: import ConfigParser as configparser # Python 2 -from collections import OrderedDict -from subprocess import Popen, PIPE, STDOUT -import logging -__version__ = '1.3' +__version__ = '1.6.2' PY2 = sys.version_info[0] == 2 + CONFIG_FILENAMES = [ 'verchew.ini', '.verchew.ini', '.verchewrc', '.verchew', ] + SAMPLE_CONFIG = """ [Python] @@ -63,7 +68,7 @@ version = Python 2.7 [virtualenv] cli = virtualenv -version = 15. +version = 15 message = Only required with Python 2. [Make] @@ -73,12 +78,14 @@ version = GNU Make optional = true """.strip() + STYLE = { "~": "✔", "*": "⭑", "?": "⚠", "x": "✘", } + COLOR = { "x": "\033[91m", # red "~": "\033[92m", # green @@ -87,12 +94,18 @@ COLOR = { None: "\033[0m", # reset } +QUIET = False + log = logging.getLogger(__name__) def main(): + global QUIET + args = parse_args() configure_logging(args.verbose) + if args.quiet: + QUIET = True log.debug("PWD: %s", os.getenv('PWD')) log.debug("PATH: %s", os.getenv('PATH')) @@ -115,8 +128,12 @@ def parse_args(): help="generate a sample configuration file") parser.add_argument('--exit-code', action='store_true', help="return a non-zero exit code on failure") - parser.add_argument('-v', '--verbose', action='count', default=0, - help="enable verbose logging") + + group = parser.add_mutually_exclusive_group() + group.add_argument('-v', '--verbose', action='count', default=0, + help="enable verbose logging") + group.add_argument('-q', '--quiet', action='store_true', + help="suppress all output on success") args = parser.parse_args() @@ -205,6 +222,11 @@ def check_dependencies(config): show(_("?") + " EXPECTED: {0}".format(settings['versions'])) success.append(_("?")) else: + if QUIET: + print("Unmatched {0} version: {1}".format( + name, + settings['versions'], + )) show(_("x") + " EXPECTED: {0}".format(settings['versions'])) success.append(_("x")) if settings.get('message'): @@ -216,18 +238,29 @@ def check_dependencies(config): def get_version(program, argument=None): - argument = argument or '--version' - args = [program, argument] + if argument is None: + args = [program, '--version'] + elif argument: + args = [program, argument] + else: + args = [program] show("$ {0}".format(" ".join(args))) output = call(args) - show(output.splitlines()[0]) + show(output.splitlines()[0] if output else "") return output def match_version(pattern, output): - return output.startswith(pattern) or " " + pattern in output + regex = pattern.replace('.', r'\.') + r'(\b|/)' + + log.debug("Matching %s: %s", regex, output) + match = re.match(regex, output) + if match is None: + match = re.match(r'.*[^\d.]' + regex, output) + + return bool(match) def call(args): @@ -246,6 +279,9 @@ def call(args): def show(text, start='', end='\n', head=False): """Python 2 and 3 compatible version of print.""" + if QUIET: + return + if head: start = '\n' end = '\n\n' diff --git a/docs/api/scripting.md b/docs/api/scripting.md index 39a43c3ea..1baebf16d 100644 --- a/docs/api/scripting.md +++ b/docs/api/scripting.md @@ -1,3 +1,75 @@ -# Scripting API +

Scripting Interface

-Documentation coming soon... +Being written in Python, Doorstop allows you to leverage the full power of Python to write scripts to manipulate requirements, run custom queries across all documents, and even inject your own validation rules. + +# REPL + +For ad hoc introspection, let Doorstop build your tree of documents in your preferred Python [REPL](https://en.wikipedia.org/wiki/Read%E2%80%93eval%E2%80%93print_loop) or notebook session: + +```python +>>> import doorstop +>>> tree = doorstop.build() +>>> tree + +>>> len(tree.documents) +4 +>>> document = tree.find_document('REQ') +>>> document +Document('/Users/Browning/Documents/doorstop/reqs') +>>> sum(1 for item in document if item.active) +18 +``` + +# Generic Scripting + +For reusable workflows, create a Python script that acts on your tree of documents: + +```python +#!/usr/bin/env python + +import doorstop + +tree = doorstop.build() +document = tree.find_document('REQ') +count = sum(1 for item in document if item.active) + +print(f"{count} active items in {document}") +``` + +# Validation Hooks + +To extend the default set of [validations](../cli/validation.md) that can be performed, Doorstop provides a "hook" mechanism to simplify scripts that need to operate on multiple documents or items. + +For this use case, create a script to call in place of the default command-line interface: + +```python +#!/usr/bin/env python + + +import sys +from doorstop import build, DoorstopInfo, DoorstopWarning, DoorstopError + + +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): + if sum(1 for i in document if i.normative) < 10: + yield DoorstopInfo("fewer than 10 normative items") + + +def check_item(item): + if not item.get('type'): + yield DoorstopWarning("no type specified") + if item.derived and not item.get('rationale'): + yield DoorstopError("derived but no rationale") + + +if __name__ == '__main__': + main() +``` + +Both `document_hook` and `item_hook` are optional, but if provided these callbacks will be passed each corresponding instance. Each callback should yield instances of Doorstop's exception classes based on severity of the issue. diff --git a/docs/cli/creation.md b/docs/cli/creation.md index c7843b5a4..cb64d801a 100644 --- a/docs/cli/creation.md +++ b/docs/cli/creation.md @@ -6,6 +6,7 @@ A document can be created inside a directory that is under version control: $ doorstop create REQ ./reqs created document: REQ (@/reqs) ``` +Note: Only one root parent requirements document is allowed per version controlled directory. Items can be added to the document and edited: @@ -35,3 +36,93 @@ added item: TST001 (@/reqs/tests/TST001.yml) $ doorstop link TST1 REQ1 linked item: TST001 (@/reqs/tests/TST001.yml) -> REQ001 (@/reqs/REQ001.yml) ``` + +It is not allowed to create links which would end up in a self reference or +cyclic dependency. + +# Add Items with Custom Default Attributes + +Items can be added to documents with custom default values for attributes +specified by the command line: + +```sh +$ doorstop add -d defaults.yml REQ +building tree... +added item: REQ001 (@/reqs/REQ001.yml) + +$ doorstop publish REQ +building tree... +1.0 REQ001 + + My default text. +``` + +defaults.yml +```yaml +text: 'My default text.' +``` + +The command line specified default values override values from the document +configuration. + +# Document Configuration + +The settings and attribute options of each document are stored in a +corresponding `.doorstop.yml` file. Some configuration options can be set via +`doorstop create` command line parameters such as the document *prefix*, the +item UID *digits*, and the *parent* prefix. Others can only be changed by +manually editing the configuration file. The list of options follows: + +* *settings* + + * *digits*: defines the number of digits in an item UID. The default value + is 3. Optionally, you can set it through the `-d` command line option of + the `doorstop create` command. It is a mandatory and read-only document + setting. + + * *parent*: defines the parent document prefix. You set it through the `-p` + command line option of the `doorstop create` command. It is an optional + and read-only document setting. + + * *prefix*: defines the document prefix. You set it through the prefix of + the `doorstop create` command. It is a mandatory and read-only document + setting. + + * *sep*: defines the separator between the document prefix and the number in + an item UID. The default value is the empty string. You have to set it + manually before an item is created. Afterwards, it should be considered as + read-only. This document setting is mandatory. + +* *attributes* + + * *defaults*: defines the + [defaults for extended attributes](../reference/item.md#defaults-for-extended-attributes). + This is an optional document configuration option. + + * *reviewed*: defines which + [ extended attributes contribute to the item fingerprint](../reference/item.md#extended-reviewed-attributes). + This is an optional document configuration option. + +In the document configuration files, you can include other YAML files through a +value tagged with `!include`. The path to the included file is always relative +to the directory of the file with the include tag. Absolute paths are not +supported. Please have a look at this example: + +.doorstop.yml +```yaml +settings: + digits: 3 + prefix: REQ + sep: '' +attributes: + defaults: + text: !include path/to/file.yml +``` + +path/to/file.yml +```yaml +| + Some template text, which may + have several + lines. +``` diff --git a/docs/cli/interchange.md b/docs/cli/interchange.md index 7ccafbdaf..521db718e 100644 --- a/docs/cli/interchange.md +++ b/docs/cli/interchange.md @@ -2,33 +2,39 @@ Documents can be exported for editing or to exchange with other systems: - $ doorstop export TST - TST001: - active: true - dervied: false - level: 1 - links: - - REQ001 - normative: true - ref: '' - text: | - Verify the foobar will foo and bar. +```sh +$ doorstop export TST +TST001: + active: true + dervied: false + level: 1 + links: + - REQ001 + normative: true + ref: '' + text: | + Verify the foobar will foo and bar. +``` Or a file can be created using one of the supported extensions: - $ doorstop export TST path/to/tst.csv - exporting TST to path/to/tst.csv... - exported: path/to/tst.csv +```sh +$ doorstop export TST path/to/tst.csv +exporting TST to path/to/tst.csv... +exported: path/to/tst.csv +``` Supported formats: -- YAML: **.yml** -- Comma-Separated Values: **.csv** -- Tab-Separated Values: **.tsv** -- Microsoft Office Excel: **.xlsx** +- YAML: `.yml` +- Comma-Separated Values: `.csv` +- Tab-Separated Values: `.tsv` +- Microsoft Office Excel: `.xlsx` # Importing Requirements Items can be created/updated from the export formats: - $ doorstop import path/to/tst.csv TST +```sh +$ doorstop import path/to/tst.csv TST +``` diff --git a/docs/cli/publishing.md b/docs/cli/publishing.md index 5ae415230..d1f626df4 100644 --- a/docs/cli/publishing.md +++ b/docs/cli/publishing.md @@ -2,7 +2,7 @@ Individual documents can be displayed: -``` +```sh $ doorstop publish TST ``` @@ -10,7 +10,7 @@ $ doorstop publish TST The collection of documents can be published as a webpage: -``` +```sh $ doorstop publish all ./dist/ ``` @@ -18,11 +18,13 @@ $ doorstop publish all ./dist/ Or a file can be created using one of the supported extensions: - $ doorstop publish TST path/to/tst.md - publishing TST to path/to/tst.md... +```sh +$ doorstop publish TST path/to/tst.md +publishing TST to path/to/tst.md... +``` Supported formats: -- Text: **.txt** -- Markdown: **.md** -- HTML: **.html** +- Text: `.txt` +- Markdown: `.md` +- HTML: `.html` diff --git a/docs/cli/validation.md b/docs/cli/validation.md index 8f69fad7a..98b8ad38d 100644 --- a/docs/cli/validation.md +++ b/docs/cli/validation.md @@ -4,13 +4,113 @@ To check a document hierarchy for consistency, run the main command: ```sh $ doorstop -valid tree: REQ <- [ TST ] +building tree... +loading documents... +validating items... + +REQ +│ +├── TUT +│ │ +│ └── HLT +│ +└── LLT ``` +By default, the validation run displays messages with a `WARNING` and `ERROR` level. +In case verbose output is enabled, also messages with an `INFO` level +are shown. + +`INFO` level messages are generated under the following conditions: + +* Skipped levels within the items of a document. +* No initial review done for an item. +* The prefix of an UID is not equal to the document prefix. +* The prefix of a link UID is not equal to the parent document prefix. + +`WARNING` level messages are generated under the following conditions: + +* A document contains no items. +* Duplicated levels within the items of a document. +* An item has an empty text attribute. +* An item has unreviewed changes. +* An item is linked to an inactive item. +* An item is linked to a non-normative item. +* An item is linked to itself. +* An item has a suspect linked to an item those fingerprint is not equal to the + one recorded in the link. +* An item in a document with child documents has no links from an item in one of its child documents. +* A normative, non-derived item in a child document has no links. +* A non-normative items has links. +* There is a cycle of item links. + +`ERROR` level messages are generated under the following conditions: + +* Link with a invalid UID. +* Unknown item for an UID. +* External reference cannot be found. + ## Links To confirm that every item in a document links to its parents: ```sh $ doorstop --strict-child-check +building tree... +loading documents... +validating items... +WARNING: REQ: REQ001: no links from document: TUT +WARNING: REQ: REQ016: no links from document: LLT +WARNING: REQ: REQ017: no links from document: LLT +WARNING: REQ: REQ008: no links from document: TUT +WARNING: REQ: REQ009: no links from document: TUT +WARNING: REQ: REQ014: no links from document: TUT +WARNING: REQ: REQ015: no links from document: TUT + +REQ +│ +├── TUT +│ │ +│ └── HLT +│ +└── LLT +``` + +## Clear Suspect Links + +Each link consists of the parent item UID and the +[fingerprint](../reference/item.md#reviewed) of the parent item. When the +fingerprint of a parent item changes, the link is reported as suspect during +validation. + +```sh +doorstop +building tree... +loading documents... +validating items... +WARNING: LLT: LLT005: suspect link: REQ001 + +REQ +│ +├── TUT +│ │ +│ └── HLT +│ +└── LLT +``` + +You can clear suspect links with the `doorstop clear` command. + +```sh +$ doorstop clear LLT005 +building tree... +clearing item LLT005's suspect links... +``` + +Optionally, you can clear only suspect links to specific parent items. + +```sh +$ doorstop clear LLT005 REQ002 REQ003 +building tree... +clearing item LLT005's suspect links to REQ002, REQ003... ``` diff --git a/docs/examples.md b/docs/examples.md index 2e92294a7..65d39fc9e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -4,4 +4,4 @@ Running `doorstop` in Docker: [tlwt/doorstop-docker](https://github.com/tlwt/doo # Sample Projects -> +[(add your open source project here)](https://github.com/jacebrowning/doorstop/edit/develop/docs/examples.md) diff --git a/docs/gui/coming-soon.md b/docs/gui/overview.md similarity index 81% rename from docs/gui/coming-soon.md rename to docs/gui/overview.md index 3873606d6..538b48463 100644 --- a/docs/gui/coming-soon.md +++ b/docs/gui/overview.md @@ -1,8 +1,6 @@ -# Coming Soon +# Desktop Client -An **experimental** desktop graphical interface (GUI) is available as a way to perform basic editing tasks. - -To launch the program: +An **experimental** desktop graphical interface (GUI) is available as a way to perform basic editing tasks. To launch the program: ```sh $ doorstop-gui diff --git a/docs/reference/document.md b/docs/reference/document.md new file mode 100644 index 000000000..31cfc112d --- /dev/null +++ b/docs/reference/document.md @@ -0,0 +1,3 @@ +

Document Reference

+ +> Help wanted! Please [contribute to this documentation](https://github.com/jacebrowning/doorstop/edit/develop/docs/reference/document.md). diff --git a/docs/reference/item.md b/docs/reference/item.md new file mode 100644 index 000000000..b9fc48cf4 --- /dev/null +++ b/docs/reference/item.md @@ -0,0 +1,340 @@ +

Item Reference

+ +Doorstop items are files formatted using YAML. When a new item is added using +`doorstop add`, Doorstop will create a YAML file and populate it with all +required attributes (key-value pairs). The UID of an item is defined by its +file name without the extension. An UID consists of two parts, the prefix and a +number. The parts are divided by an optional separator. The prefix is +determined by the document to which the item belongs. The number is +automatically assigned by Doorstop. + +Example item: +```yaml +active: true +derived: false +level: 2.1 +normative: true +reviewed: 1f33605bbc5d1a39c9a6441b91389e88 +links: [] +ref: '' +text: | + Doorstop **shall** provide unique and permanent identifiers to linkable + sections of text. +``` + +# Standard Attributes + +## `active` + +Determines if the item is active (true) or not (false). Only active items are +included when the corresponding document is published. Inactive items are +excluded from validation. + +The value of this attribute does **not** contribute to the +[fingerprint](item.md#reviewed) of the item. + +## `derived` + +Indicates if the item is derived (true) or not (false). + +[AcqNotes](http://www.acqnotes.com/acqnote/tasks/derived-requirements) defines derived requirements as: + +> '...requirements that are not explicitly stated in the set of stakeholder requirements yet is required to satisfy one or more of them. They also arise from constraints, consideration of issues implied but not explicitly stated in the requirements baseline, factors introduced by the selected architecture, Information Assurance (IA) requirements and the design.' + +Doorstop does not expect parent links on derived items. + +The value of this attribute does **not** contribute to the +[fingerprint](item.md#reviewed) of the item. + +## `normative` + +Indicates if the item is normative (true) or non-normative (false). + +[Wikipedia on Normative](https://en.wikipedia.org/wiki/Normative) in standards documents: +> 'In standards terminology still used by some organizations, "normative" means +> "considered to be a prescriptive part of the standard". It characterizes +> that part of the standard which describes what ought (see philosophy above) to +> be done within the application of that standard. It is implicit that +> application of that standard will result in a valuable outcome (ibid.). For +> example, many standards have an introduction, preface, or summary that is +> considered non-normative, as well as a main body that is considered normative. +> "Compliance" is defined as "complies with the normative sections of the +> standard"; an object that complies with the normative sections but not the +> non-normative sections of a standard is still considered to be in compliance.' + +The value of this attribute does **not** contribute to the +[fingerprint](item.md#reviewed) of the item. + +## `level` + +Indicates the presentation order within a document. A level of 1.1 will display +above level 1.2 and 1.1.5 displays below 1.1.2. + +If the level ends with .0 and the item is non-normative, Doorstop will treat the +item as a document heading. See the [text](item.md#text)-section for an +example. + +The value of this attribute does **not** contribute to the +[fingerprint](item.md#reviewed) of the item. + +If you edit the item file by hand or use other tools be aware of the implicit +typing rules in YAML. For example the following level value + +```yaml +level: 1.10 +``` + +will parsed as a 1.1 float value. Doorstop will interpret this as level 1.1 +and store this level in the item file. Use quotes around non-float values, +e.g. `level: '1.10'`. + +## ``header`` + +Gives a header (i.e. title) for the item. It will be printed alongside the item +UID when published as HTML and Markdown. Links will also include the header +text. This is **different** from a [heading item](item.md#example-heading). + +The value of this attribute does **not** contribute to the +[fingerprint](item.md#reviewed) of the item. + +### Example: Header + +TST007.yml +```yaml +level: 1.5 +normative: true +links: +- REQ023: null +header: | + Gradual Temperature Drop Test +text: | + Lower the external air temperature gradually from 0 to -15 degress Celsius over a period of 30 minutes. + Ensure the system performs a safe shutdown when -15 degrees Celsius is reached. +``` + +When this item is published, Doorstop will place the item's Header next to its UID. + +``` +TST007 Gradual Temperature Drop Test + Lower the external air temperature gradually from 0 to -15 degress Celsius over a period of 30 minutes. + Ensure the system performs a safe shutdown when -15 degrees Celsius is reached. + + Parent Item: REQ023 Temperature Interlock +``` + +## `reviewed` + +Each item has a fingerprint. By default, the UID of the item, the values of the +[text](item.md#text) and [ref](item.md#ref) attributes, and the UIDs of the +[links](item.md#links) attribute contribute to the fingerprint. Optionally, +values of extended attributes can be added to the fingerprint through a +[document configuration option](item.md#extended-reviewed-attributes). + +The value of the *reviewed* attribute indicates the fingerprint of the item +when it was last reviewed. "null" if the item has not yet been reviewed. +Doorstop will use this to detect unreviewed changes to an item by comparing the +current item fingerprint to the last reviewed fingerprint. + +You should not calculate this value manually, use `doorstop review`. + +## `links` + +A list of links to parent item(s). A link indicates a relationship between two +items in the document tree. + +In the following example, `REQ001` is a parent to the item. + +```yaml +links: +- REQ001: 1f33605bbc5d1a39c9a6441b91389e88 +``` + +A link consists of two parts, the parent item UID and the +[fingerprint](item.md#reviewed) of the parent when it last reviewed. If the +link has not yet been reviewed, the fingerprint is set to "null" or omitted. + +```yaml +links: +- REQ001: null +``` + +is equivalent to + +```yaml +links: +- REQ001 +``` + +In the cases where no fingerprint exists or a null-fingerprint is specified, +Doorstop will add a new fingerprint whenever a review occurs. + +The link fingerprint is used by Doorstop to detect when a parent item is +changed, as a convenience to the writer since such change may also affect its +children. + +Only the UID part of the link contributes to the fingerprint of the item (the +item of the reviewed attribute, not the parent item of the link). The +fingerprint of the link is does **not** contribute to the fingerprint of the +item. + +## `ref` + +External reference. An item may reference an external file or a line in an +external file. An external reference is displayed in a published document. + +Doorstop will search the project root and it's sub-directories for a filename +matching the specified reference. If multiple matching files exist, the first +found will be used. + +If a file is not found, Doorstop will also search the contents of all text-files +in the project root and it's sub-directories. If a line contains the referenced +keyword, Doorstop will reference the file and line number where it found the +keyword. If the keyword is found in multiple lines or files, the first found +will be used. + +A file is considered a text-file unless its file extension is listed in +`SKIP_EXTS` (settings.py). + +The value of this attribute contributes to the [fingerprint](item.md#reviewed) +of the item. + +### Example: Reference keyword + +```yaml +ref: 'TST001' +``` + +References the filename and line number of a text-file that contains the +keyword "TST001". + +### Example: Reference file + +```yaml +ref: 'test-tst001.c' +``` + +References a file called "test-tst001.c". + +If a reference is specified and Doorstop is unable to find it, Doorstop will +exit with an error unless reference checking is disabled. + +## `text` + +Item text. This is the main body of the item. Doorstop treats the value as +markdown to support rich text, images and tables. To specify a multi-line text, +use block scalar types as specified by the YAML standard. + +The value of this attribute contributes to the [fingerprint](item.md#reviewed) +of the item. + +### Example: Heading + +REQ001.yml +```yaml +level: 1.1.0 +normative: false +text: | + This is the heading + + This is some text that goes into chapter 1.1.0. +``` + +When this item is published, Doorstop will create a new heading with the text +"1.1.0 This is the heading" and put the remaining text into its body. + +### Example: Normative item + +REQ001.yml +```yaml +level: 1.1.0 +normative: true +text: | + Doorstop **shall** support exporting to the ReqIF file format. +``` + +When this item is published, Doorstop will create a new heading with the text +"1.1.0 REQ001" and put the all of the text in its body. + +### Example: LaTex-like math expressions + +You can use math expressions in LaTex interpreted by the markdown extension +[python-markdown-math](https://pypi.org/project/python-markdown-math/) and rendered by +[MathJax](https://github.com/mathjax/MathJax), when using the HTML publisher. + +TST008.yml +```yaml +level: 1.6 +normative: true +links: +- REQ023: null +text: | + When $a \ne 0$, there are two solutions to \(ax^2 + bx + c = 0\) and they are + $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$ +``` + +# Extended Attributes + +In addition to the standard attributes, Doorstop will allow any number of +custom attributes (key-value pairs) in the YAML file. The extended attributes +will not be part of a published document, but they can be queried by a 3rd party +application through the REST interface or the Python API. + +In this example, an extended attribute `invented-by` is added to the item. + +```yaml +invented-by: jane@example.com +``` + +## Defaults for extended attributes + +Optionally, you can add custom default values for extended attributes. Add +them as key-value pairs to the `defaults` dictionary under the `attributes` +section in the corresponding document configuration file `.doorstop.yml`. +There is no command to maintain this configuration option. You have to edit +the document configuration file `.doorstop.yml` by hand. + +```yaml +settings: + digits: 3 + prefix: REQ + sep: '' +attributes: + defaults: + attribute-key-0: a scalar default value + attribute-key-1: + - default values can + - be lists + attribute-key-2: + default: values can + be: dictionaries + attribute-key-3: ... default values can be arbitrarily complex +``` + +## Extended reviewed attributes + +By default, the values of extended attributes do **not** contribute to the +[fingerprint](item.md#reviewed) of the item. Optionally, you can add the +values of extended attributes to the fingerprint through the `reviewed` list +under the `attributes` section in the corresponding document configuration file +`.doorstop.yml`. The `reviewed` list must be a non-empty list of attribute +keys. There is no command to maintain this configuration option. You have to +edit the document configuration file `.doorstop.yml` by hand. + +```yaml +settings: + digits: 3 + prefix: REQ + sep: '' +attributes: + reviewed: + - type + - verification-method +``` + +If attributes listed in `reviewed` do not exist in an item of this document, +then a warning is issued by the validation command `doorstop`: + +``` +WARNING: REQ001: missing extended reviewed attribute: type +WARNING: REQ001: missing extended reviewed attribute: verification-method +``` diff --git a/docs/reference/items.md b/docs/reference/items.md deleted file mode 100644 index 396a4780b..000000000 --- a/docs/reference/items.md +++ /dev/null @@ -1,230 +0,0 @@ -# Overview - -Doorstop items are files formatted using YAML. When a new item is added using -```doorstop add```, Doorstop will create a YAML file and populate it with all -required attributes (key-value pairs). - -Example item: -```yaml -active: true -derived: false -level: 2.1 -normative: true -reviewed: 1f33605bbc5d1a39c9a6441b91389e88 -links: [] -ref: '' -text: | - Doorstop **shall** provide unique and permanent identifiers to linkable - sections of text. -``` - -## Standard attributes - -### active - -Determines if the item is active (true) or not (false). Only active items are -included when the corresponding document is published. Inactive items are -excluded from validation. - -### derived - -Indicates if the item is derived (true) or not (false). - -[AcqNotes](http://www.acqnotes.com/acqnote/tasks/derived-requirements) defines derived requirements as: - -> '...requirements that are not explicitly stated in the set of stakeholder requirements yet is required to satisfy one or more of them. They also arise from constraints, consideration of issues implied but not explicitly stated in the requirements baseline, factors introduced by the selected architecture, Information Assurance (IA) requirements and the design.' - -Doorstop does not expect parent links on derived items. - -### normative - -Indicates if the item is normative (true) or non-normative (false). - -[Wikipedia on Normative](https://en.wikipedia.org/wiki/Normative) in standards documents: -> 'In standards terminology still used by some organizations, "normative" means -> "considered to be a prescriptive part of the standard". It characterizes -> that part of the standard which describes what ought (see philosophy above) to -> be done within the application of that standard. It is implicit that -> application of that standard will result in a valuable outcome (ibid.). For -> example, many standards have an introduction, preface, or summary that is -> considered non-normative, as well as a main body that is considered normative. -> "Compliance" is defined as "complies with the normative sections of the -> standard"; an object that complies with the normative sections but not the -> non-normative sections of a standard is still considered to be in compliance.' - -### level - -Indicates the presentation order within a document. A level of 1.1 will display -above level 1.2 and 1.1.5 displays below 1.1.2. - -If the level ends with .0 and the item is non-normative, Doorstop will treat the -item as a document heading. See the [text](items.md#text)-section for an -example. - -### reviewed - -Indicates the fingerprint of the item when it was last reviewed. "null" if the -item has not yet been reviewed. Doorstop will use this to detect unreviewed -changes to an item by comparing the current item fingerprint to the last -reviewed fingerprint. - -You should not calculate this value manually, use ```doorstop review```. - - -### links - -A list of links to parent item(s). A link indicates a relationship between two -items in the document tree. - -In the following example, ```REQ001``` is a parent to the item. - -```yaml -links: -- REQ001: 1f33605bbc5d1a39c9a6441b91389e88 -``` - -A link consists of two parts, the parent item UID and the fingerprint of the -parent when it last reviewed. If the link has not yet been reviewed, the -fingerprint is set to "null" or omitted. - -```yaml -links: -- REQ001: null -``` - -is equivalent to - -```yaml -links: -- REQ001 -``` - -In the cases where no fingerprint exists or a null-fingerprint is specified, -Doorstop will add a new fingerprint whenever a review occurs. - -The link fingerprint is used by Doorstop to detect when a parent item is -changed, as a convenience to the writer since such change may also affect its -children. - -### ref - -External reference. An item may reference an external file or a line in an -external file. An external reference is displayed in a published document. - -Doorstop will search the project root and it's sub-directories for a filename -matching the specified reference. If multiple matching files exist, the first -found will be used. - -If a file is not found, Doorstop will also search the contents of all text-files -in the project root and it's sub-directories. If a line contains the referenced -keyword, Doorstop will reference the file and line number where it found the -keyword. If the keyword is found in multiple lines or files, the first found -will be used. - -A file is considered a text-file unless its file extension is listed in -```SKIP_EXTS``` (settings.py). - -#### Example: Reference keyword -```yaml -ref: 'TST001' -``` - -References the filename and line number of a text-file that contains the -keyword "TST001". - -#### Example: Reference file -```yaml -ref: 'test-tst001.c' -``` - -References a file called "test-tst001.c". - -If a reference is specified and Doorstop is unable to find it, Doorstop will -exit with an error unless reference checking is disabled. - - -### text - -Item text. This is the main body of the item. Doorstop treats the value as -markdown to support rich text, images and tables. To specify a multi-line text, -use block scalar types as specified by the YAML standard. - -#### Example: Heading - -REQ001.yml -```yaml -level: 1.1.0 -normative: false -text: | - This is the heading - - This is some text that goes into chapter 1.1.0. -``` - -When this item is published, Doorstop will create a new heading with the text -"1.1.0 This is the heading" and put the remaining text into its body. - -#### Example: Normative item - -REQ001.yml -```yaml -level: 1.1.0 -normative: true -text: | - Doorstop **shall** support exporting to the ReqIF file format. -``` - -When this item is published, Doorstop will create a new heading with the text -"1.1.0 REQ001" and put the all of the text in its body. - -## Extended attributes - -In addition to the standard attributes, Doorstop will allow any number of -custom attributes (key-value pairs) in the YAML file. The extended attributes -will not be part of a published document, but they can be queried by a 3rd party -application through the REST interface or the Python API. - -#### Example: - -In this example, an extended attribute ```invented-by``` is added to the item. - -```yaml -invented-by: some.guy@email.com -``` - - -## Beta Features -### header - -This is **different** from _heading_. If you want a Heading item, following instructions for Heading above. - -To enable, use flag `-b header` - -Gives a header (i.e. title) for the item. It will be printed alongside the item UID when published as HTML and Markdown. Links will also include the header text. - - -#### Example: Header - -TST007.yml -```yaml -level: 1.5 -normative: true -links: -- REQ023: null -header: | - Gradual Temperature Drop Test -text: | - Lower the external air temperature gradually from 0 to -15 degress Celsius over a period of 30 minutes. - Ensure the system performs a safe shutdown when -15 degrees Celsius is reached. -``` - -When this item is published, Doorstop will place the item's Header next to its UID. - -``` -TST007 Gradual Temperature Drop Test - Lower the external air temperature gradually from 0 to -15 degress Celsius over a period of 30 minutes. - Ensure the system performs a safe shutdown when -15 degrees Celsius is reached. - - Parent Item: REQ023 Temperature Interlock -``` - diff --git a/docs/reference/tree.md b/docs/reference/tree.md new file mode 100644 index 000000000..f4e0ccf2f --- /dev/null +++ b/docs/reference/tree.md @@ -0,0 +1,3 @@ +

Tree Reference

+ +> Help wanted! Please [contribute to this documentation](https://github.com/jacebrowning/doorstop/edit/develop/docs/reference/tree.md). diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..591f7c091 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +mkdocs==1.0.4 diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 000000000..8f553619c --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,41 @@ +## Editor + +Doorstop will open files using the editor specified by the `$EDITOR` environment variable. If that is unset, it will attempt to open files in the default editor for each file type. + +## Git + +**Linux / macOS** + +No additional configuration should be neccesary. + +**Windows** + +Windows deals with line-endings differently to most systems, favoring `CRLF` (`\r\n`) over the more traditional `LF` (`\n`). +The `YAML` files saved and revision-controlled by Doorstop have `LF` +line-endings, which can cause the following warnings: + +``` +(doorstop) C:\temp\doorstop>doorstop reorder --auto TS +building tree... +reordering document TS... +warning: LF will be replaced by CRLF in tests/sys/TS003.yml. +The file will have its original line endings in your working directory. +warning: LF will be replaced by CRLF in tests/sys/TS001.yml. +The file will have its original line endings in your working directory. +warning: LF will be replaced by CRLF in tests/sys/TS002.yml. +The file will have its original line endings in your working directory. +warning: LF will be replaced by CRLF in tests/sys/TS004.yml. +The file will have its original line endings in your working directory. +reordered document: TS +``` + +These warnings come from Git as a sub-process of the main Doorstop processes, +so the solution is to add the following to your `.gitattributes` file: + +``` +*.yml text eol=lf +``` + +From [Git's documentation](https://git-scm.com/docs/gitattributes): + +> This setting forces Git to normalize line endings [for \*.yml files] to LF on checkin and prevents conversion to CRLF when the file is checked out. diff --git a/docs/reference/webserver.md b/docs/web.md similarity index 68% rename from docs/reference/webserver.md rename to docs/web.md index 929f9cbbf..57b455c1f 100644 --- a/docs/reference/webserver.md +++ b/docs/web.md @@ -1,17 +1,17 @@ -# Using the Doorstop web front-end - -## Conventional bottle webserver +## Bottle Doorstop can be run as a standalone web server by running -`doorstop-server`. It will use the current working directory as the +`doorstop-server`. + +It will use the current working directory as the document source by default. ## WSGI Doorstop can also be used as a WSGI application by Apache or other web -servers. To configure this, copy 'bin/example-adapter.wsgi' from this +servers. To configure this, copy `bin/example-adapter.wsgi` from this repository to an appropriate place in your web data directory, such as -'/var/www/doorstop/adapter.wsgi'. Edit that file to give it the +`/var/www/doorstop/adapter.wsgi`. Edit that file to give it the correct path to your doorstop installation. Now alter your apache configuration and add something similar to this: @@ -26,5 +26,5 @@ configuration and add something similar to this: Require all granted
-Change 'path/to/your/document' to the path to the Doorstop data you -wish to display. \ No newline at end of file +Change `path/to/your/document` to the path to the Doorstop data you +wish to display. diff --git a/doorstop/__init__.py b/doorstop/__init__.py index e0830b433..77adfad95 100644 --- a/doorstop/__init__.py +++ b/doorstop/__init__.py @@ -1,12 +1,30 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Package for doorstop.""" -from doorstop.common import DoorstopError, DoorstopWarning, DoorstopInfo -from doorstop.core import Item, Document, Tree -from doorstop.core import build, find_document, find_item -from doorstop.core import importer, exporter, builder, editor, publisher +from pkg_resources import DistributionNotFound, get_distribution + +from doorstop.common import DoorstopError, DoorstopInfo, DoorstopWarning +from doorstop.core import ( + Document, + Item, + Tree, + build, + builder, + editor, + exporter, + find_document, + find_item, + importer, + publisher, +) __project__ = 'Doorstop' -__version__ = '1.5' + +try: + __version__ = get_distribution(__project__).version +except DistributionNotFound: + __version__ = '(local)' CLI = 'doorstop' GUI = 'doorstop-gui' diff --git a/doorstop/cli/__init__.py b/doorstop/cli/__init__.py index 129b1a622..e72329f47 100644 --- a/doorstop/cli/__init__.py +++ b/doorstop/cli/__init__.py @@ -1 +1,3 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Command-line interface for Doorstop.""" diff --git a/doorstop/cli/commands.py b/doorstop/cli/commands.py index f9b507aa7..776ce9894 100644 --- a/doorstop/cli/commands.py +++ b/doorstop/cli/commands.py @@ -1,17 +1,72 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Command functions.""" import os import time -from doorstop import common +from doorstop import common, server from doorstop.cli import utilities +from doorstop.core import editor, exporter, importer, publisher from doorstop.core.builder import build -from doorstop.core import editor, importer, exporter, publisher -from doorstop import server log = common.logger(__name__) +class CycleTracker: + """A cycle tracker to detect cyclic references between items. + + The cycle tracker uses a standard algorithm to detect cycles in a directed + graph (not necessarily connected) using a depth first search with a bit of + graph colouring. The time complexity is O(|V| + |E|). The vertices are + the items. The edges are the links between items. + + """ + + def __init__(self): + """Initialize a cycle tracker.""" + self.discovered = set() + self.finished = set() + + def _dfs_visit(self, uid, tree): + """Do a depth first search visit of the specified item. + + :param uid: the UID of the item to visit + :param tree: the document hierarchy tree + + :return: generator of :class:`~doorstop.common.DoorstopWarning` + + """ + self.discovered.add(uid) + item = tree.find_item(uid) + + for pid in item.links: + # Detect cycles via a back edge + if pid in self.discovered: + msg = "detected a cycle with a back edge from {} to {}".format(pid, uid) + yield common.DoorstopWarning(msg) + + # Recurse, if this a fresh item + if pid not in self.discovered and pid not in self.finished: + yield from self._dfs_visit(pid, tree) + + self.discovered.remove(uid) + self.finished.add(uid) + + def __call__(self, item, document, tree): + """Get cycles which include the specified item. + + :param item: the UID of the item to get the cycles for + :param document: unused + :param tree: the document hierarchy tree + + :return: generator of :class:`~doorstop.common.DoorstopWarning` + + """ + if item not in self.discovered and item not in self.finished: + yield from self._dfs_visit(item, tree) + + def get(name): """Get a command function by name.""" if name: @@ -38,7 +93,8 @@ def run(args, cwd, error, catch=True): # pylint: disable=W0613 # validate it utilities.show("validating items...", flush=True) - valid = tree.validate(skip=args.skip) + cycle_tracker = CycleTracker() + valid = tree.validate(skip=args.skip, item_hook=cycle_tracker) if not success: return False @@ -64,14 +120,16 @@ def run_create(args, cwd, _, catch=True): tree = _get_tree(args, cwd) # create a new document - document = tree.create_document(args.path, args.prefix, - parent=args.parent, digits=args.digits) + document = tree.create_document( + args.path, args.prefix, parent=args.parent, digits=args.digits + ) if not success: return False - utilities.show("created document: {} ({})".format(document.prefix, - document.relpath)) + utilities.show( + "created document: {} ({})".format(document.prefix, document.relpath) + ) return True @@ -120,9 +178,12 @@ def run_add(args, cwd, _, catch=True): # add items to it for _ in range(args.count): - item = document.add_item(level=args.level) - utilities.show("added item: {} ({})".format(item.uid, - item.relpath)) + item = document.add_item(level=args.level, defaults=args.defaults) + utilities.show("added item: {} ({})".format(item.uid, item.relpath)) + + # Edit item if requested + if args.edit: + item.edit(tool=args.tool) if not success: return False @@ -184,7 +245,7 @@ def run_edit(args, cwd, error, catch=True): # edit it if item: - item.edit(tool=args.tool) + item.edit(tool=args.tool, edit_all=args.all) else: _export_import(args, cwd, error, document, ext) @@ -275,10 +336,9 @@ def run_link(args, cwd, _, catch=True): if not success: return False - msg = "linked items: {} ({}) -> {} ({})".format(child.uid, - child.relpath, - parent.uid, - parent.relpath) + msg = "linked items: {} ({}) -> {} ({})".format( + child.uid, child.relpath, parent.uid, parent.relpath + ) utilities.show(msg) return True @@ -304,10 +364,9 @@ def run_unlink(args, cwd, _, catch=True): if not success: return False - msg = "unlinked items: {} ({}) -> {} ({})".format(child.uid, - child.relpath, - parent.uid, - parent.relpath) + msg = "unlinked items: {} ({}) -> {} ({})".format( + child.uid, child.relpath, parent.uid, parent.relpath + ) utilities.show(msg) return True @@ -323,11 +382,21 @@ def run_clear(args, cwd, error, catch=True): """ with utilities.capture(catch=catch) as success: + tree = _get_tree(args, cwd) + + if args.parents: + # Check that the parent item UIDs exist + for pid in args.parents: + tree.find_item(pid) + + pids = " to " + ", ".join(args.parents) + else: + pids = "" - for item in _iter_items(args, cwd, error): - msg = "clearing item {}'s suspect links...".format(item.uid) + for item in _iter_items(args, tree, error): + msg = "clearing item {}'s suspect links{}...".format(item.uid, pids) utilities.show(msg) - item.clear() + item.clear(parents=args.parents) if not success: return False @@ -345,8 +414,9 @@ def run_review(args, cwd, error, catch=True): """ with utilities.capture(catch=catch) as success: + tree = _get_tree(args, cwd) - for item in _iter_items(args, cwd, error): + for item in _iter_items(args, tree, error): utilities.show("marking item {} as reviewed...".format(item.uid)) item.review() @@ -385,31 +455,32 @@ def run_import(args, cwd, error, catch=True, _tree=None): # get the document request_next_number = _request_next_number(args) - tree = _tree or _get_tree(args, cwd, - request_next_number=request_next_number) + tree = _tree or _get_tree( + args, cwd, request_next_number=request_next_number + ) document = tree.find_document(args.prefix) # import items into it - msg = "importing '{}' into document {}...".format(args.path, - document) + msg = "importing '{}' into document {}...".format(args.path, document) utilities.show(msg, flush=True) importer.import_file(args.path, document, ext, mapping=mapping) elif args.document: prefix, path = args.document - document = importer.create_document(prefix, path, - parent=args.parent) + document = importer.create_document(prefix, path, parent=args.parent) elif args.item: prefix, uid = args.item request_next_number = _request_next_number(args) - item = importer.add_item(prefix, uid, attrs=attrs, - request_next_number=request_next_number) + item = importer.add_item( + prefix, uid, attrs=attrs, request_next_number=request_next_number + ) if not success: return False if document: - utilities.show("imported document: {} ({})".format(document.prefix, - document.relpath)) + utilities.show( + "imported document: {} ({})".format(document.prefix, document.relpath) + ) else: assert item utilities.show("imported item: {} ({})".format(item.uid, item.relpath)) @@ -449,8 +520,7 @@ def run_export(args, cwd, error, catch=True, auto=False, _tree=None): utilities.show(msg, flush=True) path = exporter.export(tree, args.path, ext, auto=auto) else: - msg = "exporting document {} to '{}'...".format(document, - args.path) + msg = "exporting document {} to '{}'...".format(document, args.path) utilities.show(msg, flush=True) path = exporter.export(document, args.path, ext, auto=auto) if path: @@ -500,14 +570,15 @@ def run_publish(args, cwd, error, catch=True): if whole_tree: msg = "publishing tree to '{}'...".format(path) utilities.show(msg, flush=True) - published_path = publisher.publish(tree, path, ext, - template=args.template, **kwargs) + published_path = publisher.publish( + tree, path, ext, template=args.template, **kwargs + ) else: - msg = "publishing document {} to '{}'...".format(document, - path) + msg = "publishing document {} to '{}'...".format(document, path) utilities.show(msg, flush=True) - published_path = publisher.publish(document, path, ext, - template=args.template, **kwargs) + published_path = publisher.publish( + document, path, ext, template=args.template, **kwargs + ) if published_path: utilities.show("published: {}".format(published_path)) @@ -543,8 +614,7 @@ def _get_tree(args, cwd, request_next_number=None, load=False): """ utilities.show("building tree...", flush=True) - tree = build(cwd=cwd, root=args.project, - request_next_number=request_next_number) + tree = build(cwd=cwd, root=args.project, request_next_number=request_next_number) if load: utilities.show("loading documents...", flush=True) @@ -553,11 +623,11 @@ def _get_tree(args, cwd, request_next_number=None, load=False): return tree -def _iter_items(args, cwd, error): - """Build a tree and iterate through items. +def _iter_items(args, tree, error): + """Iterate through items. :param args: Namespace of CLI arguments - :param cwd: current working directory + :param tree: the document hierarchy tree :param error: function to call for CLI errors Items are filtered to: @@ -582,7 +652,6 @@ def _iter_items(args, cwd, error): # Build tree item = None document = None - tree = tree = _get_tree(args, cwd) # Determine if tree, document, or item was requested if args.label != 'all': @@ -621,8 +690,7 @@ def _export_import(args, cwd, error, document, ext): args.prefix = document.prefix path = "{}-{}{}".format(args.prefix, int(time.time()), ext) args.path = path - get('export')(args, cwd, error, catch=False, auto=True, - _tree=document.tree) + get('export')(args, cwd, error, catch=False, auto=True, _tree=document.tree) # Open the exported file editor.edit(path, tool=args.tool) diff --git a/doorstop/cli/main.py b/doorstop/cli/main.py index 02585b55a..ceccc8691 100644 --- a/doorstop/cli/main.py +++ b/doorstop/cli/main.py @@ -1,17 +1,20 @@ #!/usr/bin/env python +# SPDX-License-Identifier: LGPL-3.0-only """Command-line interface for Doorstop.""" +import argparse import os import sys -import argparse from doorstop import common, settings -from doorstop.cli import utilities, commands -from doorstop.core import publisher, vcs, document +from doorstop.cli import commands, utilities +from doorstop.core import document, publisher, vcs log = common.logger(__name__) +EDITOR = os.environ.get('EDITOR') + def main(args=None): # pylint: disable=R0915 """Process command-line arguments and run the program.""" @@ -23,57 +26,122 @@ def main(args=None): # pylint: disable=R0915 root = vcs.find_root(os.getcwd()) except common.DoorstopError: root = None - project.add_argument('-j', '--project', metavar='PATH', - help="path to the root of the project", - default=root) - project.add_argument('--no-cache', action='store_true', - help=argparse.SUPPRESS) - project.add_argument('-b', '--beta', nargs='*', - help="""enable beta features. Refer to documentation on available beta features. """) + project.add_argument( + '-j', + '--project', + metavar='PATH', + help="path to the root of the project", + default=root, + ) + project.add_argument('--no-cache', action='store_true', help=argparse.SUPPRESS) server = argparse.ArgumentParser(add_help=False) - server.add_argument('--server', metavar='HOST', - help="IP address or hostname for a running server", - default=settings.SERVER_HOST) - server.add_argument('--port', metavar='NUMBER', type=int, - help="use a custom port for the server", - default=settings.SERVER_PORT) - server.add_argument('-f', '--force', action='store_true', - help="perform the action without the server") + server.add_argument( + '--server', + metavar='HOST', + help="IP address or hostname for a running server", + default=settings.SERVER_HOST, + ) + server.add_argument( + '--port', + metavar='NUMBER', + type=int, + help="use a custom port for the server", + default=settings.SERVER_PORT, + ) + server.add_argument( + '-f', + '--force', + action='store_true', + help="perform the action without the server", + ) debug = argparse.ArgumentParser(add_help=False) debug.add_argument('-V', '--version', action='version', version=VERSION) group = debug.add_mutually_exclusive_group() - group.add_argument('-v', '--verbose', action='count', default=0, - help="enable verbose logging") - group.add_argument('-q', '--quiet', action='store_const', const=-1, - dest='verbose', help="only display errors and prompts") - shared = {'formatter_class': common.HelpFormatter, - 'parents': [project, server, debug]} + group.add_argument( + '-v', '--verbose', action='count', default=0, help="enable verbose logging" + ) + group.add_argument( + '-q', + '--quiet', + action='store_const', + const=-1, + dest='verbose', + help="only display errors and prompts", + ) + shared = { + 'formatter_class': common.HelpFormatter, + 'parents': [project, server, debug], + } # Build main parser - parser = argparse.ArgumentParser(prog=CLI, description=DESCRIPTION, - **shared) - parser.add_argument('-F', '--no-reformat', action='store_true', - help="do not reformat item files during validation") - parser.add_argument('-r', '--reorder', action='store_true', - help="reorder document levels during validation") - parser.add_argument('-L', '--no-level-check', action='store_true', - help="do not validate document levels") - parser.add_argument('-R', '--no-ref-check', action='store_true', - help="do not validate external file references") - parser.add_argument('-C', '--no-child-check', action='store_true', - help="do not validate child (reverse) links") - parser.add_argument('-Z', '--strict-child-check', action='store_true', - help="require child (reverse) links from every document") - parser.add_argument('-S', '--no-suspect-check', action='store_true', - help="do not check for suspect links") - parser.add_argument('-W', '--no-review-check', action='store_true', - help="do not check item review status") - parser.add_argument('-s', '--skip', metavar='PREFIX', action='append', - help="skip a document during validation") - parser.add_argument('-w', '--warn-all', action='store_true', - help="display all info-level issues as warnings") - parser.add_argument('-e', '--error-all', action='store_true', - help="display all warning-level issues as errors") + parser = argparse.ArgumentParser(prog=CLI, description=DESCRIPTION, **shared) + parser.add_argument( + '-F', + '--no-reformat', + action='store_true', + help="do not reformat item files during validation", + ) + parser.add_argument( + '-r', + '--reorder', + action='store_true', + help="reorder document levels during validation", + ) + parser.add_argument( + '-L', + '--no-level-check', + action='store_true', + help="do not validate document levels", + ) + parser.add_argument( + '-R', + '--no-ref-check', + action='store_true', + help="do not validate external file references", + ) + parser.add_argument( + '-C', + '--no-child-check', + action='store_true', + help="do not validate child (reverse) links", + ) + parser.add_argument( + '-Z', + '--strict-child-check', + action='store_true', + help="require child (reverse) links from every document", + ) + parser.add_argument( + '-S', + '--no-suspect-check', + action='store_true', + help="do not check for suspect links", + ) + parser.add_argument( + '-W', + '--no-review-check', + action='store_true', + help="do not check item review status", + ) + parser.add_argument( + '-s', + '--skip', + metavar='PREFIX', + action='append', + help="skip a document during validation", + ) + parser.add_argument( + '-w', + '--warn-all', + action='store_true', + help="display all info-level issues as warnings", + ) + parser.add_argument( + '-e', + '--error-all', + action='store_true', + help="display all warning-level issues as errors", + ) # Build sub-parsers subs = parser.add_subparsers(help="", dest='command', metavar="") @@ -120,201 +188,324 @@ def main(args=None): # pylint: disable=R0915 def _create(subs, shared): """Configure the `doorstop create` subparser.""" info = "create a new document directory" - sub = subs.add_parser('create', description=info.capitalize() + '.', - help=info, **shared) + sub = subs.add_parser( + 'create', description=info.capitalize() + '.', help=info, **shared + ) sub.add_argument('prefix', help="document prefix for new item UIDs") sub.add_argument('path', help="path to a directory for item files") sub.add_argument('-p', '--parent', help="prefix of parent document") - sub.add_argument('-d', '--digits', help="number of digits in item UIDs", - default=document.Document.DEFAULT_DIGITS) + sub.add_argument( + '-d', + '--digits', + help="number of digits in item UIDs", + default=document.Document.DEFAULT_DIGITS, + ) def _delete(subs, shared): """Configure the `doorstop delete` subparser.""" info = "delete a document directory" - sub = subs.add_parser('delete', description=info.capitalize() + '.', - help=info, **shared) + sub = subs.add_parser( + 'delete', description=info.capitalize() + '.', help=info, **shared + ) sub.add_argument('prefix', help="prefix of document to delete") def _add(subs, shared): """Configure the `doorstop add` subparser.""" info = "create an item file in a document directory" - sub = subs.add_parser('add', description=info.capitalize() + '.', - help=info, **shared) - sub.add_argument('prefix', - help="document prefix for the new item") + sub = subs.add_parser( + 'add', description=info.capitalize() + '.', help=info, **shared + ) + sub.add_argument('prefix', help="document prefix for the new item") sub.add_argument('-l', '--level', help="desired item level (e.g. 1.2.3)") - sub.add_argument('-c', '--count', default=1, type=utilities.positive_int, - help="number of items to create") + sub.add_argument( + '-c', + '--count', + default=1, + type=utilities.positive_int, + help="number of items to create", + ) + sub.add_argument( + '--edit', + action='store_true', + help=( + "Open default editor to edit the added item. " + "Default editor can be set using the environment " + "variable EDITOR." + ), + ) + sub.add_argument( + '-T', + '--tool', + metavar='PROGRAM', + default=EDITOR, + help=( + "text editor to open the document item (only" + "required if $EDITOR is not found in" + "environment). Useless option without --edit" + ), + ) + sub.add_argument( + '-d', + '--defaults', + metavar='FILE', + help=("file in YAML format with default values for attributes of the new item"), + ) def _remove(subs, shared): """Configure the `doorstop remove` subparser.""" info = "remove an item file from a document directory" - sub = subs.add_parser('remove', description=info.capitalize() + '.', - help=info, **shared) + sub = subs.add_parser( + 'remove', description=info.capitalize() + '.', help=info, **shared + ) sub.add_argument('uid', help="item UID to remove from its document") def _edit(subs, shared): """Configure the `doorstop edit` subparser.""" info = "open an existing item or document for editing" - sub = subs.add_parser('edit', description=info.capitalize() + '.', - help=info, **shared) - sub.add_argument('label', - help="item UID or document prefix to open for editing") + sub = subs.add_parser( + 'edit', description=info.capitalize() + '.', help=info, **shared + ) + sub.add_argument('label', help="item UID or document prefix to open for editing") + sub.add_argument( + '-a', + '--all', + action='store_true', + help=( + "Edit the whole item with all its attributes. " + "Without this option, only its text is opened for " + "edition. Useless when editing a whole document." + ), + ) group = sub.add_mutually_exclusive_group() - group.add_argument('-i', '--item', action='store_true', - help="indicates the 'label' is an item UID") - group.add_argument('-d', '--document', action='store_true', - help="indicates the 'label' is a document prefix") + group.add_argument( + '-i', '--item', action='store_true', help="indicates the 'label' is an item UID" + ) + group.add_argument( + '-d', + '--document', + action='store_true', + help="indicates the 'label' is a document prefix", + ) group = sub.add_mutually_exclusive_group() - group.add_argument('-y', '--yaml', action='store_true', - help="edit document as exported YAML (default)") - group.add_argument('-c', '--csv', action='store_true', - help="edit document as exported CSV") - group.add_argument('-t', '--tsv', action='store_true', - help="edit document as exported TSV") - group.add_argument('-x', '--xlsx', action='store_true', - help="edit document as exported XLSX") + group.add_argument( + '-y', + '--yaml', + action='store_true', + help="edit document as exported YAML (default)", + ) + group.add_argument( + '-c', '--csv', action='store_true', help="edit document as exported CSV" + ) + group.add_argument( + '-t', '--tsv', action='store_true', help="edit document as exported TSV" + ) + group.add_argument( + '-x', '--xlsx', action='store_true', help="edit document as exported XLSX" + ) required = sub.add_argument_group('required arguments') - required.add_argument('-T', '--tool', metavar='PROGRAM', - help="text editor to open the document item", - required=True) + required.add_argument( + '-T', + '--tool', + metavar='PROGRAM', + default=EDITOR, + help="text editor to open the document item (only required if $EDITOR is not found in environment)", + ) def _reorder(subs, shared): """Configure the `doorstop reorder` subparser.""" info = "organize the outline structure of a document" - sub = subs.add_parser('reorder', description=info.capitalize() + '.', - help=info, **shared) + sub = subs.add_parser( + 'reorder', description=info.capitalize() + '.', help=info, **shared + ) sub.add_argument('prefix', help="prefix of document to reorder") group = sub.add_mutually_exclusive_group() - group.add_argument('-a', '--auto', action='store_true', - help="only perform automatic item reordering") - group.add_argument('-m', '--manual', action='store_true', - help="do not automatically reorder the items") - sub.add_argument('-T', '--tool', metavar='PROGRAM', - help="text editor to open the document index") + group.add_argument( + '-a', + '--auto', + action='store_true', + help="only perform automatic item reordering", + ) + group.add_argument( + '-m', + '--manual', + action='store_true', + help="do not automatically reorder the items", + ) + sub.add_argument( + '-T', + '--tool', + metavar='PROGRAM', + default=EDITOR, + help="text editor to open the document index", + ) def _link(subs, shared): """Configure the `doorstop link` subparser.""" info = "add a new link between two items" - sub = subs.add_parser('link', description=info.capitalize() + '.', - help=info, **shared) - sub.add_argument('child', - help="child item UID to link to the parent") - sub.add_argument('parent', - help="parent item UID to link from the child") + sub = subs.add_parser( + 'link', description=info.capitalize() + '.', help=info, **shared + ) + sub.add_argument('child', help="child item UID to link to the parent") + sub.add_argument('parent', help="parent item UID to link from the child") def _unlink(subs, shared): """Configure the `doorstop unlink` subparser.""" info = "remove a link between two items" - sub = subs.add_parser('unlink', description=info.capitalize() + '.', - help=info, **shared) - sub.add_argument('child', - help="child item UID to unlink from parent") - sub.add_argument('parent', - help="parent item UID child is linked to") + sub = subs.add_parser( + 'unlink', description=info.capitalize() + '.', help=info, **shared + ) + sub.add_argument('child', help="child item UID to unlink from parent") + sub.add_argument('parent', help="parent item UID child is linked to") def _clear(subs, shared): """Configure the `doorstop clear` subparser.""" info = "absolve items of their suspect link status" - sub = subs.add_parser('clear', description=info.capitalize() + '.', - help=info, **shared) + sub = subs.add_parser( + 'clear', description=info.capitalize() + '.', help=info, **shared + ) sub.add_argument('label', help="item UID, document prefix, or 'all'") group = sub.add_mutually_exclusive_group() - group.add_argument('-i', '--item', action='store_true', - help="indicates the 'label' is an item UID") - group.add_argument('-d', '--document', action='store_true', - help="indicates the 'label' is a document prefix") + group.add_argument( + '-i', '--item', action='store_true', help="indicates the 'label' is an item UID" + ) + group.add_argument( + '-d', + '--document', + action='store_true', + help="indicates the 'label' is a document prefix", + ) + sub.add_argument( + 'parents', nargs='*', help="only clear links with these parent item UIDs" + ) def _review(subs, shared): """Configure the `doorstop review` subparser.""" info = "absolve items of their unreviewed status" - sub = subs.add_parser('review', description=info.capitalize() + '.', - help=info, **shared) + sub = subs.add_parser( + 'review', description=info.capitalize() + '.', help=info, **shared + ) sub.add_argument('label', help="item UID, document prefix, or 'all'") group = sub.add_mutually_exclusive_group() - group.add_argument('-i', '--item', action='store_true', - help="indicates the 'label' is an item UID") - group.add_argument('-d', '--document', action='store_true', - help="indicates the 'label' is a document prefix") + group.add_argument( + '-i', '--item', action='store_true', help="indicates the 'label' is an item UID" + ) + group.add_argument( + '-d', + '--document', + action='store_true', + help="indicates the 'label' is a document prefix", + ) def _import(subs, shared): """Configure the `doorstop import` subparser.""" info = "import an existing document or item" - sub = subs.add_parser('import', description=info.capitalize() + '.', - help=info, **shared) - sub.add_argument('path', nargs='?', - help="path to previously exported document file") + sub = subs.add_parser( + 'import', description=info.capitalize() + '.', help=info, **shared + ) + sub.add_argument( + 'path', nargs='?', help="path to previously exported document file" + ) sub.add_argument('prefix', nargs='?', help="prefix of document for import") group = sub.add_mutually_exclusive_group() - group.add_argument('-d', '--document', nargs=2, metavar='ARG', - help="import an existing document by: PREFIX PATH") - group.add_argument('-i', '--item', nargs=2, metavar='ARG', - help="import an existing item by: PREFIX UID") - sub.add_argument('-p', '--parent', metavar='PREFIX', - help="parent document prefix for imported document") - sub.add_argument('-a', '--attrs', metavar='DICT', - help="dictionary of item attributes to import") - sub.add_argument('-m', '--map', metavar='DICT', - help="dictionary of custom item attribute names") + group.add_argument( + '-d', + '--document', + nargs=2, + metavar='ARG', + help="import an existing document by: PREFIX PATH", + ) + group.add_argument( + '-i', + '--item', + nargs=2, + metavar='ARG', + help="import an existing item by: PREFIX UID", + ) + sub.add_argument( + '-p', + '--parent', + metavar='PREFIX', + help="parent document prefix for imported document", + ) + sub.add_argument( + '-a', '--attrs', metavar='DICT', help="dictionary of item attributes to import" + ) + sub.add_argument( + '-m', '--map', metavar='DICT', help="dictionary of custom item attribute names" + ) def _export(subs, shared): """Configure the `doorstop export` subparser.""" info = "export a document as YAML or another format" - sub = subs.add_parser('export', description=info.capitalize() + '.', - help=info, **shared) + sub = subs.add_parser( + 'export', description=info.capitalize() + '.', help=info, **shared + ) sub.add_argument('prefix', help="prefix of document to export or 'all'") - sub.add_argument('path', nargs='?', - help="path to exported file or directory for 'all'") + sub.add_argument( + 'path', nargs='?', help="path to exported file or directory for 'all'" + ) group = sub.add_mutually_exclusive_group() - group.add_argument('-y', '--yaml', action='store_true', - help="output YAML (default when no path)") - group.add_argument('-c', '--csv', action='store_true', - help="output CSV (default for 'all')") - group.add_argument('-t', '--tsv', action='store_true', - help="output TSV") - group.add_argument('-x', '--xlsx', action='store_true', - help="output XLSX") - sub.add_argument('-w', '--width', type=int, - help="limit line width on text output") + group.add_argument( + '-y', '--yaml', action='store_true', help="output YAML (default when no path)" + ) + group.add_argument( + '-c', '--csv', action='store_true', help="output CSV (default for 'all')" + ) + group.add_argument('-t', '--tsv', action='store_true', help="output TSV") + group.add_argument('-x', '--xlsx', action='store_true', help="output XLSX") + sub.add_argument('-w', '--width', type=int, help="limit line width on text output") def _publish(subs, shared): """Configure the `doorstop publish` subparser.""" info = "publish a document as text or another format" - sub = subs.add_parser('publish', description=info.capitalize() + '.', - help=info, **shared) + sub = subs.add_parser( + 'publish', description=info.capitalize() + '.', help=info, **shared + ) sub.add_argument('prefix', help="prefix of document to publish or 'all'") - sub.add_argument('path', nargs='?', - help="path to published file or directory for 'all'") + sub.add_argument( + 'path', nargs='?', help="path to published file or directory for 'all'" + ) group = sub.add_mutually_exclusive_group() - group.add_argument('-t', '--text', action='store_true', - help="output text (default when no path)") - group.add_argument('-m', '--markdown', action='store_true', - help="output Markdown") - group.add_argument('-H', '--html', action='store_true', - help="output HTML (default for 'all')") - sub.add_argument('-w', '--width', type=int, - help="limit line width on text output") - sub.add_argument('-C', '--no-child-links', action='store_true', - help="do not include child links on items") - sub.add_argument('-L', '--no-body-levels', action='store_true', - default=None, - help="do not include levels on non-heading items") - sub.add_argument('--no-levels', choices=['all', 'body'], - help="do not include levels on heading and non-heading or non-heading items") + group.add_argument( + '-t', '--text', action='store_true', help="output text (default when no path)" + ) + group.add_argument('-m', '--markdown', action='store_true', help="output Markdown") + group.add_argument( + '-H', '--html', action='store_true', help="output HTML (default for 'all')" + ) + sub.add_argument('-w', '--width', type=int, help="limit line width on text output") + sub.add_argument( + '-C', + '--no-child-links', + action='store_true', + help="do not include child links on items", + ) + sub.add_argument( + '-L', + '--no-body-levels', + action='store_true', + default=None, + help="do not include levels on non-heading items", + ) + sub.add_argument( + '--no-levels', + choices=['all', 'body'], + help="do not include levels on heading and non-heading or non-heading items", + ) sub.add_argument('--template', help="template file", default=publisher.HTMLTEMPLATE) -if __name__ == '__main__': # pragma: no cover (manual test) +if __name__ == '__main__': main() diff --git a/doorstop/cli/tests/__init__.py b/doorstop/cli/tests/__init__.py index 06a19ae48..bd69b6d36 100644 --- a/doorstop/cli/tests/__init__.py +++ b/doorstop/cli/tests/__init__.py @@ -1,10 +1,12 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Package for the doorstop.cli tests.""" import os import unittest -from doorstop.cli.main import main from doorstop import settings +from doorstop.cli.main import main ROOT = os.path.join(os.path.dirname(__file__), '..', '..', '..') REQS = os.path.join(ROOT, 'reqs') @@ -19,37 +21,41 @@ class SettingsTestCase(unittest.TestCase): """Base test case class that backs up settings.""" def setUp(self): - self.backup = (settings.REFORMAT, - settings.CHECK_REF, - settings.CHECK_CHILD_LINKS, - settings.REORDER, - settings.CHECK_LEVELS, - settings.PUBLISH_CHILD_LINKS, - settings.CHECK_SUSPECT_LINKS, - settings.CHECK_REVIEW_STATUS, - settings.PUBLISH_BODY_LEVELS, - settings.CACHE_DOCUMENTS, - settings.CACHE_ITEMS, - settings.CACHE_PATHS, - settings.WARN_ALL, - settings.ERROR_ALL, - settings.SERVER_HOST, - settings.SERVER_PORT) + self.backup = ( + settings.REFORMAT, + settings.CHECK_REF, + settings.CHECK_CHILD_LINKS, + settings.REORDER, + settings.CHECK_LEVELS, + settings.PUBLISH_CHILD_LINKS, + settings.CHECK_SUSPECT_LINKS, + settings.CHECK_REVIEW_STATUS, + settings.PUBLISH_BODY_LEVELS, + settings.CACHE_DOCUMENTS, + settings.CACHE_ITEMS, + settings.CACHE_PATHS, + settings.WARN_ALL, + settings.ERROR_ALL, + settings.SERVER_HOST, + settings.SERVER_PORT, + ) def tearDown(self): - (settings.REFORMAT, - settings.CHECK_REF, - settings.CHECK_CHILD_LINKS, - settings.REORDER, - settings.CHECK_LEVELS, - settings.PUBLISH_CHILD_LINKS, - settings.CHECK_SUSPECT_LINKS, - settings.CHECK_REVIEW_STATUS, - settings.PUBLISH_BODY_LEVELS, - settings.CACHE_DOCUMENTS, - settings.CACHE_ITEMS, - settings.CACHE_PATHS, - settings.WARN_ALL, - settings.ERROR_ALL, - settings.SERVER_HOST, - settings.SERVER_PORT) = self.backup + ( + settings.REFORMAT, + settings.CHECK_REF, + settings.CHECK_CHILD_LINKS, + settings.REORDER, + settings.CHECK_LEVELS, + settings.PUBLISH_CHILD_LINKS, + settings.CHECK_SUSPECT_LINKS, + settings.CHECK_REVIEW_STATUS, + settings.PUBLISH_BODY_LEVELS, + settings.CACHE_DOCUMENTS, + settings.CACHE_ITEMS, + settings.CACHE_PATHS, + settings.WARN_ALL, + settings.ERROR_ALL, + settings.SERVER_HOST, + settings.SERVER_PORT, + ) = self.backup diff --git a/doorstop/cli/tests/docs/HLT001.yml b/doorstop/cli/tests/docs/HLT001.yml index 38e889e08..d155697b5 100644 --- a/doorstop/cli/tests/docs/HLT001.yml +++ b/doorstop/cli/tests/docs/HLT001.yml @@ -1,12 +1,13 @@ active: true derived: false +header: '' level: 1.1 links: - TUT001: 63207cc0be9a957dd43f20eb88b20170 - TUT002: 3a4234ca2b58212fa1bd6052b113daa7 - TUT004: 81b4d9e74572f0a91b46c924de931c36 - TUT008: 2dbd9266fa287cc3f765c2a81aa57a91 -- TUT017: adde6f14a8ab86bee61ba88db6960815 +- TUT017: b77c84ce6fd12c137a11197baa3064e9 - TUT019: 85c96dfa447540b5433a67f1e1114c61 normative: true ref: test_tutorial_section_1 diff --git a/doorstop/cli/tests/docs/HLT002.yml b/doorstop/cli/tests/docs/HLT002.yml index 4c0082a4f..e747b0c7f 100644 --- a/doorstop/cli/tests/docs/HLT002.yml +++ b/doorstop/cli/tests/docs/HLT002.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.2 links: - TUT009: 49abe5f194cc21ad59a12973f320e93e diff --git a/doorstop/cli/tests/docs/HLT003.yml b/doorstop/cli/tests/docs/HLT003.yml index 38e198c58..6fd630a5f 100644 --- a/doorstop/cli/tests/docs/HLT003.yml +++ b/doorstop/cli/tests/docs/HLT003.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.3 links: - TUT012: 000c0e9c39a92c6eb93902e4ea972740 diff --git a/doorstop/cli/tests/docs/HLT004.yml b/doorstop/cli/tests/docs/HLT004.yml index 9b7e08554..d8b9078a7 100644 --- a/doorstop/cli/tests/docs/HLT004.yml +++ b/doorstop/cli/tests/docs/HLT004.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.4 links: - TUT015: 208dbf9e5350223aa2c261411b9075ea diff --git a/doorstop/cli/tests/docs/HLT005.yml b/doorstop/cli/tests/docs/HLT005.yml index 22291b8a7..42a95a76d 100644 --- a/doorstop/cli/tests/docs/HLT005.yml +++ b/doorstop/cli/tests/docs/HLT005.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.0 links: [] normative: false diff --git a/doorstop/cli/tests/files/A001.txt b/doorstop/cli/tests/files/A001.txt new file mode 100644 index 000000000..7ecc7b52c --- /dev/null +++ b/doorstop/cli/tests/files/A001.txt @@ -0,0 +1,10 @@ +active: true +derived: false +level: 1.0 +links: +- B001: 4f8f0ce49c56474c07bb7d3aebee81fd +normative: true +ref: '' +reviewed: e805f473655509bd0b1d0bd167d5e9b3 +text: | + A001 diff --git a/doorstop/cli/tests/files/A002.txt b/doorstop/cli/tests/files/A002.txt new file mode 100644 index 000000000..dd32d45ee --- /dev/null +++ b/doorstop/cli/tests/files/A002.txt @@ -0,0 +1,10 @@ +active: true +derived: false +level: 1.1 +links: +- A002: 23e02cca707fc2db356546bf9b09c664 +normative: true +ref: '' +reviewed: 1e9d4868c614ccb2c73f06145df5e21b +text: | + A002 diff --git a/doorstop/cli/tests/files/B001.txt b/doorstop/cli/tests/files/B001.txt new file mode 100644 index 000000000..4da27e383 --- /dev/null +++ b/doorstop/cli/tests/files/B001.txt @@ -0,0 +1,10 @@ +active: true +derived: false +level: 1.0 +links: +- B002: 26f9500c1a290315dbfffade4a4a3eba +normative: true +ref: '' +reviewed: e393ce229853b50eb4f8b4e27457bb22 +text: | + B001 diff --git a/doorstop/cli/tests/files/B002.txt b/doorstop/cli/tests/files/B002.txt new file mode 100644 index 000000000..a4ee55008 --- /dev/null +++ b/doorstop/cli/tests/files/B002.txt @@ -0,0 +1,11 @@ +active: true +derived: false +level: 1.1 +links: +- A001: a5e44cddfa203a4e3f8ae9d93e28bda7 +- A002: 23e02cca707fc2db356546bf9b09c664 +normative: true +ref: '' +reviewed: b151d8073a4562ca541c64968e8efa6d +text: | + B002 diff --git a/doorstop/cli/tests/files/C001.txt b/doorstop/cli/tests/files/C001.txt new file mode 100644 index 000000000..f563f0c33 --- /dev/null +++ b/doorstop/cli/tests/files/C001.txt @@ -0,0 +1,11 @@ +active: true +derived: false +level: 1.0 +links: +- C002: deadbeefdeadbeefdeadbeefdeadbeef +- C003: deadbeefdeadbeefdeadbeefdeadbeef +normative: true +ref: '' +reviewed: 918294db8654e4443cc4b221a51d618b +text: | + C001 diff --git a/doorstop/core/tests/files_beta/reqs_parent/SYSHEADER001.yml b/doorstop/cli/tests/files/C002.txt similarity index 53% rename from doorstop/core/tests/files_beta/reqs_parent/SYSHEADER001.yml rename to doorstop/cli/tests/files/C002.txt index 768307986..0a4eb8060 100644 --- a/doorstop/core/tests/files_beta/reqs_parent/SYSHEADER001.yml +++ b/doorstop/cli/tests/files/C002.txt @@ -1,11 +1,9 @@ active: true derived: false -header: | - header hello world level: 1.1 links: [] normative: true ref: '' -reviewed: null +reviewed: d48826bf3f51ec9d79f9ef501e397ccf text: | - This item has a header + C002 diff --git a/doorstop/cli/tests/files/C003.txt b/doorstop/cli/tests/files/C003.txt new file mode 100644 index 000000000..6376a1d09 --- /dev/null +++ b/doorstop/cli/tests/files/C003.txt @@ -0,0 +1,9 @@ +active: true +derived: false +level: 1.2 +links: [] +normative: true +ref: '' +reviewed: 486c91baf12e1e0190e7c37b0ef9d526 +text: | + C003 diff --git a/doorstop/cli/tests/files/template.yml b/doorstop/cli/tests/files/template.yml new file mode 100644 index 000000000..77d19b951 --- /dev/null +++ b/doorstop/cli/tests/files/template.yml @@ -0,0 +1,4 @@ +text: | + Some text + with more than + one line. diff --git a/doorstop/cli/tests/test_all.py b/doorstop/cli/tests/test_all.py index 61384531a..72aacf23f 100644 --- a/doorstop/cli/tests/test_all.py +++ b/doorstop/cli/tests/test_all.py @@ -1,21 +1,27 @@ -"""Integration tests for the doorstop.cli package.""" +# SPDX-License-Identifier: LGPL-3.0-only -import unittest -from unittest.mock import patch, Mock +"""Integration tests for the doorstop.cli package.""" import os -import tempfile import shutil +import tempfile +import unittest +from unittest.mock import Mock, patch +from doorstop import common, settings from doorstop.cli.main import main -from doorstop import common +from doorstop.cli.tests import ( + ENV, + FILES, + REASON, + REQS, + ROOT, + TUTORIAL, + SettingsTestCase, +) from doorstop.core.builder import _clear_tree -from doorstop import settings from doorstop.core.document import Document -from doorstop.cli.tests import ENV, REASON, ROOT, FILES, REQS, TUTORIAL -from doorstop.cli.tests import SettingsTestCase - REQ_COUNT = 17 ALL_COUNT = 49 @@ -77,19 +83,21 @@ def test_main_custom_root(self): class TestCreate(TempTestCase): """Integration tests for the 'doorstop create' command.""" + @patch('subprocess.call', Mock()) def test_create(self): """Verify 'doorstop create' can be called.""" self.assertIs(None, main(['create', '_TEMP', self.temp, '-p', 'REQ'])) + @patch('subprocess.call', Mock()) def test_create_error_unknwon_parent(self): """Verify 'doorstop create' returns an error with an unknown parent.""" - self.assertRaises(SystemExit, main, - ['create', '_TEMP', self.temp, '-p', 'UNKNOWN']) + self.assertRaises( + SystemExit, main, ['create', '_TEMP', self.temp, '-p', 'UNKNOWN'] + ) def test_create_error_reserved_prefix(self): """Verify 'doorstop create' returns an error with a reserved prefix.""" - self.assertRaises(SystemExit, main, - ['create', 'ALL', self.temp, '-p', 'REQ']) + self.assertRaises(SystemExit, main, ['create', 'ALL', self.temp, '-p', 'REQ']) @unittest.skipUnless(os.getenv(ENV), REASON) @@ -110,9 +118,9 @@ def get_next_number(): """Helper function to get the next document number.""" last = None for last in sorted(os.listdir(TUTORIAL), reverse=True): - if "index" not in last: + if last.endswith('.yml'): break - assert last + assert last, "Unable to find last item" number = int(last.replace('TUT', '').replace('.yml', '')) + 1 return number @@ -193,7 +201,7 @@ def test_add_no_server(self): def test_add_custom_server(self, mock_add_item): """Verify 'doorstop add' can be called with a custom server.""" self.assertIs(None, main(['add', 'TUT', '--server', '1.2.3.4'])) - mock_add_item.assert_called_once_with(level=None) + mock_add_item.assert_called_once_with(defaults=None, level=None) def test_add_force(self): """Verify 'doorstop add' can be called with a missing server.""" @@ -241,7 +249,7 @@ def tearDown(self): def test_reorder_document_yes(self, mock_launch): """Verify 'doorstop reorder' can be called with a document (yes).""" self.assertIs(None, main(['reorder', self.prefix])) - mock_launch.assert_called_once_with(self.path, tool=None) + mock_launch.assert_called_once_with(self.path, tool=os.getenv('EDITOR')) self.assertFalse(os.path.exists(self.path)) @patch('doorstop.core.editor.launch') @@ -249,7 +257,7 @@ def test_reorder_document_yes(self, mock_launch): def test_reorder_document_no(self, mock_launch): """Verify 'doorstop reorder' can be called with a document (no).""" self.assertIs(None, main(['reorder', self.prefix])) - mock_launch.assert_called_once_with(self.path, tool=None) + mock_launch.assert_called_once_with(self.path, tool=os.getenv('EDITOR')) self.assertFalse(os.path.exists(self.path)) @patch('doorstop.core.editor.launch') @@ -264,7 +272,7 @@ def test_reorder_document_auto(self, mock_launch): def test_reorder_document_manual(self, mock_launch, mock_reorder_auto): """Verify 'doorstop reorder' can be called with a document (manual).""" self.assertIs(None, main(['reorder', self.prefix, '--manual'])) - mock_launch.assert_called_once_with(self.path, tool=None) + mock_launch.assert_called_once_with(self.path, tool=os.getenv('EDITOR')) self.assertEqual(0, mock_reorder_auto.call_count) self.assertFalse(os.path.exists(self.path)) @@ -292,10 +300,11 @@ def test_reorder_document_unknown(self): class TestEdit(unittest.TestCase): """Integration tests for the 'doorstop edit' command.""" + @patch('subprocess.call', Mock()) @patch('doorstop.core.editor.launch') def test_edit_item(self, mock_launch): - """Verify 'doorstop edit' can be called with an item.""" - self.assertIs(None, main(['edit', 'tut2', '-T', 'my_editor'])) + """Verify 'doorstop edit' can be called with an item (all).""" + self.assertIs(None, main(['edit', 'tut2', '-T', 'my_editor', '--all'])) path = os.path.join(TUTORIAL, 'TUT002.yml') mock_launch.assert_called_once_with(os.path.normpath(path), tool='my_editor') @@ -496,13 +505,13 @@ def tearDown(self): def test_import_document(self): """Verify 'doorstop import' can import a document.""" - self.assertRaises(SystemExit, - main, ['import', '--document', 'TMP', 'tmp']) + self.assertRaises(SystemExit, main, ['import', '--document', 'TMP', 'tmp']) def test_import_document_with_parent(self): """Verify 'doorstop import' can import a document with a parent.""" - self.assertIs(None, main(['import', '--document', 'TMP', 'tmp', - '--parent', 'REQ'])) + self.assertIs( + None, main(['import', '--document', 'TMP', 'tmp', '--parent', 'REQ']) + ) def test_import_item(self): """Verify 'doorstop import' can import an item..""" @@ -510,8 +519,19 @@ def test_import_item(self): def test_import_item_with_attrs(self): """Verify 'doorstop import' can import an item with attributes.""" - self.assertIs(None, main(['import', '--item', 'REQ', 'REQ099', - '--attrs', "{'text': 'The item text.'}"])) + self.assertIs( + None, + main( + [ + 'import', + '--item', + 'REQ', + 'REQ099', + '--attrs', + "{'text': 'The item text.'}", + ] + ), + ) def test_import_error(self): """Verify 'doorstop import' requires a document or item.""" @@ -531,10 +551,8 @@ def test_import_file_missing_prefix(self): def test_import_file_extra_flags(self): """Verify 'doorstop import' returns an error with extra flags.""" path = os.path.join(FILES, 'exported.xlsx') - self.assertRaises(SystemExit, - main, ['import', path, 'PREFIX', '-d', '_', '_']) - self.assertRaises(SystemExit, - main, ['import', path, 'PREFIX', '-i', '_', '_']) + self.assertRaises(SystemExit, main, ['import', path, 'PREFIX', '-d', '_', '_']) + self.assertRaises(SystemExit, main, ['import', path, 'PREFIX', '-i', '_', '_']) def test_import_file_to_document_unknown(self): """Verify 'doorstop import' returns an error for unknown documents.""" @@ -547,8 +565,9 @@ def test_import_file_with_map(self): dirpath = os.path.join(self.temp, 'imported', 'prefix') main(['create', 'PREFIX', dirpath]) # Act - self.assertIs(None, main(['import', path, 'PREFIX', - '--map', "{'mylevel': 'level'}"])) + self.assertIs( + None, main(['import', path, 'PREFIX', '--map', "{'mylevel': 'level'}"]) + ) # Assert path = os.path.join(dirpath, 'REQ001.yml') self.assertTrue(os.path.isfile(path)) @@ -558,8 +577,7 @@ def test_import_file_with_map(self): def test_import_file_with_map_invalid(self): """Verify 'doorstop import' returns an error with an invalid map.""" path = os.path.join(FILES, 'exported.csv') - self.assertRaises(SystemExit, - main, ['import', path, 'PREFIX', '--map', "{'my"]) + self.assertRaises(SystemExit, main, ['import', path, 'PREFIX', '--map', "{'my"]) def test_import_csv_to_document_existing(self): """Verify 'doorstop import' can import CSV to an existing document.""" @@ -606,8 +624,7 @@ def tearDown(self): def test_import_item_force(self): """Verify 'doorstop import' can import an item without a server.""" - self.assertIs(None, - main(['import', '--item', 'REQ', 'REQ099', '--force'])) + self.assertIs(None, main(['import', '--item', 'REQ', 'REQ099', '--force'])) @unittest.skipUnless(os.getenv(ENV), REASON) @@ -671,13 +688,11 @@ class TestPublish(TempTestCase): def setUp(self): super().setUp() - self.backup = (settings.PUBLISH_CHILD_LINKS, - settings.PUBLISH_BODY_LEVELS) + self.backup = (settings.PUBLISH_CHILD_LINKS, settings.PUBLISH_BODY_LEVELS) def tearDown(self): super().tearDown() - (settings.PUBLISH_CHILD_LINKS, - settings.PUBLISH_BODY_LEVELS) = self.backup + (settings.PUBLISH_CHILD_LINKS, settings.PUBLISH_BODY_LEVELS) = self.backup def test_publish_unknown(self): """Verify 'doorstop publish' returns an error for an unknown format.""" @@ -784,18 +799,20 @@ class TestPublishCommand(TempTestCase): def test_publish_document_template(self, mock_publish): """Verify 'doorstop publish' is called with template.""" path = os.path.join(self.temp, 'req.html') - self.assertIs(None, main(['publish', '--template', - 'my_template.html', 'req', path])) - mock_publish.assert_called_once_with(Document(os.path.abspath(REQS)), - path, '.html', - template='my_template.html') + self.assertIs( + None, main(['publish', '--template', 'my_template.html', 'req', path]) + ) + mock_publish.assert_called_once_with( + Document(os.path.abspath(REQS)), path, '.html', template='my_template.html' + ) @patch('doorstop.core.publisher.publish_lines') def test_publish_document_to_stdout(self, mock_publish_lines): """Verify 'doorstop publish_lines' is called when no output path specified""" self.assertIs(None, main(['publish', 'req'])) - mock_publish_lines.assert_called_once_with(Document(os.path.abspath(REQS)), - '.txt') + mock_publish_lines.assert_called_once_with( + Document(os.path.abspath(REQS)), '.txt' + ) @patch('doorstop.cli.commands.run', Mock(return_value=True)) diff --git a/doorstop/cli/tests/test_main.py b/doorstop/cli/tests/test_main.py index fa6e0d296..e03405c6c 100644 --- a/doorstop/cli/tests/test_main.py +++ b/doorstop/cli/tests/test_main.py @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Unit tests for the doorstop.cli.main module.""" -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch -from doorstop.cli import main from doorstop import settings - +from doorstop.cli import main from doorstop.cli.tests import SettingsTestCase @@ -42,16 +43,23 @@ def test_empty(self): @patch('doorstop.cli.commands.run', Mock()) def test_options(self): """Verify 'doorstop' can be run with options.""" - self.assertIs(None, main.main(['--no-reformat', - '--no-ref-check', - '--no-child-check', - '--reorder', - '--no-level-check', - '--no-suspect-check', - '--no-review-check', - '--no-cache', - '--warn-all', - '--error-all'])) + self.assertIs( + None, + main.main( + [ + '--no-reformat', + '--no-ref-check', + '--no-child-check', + '--reorder', + '--no-level-check', + '--no-suspect-check', + '--no-review-check', + '--no-cache', + '--warn-all', + '--error-all', + ] + ), + ) self.assertFalse(settings.REFORMAT) self.assertFalse(settings.CHECK_REF) self.assertFalse(settings.CHECK_CHILD_LINKS) diff --git a/doorstop/cli/tests/test_utilities.py b/doorstop/cli/tests/test_utilities.py index 50da0513b..23ef7197e 100644 --- a/doorstop/cli/tests/test_utilities.py +++ b/doorstop/cli/tests/test_utilities.py @@ -1,15 +1,15 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Unit tests for the doorstop.cli.utilities module.""" import sys import unittest -from unittest.mock import patch, Mock from argparse import ArgumentTypeError +from unittest.mock import Mock, patch from warnings import catch_warnings +from doorstop import common, settings from doorstop.cli import utilities -from doorstop import common -from doorstop import settings - from doorstop.cli.tests import SettingsTestCase @@ -62,7 +62,7 @@ def test_configure_settings(self): self.assertFalse(settings.PUBLISH_BODY_LEVELS) self.assertFalse(settings.WARN_ALL) self.assertFalse(settings.ERROR_ALL) - self.assertFalse(settings.ENABLE_HEADERS) + self.assertTrue(settings.ENABLE_HEADERS) if sys.version_info[:2] == (3, 3): pass # warnings appear to be shown inconsistently in Python 3.3 else: diff --git a/doorstop/cli/tests/test_tutorial.py b/doorstop/cli/tests/tutorial.py similarity index 52% rename from doorstop/cli/tests/test_tutorial.py rename to doorstop/cli/tests/tutorial.py index 101ee2e99..bc9fbe3dd 100644 --- a/doorstop/cli/tests/test_tutorial.py +++ b/doorstop/cli/tests/tutorial.py @@ -1,33 +1,26 @@ #!/usr/bin/env python +# SPDX-License-Identifier: LGPL-3.0-only """Integration tests for the documentation tutorials.""" -import unittest - +import logging import os import shutil -import tempfile import subprocess -import logging - -from doorstop.cli.tests import ROOT, FILES +import tempfile +import unittest -ENV = 'TEST_TUTORIAL' # environment variable to enable the tutorial example -REASON = "'{0}' variable not set".format(ENV) +from doorstop.cli.tests import FILES, ROOT -if os.name == 'nt': +if 'TRAVIS' in os.environ: + PATH = os.path.join(os.environ['VIRTUAL_ENV'], 'bin', 'doorstop') +elif os.name == 'nt': PATH = os.path.join(ROOT, '.venv', 'Scripts', 'doorstop.exe') - DOORSTOP = os.path.normpath(PATH) else: PATH = os.path.join(ROOT, '.venv', 'bin', 'doorstop') - DOORSTOP = os.path.normpath(PATH) - - -if __name__ == '__main__': - os.environ[ENV] = '1' # run the integration tests when called directly +DOORSTOP = os.path.normpath(PATH) -@unittest.skipUnless(os.getenv(ENV), REASON) class TestBase(unittest.TestCase): """Base class for tutorial tests.""" @@ -44,15 +37,16 @@ def tearDown(self): shutil.rmtree(self.temp) @staticmethod - def doorstop(args=""): + def doorstop(args="", returncode=0, stdout=None): """Call 'doorstop' with a string of arguments.""" print("$ doorstop {}".format(args)) cmd = "{} {} -v".format(DOORSTOP, args) - if subprocess.call(cmd, shell=True, stderr=subprocess.PIPE) != 0: + cp = subprocess.run(cmd, shell=True, stdout=stdout, stderr=subprocess.PIPE) + if cp.returncode != returncode: raise AssertionError("command failed: doorstop {}".format(args)) + return cp -@unittest.skipUnless(os.getenv(ENV), REASON) class TestSection1(TestBase): """Integration tests for section 1.0 of the tutorial.""" @@ -129,8 +123,9 @@ def test_tutorial_section_3(self): # 3.3 self.doorstop("import --item HLR HLR001") - self.doorstop("import --item LLR LLR001 " - "--attr \"{'text': 'The item text.'}\"") + self.doorstop( + "import --item LLR LLR001 " "--attr \"{'text': 'The item text.'}\"" + ) # 3.1 @@ -160,6 +155,97 @@ def test_tutorial_section_4(self): self.doorstop("export all dirpath/to/exports") self.doorstop("export REQ path/to/req.xlsx") + def test_validate_cycles(self): + """Verify cycle detection is working.""" + + self.doorstop("create A .") + self.doorstop("create B b --parent A") + + src = os.path.join(FILES, 'A001.txt') + dst = os.path.join(self.temp, 'A001.yml') + shutil.copy(src, dst) + src = os.path.join(FILES, 'A002.txt') + dst = os.path.join(self.temp, 'A002.yml') + shutil.copy(src, dst) + src = os.path.join(FILES, 'B001.txt') + dst = os.path.join(self.temp, 'b', 'B001.yml') + shutil.copy(src, dst) + src = os.path.join(FILES, 'B002.txt') + dst = os.path.join(self.temp, 'b', 'B002.yml') + shutil.copy(src, dst) + + cp = self.doorstop() + self.assertIn( + b'WARNING: A: A001: detected a cycle with a back edge from B001 to A001', + cp.stderr, + ) + self.assertIn( + b'WARNING: A: A001: detected a cycle with a back edge from A002 to A002', + cp.stderr, + ) + + def test_clear_links(self): + """Verify clear links is working.""" + + self.doorstop("create C .") + + src = os.path.join(FILES, 'C001.txt') + dst = os.path.join(self.temp, 'C001.yml') + shutil.copy(src, dst) + src = os.path.join(FILES, 'C002.txt') + dst = os.path.join(self.temp, 'C002.yml') + shutil.copy(src, dst) + src = os.path.join(FILES, 'C003.txt') + dst = os.path.join(self.temp, 'C003.yml') + shutil.copy(src, dst) + + cp = self.doorstop() + self.assertIn(b'WARNING: C: C001: suspect link: C002', cp.stderr) + self.assertIn(b'WARNING: C: C001: suspect link: C003', cp.stderr) + + cp = self.doorstop("clear C001 C004", 1) + self.assertIn(b'ERROR: no item with UID: C004', cp.stderr) + + cp = self.doorstop("clear C001 C001 C002", stdout=subprocess.PIPE) + self.assertIn( + b'clearing item C001\'s suspect links to C001, C002...', cp.stdout + ) + + cp = self.doorstop() + self.assertIn(b'WARNING: C: C001: suspect link: C003', cp.stderr) + + cp = self.doorstop("clear C001", stdout=subprocess.PIPE) + self.assertIn(b'clearing item C001\'s suspect links...', cp.stdout) + + cp = self.doorstop() + self.assertNotIn(b'suspect link', cp.stderr) + + def test_custom_defaults(self): + """Verify new item with custom defaults is working.""" + + self.doorstop("create REQ .") + + cp = self.doorstop("add -d no/such/file.yml REQ", 1) + self.assertIn(b'ERROR: reading ', cp.stderr) + + self.assertFalse(os.path.isfile('REQ001.yml')) + + template = os.path.join(FILES, 'template.yml') + self.doorstop("add -d {} REQ".format(template)) + + self.assertTrue(os.path.isfile('REQ001.yml')) + + cp = self.doorstop("publish REQ", stdout=subprocess.PIPE) + self.assertIn( + b'''1.0 REQ001 + + Some text + with more than + one line. +''', + cp.stdout, + ) + if __name__ == '__main__': logging.basicConfig(format="%(message)s", level=logging.INFO) diff --git a/doorstop/cli/utilities.py b/doorstop/cli/utilities.py index 2485efe4b..ce0beb797 100644 --- a/doorstop/cli/utilities.py +++ b/doorstop/cli/utilities.py @@ -1,13 +1,14 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Shared functions for the `doorstop.cli` package.""" -import os import ast -from argparse import ArgumentTypeError import logging +import os import warnings +from argparse import ArgumentTypeError -from doorstop import common -from doorstop import settings +from doorstop import common, settings log = common.logger(__name__) @@ -64,7 +65,7 @@ def configure_logging(verbosity=0): default_format = verbose_format = settings.VERBOSE2_LOGGING_FORMAT # Set a custom formatter - if not logging.root.handlers: # pragma: no cover (manual test) + if not logging.root.handlers: logging.basicConfig(level=level) logging.captureWarnings(True) formatter = common.WarningFormatter(default_format, verbose_format) @@ -107,10 +108,6 @@ def configure_settings(args): settings.WARN_ALL = args.warn_all is True if args.error_all is not None: settings.ERROR_ALL = args.error_all is True - if args.beta is not None: - print(args.beta) - if 'header' in args.beta: - settings.ENABLE_HEADERS = True # Parse `add` settings if hasattr(args, 'server') and args.server is not None: @@ -182,12 +179,14 @@ def get_ext(args, error, ext_stdout, ext_file, whole_tree=False): log.debug("extension based on path: {}".format(ext or None)) # Override the extension if a format is specified - for _ext, option in {'.txt': 'text', - '.md': 'markdown', - '.html': 'html', - '.yml': 'yaml', - '.csv': 'csv', - '.xlsx': 'xlsx'}.items(): + for _ext, option in { + '.txt': 'text', + '.md': 'markdown', + '.html': 'html', + '.yml': 'yaml', + '.csv': 'csv', + '.xlsx': 'xlsx', + }.items(): try: if getattr(args, option): ext = _ext @@ -229,13 +228,8 @@ def ask(question, default=None): :return: True = 'yes', False = 'no' """ - valid = {"yes": True, - "y": True, - "no": False, - "n": False} - prompts = {'yes': " [Y/n] ", - 'no': " [y/N] ", - None: " [y/n] "} + valid = {"yes": True, "y": True, "no": False, "n": False} + prompts = {'yes': " [Y/n] ", 'no': " [y/N] ", None: " [y/n] "} prompt = prompts.get(default, prompts[None]) message = question + prompt diff --git a/doorstop/common.py b/doorstop/common.py index 1200a3d27..8cde30792 100644 --- a/doorstop/common.py +++ b/doorstop/common.py @@ -1,10 +1,12 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Common exceptions, classes, and functions for Doorstop.""" -import os -import shutil import argparse -import logging import glob +import logging +import os +import shutil import yaml @@ -14,7 +16,7 @@ MAX_VERBOSITY = 4 # maximum verbosity level implemented -def _trace(self, message, *args, **kws): # pragma: no cover (manual test) +def _trace(self, message, *args, **kws): if self.isEnabledFor(logging.DEBUG - 1): self._log(logging.DEBUG - 1, message, args, **kws) # pylint: disable=W0212 @@ -44,6 +46,7 @@ class DoorstopWarning(DoorstopError, Warning): class DoorstopInfo(DoorstopWarning, Warning): """Generic Doorstop info.""" + # logging classes ############################################################ @@ -62,7 +65,7 @@ def __init__(self, default_format, verbose_format, *args, **kwargs): self.default_format = default_format self.verbose_format = verbose_format - def format(self, record): # pragma: no cover (manual test) + def format(self, record): """Python 3 hack to change the formatting style dynamically.""" if record.levelno > logging.INFO: self._style._fmt = self.verbose_format # pylint: disable=W0212 @@ -107,12 +110,15 @@ def read_text(path, encoding='utf-8'): """ log.trace("reading text from '{}'...".format(path)) - with open(path, 'r', encoding=encoding) as stream: - text = stream.read() - return text + try: + with open(path, 'r', encoding=encoding) as stream: + return stream.read() + except Exception as ex: + msg = "reading '{}' failed: {}".format(path, ex) + raise DoorstopError(msg) -def load_yaml(text, path): +def load_yaml(text, path, loader=yaml.SafeLoader): """Parse a dictionary from YAML text. :param text: string containing dumped YAML data @@ -123,7 +129,7 @@ def load_yaml(text, path): """ # Load the YAML data try: - data = yaml.load(text) or {} + data = yaml.load(text, Loader=loader) or {} except yaml.error.YAMLError as exc: msg = "invalid contents: {}:\n{}".format(path, exc) raise DoorstopError(msg) from None @@ -172,7 +178,7 @@ def write_text(text, path, encoding='utf-8'): return path -def touch(path): # pragma: no cover (integration test) +def touch(path): """Ensure a file exists.""" if not os.path.exists(path): log.trace("creating empty '{}'...".format(path)) @@ -185,9 +191,13 @@ def copy_dir_contents(src, dst): dest_path = os.path.join(dst, os.path.split(fpath)[-1]) if os.path.exists(dest_path): if os.path.basename(fpath) == "doorstop": - msg = "Skipping '{}' as this directory name is required by doorstop".format(fpath) + msg = "Skipping '{}' as this directory name is required by doorstop".format( + fpath + ) else: - msg = "Skipping '{}' as a file or directory with this name already exists".format(fpath) + msg = "Skipping '{}' as a file or directory with this name already exists".format( + fpath + ) log.warning(msg) else: if os.path.isdir(fpath): @@ -196,7 +206,7 @@ def copy_dir_contents(src, dst): shutil.copyfile(fpath, dest_path) -def delete(path): # pragma: no cover (integration test) +def delete(path): """Delete a file or directory with error handling.""" if os.path.isdir(path): try: @@ -220,6 +230,7 @@ def delete_contents(dirname): try: os.remove(os.path.join(dirname, file)) except FileExistsError: - log.warning("Two assets folders have files or directories " - "with the same name") + log.warning( + "Two assets folders have files or directories " "with the same name" + ) raise diff --git a/doorstop/core/__init__.py b/doorstop/core/__init__.py index c41a273ce..a61249d9d 100644 --- a/doorstop/core/__init__.py +++ b/doorstop/core/__init__.py @@ -1,6 +1,8 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Core package for Doorstop.""" -from doorstop.core.item import Item +from doorstop.core.builder import build, find_document, find_item from doorstop.core.document import Document +from doorstop.core.item import Item from doorstop.core.tree import Tree -from doorstop.core.builder import build, find_document, find_item diff --git a/doorstop/core/base.py b/doorstop/core/base.py index 55b655454..63f79c9be 100644 --- a/doorstop/core/base.py +++ b/doorstop/core/base.py @@ -1,66 +1,74 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Base classes and decorators for the doorstop.core package.""" -import os import abc import functools +import os import yaml -from doorstop import common -from doorstop.common import DoorstopError, DoorstopWarning, DoorstopInfo -from doorstop import settings +from doorstop import common, settings +from doorstop.common import DoorstopError, DoorstopInfo, DoorstopWarning log = common.logger(__name__) def add_item(func): """Add and cache the returned item.""" + @functools.wraps(func) def wrapped(self, *args, **kwargs): item = func(self, *args, **kwargs) or self if settings.ADDREMOVE_FILES and item.tree: item.tree.vcs.add(item.path) # pylint: disable=W0212 - if item.document and item not in item.document._items: + if item not in item.document._items: item.document._items.append(item) if settings.CACHE_ITEMS and item.tree: item.tree._item_cache[item.uid] = item log.trace("cached item: {}".format(item)) return item + return wrapped def edit_item(func): """Mark the returned item as modified.""" + @functools.wraps(func) def wrapped(self, *args, **kwargs): item = func(self, *args, **kwargs) or self if settings.ADDREMOVE_FILES and item.tree: item.tree.vcs.edit(item.path) return item + return wrapped def delete_item(func): """Remove and expunge the returned item.""" + @functools.wraps(func) def wrapped(self, *args, **kwargs): item = func(self, *args, **kwargs) or self if settings.ADDREMOVE_FILES and item.tree: item.tree.vcs.delete(item.path) # pylint: disable=W0212 - if item.document and item in item.document._items: + if item in item.document._items: item.document._items.remove(item) if settings.CACHE_ITEMS and item.tree: item.tree._item_cache[item.uid] = None log.trace("expunged item: {}".format(item)) BaseFileObject.delete(item, item.path) return item + return wrapped def add_document(func): """Add and cache the returned document.""" + @functools.wraps(func) def wrapped(self, *args, **kwargs): document = func(self, *args, **kwargs) or self @@ -71,22 +79,26 @@ def wrapped(self, *args, **kwargs): document.tree._document_cache[document.prefix] = document log.trace("cached document: {}".format(document)) return document + return wrapped def edit_document(func): """Mark the returned document as modified.""" + @functools.wraps(func) def wrapped(self, *args, **kwargs): document = func(self, *args, **kwargs) or self if settings.ADDREMOVE_FILES and document.tree: document.tree.vcs.edit(document.config) return document + return wrapped def delete_document(func): """Remove and expunge the returned document.""" + @functools.wraps(func) def wrapped(self, *args, **kwargs): document = func(self, *args, **kwargs) or self @@ -102,6 +114,7 @@ def wrapped(self, *args, **kwargs): # Directory wasn't empty pass return document + return wrapped @@ -121,8 +134,9 @@ def validate(self, skip=None, document_hook=None, item_hook=None): """ valid = True # Display all issues - for issue in self.get_issues(skip=skip, document_hook=document_hook, - item_hook=item_hook): + for issue in self.get_issues( + skip=skip, document_hook=document_hook, item_hook=item_hook + ): if isinstance(issue, DoorstopInfo) and not settings.WARN_ALL: log.info(issue) elif isinstance(issue, DoorstopWarning) and not settings.ERROR_ALL: @@ -157,21 +171,25 @@ def issues(self): def auto_load(func): """Call self.load() before execution.""" + @functools.wraps(func) def wrapped(self, *args, **kwargs): self.load() return func(self, *args, **kwargs) + return wrapped def auto_save(func): """Call self.save() after execution.""" + @functools.wraps(func) def wrapped(self, *args, **kwargs): result = func(self, *args, **kwargs) if self.auto: self.save() return result + return wrapped @@ -202,7 +220,7 @@ def __ne__(self, other): return not self == other @staticmethod - def _create(path, name): # pragma: no cover (integration test) + def _create(path, name): """Create a new file for the object. :param path: path to new file @@ -218,7 +236,7 @@ def _create(path, name): # pragma: no cover (integration test) common.touch(path) @abc.abstractmethod - def load(self, reload=False): # pragma: no cover (abstract method) + def load(self, reload=False): """Load the object's properties from its file.""" # 1. Start implementations of this method with: if self._loaded and not reload: @@ -227,7 +245,7 @@ def load(self, reload=False): # pragma: no cover (abstract method) # 3. End implementations of this method with: self._loaded = True - def _read(self, path): # pragma: no cover (integration test) + def _read(self, path): """Read text from the object's file. :param path: path to a text file @@ -241,7 +259,7 @@ def _read(self, path): # pragma: no cover (integration test) return common.read_text(path) @staticmethod - def _load(text, path): + def _load(text, path, **kwargs): """Load YAML data from text. :param text: text read from a file @@ -250,17 +268,17 @@ def _load(text, path): :return: dictionary of YAML data """ - return common.load_yaml(text, path) + return common.load_yaml(text, path, **kwargs) @abc.abstractmethod - def save(self): # pragma: no cover (abstract method) + def save(self): """Format and save the object's properties to its file.""" # 1. Call self._write() with the current properties here # 2. End implementations of this method with: self._loaded = False self.auto = True - def _write(self, text, path): # pragma: no cover (integration test) + def _write(self, text, path): """Write text to the object's file. :param text: text to write to a file diff --git a/doorstop/core/builder.py b/doorstop/core/builder.py index 37d4608c5..7fd6e92c9 100644 --- a/doorstop/core/builder.py +++ b/doorstop/core/builder.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Functions to build a tree and access documents and items.""" import os @@ -5,8 +7,8 @@ from doorstop import common from doorstop.common import DoorstopError from doorstop.core import vcs -from doorstop.core.tree import Tree from doorstop.core.document import Document +from doorstop.core.tree import Tree log = common.logger(__name__) _tree = None # implicit tree for convenience functions diff --git a/doorstop/core/document.py b/doorstop/core/document.py index a5ee55e66..f775e3d54 100644 --- a/doorstop/core/document.py +++ b/doorstop/core/document.py @@ -1,18 +1,27 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Representation of a collection of items.""" import os -from itertools import chain -from collections import OrderedDict import re +from collections import OrderedDict +from itertools import chain -from doorstop import common -from doorstop.common import DoorstopError, DoorstopWarning, DoorstopInfo -from doorstop.core.base import (add_document, edit_document, delete_document, - auto_load, auto_save, - BaseValidatable, BaseFileObject) -from doorstop.core.types import Prefix, UID, Level +import yaml + +from doorstop import common, settings +from doorstop.common import DoorstopError, DoorstopInfo, DoorstopWarning +from doorstop.core.base import ( + BaseFileObject, + BaseValidatable, + add_document, + auto_load, + auto_save, + delete_document, + edit_document, +) from doorstop.core.item import Item -from doorstop import settings +from doorstop.core.types import UID, Level, Prefix log = common.logger(__name__) @@ -48,10 +57,12 @@ def __init__(self, path, root=os.getcwd(), **kwargs): self.tree = kwargs.get('tree') self.auto = kwargs.get('auto', Document.auto) # Set default values + self._attribute_defaults = None self._data['prefix'] = Document.DEFAULT_PREFIX self._data['sep'] = Document.DEFAULT_SEP self._data['digits'] = Document.DEFAULT_DIGITS self._data['parent'] = None # the root document does not have a parent + self._extended_reviewed = [] self._items = [] self._itered = False self.children = [] @@ -77,7 +88,9 @@ def __bool__(self): @staticmethod @add_document - def new(tree, path, root, prefix, sep=None, digits=None, parent=None, auto=None): # pylint: disable=R0913,C0301 + def new( + tree, path, root, prefix, sep=None, digits=None, parent=None, auto=None + ): # pylint: disable=R0913,C0301 """Create a new document. :param tree: reference to tree that contains this document @@ -116,26 +129,68 @@ def new(tree, path, root, prefix, sep=None, digits=None, parent=None, auto=None) # Return the document return document + def _load_with_include(self, yamlfile): + """Load the YAML file and process input tags.""" + # Read text from file + text = self._read(yamlfile) + # Parse YAML data from text + class IncludeLoader(yaml.SafeLoader): + def include(self, node): + container = IncludeLoader.filenames[0] + dirname = os.path.dirname(container) + filename = os.path.join(dirname, self.construct_scalar(node)) + IncludeLoader.filenames.insert(0, filename) + try: + with open(filename, 'r') as f: + data = yaml.load(f, IncludeLoader) + except Exception as ex: + msg = "include in '{}' failed: {}".format(container, ex) + raise DoorstopError(msg) + IncludeLoader.filenames.pop() + return data + + IncludeLoader.add_constructor('!include', IncludeLoader.include) + IncludeLoader.filenames = [yamlfile] + return self._load(text, yamlfile, loader=IncludeLoader) + def load(self, reload=False): """Load the document's properties from its file.""" if self._loaded and not reload: return log.debug("loading {}...".format(repr(self))) - # Read text from file - text = self._read(self.config) - # Parse YAML data from text - data = self._load(text, self.config) + data = self._load_with_include(self.config) # Store parsed data sets = data.get('settings', {}) for key, value in sets.items(): - if key == 'prefix': - self._data['prefix'] = Prefix(value) - elif key == 'sep': - self._data['sep'] = value.strip() - elif key == 'parent': - self._data['parent'] = value.strip() - elif key == 'digits': - self._data['digits'] = int(value) + try: + if key == 'prefix': + self._data[key] = Prefix(value) + elif key == 'sep': + self._data[key] = value.strip() + elif key == 'parent': + self._data[key] = value.strip() + elif key == 'digits': + self._data[key] = int(value) + else: + msg = "unexpected document setting '{}' in: {}".format( + key, self.config + ) + raise DoorstopError(msg) + except (AttributeError, TypeError, ValueError): + msg = "invalid value for '{}' in: {}".format(key, self.config) + raise DoorstopError(msg) + # Store parsed attributes + attributes = data.get('attributes', {}) + for key, value in attributes.items(): + if key == 'defaults': + self._attribute_defaults = value + elif key == 'reviewed': + self._extended_reviewed = sorted(set(v for v in value)) + else: + msg = "unexpected attributes configuration '{}' in: {}".format( + key, self.config + ) + raise DoorstopError(msg) # Set meta attributes self._loaded = True if reload: @@ -150,17 +205,21 @@ def save(self): sets = {} for key, value in self._data.items(): if key == 'prefix': - sets['prefix'] = str(value) - elif key == 'sep': - sets['sep'] = value - elif key == 'digits': - sets['digits'] = value + sets[key] = str(value) elif key == 'parent': if value: - sets['parent'] = value + sets[key] = value else: - data[key] = value + sets[key] = value data['settings'] = sets + # Save the attributes + attributes = {} + if self._attribute_defaults: + attributes['defaults'] = self._attribute_defaults + if self._extended_reviewed: + attributes['reviewed'] = self._extended_reviewed + if attributes: + data['attributes'] = attributes # Dump the data to YAML text = self._dump(data) # Save the YAML to file @@ -189,8 +248,7 @@ def _iter(self, reload=False): for filename in filenames: path = os.path.join(dirpath, filename) try: - item = Item(path, root=self.root, - document=self, tree=self.tree) + item = Item(self, path, root=self.root, tree=self.tree) except DoorstopError: pass # skip non-item files else: @@ -242,6 +300,12 @@ def prefix(self, value): self._data['prefix'] = Prefix(value) # TODO: should the new prefix be applied to all items? + @property + @auto_load + def extended_reviewed(self): + """Get the document's extended reviewed attribute keys.""" + return self._extended_reviewed + @property @auto_load def sep(self): @@ -344,7 +408,7 @@ def index(self): # actions ################################################################ # decorators are applied to methods in the associated classes - def add_item(self, number=None, level=None, reorder=True): + def add_item(self, number=None, level=None, reorder=True, defaults=None): """Create a new item for the document and return it. :param number: desired item number @@ -369,10 +433,17 @@ def add_item(self, number=None, level=None, reorder=True): else: next_level = last.level + 1 log.debug("next level: {}".format(next_level)) + + # Load more defaults before the item is created to avoid partially + # constructed items in case the loading fails. + more_defaults = self._load_with_include(defaults) if defaults else None + uid = UID(self.prefix, self.sep, number, self.digits) - item = Item.new(self.tree, self, - self.path, self.root, uid, - level=next_level) + item = Item.new(self.tree, self, self.path, self.root, uid, level=next_level) + if self._attribute_defaults: + item.set_attributes(self._attribute_defaults) + if more_defaults: + item.set_attributes(more_defaults) if level and reorder: self.reorder(keep=item) return item @@ -398,8 +469,7 @@ def remove_item(self, value, reorder=True): return item # decorators are applied to methods in the associated classes - def reorder(self, manual=True, automatic=True, start=None, keep=None, - _items=None): + def reorder(self, manual=True, automatic=True, start=None, keep=None, _items=None): """Reorder a document's items. Two methods are using to create the outline order: @@ -445,7 +515,7 @@ def _lines_index(items): comment = lines[0] if lines else "" line = space + "- {u}: # {c}".format(u=item.uid, c=comment) if len(line) > settings.MAX_LINE_LENGTH: - line = line[:settings.MAX_LINE_LENGTH - 3] + '...' + line = line[: settings.MAX_LINE_LENGTH - 3] + '...' yield line @staticmethod @@ -531,7 +601,9 @@ def _reorder_section(section, level, document, list_of_ids): # Process each subsection for index, subsection in enumerate(section): - Document._reorder_section(subsection, level + index, document, list_of_ids) + Document._reorder_section( + subsection, level + index, document, list_of_ids + ) @staticmethod def _reorder_automatic(items, start=None, keep=None): @@ -631,7 +703,9 @@ def find_item(self, value, _kind=''): raise DoorstopError("no matching{} UID: {}".format(_kind, uid)) - def get_issues(self, skip=None, document_hook=None, item_hook=None): # pylint: disable=unused-argument + def get_issues( + self, skip=None, document_hook=None, item_hook=None + ): # pylint: disable=unused-argument """Yield all the document's issues. :param skip: list of document prefixes to skip @@ -668,8 +742,10 @@ def get_issues(self, skip=None, document_hook=None, item_hook=None): # pylint: for item in items: # Check item - for issue in chain(hook(item=item, document=self, tree=self.tree), - item.get_issues(skip=skip)): + for issue in chain( + hook(item=item, document=self, tree=self.tree), + item.get_issues(skip=skip), + ): # Prepend the item's UID to yielded exceptions if isinstance(issue, Exception): @@ -696,13 +772,19 @@ def _get_issues_level(items): # Types of skipped levels: # 1. over: 1.0 --> 1.2 # 2. out: 1.1 --> 3.0 - if (nlev.value[index] - plev.value[index] > 1 or - # 3. over and out: 1.1 --> 2.2 - (plev.value[index] != nlev.value[index] and - index + 1 < length and - nlev.value[index + 1] not in (0, 1))): - msg = "skipped level: {} ({}), {} ({})".format(plev, puid, - nlev, nuid) + if ( + nlev.value[index] - plev.value[index] > 1 + or + # 3. over and out: 1.1 --> 2.2 + ( + plev.value[index] != nlev.value[index] + and index + 1 < length + and nlev.value[index + 1] not in (0, 1) + ) + ): + msg = "skipped level: {} ({}), {} ({})".format( + plev, puid, nlev, nuid + ) yield DoorstopInfo(msg) break prev = item diff --git a/doorstop/core/editor.py b/doorstop/core/editor.py index be1f29e97..370acf9a2 100644 --- a/doorstop/core/editor.py +++ b/doorstop/core/editor.py @@ -1,10 +1,13 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Functions to edit documents and items.""" import os -import sys -from distutils.spawn import find_executable import subprocess +import sys +import tempfile import time +from distutils.spawn import find_executable from doorstop import common from doorstop.common import DoorstopError @@ -14,7 +17,7 @@ log = common.logger(__name__) -def edit(path, tool=None): # pragma: no cover (integration test) +def edit(path, tool=None): """Open a file and wait for the default editor to exit. :param path: path of file to open @@ -36,7 +39,37 @@ def edit(path, tool=None): # pragma: no cover (integration test) log.debug("process exited: {}".format(process.returncode)) -def launch(path, tool=None): # pragma: no cover (integration test) +def edit_tmp_content(title=None, original_content=None, tool=None): + """Edit content in a temporary file and return the saved content. + + :param title: text that will appear in the name of the temporary file. + If not given, name is only random characters. + :param original_content: content to insert in the temporary file before + opening it with the editor. If not given, file is empty. + Must be a string object. + :param tool: path of alternate editor + + :return: content of the temporary file after user closes the editor. + + """ + # Create a temporary file to edit the text + tmp_fd, tmp_path = tempfile.mkstemp(prefix='{}_'.format(title), text=True) + os.close(tmp_fd) # release the file descriptor because it is not needed + with open(tmp_path, 'w') as tmp_f: + tmp_f.write(original_content) + + # Open the editor to edit the temporary file with the original text + edit(tmp_path, tool=tool) + + # Read the edited text and remove the tmp file + with open(tmp_path, 'r') as tmp_f: + edited_content = tmp_f.read() + os.remove(tmp_path) + + return edited_content + + +def launch(path, tool=None): """Open a file using the default editor. :param path: path of file to open @@ -82,7 +115,7 @@ def launch(path, tool=None): # pragma: no cover (integration test) return process if process.returncode is None else None -def _call(args): # pragma: no cover (integration test) +def _call(args): """Call a program with arguments and return the process.""" log.debug("$ {}".format(' '.join(args))) process = subprocess.Popen(args) diff --git a/doorstop/core/exporter.py b/doorstop/core/exporter.py index 4b616725c..101e51b68 100644 --- a/doorstop/core/exporter.py +++ b/doorstop/core/exporter.py @@ -1,17 +1,18 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Functions to export documents and items.""" -import os import csv import datetime +import os from collections import defaultdict -import yaml import openpyxl +import yaml -from doorstop import common +from doorstop import common, settings from doorstop.common import DoorstopError from doorstop.core.types import iter_documents, iter_items -from doorstop import settings LIST_SEP = '\n' # string separating list values when joined in a string @@ -234,30 +235,30 @@ def _get_xlsx(obj, auto): # Populate cells for row, data in enumerate(_tabulate(obj, auto=auto), start=1): for col_idx, value in enumerate(data, start=1): - col = openpyxl.cell.get_column_letter(col_idx) - cell = worksheet.cell('%s%s' % (col, row)) + cell = worksheet.cell(column=col_idx, row=row) # wrap text in every cell - alignment = openpyxl.styles.Alignment(vertical='top', - horizontal='left', - wrap_text=True) - style = cell.style.copy(alignment=alignment) + alignment = openpyxl.styles.Alignment( + vertical='top', horizontal='left', wrap_text=True + ) + cell.alignment = alignment # and bold header rows if row == 1: - style = style.copy(font=openpyxl.styles.Font(bold=True)) - cell.style = style + cell.font = openpyxl.styles.Font(bold=True) # convert incompatible Excel types: # http://pythonhosted.org/openpyxl/api.html#openpyxl.cell.Cell.value - if not isinstance(value, (int, float, str, datetime.datetime)): - value = str(value) - cell.value = value + if isinstance(value, (int, float, datetime.datetime)): + cell.value = value + else: + cell.value = str(value) # track cell width - col_widths[col] = max(col_widths[col], _width(str(value))) + col_widths[col_idx] = max(col_widths[col_idx], _width(str(value))) # Add filter up to the last column - worksheet.auto_filter.ref = "A1:%s1" % col + col_letter = openpyxl.utils.get_column_letter(len(col_widths)) + worksheet.auto_filter.ref = "A1:%s1" % col_letter # Set column width based on column contents for col in col_widths: @@ -265,10 +266,11 @@ def _get_xlsx(obj, auto): width = XLSX_MAX_WIDTH else: width = col_widths[col] + XLSX_FILTER_PADDING - worksheet.column_dimensions[col].width = width + col_letter = openpyxl.utils.get_column_letter(col) + worksheet.column_dimensions[col_letter].width = width # Freeze top row - worksheet.freeze_panes = worksheet.cell('A2') + worksheet.freeze_panes = worksheet.cell(row=2, column=1) return workbook @@ -284,9 +286,7 @@ def _width(text): # Mapping from file extension to lines generator FORMAT_LINES = {'.yml': _lines_yaml} # Mapping from file extension to file generator -FORMAT_FILE = {'.csv': _file_csv, - '.tsv': _file_tsv, - '.xlsx': _file_xlsx} +FORMAT_FILE = {'.csv': _file_csv, '.tsv': _file_tsv, '.xlsx': _file_xlsx} # Union of format dictionaries FORMAT = dict(list(FORMAT_LINES.items()) + list(FORMAT_FILE.items())) diff --git a/doorstop/core/files/template.html b/doorstop/core/files/template.html index b743e22e4..0f145a287 100644 --- a/doorstop/core/files/template.html +++ b/doorstop/core/files/template.html @@ -115,6 +115,13 @@ padding-left: 10px; } + + $body diff --git a/doorstop/core/importer.py b/doorstop/core/importer.py index e163621fa..54757ceb0 100644 --- a/doorstop/core/importer.py +++ b/doorstop/core/importer.py @@ -1,20 +1,20 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Functions to import exiting documents and items.""" +import csv import os import re -import csv import warnings import openpyxl -from doorstop import common +from doorstop import common, settings from doorstop.common import DoorstopError -from doorstop.core.types import UID +from doorstop.core.builder import _get_tree from doorstop.core.document import Document from doorstop.core.item import Item -from doorstop.core.builder import _get_tree -from doorstop import settings - +from doorstop.core.types import UID LIST_SEP_RE = re.compile(r"[\s;,]+") # regex to split list strings into parts @@ -65,9 +65,7 @@ def create_document(prefix, path, parent=None, tree=None): raise exc from None # pylint: disable=raising-bad-type # Create the document despite an unavailable parent - document = Document.new(tree, - path, tree.root, prefix, - parent=parent) + document = Document.new(tree, path, tree.root, prefix, parent=parent) log.warning(exc) _documents.append(document) @@ -100,9 +98,7 @@ def add_item(prefix, uid, attrs=None, document=None, request_next_number=None): # Add an item using the specified UID log.info("importing item '{}'...".format(uid)) - item = Item.new(tree, document, - document.path, document.root, uid, - auto=False) + item = Item.new(tree, document, document.path, document.root, uid, auto=False) for key, value in (attrs or {}).items(): item.set(key, value) item.save() @@ -193,7 +189,7 @@ def _file_xlsx(path, document, mapping=None): # Parse the file log.debug("reading rows in {}...".format(path)) - workbook = openpyxl.load_workbook(path, use_iterators=True) + workbook = openpyxl.load_workbook(path, data_only=True) worksheet = workbook.active index = 0 @@ -210,7 +206,7 @@ def _file_xlsx(path, document, mapping=None): data.append(row2) # Warn about workbooks that may be sized incorrectly - if index >= 2 ** 20 - 1: # pragma: no cover (integration test) + if index >= 2 ** 20 - 1: msg = "workbook contains the maximum number of rows" warnings.warn(msg, Warning) @@ -264,8 +260,9 @@ def _itemize(header, data, document, mapping=None): # Get the next UID if the row is a new item if attrs.get('text') and uid in (None, '', settings.PLACEHOLDER): - uid = UID(document.prefix, document.sep, - document.next_number, document.digits) + uid = UID( + document.prefix, document.sep, document.next_number, document.digits + ) # Convert the row to an item if uid and uid != settings.PLACEHOLDER: @@ -281,8 +278,7 @@ def _itemize(header, data, document, mapping=None): # Import the item try: - item = add_item(document.prefix, uid, - attrs=attrs, document=document) + item = add_item(document.prefix, uid, attrs=attrs, document=document) except DoorstopError as exc: log.warning(exc) @@ -296,10 +292,12 @@ def _split_list(value): # Mapping from file extension to file reader -FORMAT_FILE = {'.yml': _file_yml, - '.csv': _file_csv, - '.tsv': _file_tsv, - '.xlsx': _file_xlsx} +FORMAT_FILE = { + '.yml': _file_yml, + '.csv': _file_csv, + '.tsv': _file_tsv, + '.xlsx': _file_xlsx, +} def check(ext): diff --git a/doorstop/core/item.py b/doorstop/core/item.py index 2c7b5ca1b..f621712e5 100644 --- a/doorstop/core/item.py +++ b/doorstop/core/item.py @@ -1,26 +1,59 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Representation of an item in a document.""" +import functools import os import re -import functools import pyficache -from doorstop import common -from doorstop.common import DoorstopError, DoorstopWarning, DoorstopInfo -from doorstop.core.base import (add_item, edit_item, delete_item, - auto_load, auto_save, - BaseValidatable, BaseFileObject) -from doorstop.core.types import Prefix, UID, Text, Level, Stamp, to_bool +from doorstop import common, settings +from doorstop.common import DoorstopError, DoorstopInfo, DoorstopWarning from doorstop.core import editor -from doorstop import settings - +from doorstop.core.base import ( + BaseFileObject, + BaseValidatable, + add_item, + auto_load, + auto_save, + delete_item, + edit_item, +) +from doorstop.core.types import UID, Level, Prefix, Stamp, Text, to_bool log = common.logger(__name__) +def _convert_to_yaml(indent, prefix, value): + """Convert value to YAML output format. + + :param indent: the indentation level + :param prefix: the length of the prefix before the value, e.g. '- ' for + lists or 'key: ' for keys + :param value: the value to convert + + :return: the value converted to YAML output format + + """ + if isinstance(value, str): + length = indent + prefix + len(value) + if length > settings.MAX_LINE_LENGTH or '\n' in value: + value = Text.save_text(value.strip()) + else: + value = str(value) # line is short enough as a string + elif isinstance(value, list): + value = [_convert_to_yaml(indent, 2, v) for v in value] + elif isinstance(value, dict): + value = { + k: _convert_to_yaml(indent + 2, len(k) + 2, v) for k, v in value.items() + } + return value + + def requires_tree(func): """Require a tree reference.""" + @functools.wraps(func) def wrapped(self, *args, **kwargs): if not self.tree: @@ -28,19 +61,7 @@ def wrapped(self, *args, **kwargs): log.critical("`{}` can only be called with a tree".format(name)) return None return func(self, *args, **kwargs) - return wrapped - -def requires_document(func): - """Require a document reference.""" - @functools.wraps(func) - def wrapped(self, *args, **kwargs): - if not self.document: - name = func.__name__ - msg = "`{}` can only be called with a document".format(name) - log.critical(msg) - return None - return func(self, *args, **kwargs) return wrapped @@ -58,7 +79,7 @@ class Item(BaseValidatable, BaseFileObject): # pylint: disable=R0902 DEFAULT_REF = "" DEFAULT_HEADER = Text() - def __init__(self, path, root=os.getcwd(), **kwargs): + def __init__(self, document, path, root=os.getcwd(), **kwargs): """Initialize an item from an existing file. :param path: path to Item file @@ -84,7 +105,7 @@ def __init__(self, path, root=os.getcwd(), **kwargs): # Initialize the item self.path = path self.root = root - self.document = kwargs.get('document') + self.document = document self.tree = kwargs.get('tree') self.auto = kwargs.get('auto', Item.auto) # Set default values @@ -116,7 +137,9 @@ def __lt__(self, other): @staticmethod @add_item - def new(tree, document, path, root, uid, level=None, auto=None): # pylint: disable=R0913 + def new( + tree, document, path, root, uid, level=None, auto=None + ): # pylint: disable=R0913 """Create a new item. :param tree: reference to the tree that contains this item @@ -142,24 +165,16 @@ def new(tree, document, path, root, uid, level=None, auto=None): # pylint: disa log.debug("creating item file at {}...".format(path2)) Item._create(path2, name='item') # Initialize the item - item = Item(path2, root=root, document=document, tree=tree, auto=False) + item = Item(document, path2, root=root, tree=tree, auto=False) item.level = level if level is not None else item.level if auto or (auto is None and Item.auto): item.save() # Return the item return item - def load(self, reload=False): - """Load the item's properties from its file.""" - if self._loaded and not reload: - return - log.debug("loading {}...".format(repr(self))) - # Read text from file - text = self._read(self.path) - # Parse YAML data from text - data = self._load(text, self.path) - # Store parsed data - for key, value in data.items(): + def _set_attributes(self, attributes): + """Set the item's attributes.""" + for key, value in attributes.items(): if key == 'level': value = Level(value) elif key == 'active': @@ -178,10 +193,19 @@ def load(self, reload=False): value = set(UID(part) for part in value) elif key == 'header': value = Text(value) - else: - if isinstance(value, str): - value = Text(value) self._data[key] = value + + def load(self, reload=False): + """Load the item's properties from its file.""" + if self._loaded and not reload: + return + log.debug("loading {}...".format(repr(self))) + # Read text from file + text = self._read(self.path) + # Parse YAML data from text + data = self._load(text, self.path) + # Store parsed data + self._set_attributes(data) # Set meta attributes self._loaded = True @@ -190,20 +214,18 @@ def save(self): """Format and save the item's properties to its file.""" log.debug("saving {}...".format(repr(self))) # Format the data items - data = self.data + data = self._yaml_data() # Dump the data to YAML text = self._dump(data) # Save the YAML to file self._write(text, self.path) # Set meta attributes - self._loaded = False + self._loaded = True self.auto = True # properties ############################################################# - @property - @auto_load - def data(self): + def _yaml_data(self): """Get all the item's data formatted for YAML dumping.""" data = {} for key, value in self._data.items(): @@ -224,16 +246,16 @@ def data(self): elif key == 'reviewed': value = value.yaml else: - if isinstance(value, str): - # length of "key_text: value_text" - length = len(key) + 2 + len(value) - if length > settings.MAX_LINE_LENGTH or '\n' in value: - value = Text.save_text(value) - else: - value = str(value) # line is short enough as a string + value = _convert_to_yaml(0, len(key) + 2, value) data[key] = value return data + @property + @auto_load + def data(self): + """Load and get all the item's data formatted for YAML dumping.""" + return self._yaml_data() + @property def uid(self): """Get the item's UID.""" @@ -258,7 +280,6 @@ def level(self): @level.setter @auto_save - @auto_load def level(self, value): """Set the item's level.""" self._data['level'] = Level(value) @@ -286,7 +307,6 @@ def active(self): @active.setter @auto_save - @auto_load def active(self, value): """Set the item's active status.""" self._data['active'] = to_bool(value) @@ -305,7 +325,6 @@ def derived(self): @derived.setter @auto_save - @auto_load def derived(self, value): """Set the item's derived status.""" self._data['derived'] = to_bool(value) @@ -327,7 +346,6 @@ def normative(self): @normative.setter @auto_save - @auto_load def normative(self, value): """Set the item's normative status.""" self._data['normative'] = to_bool(value) @@ -343,7 +361,6 @@ def heading(self): @heading.setter @auto_save - @auto_load def heading(self, value): """Set the item's heading status.""" heading = to_bool(value) @@ -358,21 +375,11 @@ def heading(self, value): @auto_load def cleared(self): """Indicate if no links are suspect.""" - items = self.parent_items - for uid in self.links: - for item in items: - if uid == item.uid: - if uid.stamp != item.stamp(): - return False + for uid, item in self._get_parent_uid_and_item(): + if uid.stamp != item.stamp(): + return False return True - @cleared.setter - @auto_save - @auto_load - def cleared(self, value): - """Set the item's suspect link status.""" - self.clear(_inverse=not to_bool(value)) - @property @auto_load def reviewed(self): @@ -384,7 +391,6 @@ def reviewed(self): @reviewed.setter @auto_save - @auto_load def reviewed(self, value): """Set the item's review status.""" self._data['reviewed'] = Stamp(value) @@ -397,7 +403,6 @@ def text(self): @text.setter @auto_save - @auto_load def text(self, value): """Set the item's text.""" self._data['text'] = Text(value) @@ -412,7 +417,6 @@ def header(self): @header.setter @auto_save - @auto_load def header(self, value): """Set the item's header.""" if settings.ENABLE_HEADERS: @@ -431,7 +435,6 @@ def ref(self): @ref.setter @auto_save - @auto_load def ref(self, value): """Set the item's external file reference.""" self._data['ref'] = str(value) if value else "" @@ -444,7 +447,6 @@ def links(self): @links.setter @auto_save - @auto_load def links(self, value): """Set the list of item UIDs this item links to.""" self._data['links'] = set(UID(v) for v in value) @@ -459,23 +461,24 @@ def parent_links(self, value): """Set the list of item UIDs this item links to.""" self.links = value # alias - @property @requires_tree - def parent_items(self): - """Get a list of items that this item links to.""" - items = [] + def _get_parent_uid_and_item(self): + """Yield UID and item of all links of this item.""" for uid in self.links: try: item = self.tree.find_item(uid) except DoorstopError: item = UnknownItem(uid) log.warning(item.exception) - items.append(item) - return items + yield uid, item + + @property + def parent_items(self): + """Get a list of items that this item links to.""" + return [item for uid, item in self._get_parent_uid_and_item()] @property @requires_tree - @requires_document def parent_documents(self): """Get a list of documents that this item's document should link to. @@ -493,22 +496,39 @@ def parent_documents(self): # actions ################################################################ @auto_save - def edit(self, tool=None): + def set_attributes(self, attributes): + """Set the item's attributes and save them.""" + self._set_attributes(attributes) + + @auto_save + def edit(self, tool=None, edit_all=True): """Open the item for editing. :param tool: path of alternate editor + :param edit_all: True to edit the whole item, + False to only edit the text. """ # Lock the item if self.tree: self.tree.vcs.lock(self.path) - # Open in an editor - editor.edit(self.path, tool=tool) + # Edit the whole file in an editor + if edit_all: + editor.edit(self.path, tool=tool) + # Edit only the text part in an editor + else: + # Edit the text in a temporary file + edited_text = editor.edit_tmp_content( + title=str(self.uid), original_content=str(self.text), tool=tool + ) + # Save the text in the actual item file + self.text = edited_text + self.save() + # Force reloaded self._loaded = False @auto_save - @auto_load def link(self, value): """Add a new link to another item UID. @@ -520,7 +540,6 @@ def link(self, value): self._data['links'].add(uid) @auto_save - @auto_load def unlink(self, value): """Remove an existing link by item UID. @@ -533,7 +552,9 @@ def unlink(self, value): except KeyError: log.warning("link to {0} does not exist".format(uid)) - def get_issues(self, skip=None, document_hook=None, item_hook=None): # pylint: disable=unused-argument + def get_issues( + self, skip=None, document_hook=None, item_hook=None + ): # pylint: disable=unused-argument """Yield all the item's issues. :param skip: list of document prefixes to skip @@ -577,17 +598,14 @@ def get_issues(self, skip=None, document_hook=None, item_hook=None): # pylint: yield DoorstopWarning("non-normative, but has links") # Check links against the document - if self.document: - yield from self._get_issues_document(self.document, skip) + yield from self._get_issues_document(self.document, skip) - # Check links against the tree if self.tree: + # Check links against the tree yield from self._get_issues_tree(self.tree) - # Check links against both document and tree - if self.document and self.tree: - yield from self._get_issues_both(self.document, self.tree, - skip) + # Check links against both document and tree + yield from self._get_issues_both(self.document, self.tree, skip) # Check review status if not self.reviewed: @@ -618,10 +636,11 @@ def _get_issues_document(self, document, skip): msg = "prefix differs from document ({})".format(document.prefix) yield DoorstopInfo(msg) - # Verify an item has upward links - if all((document.parent, - self.normative, - not self.derived)) and not self.links: + # Verify that normative, non-derived items in a child document have at + # least one link. It is recommended that these items have an upward + # link to an item in the parent document, however, this is not + # enforced. An info message is generated if this is not the case. + if all((document.parent, self.normative, not self.derived)) and not self.links: msg = "no links to parent document: {}".format(document.parent) yield DoorstopWarning(msg) @@ -638,7 +657,8 @@ def _get_issues_document(self, document, skip): # to contain items with a different prefix, but # Doorstop will not create items like this msg = "parent is '{}', but linked to: {}".format( - document.parent, uid) + document.parent, uid + ) yield DoorstopInfo(msg) def _get_issues_tree(self, tree): @@ -690,9 +710,9 @@ def _get_issues_both(self, document, tree, skip): # Verify an item is being linked to (child links) if settings.CHECK_CHILD_LINKS and self.normative: find_all = settings.CHECK_CHILD_LINKS_STRICT or False - items, documents = self._find_child_objects(document=document, - tree=tree, - find_all=find_all) + items, documents = self._find_child_objects( + document=document, tree=tree, find_all=find_all + ) if not items: for child_document in documents: @@ -700,8 +720,7 @@ def _get_issues_both(self, document, tree, skip): msg = "skipping issues against document %s..." log.debug(msg, child_document) continue - msg = ("no links from child document: {}". - format(child_document)) + msg = "no links from child document: {}".format(child_document) yield DoorstopWarning(msg) elif settings.CHECK_CHILD_LINKS_STRICT: prefix = [item.prefix for item in items] @@ -849,24 +868,24 @@ def stamp(self, links=False): values = [self.uid, self.text, self.ref] if links: values.extend(self.links) + for key in self.document.extended_reviewed: + if key in self._data: + values.append(self._dump(self._data[key])) + else: + log.warning( + "{}: missing extended reviewed attribute: {}".format(self.uid, key) + ) return Stamp(*values) @auto_save - @auto_load - def clear(self, _inverse=False): + def clear(self, parents=None): """Clear suspect links.""" log.info("clearing suspect links...") - items = self.parent_items - for uid in self.links: - for item in items: - if uid == item.uid: - if _inverse: - uid.stamp = Stamp() - else: - uid.stamp = item.stamp() + for uid, item in self._get_parent_uid_and_item(): + if not parents or uid in parents: + uid.stamp = item.stamp() @auto_save - @auto_load def review(self): """Mark the item as reviewed.""" log.info("marking item as reviewed...") @@ -875,7 +894,6 @@ def review(self): @delete_item def delete(self, path=None): """Delete the item.""" - pass # the item is deleted in the decorated method class UnknownItem: diff --git a/doorstop/core/publisher.py b/doorstop/core/publisher.py index b3403ea5d..4f798f319 100644 --- a/doorstop/core/publisher.py +++ b/doorstop/core/publisher.py @@ -1,23 +1,34 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Functions to publish documents and items.""" import os +import tempfile import textwrap +import bottle import markdown +from bottle import template as bottle_template +from plantuml_markdown import PlantUMLMarkdownExtension -from doorstop import common +from doorstop import common, settings from doorstop.common import DoorstopError -from doorstop.core.types import iter_documents, iter_items, is_tree, is_item -from doorstop import settings from doorstop.core import Document - -from bottle import template as bottle_template -import bottle +from doorstop.core.types import is_item, is_tree, iter_documents, iter_items EXTENSIONS = ( 'markdown.extensions.extra', 'markdown.extensions.sane_lists', 'mdx_outline', + 'mdx_math', + PlantUMLMarkdownExtension( + server='http://www.plantuml.com/plantuml', + cachedir=tempfile.gettempdir(), + format='svg', + classes='class1,class2', + title='UML', + alt='UML Diagram', + ), ) CSS = os.path.join(os.path.dirname(__file__), 'files', 'doorstop.css') HTMLTEMPLATE = 'sidebar' @@ -26,8 +37,9 @@ log = common.logger(__name__) -def publish(obj, path, ext=None, linkify=None, index=None, - template=None, toc=True, **kwargs): +def publish( + obj, path, ext=None, linkify=None, index=None, template=None, toc=True, **kwargs +): """Publish an object to a given format. The function can be called in two ways: @@ -57,7 +69,9 @@ def publish(obj, path, ext=None, linkify=None, index=None, if is_tree(obj): assets_dir = os.path.join(path, Document.ASSETS) # path is a directory name else: - assets_dir = os.path.join(os.path.dirname(path), Document.ASSETS) # path is a filename + assets_dir = os.path.join( + os.path.dirname(path), Document.ASSETS + ) # path is a filename if os.path.isdir(assets_dir): log.info('Deleting contents of assets directory %s', assets_dir) @@ -80,8 +94,9 @@ def publish(obj, path, ext=None, linkify=None, index=None, # Publish content to the specified path log.info("publishing to {}...".format(path2)) - lines = publish_lines(obj2, ext, linkify=linkify, template=template, - toc=toc, **kwargs) + lines = publish_lines( + obj2, ext, linkify=linkify, template=template, toc=toc, **kwargs + ) common.write_lines(lines, path2) if obj2.copy_assets(assets_dir): log.info('Copied assets from %s to %s', obj.assets, assets_dir) @@ -135,8 +150,10 @@ def _lines_index(filenames, charset='UTF-8', tree=None): """ yield '' yield '' - yield (''.format(charset=charset)) + yield ( + ''.format(charset=charset) + ) yield '' @@ -179,8 +196,7 @@ def _lines_index(filenames, charset='UTF-8', tree=None): yield '' for document in documents: link = '{p}'.format(p=document.prefix) - yield (' {link} '. - format(link=link)) + yield (' {link} '.format(link=link)) yield '' # data for index, row in enumerate(tree.get_traceability()): @@ -250,7 +266,9 @@ def _lines_text(obj, indent=8, width=79, **_): # Level and UID if item.header: - yield "{lev:<{s}}{u} {header}".format(lev=level, s=indent, u=item.uid, header=item.header) + yield "{lev:<{s}}{u} {header}".format( + lev=level, s=indent, u=item.uid, header=item.header + ) else: yield "{lev:<{s}}{u}".format(lev=level, s=indent, u=item.uid) @@ -260,7 +278,7 @@ def _lines_text(obj, indent=8, width=79, **_): for line in item.text.splitlines(): yield from _chunks(line, width, indent) - if not line: # pragma: no cover (integration test) + if not line: yield "" # break between paragraphs # Reference @@ -290,9 +308,9 @@ def _lines_text(obj, indent=8, width=79, **_): def _chunks(text, width, indent): """Yield wrapped lines of text.""" - yield from textwrap.wrap(text, width, - initial_indent=' ' * indent, - subsequent_indent=' ' * indent) + yield from textwrap.wrap( + text, width, initial_indent=' ' * indent, subsequent_indent=' ' * indent + ) def _lines_markdown(obj, **kwargs): @@ -315,10 +333,12 @@ def _lines_markdown(obj, **kwargs): # Level and Text if settings.PUBLISH_HEADING_LEVELS: standard = "{h} {lev} {t}".format( - h=heading, lev=level, - t=text_lines[0] if text_lines else '') + h=heading, lev=level, t=text_lines[0] if text_lines else '' + ) else: - standard = "{h} {t}".format(h=heading, t=text_lines[0] if text_lines else '') + standard = "{h} {t}".format( + h=heading, t=text_lines[0] if text_lines else '' + ) attr_list = _format_md_attr_list(item, True) yield standard + attr_list yield from text_lines[1:] @@ -333,8 +353,7 @@ def _lines_markdown(obj, **kwargs): # Level and UID if settings.PUBLISH_BODY_LEVELS: - standard = "{h} {lev} {u}".format(h=heading, - lev=level, u=uid) + standard = "{h} {lev} {u}".format(h=heading, lev=level, u=uid) else: standard = "{h} {u}".format(h=heading, u=uid) @@ -428,7 +447,9 @@ def _format_md_item_link(item, linkify=True): """Format an item link in Markdown.""" if linkify and is_item(item): if item.header: - return "[{u} {h}]({p}.html#{u})".format(u=item.uid, h=item.header, p=item.document.prefix) + return "[{u} {h}]({p}.html#{u})".format( + u=item.uid, h=item.header, p=item.document.prefix + ) return "[{u}]({p}.html#{u})".format(u=item.uid, p=item.document.prefix) else: return str(item.uid) # if not `Item`, assume this is an `UnknownItem` @@ -438,9 +459,13 @@ def _format_html_item_link(item, linkify=True): """Format an item link in HTML.""" if linkify and is_item(item): if item.header: - link = '{u} {h}'.format(u=item.uid, h=item.header, p=item.document.prefix) + link = '{u} {h}'.format( + u=item.uid, h=item.header, p=item.document.prefix + ) else: - link = '{u}'.format(u=item.uid, p=item.document.prefix) + link = '{u}'.format( + u=item.uid, p=item.document.prefix + ) return link else: return str(item.uid) # if not `Item`, assume this is an `UnknownItem` @@ -479,18 +504,16 @@ def _table_of_contents_md(obj, linkify=None): lbl = heading if linkify: - line = '{p}[{lbl}](#{uid})\n'.format(p=prefix, - lbl=lbl, - uid=item.uid) + line = '{p}[{lbl}](#{uid})\n'.format(p=prefix, lbl=lbl, uid=item.uid) else: - line = '{p}{lbl}\n'.format(p=prefix, - lbl=lbl) + line = '{p}{lbl}\n'.format(p=prefix, lbl=lbl) toc += line return toc -def _lines_html(obj, linkify=False, extensions=EXTENSIONS, - template=HTMLTEMPLATE, toc=True): +def _lines_html( + obj, linkify=False, extensions=EXTENSIONS, template=HTMLTEMPLATE, toc=True +): """Yield lines for an HTML report. :param obj: Item, list of Items, or Document to publish @@ -519,12 +542,14 @@ def _lines_html(obj, linkify=False, extensions=EXTENSIONS, if document: try: - bottle.TEMPLATE_PATH.insert(0, - os.path.join(os.path.dirname(__file__), - '..', 'views')) + bottle.TEMPLATE_PATH.insert( + 0, os.path.join(os.path.dirname(__file__), '..', 'views') + ) if 'baseurl' not in bottle.SimpleTemplate.defaults: bottle.SimpleTemplate.defaults['baseurl'] = '' - html = bottle_template(template, body=body, toc=toc_html, parent=obj.parent) + html = bottle_template( + template, body=body, toc=toc_html, parent=obj.parent, document=obj + ) except Exception: log.error("Problem parsing the template %s", template) raise @@ -534,9 +559,7 @@ def _lines_html(obj, linkify=False, extensions=EXTENSIONS, # Mapping from file extension to lines generator -FORMAT_LINES = {'.txt': _lines_text, - '.md': _lines_markdown, - '.html': _lines_html} +FORMAT_LINES = {'.txt': _lines_text, '.md': _lines_markdown, '.html': _lines_html} def check(ext): diff --git a/doorstop/core/tests/__init__.py b/doorstop/core/tests/__init__.py index d76539db4..f36b058b0 100644 --- a/doorstop/core/tests/__init__.py +++ b/doorstop/core/tests/__init__.py @@ -1,22 +1,20 @@ -"""Package for the doorstop.core tests.""" +# SPDX-License-Identifier: LGPL-3.0-only -import unittest -from unittest.mock import patch, Mock, MagicMock +"""Package for the doorstop.core tests.""" -import os import logging +import os +import unittest +from unittest.mock import MagicMock, Mock, patch from doorstop.core.base import BaseFileObject -from doorstop.core.item import Item from doorstop.core.document import Document +from doorstop.core.item import Item from doorstop.core.vcs.mockvcs import WorkingCopy - -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), - '..', '..', '..')) +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) FILES = os.path.join(os.path.dirname(__file__), 'files') -FILES_BETA = os.path.join(os.path.dirname(__file__), 'files_beta') # tests for beta features SYS = os.path.join(FILES, 'parent') TST = os.path.join(FILES, 'child') EMPTY = os.path.join(FILES, 'empty') # an empty directory @@ -69,11 +67,34 @@ def __bool__(self): # override __len__ behavior, pylint: disable=R0201 class MockItem(MockFileObject, Item): # pylint: disable=W0223,R0902 """Mock Item class with stubbed file IO.""" + def _no_get_issues_document(self, document, skip): # pylint: disable=W0613,R0201 + return + yield # pylint: disable=W0101 + + def disable_get_issues_document(self): + self._get_issues_document = self._no_get_issues_document + class MockDocument(MockFileObject, Document): # pylint: disable=W0223,R0902 """Mock Document class with stubbed file IO.""" +class MockSimpleDocument: + """Mock Document class with basic default members.""" + + def __init__(self): + self.parent = None + self.prefix = 'RQ' + self._items = [] + self.extended_reviewed = [] + + def __iter__(self): + yield from self._items + + def set_items(self, items): + self._items = items + + class MockDocumentSkip(MockDocument): # pylint: disable=W0223,R0902 """Mock Document class that is always skipped in tree placement.""" @@ -90,7 +111,7 @@ class MockItemAndVCS(MockItem): # pylint: disable=W0223,R0902 """Mock item class with stubbed IO and a mock VCS reference.""" def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + super().__init__(None, *args, **kwargs) self.tree = Mock() self.tree.vcs = WorkingCopy(None) @@ -109,16 +130,20 @@ class MockDataMixIn: # pylint: disable=W0232,R0903 # mock objects that behave like the real thing - item = MockItemAndVCS('path/to/req3.yml', - _file=("links: [sys3]" + '\n' - "text: 'Heading'" + '\n' - "level: 1.1.0" + '\n' - "normative: false")) - - item2 = MockItemAndVCS('path/to/req3.yml', - _file=("links: [sys3]\ntext: '" + - ("Hello, world! " * 10) + - "'\nlevel: 1.2")) + item = MockItemAndVCS( + 'path/to/req3.yml', + _file=( + "links: [sys3]" + '\n' + "text: 'Heading'" + '\n' + "level: 1.1.0" + '\n' + "normative: false" + ), + ) + + item2 = MockItemAndVCS( + 'path/to/req3.yml', + _file=("links: [sys3]\ntext: '" + ("Hello, world! " * 10) + "'\nlevel: 1.2"), + ) _mock_item = Mock() _mock_item.uid = 'sys3' _mock_item.document.prefix = 'sys' @@ -135,45 +160,64 @@ class MockDataMixIn: # pylint: disable=W0232,R0903 document.items = [ item, item2, - MockItemAndVCS('path/to/req1.yml', - _file="links: []\ntext: 'abc\n123'\nlevel: 1.1"), - MockItemAndVCS('path/to/req2.yml', - _file="links: []\ntext: ''\nlevel: 2"), - MockItemAndVCS('path/to/req4.yml', - _file="links: []\nref: 'CHECK_PUBLISHED_CONTENT'\n" - "level: 2.1.1"), - MockItemAndVCS('path/to/req2.yml', - _file="links: [sys1]\ntext: 'Heading 2'\nlevel: 2.1.0\n" - "normative: false"), + MockItemAndVCS( + 'path/to/req1.yml', _file="links: []\ntext: 'abc\n123'\nlevel: 1.1" + ), + MockItemAndVCS('path/to/req2.yml', _file="links: []\ntext: ''\nlevel: 2"), + MockItemAndVCS( + 'path/to/req4.yml', + _file="links: []\nref: 'CHECK_PUBLISHED_CONTENT'\n" "level: 2.1.1", + ), + MockItemAndVCS( + 'path/to/req2.yml', + _file="links: [sys1]\ntext: 'Heading 2'\nlevel: 2.1.0\n" "normative: false", + ), ] document.copy_assets = Mock() document.assets = None - item3 = MockItem('path/to/req4.yml', _file=( - "links: [sys4]" + '\n' - "text: 'This shall...'" + '\n' - "ref: Doorstop.sublime-project" + '\n' - "level: 1.2" + '\n' - "normative: true")) + item3 = MockItem( + None, + 'path/to/req4.yml', + _file=( + "links: [sys4]" + '\n' + "text: 'This shall...'" + '\n' + "ref: Doorstop.sublime-project" + '\n' + "level: 1.2" + '\n' + "normative: true" + ), + ) _mock_item3 = Mock() _mock_item3.uid = 'sys4' _mock_item3.document.prefix = 'sys' item3.tree = Mock() item3.tree.find_item = Mock(return_value=_mock_item3) - item3.tree.vcs.paths = [("Doorstop.sublime-project", - "Doorstop.sublime-project", - "Doorstop.sublime-project")] - - item4 = MockItemAndVCS('path/to/req3.yml', - _file=("links: [sys3]" + '\n' - "text: 'Heading'" + '\n' - "long: " + ('"' + '0' * 66 + '"') + '\n' - "level: 1.1.0" + '\n' - "normative: false")) - - item5 = MockItemAndVCS('path/to/req3.yml', - _file=("links: [sys3]" + '\n' - "text: 'Heading'" + '\n' - "level: 2.1.2" + '\n' - "normative: false" + '\n' - "ref: 'abc123'")) + item3.tree.vcs.paths = [ + ( + "Doorstop.sublime-project", + "Doorstop.sublime-project", + "Doorstop.sublime-project", + ) + ] + + item4 = MockItemAndVCS( + 'path/to/req3.yml', + _file=( + "links: [sys3]" + '\n' + "text: 'Heading'" + '\n' + "long: " + ('"' + '0' * 66 + '"') + '\n' + "level: 1.1.0" + '\n' + "normative: false" + ), + ) + + item5 = MockItemAndVCS( + 'path/to/req3.yml', + _file=( + "links: [sys3]" + '\n' + "text: 'Heading'" + '\n' + "level: 2.1.2" + '\n' + "normative: false" + '\n' + "ref: 'abc123'" + ), + ) diff --git a/doorstop/core/tests/docs/LLT001.yml b/doorstop/core/tests/docs/LLT001.yml index 29b812c19..59e0fb50f 100644 --- a/doorstop/core/tests/docs/LLT001.yml +++ b/doorstop/core/tests/docs/LLT001.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.1 links: - REQ003: 1f33605bbc5d1a39c9a6441b91389e88 diff --git a/doorstop/core/tests/docs/LLT002.yml b/doorstop/core/tests/docs/LLT002.yml index e9b784af9..c30e15d16 100644 --- a/doorstop/core/tests/docs/LLT002.yml +++ b/doorstop/core/tests/docs/LLT002.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.2 links: - REQ004: 94f4db8d1a50ab62ee0edec1e28c0afb diff --git a/doorstop/core/tests/docs/LLT003.yml b/doorstop/core/tests/docs/LLT003.yml index 893d4720d..99700e991 100644 --- a/doorstop/core/tests/docs/LLT003.yml +++ b/doorstop/core/tests/docs/LLT003.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.3 links: - REQ007: 1b2201126b830e4ea9f57c77dbd6a38e diff --git a/doorstop/core/tests/docs/LLT004.yml b/doorstop/core/tests/docs/LLT004.yml index 5df6b5ddc..53d885722 100644 --- a/doorstop/core/tests/docs/LLT004.yml +++ b/doorstop/core/tests/docs/LLT004.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.4 links: - REQ008: 0bcefd81b9d92145690ac5ab7d6e2ca0 diff --git a/doorstop/core/tests/docs/LLT005.yml b/doorstop/core/tests/docs/LLT005.yml index 1cca2f543..36840f80d 100644 --- a/doorstop/core/tests/docs/LLT005.yml +++ b/doorstop/core/tests/docs/LLT005.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.5 links: - REQ001: 37a3faae380b4e466aaf18c31f2184c0 diff --git a/doorstop/core/tests/docs/LLT007.yml b/doorstop/core/tests/docs/LLT007.yml index 260edc4ab..032058174 100644 --- a/doorstop/core/tests/docs/LLT007.yml +++ b/doorstop/core/tests/docs/LLT007.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 2.1 links: - REQ009: 6c23761dd907de37c62614155cca22cc diff --git a/doorstop/core/tests/docs/LLT008.yml b/doorstop/core/tests/docs/LLT008.yml index 150a47a9f..5eb849701 100644 --- a/doorstop/core/tests/docs/LLT008.yml +++ b/doorstop/core/tests/docs/LLT008.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 2.2 links: - REQ015: 5057c48dd88b53fe5e639e1e33126714 diff --git a/doorstop/core/tests/docs/LLT009.yml b/doorstop/core/tests/docs/LLT009.yml index 58cf29ed6..8630980ce 100644 --- a/doorstop/core/tests/docs/LLT009.yml +++ b/doorstop/core/tests/docs/LLT009.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.0 links: [] normative: false diff --git a/doorstop/core/tests/docs/LLT010.yml b/doorstop/core/tests/docs/LLT010.yml index 6f092f747..a2b77eeeb 100644 --- a/doorstop/core/tests/docs/LLT010.yml +++ b/doorstop/core/tests/docs/LLT010.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 2.0 links: [] normative: false diff --git a/doorstop/core/tests/files/REQ001.yml b/doorstop/core/tests/files/REQ001.yml index 0998bc828..81b27e824 100644 --- a/doorstop/core/tests/files/REQ001.yml +++ b/doorstop/core/tests/files/REQ001.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.2.3 links: - SYS001: null diff --git a/doorstop/core/tests/files/REQ002.yml b/doorstop/core/tests/files/REQ002.yml index b98cdf918..c26fadd52 100644 --- a/doorstop/core/tests/files/REQ002.yml +++ b/doorstop/core/tests/files/REQ002.yml @@ -1,9 +1,29 @@ active: true derived: false +header: | + Plantuml level: 2.1 links: [] normative: true ref: '' -reviewed: b5fbcc355112791bbcd2ea881c7c5f81 +reviewed: 50ae164a198e612dee696cc80942dc29 text: | Hello, world! + + ```plantuml format="svg_inline" alt="Use Cases of Doorstop" title="Use Cases of Doorstop" + @startuml + Author --> (Create Document) + Author --> (Create Item) + Author --> (Link Item to Document) + Author --> (Link Item to other Item) + Author --> (Edit Item) + Author --> (Review Item) + Author -> (Delete Item) + Author -> (Delete Document) + (Export) <- (Author) + (Import) <- (Author) + Reviewer --> (Review Item) + System --> (Suspect Changes) + System --> (Integrity) + @enduml + ``` diff --git a/doorstop/core/tests/files/REQ003.yml b/doorstop/core/tests/files/REQ003.yml index a89495d0a..d5a372da6 100644 --- a/doorstop/core/tests/files/REQ003.yml +++ b/doorstop/core/tests/files/REQ003.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.4 links: - REQ001: null diff --git a/doorstop/core/tests/files/REQ2-001.yml b/doorstop/core/tests/files/REQ2-001.yml index c624aaa85..066cdfa25 100644 --- a/doorstop/core/tests/files/REQ2-001.yml +++ b/doorstop/core/tests/files/REQ2-001.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 2.1 links: - REQ001: null @@ -8,3 +9,9 @@ ref: '' reviewed: null text: | Hello, world! + + Test Math Expressions in Latex Style: + + Inline Style 1: $a \ne 0$ + Inline Style 2: \(ax^2 + bx + c = 0\) + Multiline: $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$ diff --git a/doorstop/core/tests/files/a/b/template.yml b/doorstop/core/tests/files/a/b/template.yml new file mode 100644 index 000000000..17e37e4f2 --- /dev/null +++ b/doorstop/core/tests/files/a/b/template.yml @@ -0,0 +1 @@ +text: 'Some text' diff --git a/doorstop/core/tests/files/a/template.yml b/doorstop/core/tests/files/a/template.yml new file mode 100644 index 000000000..7d5f214ff --- /dev/null +++ b/doorstop/core/tests/files/a/template.yml @@ -0,0 +1 @@ +defaults: !include b/template.yml diff --git a/doorstop/core/tests/files/child/TST001.yml b/doorstop/core/tests/files/child/TST001.yml index 923b4ef90..20a39d443 100644 --- a/doorstop/core/tests/files/child/TST001.yml +++ b/doorstop/core/tests/files/child/TST001.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1 links: - REQ002: null diff --git a/doorstop/core/tests/files/child/TST002.yml b/doorstop/core/tests/files/child/TST002.yml index ff0c41b0c..47d4a61a2 100644 --- a/doorstop/core/tests/files/child/TST002.yml +++ b/doorstop/core/tests/files/child/TST002.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 2 links: - REQ002: null diff --git a/doorstop/core/tests/files/exported.csv b/doorstop/core/tests/files/exported.csv index f5daa939f..14c9a7bcd 100644 --- a/doorstop/core/tests/files/exported.csv +++ b/doorstop/core/tests/files/exported.csv @@ -1,4 +1,4 @@ -uid,level,text,ref,links,active,derived,normative,reviewed +uid,level,text,ref,links,active,derived,header,normative,reviewed REQ001,1.2.3,"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut @@ -7,8 +7,32 @@ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",,"SYS001 -SYS002:abc123",True,False,True, -REQ003,1.4,Unicode: -40° ±1%,REF123,REQ001,True,False,True, -REQ004,1.6,"Hello, world!",,,True,False,True, -REQ002,2.1,"Hello, world!",,,True,False,True,b5fbcc355112791bbcd2ea881c7c5f81 -REQ2-001,2.1,"Hello, world!",,REQ001,True,False,True, +SYS002:abc123",True,False,,True, +REQ003,1.4,Unicode: -40° ±1%,REF123,REQ001,True,False,,True, +REQ004,1.6,"Hello, world!",,,True,False,,True, +REQ002,2.1,"Hello, world! + +```plantuml format=""svg_inline"" alt=""Use Cases of Doorstop"" title=""Use Cases of Doorstop"" +@startuml +Author --> (Create Document) +Author --> (Create Item) +Author --> (Link Item to Document) +Author --> (Link Item to other Item) +Author --> (Edit Item) +Author --> (Review Item) +Author -> (Delete Item) +Author -> (Delete Document) +(Export) <- (Author) +(Import) <- (Author) +Reviewer --> (Review Item) +System --> (Suspect Changes) +System --> (Integrity) +@enduml +```",,,True,False,Plantuml,True,50ae164a198e612dee696cc80942dc29 +REQ2-001,2.1,"Hello, world! + +Test Math Expressions in Latex Style: + +Inline Style 1: $a \ne 0$ +Inline Style 2: \(ax^2 + bx + c = 0\) +Multiline: $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$",,REQ001,True,False,,True, diff --git a/doorstop/core/tests/files/exported.tsv b/doorstop/core/tests/files/exported.tsv index d15e3901f..b340b564b 100644 --- a/doorstop/core/tests/files/exported.tsv +++ b/doorstop/core/tests/files/exported.tsv @@ -1,4 +1,4 @@ -uid level text ref links active derived normative reviewed +uid level text ref links active derived header normative reviewed REQ001 1.2.3 "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut @@ -7,8 +7,32 @@ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." "SYS001 -SYS002:abc123" True False True -REQ003 1.4 Unicode: -40° ±1% REF123 REQ001 True False True -REQ004 1.6 Hello, world! True False True -REQ002 2.1 Hello, world! True False True b5fbcc355112791bbcd2ea881c7c5f81 -REQ2-001 2.1 Hello, world! REQ001 True False True +SYS002:abc123" True False True +REQ003 1.4 Unicode: -40° ±1% REF123 REQ001 True False True +REQ004 1.6 Hello, world! True False True +REQ002 2.1 "Hello, world! + +```plantuml format=""svg_inline"" alt=""Use Cases of Doorstop"" title=""Use Cases of Doorstop"" +@startuml +Author --> (Create Document) +Author --> (Create Item) +Author --> (Link Item to Document) +Author --> (Link Item to other Item) +Author --> (Edit Item) +Author --> (Review Item) +Author -> (Delete Item) +Author -> (Delete Document) +(Export) <- (Author) +(Import) <- (Author) +Reviewer --> (Review Item) +System --> (Suspect Changes) +System --> (Integrity) +@enduml +```" True False Plantuml True 50ae164a198e612dee696cc80942dc29 +REQ2-001 2.1 "Hello, world! + +Test Math Expressions in Latex Style: + +Inline Style 1: $a \ne 0$ +Inline Style 2: \(ax^2 + bx + c = 0\) +Multiline: $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$" REQ001 True False True diff --git a/doorstop/core/tests/files/exported.xlsx b/doorstop/core/tests/files/exported.xlsx index 6bd6a20ac..38bb7b67b 100644 Binary files a/doorstop/core/tests/files/exported.xlsx and b/doorstop/core/tests/files/exported.xlsx differ diff --git a/doorstop/core/tests/files/exported.yml b/doorstop/core/tests/files/exported.yml index f8a80ec08..9e2ae2f0b 100644 --- a/doorstop/core/tests/files/exported.yml +++ b/doorstop/core/tests/files/exported.yml @@ -1,6 +1,7 @@ REQ001: active: true derived: false + header: '' level: 1.2.3 links: - SYS001: null @@ -21,6 +22,7 @@ REQ001: REQ003: active: true derived: false + header: '' level: 1.4 links: - REQ001: null @@ -33,6 +35,7 @@ REQ003: REQ004: active: true derived: false + header: '' level: 1.6 links: [] normative: true @@ -44,17 +47,38 @@ REQ004: REQ002: active: true derived: false + header: | + Plantuml level: 2.1 links: [] normative: true ref: '' - reviewed: b5fbcc355112791bbcd2ea881c7c5f81 + reviewed: 50ae164a198e612dee696cc80942dc29 text: | Hello, world! + ```plantuml format="svg_inline" alt="Use Cases of Doorstop" title="Use Cases of Doorstop" + @startuml + Author --> (Create Document) + Author --> (Create Item) + Author --> (Link Item to Document) + Author --> (Link Item to other Item) + Author --> (Edit Item) + Author --> (Review Item) + Author -> (Delete Item) + Author -> (Delete Document) + (Export) <- (Author) + (Import) <- (Author) + Reviewer --> (Review Item) + System --> (Suspect Changes) + System --> (Integrity) + @enduml + ``` + REQ2-001: active: true derived: false + header: '' level: 2.1 links: - REQ001: null @@ -64,3 +88,9 @@ REQ2-001: text: | Hello, world! + Test Math Expressions in Latex Style: + + Inline Style 1: $a \ne 0$ + Inline Style 2: \(ax^2 + bx + c = 0\) + Multiline: $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$ + diff --git a/doorstop/core/tests/files/formula.xlsx b/doorstop/core/tests/files/formula.xlsx new file mode 100644 index 000000000..07b77e618 Binary files /dev/null and b/doorstop/core/tests/files/formula.xlsx differ diff --git a/doorstop/core/tests/files/parent/SYS001.yml b/doorstop/core/tests/files/parent/SYS001.yml index ac4c1709b..a0ac3848e 100644 --- a/doorstop/core/tests/files/parent/SYS001.yml +++ b/doorstop/core/tests/files/parent/SYS001.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.0 links: [] normative: true diff --git a/doorstop/core/tests/files/parent/SYS002.yml b/doorstop/core/tests/files/parent/SYS002.yml index f1d067e86..d887fe735 100644 --- a/doorstop/core/tests/files/parent/SYS002.yml +++ b/doorstop/core/tests/files/parent/SYS002.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 2.0 links: [] normative: true diff --git a/doorstop/core/tests/files/published.html b/doorstop/core/tests/files/published.html index 60d9a5c71..440a46308 100644 --- a/doorstop/core/tests/files/published.html +++ b/doorstop/core/tests/files/published.html @@ -6,6 +6,12 @@ + + @@ -17,7 +23,7 @@
    * [1.2.3 REQ001](#REQ001)
 * [1.4 REQ003](#REQ003)
 * [1.6 REQ004](#REQ004)
-* [2.1 REQ002](#REQ002)
+* [2.1 Plantuml](#REQ002)
 * [2.1 REQ2-001](#REQ2-001)
 
@@ -42,11 +48,17 @@

Parent links: REQ001

1.6 REQ004

Hello, world!

-

2.1 REQ002

+

2.1 Plantuml REQ002

Hello, world!

+

AuthorCreate DocumentCreate ItemLink Item to DocumentLink Item to other ItemEdit ItemReview ItemDelete ItemDelete DocumentExportImportReviewerSystemSuspect ChangesIntegrity

Child links: TST001, TST002

2.1 REQ2-001

Hello, world!

+

Test Math Expressions in Latex Style:

+

Inline Style 1: $a \ne 0$ +Inline Style 2: +Multiline: +

Parent links: REQ001

Child links: TST001

diff --git a/doorstop/core/tests/files/published.md b/doorstop/core/tests/files/published.md index c24fb1a76..547accb86 100644 --- a/doorstop/core/tests/files/published.md +++ b/doorstop/core/tests/files/published.md @@ -23,16 +23,40 @@ Unicode: -40° ±1% Hello, world! -## 2.1 REQ002 {#REQ002 } +## 2.1 Plantuml REQ002 {#REQ002 } Hello, world! +```plantuml format="svg_inline" alt="Use Cases of Doorstop" title="Use Cases of Doorstop" +@startuml +Author --> (Create Document) +Author --> (Create Item) +Author --> (Link Item to Document) +Author --> (Link Item to other Item) +Author --> (Edit Item) +Author --> (Review Item) +Author -> (Delete Item) +Author -> (Delete Document) +(Export) <- (Author) +(Import) <- (Author) +Reviewer --> (Review Item) +System --> (Suspect Changes) +System --> (Integrity) +@enduml +``` + *Child links: TST001, TST002* ## 2.1 REQ2-001 {#REQ2-001 } Hello, world! +Test Math Expressions in Latex Style: + +Inline Style 1: $a \ne 0$ +Inline Style 2: \(ax^2 + bx + c = 0\) +Multiline: $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$ + *Parent links: REQ001* *Child links: TST001* diff --git a/doorstop/core/tests/files/published.txt b/doorstop/core/tests/files/published.txt index 7614be3f6..aa2ca9437 100644 --- a/doorstop/core/tests/files/published.txt +++ b/doorstop/core/tests/files/published.txt @@ -26,16 +26,41 @@ Hello, world! -2.1 REQ002 +2.1 REQ002 Plantuml Hello, world! + ```plantuml format="svg_inline" alt="Use Cases of Doorstop" title="Use + Cases of Doorstop" + @startuml + Author --> (Create Document) + Author --> (Create Item) + Author --> (Link Item to Document) + Author --> (Link Item to other Item) + Author --> (Edit Item) + Author --> (Review Item) + Author -> (Delete Item) + Author -> (Delete Document) + (Export) <- (Author) + (Import) <- (Author) + Reviewer --> (Review Item) + System --> (Suspect Changes) + System --> (Integrity) + @enduml + ``` + Child links: TST001, TST002 2.1 REQ2-001 Hello, world! + Test Math Expressions in Latex Style: + + Inline Style 1: $a \ne 0$ + Inline Style 2: \(ax^2 + bx + c = 0\) + Multiline: $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$ + Parent links: REQ001 Child links: TST001 diff --git a/doorstop/core/tests/files/published2.html b/doorstop/core/tests/files/published2.html index 5f0a93068..8449194c8 100644 --- a/doorstop/core/tests/files/published2.html +++ b/doorstop/core/tests/files/published2.html @@ -6,6 +6,12 @@ + + @@ -17,7 +23,7 @@
    * [1.2.3 REQ001](#REQ001)
 * [1.4 REQ003](#REQ003)
 * [1.6 REQ004](#REQ004)
-* [2.1 REQ002](#REQ002)
+* [2.1 Plantuml](#REQ002)
 * [2.1 REQ2-001](#REQ2-001)
 
@@ -42,10 +48,16 @@

Links: REQ001

1.6 REQ004

Hello, world!

-

2.1 REQ002

+

2.1 Plantuml REQ002

Hello, world!

+

AuthorCreate DocumentCreate ItemLink Item to DocumentLink Item to other ItemEdit ItemReview ItemDelete ItemDelete DocumentExportImportReviewerSystemSuspect ChangesIntegrity

2.1 REQ2-001

Hello, world!

+

Test Math Expressions in Latex Style:

+

Inline Style 1: $a \ne 0$ +Inline Style 2: +Multiline: +

Links: REQ001

diff --git a/doorstop/core/tests/files/published2.md b/doorstop/core/tests/files/published2.md index 5b41ee79a..a9ff677d3 100644 --- a/doorstop/core/tests/files/published2.md +++ b/doorstop/core/tests/files/published2.md @@ -23,13 +23,37 @@ Unicode: -40° ±1% Hello, world! -## 2.1 REQ002 {#REQ002 } +## 2.1 Plantuml REQ002 {#REQ002 } Hello, world! +```plantuml format="svg_inline" alt="Use Cases of Doorstop" title="Use Cases of Doorstop" +@startuml +Author --> (Create Document) +Author --> (Create Item) +Author --> (Link Item to Document) +Author --> (Link Item to other Item) +Author --> (Edit Item) +Author --> (Review Item) +Author -> (Delete Item) +Author -> (Delete Document) +(Export) <- (Author) +(Import) <- (Author) +Reviewer --> (Review Item) +System --> (Suspect Changes) +System --> (Integrity) +@enduml +``` + ## 2.1 REQ2-001 {#REQ2-001 } Hello, world! +Test Math Expressions in Latex Style: + +Inline Style 1: $a \ne 0$ +Inline Style 2: \(ax^2 + bx + c = 0\) +Multiline: $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$ + *Links: REQ001* diff --git a/doorstop/core/tests/files/published2.txt b/doorstop/core/tests/files/published2.txt index ab194a901..84fb89578 100644 --- a/doorstop/core/tests/files/published2.txt +++ b/doorstop/core/tests/files/published2.txt @@ -26,13 +26,38 @@ Hello, world! -2.1 REQ002 +2.1 REQ002 Plantuml Hello, world! + ```plantuml format="svg_inline" alt="Use Cases of Doorstop" title="Use + Cases of Doorstop" + @startuml + Author --> (Create Document) + Author --> (Create Item) + Author --> (Link Item to Document) + Author --> (Link Item to other Item) + Author --> (Edit Item) + Author --> (Review Item) + Author -> (Delete Item) + Author -> (Delete Document) + (Export) <- (Author) + (Import) <- (Author) + Reviewer --> (Review Item) + System --> (Suspect Changes) + System --> (Integrity) + @enduml + ``` + 2.1 REQ2-001 Hello, world! + Test Math Expressions in Latex Style: + + Inline Style 1: $a \ne 0$ + Inline Style 2: \(ax^2 + bx + c = 0\) + Multiline: $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$ + Links: REQ001 diff --git a/doorstop/core/tests/files/subfolder/REQ004.yml b/doorstop/core/tests/files/subfolder/REQ004.yml index 875d24781..78b395ec8 100644 --- a/doorstop/core/tests/files/subfolder/REQ004.yml +++ b/doorstop/core/tests/files/subfolder/REQ004.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.6 links: [] normative: true diff --git a/doorstop/core/tests/files_beta/published3.html b/doorstop/core/tests/files_beta/published3.html deleted file mode 100644 index b92410007..000000000 --- a/doorstop/core/tests/files_beta/published3.html +++ /dev/null @@ -1,49 +0,0 @@ - - -Doorstop - - - - - - - - -
-
- -
-

1.1 Some Header for this Item REQHEADER001

-

This one has a header and its linked parent also has a header.

-

Parent links: SYSHEADER001 header hello world

-
-
-
-
- - - - - - - diff --git a/doorstop/core/tests/files_beta/reqs_child/.doorstop.skip b/doorstop/core/tests/files_beta/reqs_child/.doorstop.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/doorstop/core/tests/files_beta/reqs_child/.doorstop.yml b/doorstop/core/tests/files_beta/reqs_child/.doorstop.yml deleted file mode 100644 index 4a7bddb13..000000000 --- a/doorstop/core/tests/files_beta/reqs_child/.doorstop.yml +++ /dev/null @@ -1,5 +0,0 @@ -settings: - digits: 3 - prefix: REQHEADER - parent: SYSHEADER - sep: '' diff --git a/doorstop/core/tests/files_beta/reqs_child/REQHEADER001.yml b/doorstop/core/tests/files_beta/reqs_child/REQHEADER001.yml deleted file mode 100644 index 6fe62739a..000000000 --- a/doorstop/core/tests/files_beta/reqs_child/REQHEADER001.yml +++ /dev/null @@ -1,12 +0,0 @@ -active: true -derived: false -header: | - Some Header for this Item -level: 1.1 -links: -- SYSHEADER001: null -normative: true -ref: '' -reviewed: null -text: | - This one has a header and its linked parent also has a header. diff --git a/doorstop/core/tests/files_beta/reqs_parent/.doorstop.skip b/doorstop/core/tests/files_beta/reqs_parent/.doorstop.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/doorstop/core/tests/files_beta/reqs_parent/.doorstop.yml b/doorstop/core/tests/files_beta/reqs_parent/.doorstop.yml deleted file mode 100644 index 85bd98e04..000000000 --- a/doorstop/core/tests/files_beta/reqs_parent/.doorstop.yml +++ /dev/null @@ -1,4 +0,0 @@ -settings: - digits: 3 - prefix: SYSHEADER - sep: '' diff --git a/doorstop/core/tests/test_all.py b/doorstop/core/tests/test_all.py index 446eca748..74380cbb9 100644 --- a/doorstop/core/tests/test_all.py +++ b/doorstop/core/tests/test_all.py @@ -1,30 +1,27 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Tests for the doorstop.core package.""" # pylint: disable=protected-access,unidiomatic-typecheck -import unittest -from unittest.mock import patch, Mock - -import os import csv -import tempfile -import shutil -import pprint import logging -import warnings +import os +import pprint +import shutil +import tempfile +import unittest +from unittest.mock import Mock, patch -import yaml import openpyxl +import yaml -from doorstop import common -from doorstop.common import DoorstopError, DoorstopWarning, DoorstopInfo -from doorstop import core -from doorstop.core.builder import _get_tree, _clear_tree +from doorstop import common, core +from doorstop.common import DoorstopError, DoorstopInfo, DoorstopWarning +from doorstop.core.builder import _clear_tree, _get_tree +from doorstop.core.tests import EMPTY, ENV, FILES, REASON, ROOT, SYS, DocumentNoSkip from doorstop.core.vcs import mockvcs -from doorstop.core.tests import ENV, REASON, ROOT, FILES, EMPTY, SYS, FILES_BETA -from doorstop.core.tests import DocumentNoSkip - # Whenever the export format is changed: # 1. set CHECK_EXPORTED_CONTENT to False # 2. re-run all tests @@ -46,7 +43,7 @@ class TestItem(unittest.TestCase): def setUp(self): self.path = os.path.join(FILES, 'REQ001.yml') self.backup = common.read_text(self.path) - self.item = core.Item(self.path) + self.item = core.Item(None, self.path) self.item.tree = Mock() self.item.tree.vcs = mockvcs.WorkingCopy(EMPTY) @@ -58,7 +55,7 @@ def test_save_load(self): self.item.level = '1.2.3' self.item.text = "Hello, world!" self.item.links = ['SYS001', 'SYS002'] - item2 = core.Item(os.path.join(FILES, 'REQ001.yml')) + item2 = core.Item(None, os.path.join(FILES, 'REQ001.yml')) self.assertEqual((1, 2, 3), item2.level) self.assertEqual("Hello, world!", item2.text) self.assertEqual(['SYS001', 'SYS002'], item2.links) @@ -66,12 +63,11 @@ def test_save_load(self): @unittest.skipUnless(os.getenv(ENV), REASON) def test_find_ref(self): """Verify an item's external reference can be found.""" - item = core.Item(os.path.join(FILES, 'REQ003.yml')) + item = core.Item(None, os.path.join(FILES, 'REQ003.yml')) item.tree = Mock() item.tree.vcs = mockvcs.WorkingCopy(ROOT) path, line = item.find_ref() - relpath = os.path.relpath(os.path.join(FILES, 'external', 'text.txt'), - ROOT) + relpath = os.path.relpath(os.path.join(FILES, 'external', 'text.txt'), ROOT) self.assertEqual(relpath, path) self.assertEqual(3, line) @@ -102,9 +98,7 @@ def test_load(self): def test_new(self): """Verify a new document can be created.""" - document = core.Document.new(None, - EMPTY, FILES, - prefix='SYS', digits=4) + document = core.Document.new(None, EMPTY, FILES, prefix='SYS', digits=4) self.assertEqual('SYS', document.prefix) self.assertEqual(4, document.digits) self.assertEqual(0, len(document.items)) @@ -150,9 +144,7 @@ def test_issues_skipped_level(self): def test_add_item_with_reordering(self): """Verify an item can be inserted into a document.""" - document = core.Document.new(None, - EMPTY, FILES, - prefix='TMP') + document = core.Document.new(None, EMPTY, FILES, prefix='TMP') item_1_0 = document.add_item() item_1_2 = document.add_item() # will get displaced item_1_1 = document.add_item(level='1.1') @@ -162,9 +154,7 @@ def test_add_item_with_reordering(self): def test_remove_item_with_reordering(self): """Verify an item can be removed from a document.""" - document = core.Document.new(None, - EMPTY, FILES, - prefix='TMP') + document = core.Document.new(None, EMPTY, FILES, prefix='TMP') item_1_0 = document.add_item() item_1_2 = document.add_item() # to be removed item_1_1 = document.add_item(level='1.1') # will get relocated @@ -174,9 +164,7 @@ def test_remove_item_with_reordering(self): def test_reorder(self): """Verify a document's order can be corrected.""" - document = core.Document.new(None, - EMPTY, FILES, - prefix='TMP') + document = core.Document.new(None, EMPTY, FILES, prefix='TMP') document.add_item(level='2.0', reorder=False) document.add_item(level='2.1', reorder=False) document.add_item(level='2.1', reorder=False) @@ -190,9 +178,7 @@ def test_reorder(self): def test_reorder_with_keep(self): """Verify a document's order can be corrected with a kept level.""" - document = core.Document.new(None, - EMPTY, FILES, - prefix='TMP') + document = core.Document.new(None, EMPTY, FILES, prefix='TMP') document.add_item(level='1.0', reorder=False) item = document.add_item(level='1.0', reorder=False) document.add_item(level='1.0', reorder=False) @@ -204,9 +190,7 @@ def test_reorder_with_keep(self): def test_reorder_with_start(self): """Verify a document's order can be corrected with a given start.""" - document = core.Document.new(None, - EMPTY, FILES, - prefix='TMP') + document = core.Document.new(None, EMPTY, FILES, prefix='TMP') document.add_item(level='2.0', reorder=False) document.add_item(level='2.1', reorder=False) document.add_item(level='2.1', reorder=False) @@ -222,9 +206,7 @@ def test_reorder_with_start(self): @patch('doorstop.settings.REVIEW_NEW_ITEMS', False) def test_validate_with_reordering(self): """Verify a document's order is corrected during validation.""" - document = core.Document.new(None, - EMPTY, FILES, - prefix='TMP') + document = core.Document.new(None, EMPTY, FILES, prefix='TMP') document.add_item(level='1.0', reorder=False) document.add_item(level='1.1', reorder=False) document.add_item(level='1.2.0', reorder=False) @@ -243,7 +225,7 @@ class TestTree(unittest.TestCase): def setUp(self): self.path = os.path.join(FILES, 'REQ001.yml') self.backup = common.read_text(self.path) - self.item = core.Item(self.path) + self.item = core.Item(None, self.path) self.tree = core.Tree(core.Document(SYS)) self.tree._place(core.Document(FILES)) @@ -382,28 +364,6 @@ def test_import_xlsx(self): log_data(expected, actual) self.assertListEqual(expected, actual) - # TODO: determine when this test should be run (if at all) - # currently, 'TEST_LONG' isn't set under any condition - @unittest.skipUnless(os.getenv(ENV), REASON) - @unittest.skipUnless(os.getenv('TEST_LONG'), "this test takes too long") - @unittest.skipIf(os.getenv('TRAVIS'), "this test takes too long") - def test_import_xlsx_huge(self): - """Verify huge XLSX files are handled.""" - path = os.path.join(FILES, 'exported-huge.xlsx') - _path = os.path.join(self.temp, 'imports', 'req') - _tree = _get_tree() - document = _tree.create_document(_path, 'REQ') - # Act - with warnings.catch_warnings(record=True) as warns: - core.importer.import_file(path, document) - # Assert - self.assertEqual(1, len(warns)) - self.assertIn("maximum number of rows", str(warns[-1].message)) - expected = [] - actual = [item.data for item in document.items] - log_data(expected, actual) - self.assertListEqual(expected, actual) - def test_create_document(self): """Verify a new document can be created to import items.""" document = core.importer.create_document(self.prefix, self.path) @@ -415,8 +375,9 @@ def test_create_document_with_unknown_parent(self): # Verify the document does not already exist self.assertRaises(DoorstopError, core.find_document, self.prefix) # Import a document - document = core.importer.create_document(self.prefix, self.path, - parent=self.parent) + document = core.importer.create_document( + self.prefix, self.path, parent=self.parent + ) # Verify the imported document's attributes are correct self.assertEqual(self.prefix, document.prefix) self.assertEqual(self.path, document.path) @@ -430,8 +391,9 @@ def test_create_document_already_exists(self): # Create a document core.importer.create_document(self.prefix, self.path) # Attempt to create the same document - self.assertRaises(DoorstopError, core.importer.create_document, - self.prefix, self.path) + self.assertRaises( + DoorstopError, core.importer.create_document, self.prefix, self.path + ) def test_add_item(self): """Verify an item can be imported into a document.""" @@ -456,8 +418,7 @@ def test_add_item_with_attrs(self): core.importer.create_document(self.prefix, self.path) # Import an item attrs = {'text': "Item text", 'ext1': "Extended 1"} - item = core.importer.add_item(self.prefix, self.uid, - attrs=attrs) + item = core.importer.add_item(self.prefix, self.uid, attrs=attrs) # Verify the item is correct self.assertEqual(self.uid, item.uid) self.assertEqual(attrs['text'], item.text) @@ -629,8 +590,7 @@ def test_lines_html_document_linkify(self): path = os.path.join(FILES, 'published.html') expected = common.read_text(path) # Act - lines = core.publisher.publish_lines(self.document, '.html', - linkify=True) + lines = core.publisher.publish_lines(self.document, '.html', linkify=True) text = ''.join(line + '\n' for line in lines) # Assert if CHECK_PUBLISHED_CONTENT: @@ -650,24 +610,6 @@ def test_lines_html_document_without_child_links(self): self.assertEqual(expected, text) common.write_text(text, path) - @patch('doorstop.core.document.Document', DocumentNoSkip) - @patch('doorstop.settings.PUBLISH_CHILD_LINKS', True) - @patch('doorstop.settings.ENABLE_HEADERS', True) - def test_lines_html_document_with_header(self): - """Verify HTML can be published from a document with headers and child links contain header""" - path = os.path.join(FILES_BETA, 'published3.html') - expected = common.read_text(path) - beta_features_tree = core.build(cwd=FILES_BETA, root=FILES_BETA) - document_with_header = beta_features_tree.find_document('REQHEADER') - # Act - lines = core.publisher.publish_lines(document_with_header, '.html', - linkify=True) - text = ''.join(line + '\n' for line in lines) - # Assert - if CHECK_PUBLISHED_CONTENT: - self.assertEqual(expected, text) - common.write_text(text, path) - class TestModule(unittest.TestCase): """Integration tests for the doorstop.core module.""" @@ -701,16 +643,17 @@ def test_find_item(self): def log_data(expected, actual): """Log list values.""" for index, (evalue, avalue) in enumerate(zip(expected, actual)): - logging.debug("\n{i} expected:\n{e}\n{i} actual:\n{a}".format( - i=index, - e=pprint.pformat(evalue), - a=pprint.pformat(avalue))) + logging.debug( + "\n{i} expected:\n{e}\n{i} actual:\n{a}".format( + i=index, e=pprint.pformat(evalue), a=pprint.pformat(avalue) + ) + ) def read_yml(path): """Return a dictionary of items from a YAML file.""" text = common.read_text(path) - data = yaml.load(text) + data = yaml.load(text, Loader=yaml.SafeLoader) return data @@ -739,9 +682,13 @@ def read_xlsx(path): worksheet = workbook.active for row in worksheet.rows: for cell in row: - values = (cell.value, - cell.style, - worksheet.column_dimensions[cell.column].width) + values = ( + cell.value, + cell.style, + worksheet.column_dimensions[ + openpyxl.utils.get_column_letter(cell.column) + ].width, + ) data.append(values) data.append(worksheet.auto_filter.ref) diff --git a/doorstop/core/tests/test_builder.py b/doorstop/core/tests/test_builder.py index 4eadf523c..4105fada6 100644 --- a/doorstop/core/tests/test_builder.py +++ b/doorstop/core/tests/test_builder.py @@ -1,13 +1,13 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Unit tests for the doorstop.core.builder module.""" import unittest -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch +from doorstop.core.builder import _clear_tree, build, find_document, find_item +from doorstop.core.tests import EMPTY, FILES, MockDocumentNoSkip, MockDocumentSkip from doorstop.core.tree import Tree -from doorstop.core.builder import build, find_document, find_item, _clear_tree - -from doorstop.core.tests import FILES, EMPTY -from doorstop.core.tests import MockDocumentSkip, MockDocumentNoSkip class TestModule(unittest.TestCase): diff --git a/doorstop/core/tests/test_document.py b/doorstop/core/tests/test_document.py index d2460c37c..892012e44 100644 --- a/doorstop/core/tests/test_document.py +++ b/doorstop/core/tests/test_document.py @@ -1,21 +1,20 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Unit tests for the doorstop.core.document module.""" # pylint: disable=unused-argument,protected-access +import logging +import os import unittest -from unittest.mock import patch, Mock, MagicMock, call from unittest import mock - -import os -import logging +from unittest.mock import MagicMock, Mock, call, patch from doorstop import common -from doorstop.common import DoorstopError, DoorstopWarning, DoorstopInfo -from doorstop.core.types import Level +from doorstop.common import DoorstopError, DoorstopInfo, DoorstopWarning from doorstop.core.document import Document - -from doorstop.core.tests import ROOT, FILES, EMPTY, NEW -from doorstop.core.tests import MockItem, MockDocument +from doorstop.core.tests import EMPTY, FILES, NEW, ROOT, MockDocument, MockItem +from doorstop.core.types import Level YAML_DEFAULT = """ settings: @@ -39,6 +38,69 @@ sep: '-' """.lstrip() +YAML_INVALID = """ +settings: + digits: oops +""".lstrip() + +YAML_UNKNOWN = """ +settings: + John: 'Doe' +""".lstrip() + +YAML_UNKNOWN_ATTRIBUTES = """ +settings: + digits: 3 + prefix: REQ + sep: '' +attributes: + unknown: empty +""".lstrip() + +YAML_EXTENDED_REVIEWED = """ +settings: + digits: 3 + prefix: REQ + sep: '' +attributes: + reviewed: + - type + - verification-method +""".lstrip() + +YAML_CUSTOM_DEFAULTS = """ +settings: + digits: 3 + prefix: REQ + sep: '' +attributes: + defaults: + a: + - b + - c + d: + e: f + g: h + i: j + k: null +""".lstrip() + +YAML_INCLUDE_DEFAULTS = """ +settings: + digits: 3 + prefix: REQ + sep: '' +attributes: !include a/template.yml +""".lstrip() + +YAML_INCLUDE_NO_SUCH_FILE = """ +settings: + digits: 3 + prefix: REQ + sep: '' +attributes: !include no/such/file.yml +""".lstrip() + @patch('doorstop.settings.REORDER', False) @patch('doorstop.core.item.Item', MockItem) @@ -87,14 +149,59 @@ def test_load_parent(self): self.document.load() self.assertEqual('PARENT', self.document.parent) + def test_load_invalid(self): + """Verify that an invalid document config raises an exception.""" + self.document._file = YAML_INVALID + msg = "^invalid value for 'digits' in: .*\\.doorstop.yml$" + self.assertRaisesRegex(DoorstopError, msg, self.document.load) + + def test_load_unknown(self): + """Verify loading a document config with an unknown key fails.""" + self.document._file = YAML_UNKNOWN + msg = "^unexpected document setting 'John' in: .*\\.doorstop.yml$" + self.assertRaisesRegex(DoorstopError, msg, self.document.load) + + def test_load_unknown_attributes(self): + """Verify loading a document config with unknown attributes fails.""" + self.document._file = YAML_UNKNOWN_ATTRIBUTES + msg = "^unexpected attributes configuration 'unknown' in: .*\\.doorstop.yml$" + self.assertRaisesRegex(DoorstopError, msg, self.document.load) + + def test_load_with_non_existing_include(self): + """Verify include of non-existing file fails.""" + self.document._file = YAML_INCLUDE_NO_SUCH_FILE + msg = "^include in '.*\\.doorstop.yml' failed: .*$" + self.assertRaisesRegex(DoorstopError, msg, self.document.load) + + def test_load_extended_reviewed(self): + """Verify loaded extended reviewed attribute keys of a document.""" + self.document._file = YAML_EXTENDED_REVIEWED + self.document.load() + self.assertEqual( + self.document.extended_reviewed, ['type', 'verification-method'] + ) + + def test_load_custom_defaults(self): + """Verify loaded custom defaults for attributes of a document.""" + self.document._file = YAML_CUSTOM_DEFAULTS + self.document.load() + self.assertEqual( + self.document._attribute_defaults, + {'a': ['b', 'c'], 'd': {'e': 'f', 'g': 'h'}, 'i': 'j', 'k': None}, + ) + + def test_load_defaults_via_include(self): + """Verify loaded defaults for attributes via includes.""" + self.document._file = YAML_INCLUDE_DEFAULTS + self.document.load() + self.assertEqual(self.document._attribute_defaults, {'text': 'Some text'}) + def test_save_empty(self): """Verify saving calls write.""" self.document.tree = Mock() self.document.save() - self.document._write.assert_called_once_with(YAML_DEFAULT, - self.document.config) - self.document.tree.vcs.edit.assert_called_once_with( - self.document.config) + self.document._write.assert_called_once_with(YAML_DEFAULT, self.document.config) + self.document.tree.vcs.edit.assert_called_once_with(self.document.config) def test_save_parent(self): """Verify a document can be saved with a parent.""" @@ -108,6 +215,36 @@ def test_save_custom(self): self.document.save() self.assertIn("custom: this", self.document._file) + def test_save_extended_reviewed(self): + """Verify saving of extended reviewed attribute keys.""" + self.document._extended_reviewed = ['type', 'verification-method'] + self.document.save() + self.assertIn("attributes:", self.document._file) + self.assertIn(" reviewed:", self.document._file) + self.assertIn(" - type", self.document._file) + self.assertIn(" - verification-method", self.document._file) + + def test_no_save_empty_extended_reviewed(self): + """Verify not saving of empty extended reviewed attribute keys.""" + self.document._extended_reviewed = [] + self.document.save() + self.assertNotIn("attributes:", self.document._file) + self.assertNotIn(" reviewed:", self.document._file) + + def test_save_custom_defaults(self): + """Verify saving of custom default attributes.""" + self.document._attribute_defaults = {'key': 'value'} + self.document.save() + self.assertIn("attributes:", self.document._file) + self.assertIn(" defaults:", self.document._file) + self.assertIn(" key: value", self.document._file) + + def test_no_save_missing_custom_defaults(self): + """Verify not saving of missing custom default attributes.""" + self.document.save() + self.assertNotIn("attributes:", self.document._file) + self.assertNotIn(" defaults:", self.document._file) + @patch('doorstop.common.verbosity', 2) def test_str(self): """Verify a document can be converted to a string.""" @@ -162,26 +299,25 @@ def test_new(self): """Verify a new document can be created with defaults.""" MockDocument._create.reset_mock() path = os.path.join(EMPTY, '.doorstop.yml') - document = MockDocument.new(None, - EMPTY, root=FILES, prefix='NEW', digits=2) + document = MockDocument.new(None, EMPTY, root=FILES, prefix='NEW', digits=2) self.assertEqual('NEW', document.prefix) self.assertEqual(2, document.digits) + self.assertEqual(None, document._attribute_defaults) + self.assertEqual([], document.extended_reviewed) MockDocument._create.assert_called_once_with(path, name='document') def test_new_existing(self): """Verify an exception is raised if the document already exists.""" - self.assertRaises(DoorstopError, Document.new, - None, - FILES, ROOT, - prefix='DUPL') + self.assertRaises(DoorstopError, Document.new, None, FILES, ROOT, prefix='DUPL') @patch('doorstop.core.document.Document', MockDocument) def test_new_cache(self): """Verify a new documents are cached.""" mock_tree = Mock() mock_tree._document_cache = {} - document = MockDocument.new(mock_tree, - EMPTY, root=FILES, prefix='NEW', digits=2) + document = MockDocument.new( + mock_tree, EMPTY, root=FILES, prefix='NEW', digits=2 + ) mock_tree.vcs.add.assert_called_once_with(document.config) self.assertEqual(document, mock_tree._document_cache[document.prefix]) @@ -236,13 +372,15 @@ def test_index_get(self): @patch('doorstop.settings.MAX_LINE_LENGTH', 40) def test_index_set(self, mock_write_lines): """Verify an document's index can be created.""" - lines = ['initial: 1.2.3', - 'outline:', - ' - REQ001: # Lorem ipsum d...', - ' - REQ003: # Unicode: -40° ±1%', - ' - REQ004: # Hello, world!', - ' - REQ002: # Hello, world!', - ' - REQ2-001: # Hello, world!'] + lines = [ + 'initial: 1.2.3', + 'outline:', + ' - REQ001: # Lorem ipsum d...', + ' - REQ003: # Unicode: -40° ±1%', + ' - REQ004: # Hello, world!', + ' - REQ002: # Hello, world!', + ' - REQ2-001: # Hello, world!', + ] # Act self.document.index = True # create index # Assert @@ -263,15 +401,21 @@ def test_read_index(self, mock_write_lines): - REQ002: # Hello, world! !["... - REQ2-001: # Hello, world!''' - expected = {'initial': '1.2.3', - 'outline': [{'REQ001': [{'text': 'Lorem ipsum d...'}]}, - {'REQ003': [{'text': 'Unicode: -40° ±1%'}]}, - {'REQ004': [{'text': "Hello, world! !['.."}]}, - {'REQ002': [{'text': 'Hello, world! !["...'}]}, - {'REQ2-001': [{'text': 'Hello, world!'}]}]} + expected = { + 'initial': '1.2.3', + 'outline': [ + {'REQ001': [{'text': 'Lorem ipsum d...'}]}, + {'REQ003': [{'text': 'Unicode: -40° ±1%'}]}, + {'REQ004': [{'text': "Hello, world! !['.."}]}, + {'REQ002': [{'text': 'Hello, world! !["...'}]}, + {'REQ2-001': [{'text': 'Hello, world!'}]}, + ], + } # Act with patch('builtins.open') as mock_open: - mock_open.side_effect = lambda *args, **kw: mock.mock_open(read_data=lines).return_value + mock_open.side_effect = lambda *args, **kw: mock.mock_open( + read_data=lines + ).return_value actual = self.document._read_index('mock_path') # Check string can be parsed as yaml # Assert @@ -289,9 +433,9 @@ def test_add_item(self, mock_new, mock_reorder): """Verify an item can be added to a document.""" with patch('doorstop.settings.REORDER', True): self.document.add_item() - mock_new.assert_called_once_with(None, self.document, - FILES, ROOT, 'REQ006', - level=Level('2.2')) + mock_new.assert_called_once_with( + None, self.document, FILES, ROOT, 'REQ006', level=Level('2.2') + ) self.assertEqual(0, mock_reorder.call_count) @patch('doorstop.core.document.Document.reorder') @@ -300,18 +444,25 @@ def test_add_item_with_level(self, mock_new, mock_reorder): """Verify an item can be added to a document with a level.""" with patch('doorstop.settings.REORDER', True): item = self.document.add_item(level='4.2') - mock_new.assert_called_once_with(None, self.document, - FILES, ROOT, 'REQ006', - level='4.2') + mock_new.assert_called_once_with( + None, self.document, FILES, ROOT, 'REQ006', level='4.2' + ) mock_reorder.assert_called_once_with(keep=item) @patch('doorstop.core.item.Item.new') def test_add_item_with_number(self, mock_new): """Verify an item can be added to a document with a number.""" self.document.add_item(number=999) - mock_new.assert_called_once_with(None, self.document, - FILES, ROOT, 'REQ999', - level=Level('2.2')) + mock_new.assert_called_once_with( + None, self.document, FILES, ROOT, 'REQ999', level=Level('2.2') + ) + + @patch('doorstop.core.item.Item.set_attributes') + def test_add_item_with_defaults(self, mock_set_attributes): + """Verify an item can be added to a document with defaults.""" + self.document._file = "text: 'abc'" + self.document.add_item(defaults='mock.yml') + mock_set_attributes.assert_called_once_with({'text': 'abc'}) @patch('doorstop.core.item.Item.new') def test_add_item_empty(self, mock_new): @@ -319,9 +470,9 @@ def test_add_item_empty(self, mock_new): document = MockDocument(NEW, ROOT) document.prefix = 'NEW' self.assertIsNot(None, document.add_item(reorder=False)) - mock_new.assert_called_once_with(None, document, - NEW, ROOT, 'NEW001', - level=None) + mock_new.assert_called_once_with( + None, document, NEW, ROOT, 'NEW001', level=None + ) @patch('doorstop.core.item.Item.new') def test_add_item_after_header(self, mock_new): @@ -331,9 +482,9 @@ def test_add_item_after_header(self, mock_new): mock_item.level = Level('1.0') self.document._iter = Mock(return_value=[mock_item]) self.document.add_item() - mock_new.assert_called_once_with(None, self.document, - FILES, ROOT, 'REQ002', - level=Level('1.1')) + mock_new.assert_called_once_with( + None, self.document, FILES, ROOT, 'REQ002', level=Level('1.1') + ) def test_add_item_contains(self): """Verify an added item is contained in the document.""" @@ -356,8 +507,7 @@ def test_remove_item(self, mock_remove, mock_reorder): """Verify an item can be removed.""" with patch('doorstop.settings.REORDER', True): item = self.document.remove_item('REQ001') - mock_reorder.assert_called_once_with(self.document.items, - keep=None, start=None) + mock_reorder.assert_called_once_with(self.document.items, keep=None, start=None) mock_remove.assert_called_once_with(item.path) @patch('os.remove') @@ -398,47 +548,54 @@ def test_reorder(self, mock_index, mock_auto): self.document.reorder() # Assert mock_index.assert_called_once_with(self.document, path) - mock_auto.assert_called_once_with(self.document.items, - start=None, keep=None) + mock_auto.assert_called_once_with(self.document.items, start=None, keep=None) def test_reorder_automatic(self): """Verify items can be reordered automatically.""" - mock_items = [Mock(level=Level('2.3')), - Mock(level=Level('2.3')), - Mock(level=Level('2.7')), - Mock(level=Level('3.2.2')), - Mock(level=Level('3.4.2')), - Mock(level=Level('3.5.0')), - Mock(level=Level('3.5.0')), - Mock(level=Level('3.6')), - Mock(level=Level('5.0')), - Mock(level=Level('5.9'))] - expected = [Level('2.3'), - Level('2.4'), - Level('2.5'), - Level('3.1.1'), - Level('3.2.1'), - Level('3.3.0'), - Level('3.4.0'), - Level('3.5'), - Level('4.0'), - Level('4.1')] + mock_items = [ + Mock(level=Level('2.3')), + Mock(level=Level('2.3')), + Mock(level=Level('2.7')), + Mock(level=Level('3.2.2')), + Mock(level=Level('3.4.2')), + Mock(level=Level('3.5.0')), + Mock(level=Level('3.5.0')), + Mock(level=Level('3.6')), + Mock(level=Level('5.0')), + Mock(level=Level('5.9')), + ] + expected = [ + Level('2.3'), + Level('2.4'), + Level('2.5'), + Level('3.1.1'), + Level('3.2.1'), + Level('3.3.0'), + Level('3.4.0'), + Level('3.5'), + Level('4.0'), + Level('4.1'), + ] Document._reorder_automatic(mock_items) actual = [item.level for item in mock_items] self.assertListEqual(expected, actual) def test_reorder_automatic_no_change(self): """Verify already ordered items can be reordered.""" - mock_items = [Mock(level=Level('1.1')), - Mock(level=Level('1.1.1.1')), - Mock(level=Level('2')), - Mock(level=Level('3')), - Mock(level=Level('4.1.1'))] - expected = [Level('1.1'), - Level('1.1.1.1'), - Level('2'), - Level('3'), - Level('4.1.1')] + mock_items = [ + Mock(level=Level('1.1')), + Mock(level=Level('1.1.1.1')), + Mock(level=Level('2')), + Mock(level=Level('3')), + Mock(level=Level('4.1.1')), + ] + expected = [ + Level('1.1'), + Level('1.1.1.1'), + Level('2'), + Level('3'), + Level('4.1.1'), + ] Document._reorder_automatic(mock_items) actual = [item.level for item in mock_items] self.assertListEqual(expected, actual) @@ -446,28 +603,32 @@ def test_reorder_automatic_no_change(self): def test_reorder_automatic_with_start(self): """Verify items can be reordered with a given start.""" mock_item = Mock(level=Level('2.3')) - mock_items = [Mock(level=Level('2.2')), - mock_item, - Mock(level=Level('2.3')), - Mock(level=Level('2.7')), - Mock(level=Level('3.2.2')), - Mock(level=Level('3.4.2')), - Mock(level=Level('3.5.0')), - Mock(level=Level('3.5.0')), - Mock(level=Level('3.6')), - Mock(level=Level('5.0')), - Mock(level=Level('5.9'))] - expected = [Level('1.2'), - Level('1.3'), - Level('1.4'), - Level('1.5'), - Level('2.1.1'), - Level('2.2.1'), - Level('2.3.0'), - Level('2.4.0'), - Level('2.5'), - Level('3.0'), - Level('3.1')] + mock_items = [ + Mock(level=Level('2.2')), + mock_item, + Mock(level=Level('2.3')), + Mock(level=Level('2.7')), + Mock(level=Level('3.2.2')), + Mock(level=Level('3.4.2')), + Mock(level=Level('3.5.0')), + Mock(level=Level('3.5.0')), + Mock(level=Level('3.6')), + Mock(level=Level('5.0')), + Mock(level=Level('5.9')), + ] + expected = [ + Level('1.2'), + Level('1.3'), + Level('1.4'), + Level('1.5'), + Level('2.1.1'), + Level('2.2.1'), + Level('2.3.0'), + Level('2.4.0'), + Level('2.5'), + Level('3.0'), + Level('3.1'), + ] Document._reorder_automatic(mock_items, start=(1, 2), keep=mock_item) actual = [item.level for item in mock_items] self.assertListEqual(expected, actual) @@ -500,10 +661,16 @@ def test_validate(self, mock_reorder, mock_get_issues): mock_reorder.assert_called_once_with(_items=self.document.items) self.assertEqual(5, mock_get_issues.call_count) - @patch('doorstop.core.item.Item.get_issues', - Mock(return_value=[DoorstopError('error'), - DoorstopWarning('warning'), - DoorstopInfo('info')])) + @patch( + 'doorstop.core.item.Item.get_issues', + Mock( + return_value=[ + DoorstopError('error'), + DoorstopWarning('warning'), + DoorstopInfo('info'), + ] + ), + ) def test_validate_item(self): """Verify an item error fails the document check.""" self.assertFalse(self.document.validate()) @@ -542,10 +709,8 @@ def test_delete_cache(self): self.document.tree._item_cache = {} self.document.tree._document_cache = {} self.document.delete() - self.document.tree.vcs.delete.assert_called_once_with( - self.document.config) - self.assertIs( - None, self.document.tree._document_cache[self.document.prefix]) + self.document.tree.vcs.delete.assert_called_once_with(self.document.config) + self.assertIs(None, self.document.tree._document_cache[self.document.prefix]) @patch('doorstop.core.document.Document.get_issues', Mock(return_value=[])) def test_issues(self): @@ -628,11 +793,16 @@ def test_assets_missing(self): def test_copy_assets(self, mock_copytree, mock_glob): """Verify a document can copy its assets""" assets = ['css', 'logo.png'] - assets_full_path = [os.path.join(self.document.path, self.document.ASSETS, dir) for dir in assets] + assets_full_path = [ + os.path.join(self.document.path, self.document.ASSETS, dir) + for dir in assets + ] mock_glob.return_value = assets_full_path dst = os.path.join('publishdir', 'assets') - expected_calls = [call(assets_full_path[0], os.path.join(dst, assets[0])), - call(assets_full_path[1], os.path.join(dst, assets[1]))] + expected_calls = [ + call(assets_full_path[0], os.path.join(dst, assets[0])), + call(assets_full_path[1], os.path.join(dst, assets[1])), + ] # Act] self.document.copy_assets(dst) # Assert @@ -703,24 +873,29 @@ def test_find_item(self): def test_reorder_from_index(self): """Verify items can be reordered from an index.""" - data = {'initial': 2.0, - 'outline': [ - {'a': None}, - {'b': [ - {'ba': [ - {'baa': None}, - {'bac': None}]}, - {'bb': [ - {'new': None}]}]}, - {'c': None}]} - expected = [Level('2'), - Level('3.0'), - Level('3.1.0'), - Level('3.1.1'), - Level('3.1.2'), - Level('3.2.0'), - Level('3.2.1'), - Level('4')] + data = { + 'initial': 2.0, + 'outline': [ + {'a': None}, + { + 'b': [ + {'ba': [{'baa': None}, {'bac': None}]}, + {'bb': [{'new': None}]}, + ] + }, + {'c': None}, + ], + } + expected = [ + Level('2'), + Level('3.0'), + Level('3.1.0'), + Level('3.1.1'), + Level('3.1.2'), + Level('3.2.0'), + Level('3.2.1'), + Level('4'), + ] # Act self.document._read_index = MagicMock(return_value=data) @@ -735,26 +910,30 @@ def test_reorder_from_index(self): def test_reorder_from_index_add(self): """Verify items can be added when reordering from an index.""" - data = {'initial': 2.0, - 'outline': [ - {'a': None}, - {'b': [ - {'ba': [ - {'baa': None}, - {'new': None}, - {'bac': None}]}, - {'bb': [ - {'new': [{'text': 'item_text'}]}]}]}, - {'c': None}]} - expected = [Level('2'), - Level('3.0'), - Level('3.1.0'), - Level('3.1.1'), - Level('3.1.2'), - Level('3.1.3'), - Level('3.2.0'), - Level('3.2.1'), - Level('4')] + data = { + 'initial': 2.0, + 'outline': [ + {'a': None}, + { + 'b': [ + {'ba': [{'baa': None}, {'new': None}, {'bac': None}]}, + {'bb': [{'new': [{'text': 'item_text'}]}]}, + ] + }, + {'c': None}, + ], + } + expected = [ + Level('2'), + Level('3.0'), + Level('3.1.0'), + Level('3.1.1'), + Level('3.1.2'), + Level('3.1.3'), + Level('3.2.0'), + Level('3.2.1'), + Level('4'), + ] # Act self.document._read_index = MagicMock(return_value=data) Document._reorder_from_index(self.document, 'mock_path') @@ -767,26 +946,30 @@ def test_reorder_from_index_add(self): def test_reorder_from_index_delete(self): """Verify items can be deleted when reordering from an index.""" - data = {'initial': 2.0, - 'outline': [ - {'a': None}, - {'b': [ - {'ba': [ - {'baa': None}, - {'bab': None}, - {'bac': None}]}, - {'bb': [ - {'bba': None}]}]}, - {'c': None}]} - expected = [Level('2'), - Level('3.0'), - Level('3.1.0'), - Level('3.1.1'), - Level('3.1.2'), - Level('3.1.3'), - Level('3.2.0'), - Level('3.2.1'), - Level('4')] + data = { + 'initial': 2.0, + 'outline': [ + {'a': None}, + { + 'b': [ + {'ba': [{'baa': None}, {'bab': None}, {'bac': None}]}, + {'bb': [{'bba': None}]}, + ] + }, + {'c': None}, + ], + } + expected = [ + Level('2'), + Level('3.0'), + Level('3.1.0'), + Level('3.1.1'), + Level('3.1.2'), + Level('3.1.3'), + Level('3.2.0'), + Level('3.2.1'), + Level('4'), + ] mock_item = self.document.add_item() # Act diff --git a/doorstop/core/tests/test_exporter.py b/doorstop/core/tests/test_exporter.py index 28888680f..8bbd424b7 100644 --- a/doorstop/core/tests/test_exporter.py +++ b/doorstop/core/tests/test_exporter.py @@ -1,14 +1,14 @@ -"""Unit tests for the doorstop.core.exporter module.""" +# SPDX-License-Identifier: LGPL-3.0-only -import unittest -from unittest.mock import patch, MagicMock, Mock +"""Unit tests for the doorstop.core.exporter module.""" import os import tempfile +import unittest +from unittest.mock import MagicMock, Mock, patch from doorstop.common import DoorstopError from doorstop.core import exporter - from doorstop.core.tests import MockDataMixIn @@ -31,10 +31,8 @@ def test_export_document(self, mock_export_file, mock_makedirs): def test_export_document_unknown(self): """Verify an exception is raised when exporting unknown formats.""" - self.assertRaises(DoorstopError, - exporter.export, self.document, 'a.a') - self.assertRaises(DoorstopError, - exporter.export, self.document, 'a.yml', '.a') + self.assertRaises(DoorstopError, exporter.export, self.document, 'a.a') + self.assertRaises(DoorstopError, exporter.export, self.document, 'a.yml', '.a') @patch('os.path.isdir', Mock(return_value=False)) @patch('os.makedirs') @@ -79,17 +77,20 @@ def test_export_document_lines(self, mock_write_lines, mock_makedirs): def test_lines(self): """Verify an item can be exported as lines.""" - expected = ("req3:" + '\n' - " active: true" + '\n' - " derived: false" + '\n' - " level: 1.1.0" + '\n' - " links:" + '\n' - " - sys3: null" + '\n' - " normative: false" + '\n' - " ref: ''" + '\n' - " reviewed: null" + '\n' - " text: |" + '\n' - " Heading" + '\n\n') + expected = ( + "req3:" + '\n' + " active: true" + '\n' + " derived: false" + '\n' + " header: ''" + '\n' + " level: 1.1.0" + '\n' + " links:" + '\n' + " - sys3: null" + '\n' + " normative: false" + '\n' + " ref: ''" + '\n' + " reviewed: null" + '\n' + " text: |" + '\n' + " Heading" + '\n\n' + ) # Act lines = exporter.export_lines(self.item) text = ''.join(line + '\n' for line in lines) @@ -114,10 +115,10 @@ def test_export_file(self): def test_export_file_unknown(self): """Verify an item can be exported as a file.""" - self.assertRaises(DoorstopError, - exporter.export_file, self.document, 'a.a') - self.assertRaises(DoorstopError, - exporter.export_file, self.document, 'a.csv', '.a') + self.assertRaises(DoorstopError, exporter.export_file, self.document, 'a.a') + self.assertRaises( + DoorstopError, exporter.export_file, self.document, 'a.csv', '.a' + ) @patch('doorstop.core.exporter._file_csv') def test_file_tsv(self, mock_file_csv): @@ -127,8 +128,9 @@ def test_file_tsv(self, mock_file_csv): # Act exporter._file_tsv(self.item, path) # pylint:disable=W0212 # Assert - mock_file_csv.assert_called_once_with(self.item, path, - delimiter='\t', auto=False) + mock_file_csv.assert_called_once_with( + self.item, path, delimiter='\t', auto=False + ) @patch('doorstop.core.exporter._get_xlsx') def test_file_xlsx(self, mock_get_xlsx): diff --git a/doorstop/core/tests/test_importer.py b/doorstop/core/tests/test_importer.py index fed521030..a92ffcd3e 100644 --- a/doorstop/core/tests/test_importer.py +++ b/doorstop/core/tests/test_importer.py @@ -1,22 +1,21 @@ # -*- coding: utf-8 -*- +# SPDX-License-Identifier: LGPL-3.0-only """Unit tests for the doorstop.core.importer module.""" # pylint: disable=no-self-use,protected-access +import logging +import os import unittest -from unittest.mock import patch, Mock, MagicMock +from unittest.mock import MagicMock, Mock, patch from warnings import catch_warnings -import os -import logging - from doorstop.common import DoorstopError -from doorstop.core.tree import Tree from doorstop.core import importer from doorstop.core.builder import _set_tree - from doorstop.core.tests.test_document import FILES, MockItem +from doorstop.core.tree import Tree LOREM_IPSUM = '''Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. @@ -27,6 +26,30 @@ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.''' +LATEX_MATH = '''Test Math Expressions in Latex Style: + +Inline Style 1: $a \\ne 0$ +Inline Style 2: \\(ax^2 + bx + c = 0\\) +Multiline: $$x = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}.$$''' + +PLANTUML_TXT = '''```plantuml format="svg_inline" alt="Use Cases of Doorstop" title="Use Cases of Doorstop" +@startuml +Author --> (Create Document) +Author --> (Create Item) +Author --> (Link Item to Document) +Author --> (Link Item to other Item) +Author --> (Edit Item) +Author --> (Review Item) +Author -> (Delete Item) +Author -> (Delete Document) +(Export) <- (Author) +(Import) <- (Author) +Reviewer --> (Review Item) +System --> (Suspect Changes) +System --> (Integrity) +@enduml +```''' + class TestModule(unittest.TestCase): """Unit tests for the doorstop.core.importer module.""" @@ -36,10 +59,10 @@ class TestModule(unittest.TestCase): def test_import_file_unknown(self): """Verify an exception is raised when importing unknown formats.""" mock_document = Mock() - self.assertRaises(DoorstopError, - importer.import_file, 'a.a', mock_document) - self.assertRaises(DoorstopError, - importer.import_file, 'a.csv', mock_document, '.a') + self.assertRaises(DoorstopError, importer.import_file, 'a.a', mock_document) + self.assertRaises( + DoorstopError, importer.import_file, 'a.csv', mock_document, '.a' + ) @patch('doorstop.core.importer._file_csv') def test_import_file(self, mock_file_csv): @@ -48,8 +71,7 @@ def test_import_file(self, mock_file_csv): mock_document = Mock() importer.FORMAT_FILE['.csv'] = mock_file_csv importer.import_file(mock_path, mock_document) - mock_file_csv.assert_called_once_with(mock_path, mock_document, - mapping=None) + mock_file_csv.assert_called_once_with(mock_path, mock_document, mapping=None) @patch('doorstop.core.importer.check') def test_import_file_custom_ext(self, mock_check): @@ -97,15 +119,71 @@ def test_file_csv(self, mock_itemize): logging.debug("args: {}".format(args)) logging.debug("kwargs: {}".format(kwargs)) header, data, document = args - expected_header = ['uid', 'level', 'text', 'ref', 'links', - 'active', 'derived', 'normative', 'reviewed'] + expected_header = [ + 'uid', + 'level', + 'text', + 'ref', + 'links', + 'active', + 'derived', + 'header', + 'normative', + 'reviewed', + ] self.assertEqual(expected_header, header) expected_data = [ - ['REQ001', '1.2.3', LOREM_IPSUM, '', 'SYS001\nSYS002:abc123', True, False, True, ''], - ['REQ003', '1.4', 'Unicode: -40° ±1%', 'REF''123', 'REQ001', True, False, True, ''], - ['REQ004', '1.6', 'Hello, world!', '', '', True, False, True, ''], - ['REQ002', '2.1', 'Hello, world!', '', '', True, False, True, 'b5fbcc355112791bbcd2ea881c7c5f81'], - ['REQ2-001', '2.1', 'Hello, world!', '', 'REQ001', True, False, True, ''], + [ + 'REQ001', + '1.2.3', + LOREM_IPSUM, + '', + 'SYS001\nSYS002:abc123', + True, + False, + '', + True, + '', + ], + # pylint: disable=implicit-str-concat-in-sequence + # 'REF''123' is intentional to avoid matching the reference in this test file + [ + 'REQ003', + '1.4', + 'Unicode: -40° ±1%', + 'REF' '123', + 'REQ001', + True, + False, + '', + True, + '', + ], + ['REQ004', '1.6', 'Hello, world!', '', '', True, False, '', True, ''], + [ + 'REQ002', + '2.1', + 'Hello, world!\n\n' + PLANTUML_TXT, + '', + '', + True, + False, + 'Plantuml', + True, + '50ae164a198e612dee696cc80942dc29', + ], + [ + 'REQ2-001', + '2.1', + 'Hello, world!\n\n' + LATEX_MATH, + '', + 'REQ001', + True, + False, + '', + True, + '', + ], ] self.assertEqual(expected_data, data) self.assertIs(mock_document, document) @@ -122,12 +200,43 @@ def test_file_csv_modified(self, mock_itemize): logging.debug("args: {}".format(args)) logging.debug("kwargs: {}".format(kwargs)) header, data, document = args - expected_header = ['id', 'level', 'text', 'ref', 'links', - 'active', 'derived', 'normative', 'additional'] + expected_header = [ + 'id', + 'level', + 'text', + 'ref', + 'links', + 'active', + 'derived', + 'normative', + 'additional', + ] self.assertEqual(expected_header, header) expected_data = [ - ['REQ0555', '1.2.3', 'Hello, world!\n', '', 'SYS001,\nSYS002', True, False, False, ''], - ['REQ003', '1.4', 'Hello, world!\n', 'REF''123', 'REQ001', False, False, True, 'Some "quoted" text \'here\'.'], + [ + 'REQ0555', + '1.2.3', + 'Hello, world!\n', + '', + 'SYS001,\nSYS002', + True, + False, + False, + '', + ], + # pylint: disable=implicit-str-concat-in-sequence + # 'REF''123' is intentional to avoid matching the reference in this test file + [ + 'REQ003', + '1.4', + 'Hello, world!\n', + 'REF' '123', + 'REQ001', + False, + False, + True, + 'Some "quoted" text \'here\'.', + ], ['REQ004', '1.6', 'Hello, world!\n', '', '', False, True, True, ''], ['REQ002', '2.1', 'Hello, world!\n', '', '', True, False, True, ''], ['REQ2-001', '2.1', 'Hello, world!\n', '', 'REQ001', True, False, True, ''], @@ -143,8 +252,9 @@ def test_file_tsv(self, mock_file_csv): # Act importer._file_tsv(mock_path, mock_document) # Assert - mock_file_csv.assert_called_once_with(mock_path, mock_document, - delimiter='\t', mapping=None) + mock_file_csv.assert_called_once_with( + mock_path, mock_document, delimiter='\t', mapping=None + ) @patch('doorstop.core.importer._itemize') def test_file_xlsx(self, mock_itemize): @@ -159,15 +269,115 @@ def test_file_xlsx(self, mock_itemize): logging.debug("args: {}".format(args)) logging.debug("kwargs: {}".format(kwargs)) header, data, document = args - expected_header = ['uid', 'level', 'text', 'ref', 'links', - 'active', 'derived', 'normative', 'reviewed'] + expected_header = [ + 'uid', + 'level', + 'text', + 'ref', + 'links', + 'active', + 'derived', + 'header', + 'normative', + 'reviewed', + ] + self.assertEqual(expected_header, header) + expected_data = [ + [ + 'REQ001', + '1.2.3', + LOREM_IPSUM, + None, + 'SYS001\nSYS002:abc123', + True, + False, + None, + True, + None, + ], + # pylint: disable=implicit-str-concat-in-sequence + # 'REF''123' is intentional to avoid matching the reference in this test file + [ + 'REQ003', + '1.4', + 'Unicode: -40° ±1%', + 'REF' '123', + 'REQ001', + True, + False, + None, + True, + None, + ], + [ + 'REQ004', + '1.6', + 'Hello, world!', + None, + None, + True, + False, + None, + True, + None, + ], + [ + 'REQ002', + '2.1', + 'Hello, world!\n\n```plantuml format="svg_inline" alt="Use Cases of Doorstop" title="Use Cases of Doorstop"\n@startuml\nAuthor --> (Create Document)\nAuthor --> (Create Item)\nAuthor --> (Link Item to Document)\nAuthor --> (Link Item to other Item)\nAuthor --> (Edit Item)\nAuthor --> (Review Item)\nAuthor -> (Delete Item)\nAuthor -> (Delete Document)\n(Export) <- (Author)\n(Import) <- (Author)\nReviewer --> (Review Item)\nSystem --> (Suspect Changes)\nSystem --> (Integrity)\n@enduml\n```', + None, + None, + True, + False, + 'Plantuml', + True, + '50ae164a198e612dee696cc80942dc29', + ], + [ + 'REQ2-001', + '2.1', + 'Hello, world!\n\nTest Math Expressions in Latex Style:\n\nInline Style 1: $a \\ne 0$\nInline Style 2: \\(ax^2 + bx + c = 0\\)\nMultiline: $$x = {-b \\pm \\sqrt{b^2-4ac} \\over 2a}.$$', + None, + 'REQ001', + True, + False, + None, + True, + None, + ], + ] + self.assertEqual(expected_data, data) + self.assertIs(mock_document, document) + + @patch('doorstop.core.importer._itemize') + def test_file_xlsx_formula(self, mock_itemize): + """Verify a XLSX file with formula can be imported.""" + path = os.path.join(FILES, 'formula.xlsx') + mock_document = Mock() + # Act + with catch_warnings(): + importer._file_xlsx(path, mock_document) + # Assert + args, kwargs = mock_itemize.call_args + logging.debug("args: {}".format(args)) + logging.debug("kwargs: {}".format(kwargs)) + header, data, document = args + expected_header = [ + 'uid', + 'level', + 'text', + 'ref', + 'links', + 'active', + 'derived', + 'header', + 'normative', + 'reviewed', + ] self.assertEqual(expected_header, header) expected_data = [ - ['REQ001', '1.2.3', LOREM_IPSUM, None, 'SYS001\nSYS002:abc123', True, False, True, None], - ['REQ003', '1.4', 'Unicode: -40° ±1%', 'REF''123', 'REQ001', True, False, True, None], - ['REQ004', '1.6', 'Hello, world!', None, None, True, False, True, None], - ['REQ002', '2.1', 'Hello, world!', None, None, True, False, True, 'b5fbcc355112791bbcd2ea881c7c5f81'], - ['REQ2-001', '2.1', 'Hello, world!', None, 'REQ001', True, False, True, None], + ['REQ001', '1.2.3', 'active', None, None, 1, 0, None, 1, None], + ['REQ002', '1.2.4', 'inactive', None, None, 0, 0, None, 1, None], ] self.assertEqual(expected_data, data) self.assertIs(mock_document, document) @@ -176,8 +386,7 @@ def test_file_xlsx(self, mock_itemize): def test_itemize(self, mock_add_item): """Verify item data can be converted to items.""" header = ['uid', 'text', 'links', 'ext1'] - data = [['req1', 'text1', '', 'val1'], - ['req2', '', 'sys1,sys2', False]] + data = [['req1', 'text1', '', 'val1'], ['req2', '', 'sys1,sys2', False]] mock_document = Mock() mock_document.prefix = 'PREFIX' # Act @@ -187,9 +396,7 @@ def test_itemize(self, mock_add_item): args, kwargs = mock_add_item.call_args self.assertEqual('PREFIX', args[0]) self.assertEqual('req2', args[1]) - expected_attrs = {'ext1': False, - 'links': ['sys1', 'sys2'], - 'text': ''} + expected_attrs = {'ext1': False, 'links': ['sys1', 'sys2'], 'text': ''} self.assertEqual(expected_attrs, kwargs['attrs']) self.assertIs(mock_document, kwargs['document']) @@ -206,10 +413,7 @@ def test_itemize_implicit_active(self, mock_add_item): args, kwargs = mock_add_item.call_args self.assertEqual('PREFIX', args[0]) self.assertEqual('req2', args[1]) - expected_attrs = {'active': True, - 'ext1': False, - 'links': [], - 'text': ''} + expected_attrs = {'active': True, 'ext1': False, 'links': [], 'text': ''} self.assertEqual(expected_attrs, kwargs['attrs']) @patch('doorstop.core.importer.add_item') @@ -225,18 +429,14 @@ def test_itemize_explicit_inactive(self, mock_add_item): args, kwargs = mock_add_item.call_args self.assertEqual('PREFIX', args[0]) self.assertEqual('req2', args[1]) - expected_attrs = {'active': False, - 'ext1': False, - 'links': [], - 'text': ''} + expected_attrs = {'active': False, 'ext1': False, 'links': [], 'text': ''} self.assertEqual(expected_attrs, kwargs['attrs']) @patch('doorstop.core.importer.add_item') def test_itemize_with_mapping(self, mock_add_item): """Verify item data can be converted to items with mapping.""" header = ['myid', 'text', 'links', 'ext1'] - data = [['req1', 'text1', '', 'val1'], - ['req2', 'text2', 'sys1,sys2', None]] + data = [['req1', 'text1', '', 'val1'], ['req2', 'text2', 'sys1,sys2', None]] mock_document = Mock() mapping = {'MyID': 'uid'} # Act @@ -248,8 +448,7 @@ def test_itemize_with_mapping(self, mock_add_item): def test_itemize_replace_existing(self, mock_add_item): """Verify item data can replace existing items.""" header = ['uid', 'text', 'links', 'ext1'] - data = [['req1', 'text1', '', 'val1'], - ['req2', 'text2', 'sys1,sys2', None]] + data = [['req1', 'text1', '', 'val1'], ['req2', 'text2', 'sys1,sys2', None]] mock_document = Mock() mock_document.find_item = Mock(side_effect=DoorstopError) # Act @@ -266,9 +465,9 @@ def test_itemize_blank_column(self, mock_add_item): mock_document.prefix = 'prefix' importer._itemize(header, data, mock_document) expected_attrs = {'links': [], 'ext1': 'val1', 'text': 'text1'} - mock_add_item.assert_called_once_with(mock_document.prefix, 'req1', - attrs=expected_attrs, - document=mock_document) + mock_add_item.assert_called_once_with( + mock_document.prefix, 'req1', attrs=expected_attrs, document=mock_document + ) @patch('doorstop.core.importer.add_item') def test_itemize_new_rows(self, mock_add_item): @@ -296,8 +495,7 @@ def test_itemize_new_rows(self, mock_add_item): def test_itemize_invalid(self): """Verify item data can include invalid values.""" header = ['uid', 'text', 'links', 'ext1'] - data = [['req1', 'text1', '', 'val1'], - ['invalid']] + data = [['req1', 'text1', '', 'val1'], ['invalid']] mock_document = Mock() importer._itemize(header, data, mock_document) @@ -339,25 +537,23 @@ def test_create_document_explicit_tree(self, mock_new, mock_get_tree): def test_create_document_with_parent(self, mock_new): """Verify a new document can be created for import with a parent.""" importer.create_document(self.prefix, self.path, parent=self.parent) - mock_new.assert_called_once_with(self.path, self.prefix, - parent=self.parent) + mock_new.assert_called_once_with(self.path, self.prefix, parent=self.parent) - @patch('doorstop.core.tree.Tree.create_document', - Mock(side_effect=DoorstopError)) + @patch('doorstop.core.tree.Tree.create_document', Mock(side_effect=DoorstopError)) def test_create_document_already_exists(self): """Verify non-parent import exceptions are re-raised.""" - self.assertRaises(DoorstopError, - importer.create_document, self.prefix, self.path) + self.assertRaises( + DoorstopError, importer.create_document, self.prefix, self.path + ) - @patch('doorstop.core.tree.Tree.create_document', - Mock(side_effect=DoorstopError)) + @patch('doorstop.core.tree.Tree.create_document', Mock(side_effect=DoorstopError)) @patch('doorstop.core.document.Document.new') def test_create_document_unknown_parent(self, mock_new): """Verify documents can be created for import with unknown parents.""" importer.create_document(self.prefix, self.path, parent=self.parent) - mock_new.assert_called_once_with(self.mock_tree, - self.path, self.root, self.prefix, - parent=self.parent) + mock_new.assert_called_once_with( + self.mock_tree, self.path, self.root, self.prefix, parent=self.parent + ) @patch('doorstop.core.item.Item', MockItem) @@ -396,9 +592,14 @@ def mock_find_document(self, prefix): def test_add_item(self, mock_new): """Verify an item can be imported into an existing document.""" importer.add_item(self.prefix, self.uid) - mock_new.assert_called_once_with(self.mock_tree, self.mock_document, - self.path, self.root, self.uid, - auto=False) + mock_new.assert_called_once_with( + self.mock_tree, + self.mock_document, + self.path, + self.root, + self.uid, + auto=False, + ) @patch('doorstop.core.builder._get_tree') @patch('doorstop.core.tree.Tree.find_document', mock_find_document) @@ -410,9 +611,9 @@ def test_add_item_explicit_document(self, mock_new, mock_get_tree): mock_document.tree._item_cache = MagicMock() importer.add_item(self.prefix, self.uid, document=mock_document) self.assertFalse(mock_get_tree.called) - mock_new.assert_called_once_with(mock_tree, mock_document, - self.path, self.root, self.uid, - auto=False) + mock_new.assert_called_once_with( + mock_tree, mock_document, self.path, self.root, self.uid, auto=False + ) @patch('doorstop.settings.ADDREMOVE_FILES', False) @patch('doorstop.core.tree.Tree.find_document', mock_find_document) diff --git a/doorstop/core/tests/test_item.py b/doorstop/core/tests/test_item.py index f0efc444d..43fde33b8 100644 --- a/doorstop/core/tests/test_item.py +++ b/doorstop/core/tests/test_item.py @@ -1,23 +1,74 @@ -"""Unit tests for the doorstop.core.item module.""" +# SPDX-License-Identifier: LGPL-3.0-only +# pylint: disable=C0302 -import unittest -from unittest.mock import patch, Mock, MagicMock +"""Unit tests for the doorstop.core.item module.""" +import logging import os +import unittest +from unittest.mock import MagicMock, Mock, patch -from doorstop import common +from doorstop import common, core from doorstop.common import DoorstopError -from doorstop.core.types import Text, Stamp from doorstop.core.item import Item, UnknownItem +from doorstop.core.tests import EMPTY, EXTERNAL, FILES, MockItem, MockSimpleDocument +from doorstop.core.types import Stamp, Text from doorstop.core.vcs.mockvcs import WorkingCopy -from doorstop.core.tests import FILES, EMPTY, EXTERNAL -from doorstop.core.tests import MockItem +YAML_DEFAULT = """ +active: true +derived: false +header: '' +level: 1.0 +links: [] +normative: true +ref: '' +reviewed: null +text: '' +""".lstrip() +YAML_EXTENDED_ATTRIBUTES = """ +a: +- b +- c +active: true +d: + e: f + g: h +derived: false +header: '' +i: j +k: null +level: 1.0 +links: [] +normative: true +ref: '' +reviewed: null +text: | + something +""".lstrip() -YAML_DEFAULT = """ +YAML_STRING_ATTRIBUTES = """ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: | + b active: true +cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc: d derived: false +e: | + fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +g: hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh +header: '' +i: + jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj: | + k + llllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll: m + n: | + ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo + p: qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq + r: + - | + ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss + - ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt level: 1.0 links: [] normative: true @@ -27,6 +78,23 @@ """.lstrip() +class ListLogHandler(logging.NullHandler): + def __init__(self, log): + super().__init__() + self.records = [] + self.log = log + + def __enter__(self): + self.log.addHandler(self) + return self + + def __exit__(self, kind, value, traceback): + self.log.removeHandler(self) + + def handle(self, record): + self.records.append(str(record.msg)) + + class TestItem(unittest.TestCase): """Unit tests for the Item class.""" @@ -34,15 +102,14 @@ class TestItem(unittest.TestCase): def setUp(self): path = os.path.join('path', 'to', 'RQ001.yml') - self.item = MockItem(path) + self.item = MockItem(MockSimpleDocument(), path) def test_init_invalid(self): """Verify an item cannot be initialized from an invalid path.""" - self.assertRaises(DoorstopError, Item, 'not/a/path') + self.assertRaises(DoorstopError, Item, None, 'not/a/path') - def test_object_references(self): - """Verify a standalone item does not have object references.""" - self.assertIs(None, self.item.document) + def test_no_tree_references(self): + """Verify a standalone item has no tree reference.""" self.assertIs(None, self.item.tree) def test_load_empty(self): @@ -65,6 +132,43 @@ def test_save_empty(self): self.item.save() self.item._write.assert_called_once_with(YAML_DEFAULT, self.item.path) + def test_set_attributes(self): + """Verify setting attributes calls write with the attributes.""" + self.item.set_attributes( + { + 'a': ['b', 'c'], + 'd': {'e': 'f', 'g': 'h'}, + 'i': 'j', + 'k': None, + 'text': 'something', + } + ) + self.item._write.assert_called_once_with( + YAML_EXTENDED_ATTRIBUTES, self.item.path + ) + + def test_string_attributes(self): + """Verify string attributes are properly formatted.""" + self.item.set_attributes( + { + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa': 'b', + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc': 'd', + 'e': 'fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + 'g': 'hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh', + 'i': { + 'jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj': 'k', + 'llllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll': 'm', + 'n': 'ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo', + 'p': 'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', + 'r': [ + 'ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss', + 'ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt', + ], + }, + } + ) + self.item._write.assert_called_once_with(YAML_STRING_ATTRIBUTES, self.item.path) + @patch('doorstop.common.verbosity', 2) def test_str(self): """Verify an item can be converted to a string.""" @@ -78,9 +182,9 @@ def test_str_verbose(self): def test_hash(self): """Verify items can be hashed.""" - item1 = MockItem('path/to/fake1.yml') - item2 = MockItem('path/to/fake2.yml') - item3 = MockItem('path/to/fake2.yml') + item1 = MockItem(None, 'path/to/fake1.yml') + item2 = MockItem(None, 'path/to/fake2.yml') + item3 = MockItem(None, 'path/to/fake2.yml') my_set = set() # Act my_set.add(item1) @@ -95,11 +199,11 @@ def test_ne(self): def test_lt(self): """Verify items can be compared.""" - item1 = MockItem('path/to/fake1.yml') + item1 = MockItem(None, 'path/to/fake1.yml') item1.level = (1, 1) - item2 = MockItem('path/to/fake1.yml') + item2 = MockItem(None, 'path/to/fake1.yml') item2.level = (1, 1, 1) - item3 = MockItem('path/to/fake1.yml') + item3 = MockItem(None, 'path/to/fake1.yml') item3.level = (1, 1, 2) self.assertLess(item1, item2) self.assertLess(item2, item3) @@ -201,20 +305,6 @@ def test_heading(self): self.assertTrue(self.item.normative) self.assertFalse(self.item.heading) - def test_cleared(self): - """Verify an item's suspect link status can be set and read.""" - mock_item = Mock() - mock_item.uid = 'mock_uid' - mock_item.stamp = Mock(return_value=Stamp('abc123')) - mock_tree = MagicMock() - mock_tree.find_item = Mock(return_value=mock_item) - self.item.tree = mock_tree - self.item.link('mock_uid') - self.item.cleared = 1 # updates each stamp - self.assertTrue(self.item.cleared) - self.item.cleared = 0 # sets each stamp to None - self.assertFalse(self.item.cleared) - def test_reviwed(self): """Verify an item's review status can be set and read.""" self.assertFalse(self.item.reviewed) # not reviewed by default @@ -234,13 +324,19 @@ def test_text(self): def test_text_sbd(self): """Verify newlines separate sentences in an item's text.""" - value = ("A sentence. Another sentence! Hello? Hi.\n" - "A new line (here). And another sentence.") - text = ("A sentence. Another sentence! Hello? Hi.\n" - "A new line (here). And another sentence.") - yaml = ("text: |\n" - " A sentence. Another sentence! Hello? Hi.\n" - " A new line (here). And another sentence.\n") + value = ( + "A sentence. Another sentence! Hello? Hi.\n" + "A new line (here). And another sentence." + ) + text = ( + "A sentence. Another sentence! Hello? Hi.\n" + "A new line (here). And another sentence." + ) + yaml = ( + "text: |\n" + " A sentence. Another sentence! Hello? Hi.\n" + " A new line (here). And another sentence.\n" + ) self.item.text = value self.assertEqual(text, self.item.text) self.assertIn(yaml, self.item._write.call_args[0][0]) @@ -362,14 +458,14 @@ def test_unlink_duplicate(self): def test_link_by_item(self): """Verify links can be added to an item (by item).""" path = os.path.join('path', 'to', 'ABC123.yml') - item = MockItem(path) + item = MockItem(None, path) self.item.link(item) self.assertEqual(['ABC123'], self.item.links) def test_unlink_by_item(self): """Verify links can be removed (by item).""" path = os.path.join('path', 'to', 'ABC123.yml') - item = MockItem(path) + item = MockItem(None, path) self.item.links = ['ABC123'] self.item.unlink(item) self.assertEqual([], self.item.links) @@ -431,11 +527,6 @@ def test_parent_documents_unknown(self): # Assert self.assertEqual([], documents) - def test_parent_documents_no_document(self): - """Verify 'parent_documents' is only valid with a document.""" - self.item.tree = Mock() - self.assertIs(None, self.item.parent_documents) - @patch('doorstop.settings.CACHE_PATHS', False) def test_find_ref(self): """Verify an item's reference can be found.""" @@ -528,21 +619,21 @@ def test_find_child_objects_standalone(self): def test_invalid_file_name(self): """Verify an invalid file name cannot be a requirement.""" - self.assertRaises(DoorstopError, MockItem, "path/to/REQ.yaml") - self.assertRaises(DoorstopError, MockItem, "path/to/001.yaml") + self.assertRaises(DoorstopError, MockItem, None, "path/to/REQ.yaml") + self.assertRaises(DoorstopError, MockItem, None, "path/to/001.yaml") def test_invalid_file_ext(self): """Verify an invalid file extension cannot be a requirement.""" - self.assertRaises(DoorstopError, MockItem, "path/to/REQ001") - self.assertRaises(DoorstopError, MockItem, "path/to/REQ001.txt") + self.assertRaises(DoorstopError, MockItem, None, "path/to/REQ001") + self.assertRaises(DoorstopError, MockItem, None, "path/to/REQ001.txt") @patch('doorstop.core.item.Item', MockItem) def test_new(self): """Verify items can be created.""" MockItem._create.reset_mock() - item = MockItem.new(None, None, - EMPTY, FILES, 'TEST00042', - level=(1, 2, 3)) + item = MockItem.new( + None, MockSimpleDocument(), EMPTY, FILES, 'TEST00042', level=(1, 2, 3) + ) path = os.path.join(EMPTY, 'TEST00042.yml') self.assertEqual(path, item.path) self.assertEqual((1, 2, 3), item.level) @@ -553,9 +644,9 @@ def test_new_cache(self): """Verify new items are cached.""" mock_tree = Mock() mock_tree._item_cache = {} - item = MockItem.new(mock_tree, None, - EMPTY, FILES, 'TEST00042', - level=(1, 2, 3)) + item = MockItem.new( + mock_tree, MockSimpleDocument(), EMPTY, FILES, 'TEST00042', level=(1, 2, 3) + ) self.assertEqual(item, mock_tree._item_cache[item.uid]) mock_tree.vcs.add.assert_called_once_with(item.path) @@ -563,9 +654,9 @@ def test_new_cache(self): def test_new_special(self): """Verify items can be created with a specially named prefix.""" MockItem._create.reset_mock() - item = MockItem.new(None, None, - EMPTY, FILES, 'VSM.HLR_01-002-042', - level=(1, 0)) + item = MockItem.new( + None, MockSimpleDocument(), EMPTY, FILES, 'VSM.HLR_01-002-042', level=(1, 0) + ) path = os.path.join(EMPTY, 'VSM.HLR_01-002-042.yml') self.assertEqual(path, item.path) self.assertEqual((1,), item.level) @@ -573,22 +664,24 @@ def test_new_special(self): def test_new_existing(self): """Verify an exception is raised if the item already exists.""" - self.assertRaises(DoorstopError, Item.new, - None, None, - FILES, FILES, 'REQ002', - level=(1, 2, 3)) + self.assertRaises( + DoorstopError, Item.new, None, None, FILES, FILES, 'REQ002', level=(1, 2, 3) + ) def test_validate_invalid_ref(self): """Verify an invalid reference fails validity.""" - with patch('doorstop.core.item.Item.find_ref', - Mock(side_effect=DoorstopError)): - self.assertFalse(self.item.validate()) + with patch( + 'doorstop.core.item.Item.find_ref', + Mock(side_effect=DoorstopError("test invalid ref")), + ): + with ListLogHandler(core.base.log) as handler: + self.assertFalse(self.item.validate()) + self.assertIn("test invalid ref", handler.records) def test_validate_inactive(self): """Verify an inactive item is not checked.""" self.item.active = False - with patch('doorstop.core.item.Item.find_ref', - Mock(side_effect=DoorstopError)): + with patch('doorstop.core.item.Item.find_ref', Mock(side_effect=DoorstopError)): self.assertTrue(self.item.validate()) def test_validate_reviewed(self): @@ -609,7 +702,9 @@ def test_validate_reviewed_first(self): def test_validate_reviewed_second(self): """Verify that a modified stamp fails review.""" self.item._data['reviewed'] = Stamp('abc123') - self.assertFalse(self.item.validate()) + with ListLogHandler(core.base.log) as handler: + self.assertFalse(self.item.validate()) + self.assertIn("unreviewed changes", handler.records) def test_validate_cleared(self): """Verify that checking a cleared link updates the stamp.""" @@ -619,6 +714,7 @@ def test_validate_cleared(self): mock_tree.find_item = Mock(return_value=mock_item) self.item.tree = mock_tree self.item.links = [{'mock_uid': True}] + self.item.disable_get_issues_document() self.assertTrue(self.item.validate()) self.assertEqual('abc123', self.item.links[0].stamp) @@ -630,6 +726,7 @@ def test_validate_cleared_new(self): mock_tree.find_item = Mock(return_value=mock_item) self.item.tree = mock_tree self.item.links = [{'mock_uid': None}] + self.item.disable_get_issues_document() self.assertTrue(self.item.validate()) self.assertEqual('abc123', self.item.links[0].stamp) @@ -637,6 +734,7 @@ def test_validate_nonnormative_with_links(self): """Verify a non-normative item with links can be checked.""" self.item.normative = False self.item.links = ['a'] + self.item.disable_get_issues_document() self.assertTrue(self.item.validate()) @patch('doorstop.settings.STAMP_NEW_LINKS', False) @@ -648,6 +746,7 @@ def test_validate_link_to_inactive(self): mock_tree.find_item = Mock(return_value=mock_item) self.item.links = ['a'] self.item.tree = mock_tree + self.item.disable_get_issues_document() self.assertTrue(self.item.validate()) @patch('doorstop.settings.STAMP_NEW_LINKS', False) @@ -659,30 +758,27 @@ def test_validate_link_to_nonnormative(self): mock_tree.find_item = Mock(return_value=mock_item) self.item.links = ['a'] self.item.tree = mock_tree + self.item.disable_get_issues_document() self.assertTrue(self.item.validate()) def test_validate_document(self): """Verify an item can be checked against a document.""" - mock_document = Mock() - mock_document.parent = 'fake' - self.item.document = mock_document + self.item.document.parent = 'fake' self.assertTrue(self.item.validate()) def test_validate_document_with_links(self): """Verify an item can be checked against a document with links.""" self.item.link('unknown1') - mock_document = Mock() - mock_document.parent = 'fake' - self.item.document = mock_document + self.item.document.parent = 'fake' self.assertTrue(self.item.validate()) def test_validate_document_with_bad_link_uids(self): """Verify an item can be checked against a document w/ bad links.""" self.item.link('invalid') - mock_document = Mock() - mock_document.parent = 'fake' - self.item.document = mock_document - self.assertFalse(self.item.validate()) + self.item.document.parent = 'fake' + with ListLogHandler(core.base.log) as handler: + self.assertFalse(self.item.validate()) + self.assertIn("invalid UID in links: invalid", handler.records) @patch('doorstop.settings.STAMP_NEW_LINKS', False) def test_validate_tree(self): @@ -719,7 +815,9 @@ def test_validate_tree_error(self): mock_tree = MagicMock() mock_tree.find_item = Mock(side_effect=DoorstopError) self.item.tree = mock_tree - self.assertFalse(self.item.validate()) + with ListLogHandler(core.base.log) as handler: + self.assertFalse(self.item.validate()) + self.assertIn("linked to unknown item: fake1", handler.records) @patch('doorstop.settings.REVIEW_NEW_ITEMS', False) def test_validate_both(self): @@ -731,20 +829,18 @@ def mock_iter(seq): def _iter(self): # pylint: disable=W0613 """Mock __iter__method.""" yield from seq + return _iter mock_item = Mock() mock_item.links = [self.item.uid] - mock_document = Mock() - mock_document.parent = 'BOTH' - mock_document.prefix = 'BOTH' + self.item.document.parent = 'BOTH' + self.item.document.prefix = 'BOTH' + self.item.document.set_items([mock_item]) - mock_document.__iter__ = mock_iter([mock_item]) mock_tree = Mock() - mock_tree.__iter__ = mock_iter([mock_document]) - - self.item.document = mock_document + mock_tree.__iter__ = mock_iter([self.item.document]) self.item.tree = mock_tree self.assertTrue(self.item.validate()) @@ -771,14 +867,9 @@ def mock_iter2(self): # pylint: disable=W0613 self.item.link('fake1') - mock_document = Mock() - mock_document.prefix = 'RQ' - mock_tree = Mock() mock_tree.__iter__ = mock_iter mock_tree.find_item = lambda uid: Mock(uid='fake1') - - self.item.document = mock_document self.item.tree = mock_tree self.assertTrue(self.item.validate()) @@ -793,6 +884,45 @@ def test_stamp(self): stamp = 'c6a87755b8756b61731c704c6a7be4a2' self.assertEqual(stamp, self.item.stamp()) + def test_stamp_with_one_extended_reviewed(self): + """Verify fingerprint with one extended reviewed attribute.""" + self.item._data['type'] = 'functional' + self.item.document.extended_reviewed = ['type'] + stamp = '04fdd093f67ce3a3160dfdc5d93e7813' + self.assertEqual(stamp, self.item.stamp()) + + def test_stamp_with_two_extended_reviewed(self): + """Verify fingerprint with two extended reviewed attributes.""" + self.item._data['type'] = 'functional' + self.item._data['verification-method'] = 'test' + self.item.document.extended_reviewed = ['type', 'verification-method'] + stamp = 'cf8aaea03cd5765bac978ad74a42d729' + self.assertEqual(stamp, self.item.stamp()) + + def test_stamp_with_reversed_extended_reviewed_reverse(self): + """Verify fingerprint with reversed extended reviewed attributes.""" + self.item._data['type'] = 'functional' + self.item._data['verification-method'] = 'test' + self.item.document.extended_reviewed = ['verification-method', 'type'] + stamp = '7b14dfcc17026e98790284c5cddb0900' + self.assertEqual(stamp, self.item.stamp()) + + def test_stamp_with_missing_extended_reviewed_reverse(self): + """Verify fingerprint with missing extended reviewed attribute.""" + with ListLogHandler(core.item.log) as handler: + self.item._data['type'] = 'functional' + self.item._data['verification-method'] = 'test' + self.item.document.extended_reviewed = [ + 'missing', + 'type', + 'verification-method', + ] + stamp = 'cf8aaea03cd5765bac978ad74a42d729' + self.assertEqual(stamp, self.item.stamp()) + self.assertIn( + "RQ001: missing extended reviewed attribute: missing", handler.records + ) + def test_stamp_links(self): """Verify an item's contents can be stamped.""" self.item.link('mock_link') @@ -816,6 +946,28 @@ def test_clear(self): self.assertTrue(self.item.cleared) self.assertEqual('abc123', self.item.links[0].stamp) + def test_clear_by_uid(self): + """Verify an item's links can be cleared as suspect by UID.""" + mock_item = Mock() + mock_item.uid = 'mock_uid' + mock_item.stamp = Mock(return_value=Stamp('abc123')) + mock_tree = MagicMock() + mock_tree.find_item = Mock(return_value=mock_item) + self.item.tree = mock_tree + self.item.link('mock_uid') + self.assertFalse(self.item.cleared) + self.assertEqual(None, self.item.links[0].stamp) + # Act + self.item.clear(['other_uid']) + # Assert + self.assertFalse(self.item.cleared) + self.assertEqual(None, self.item.links[0].stamp) + # Act + self.item.clear(['mock_uid']) + # Assert + self.assertTrue(self.item.cleared) + self.assertEqual('abc123', self.item.links[0].stamp) + def test_review(self): """Verify an item can be marked as reviewed.""" self.item.reviewed = False @@ -852,7 +1004,7 @@ def tearDown(self): def test_load_save(self): """Verify text formatting is preserved.""" - item = Item(self.ITEM) + item = Item(None, self.ITEM) item.load() item.save() text = common.read_text(self.ITEM) @@ -911,7 +1063,7 @@ def test_attributes(self, mock_warning): @patch('doorstop.core.item.log.debug') def test_attributes_with_spec(self, mock_warning): """Verify all other `Item` attributes raise an exception.""" - spec = Item(os.path.join(FILES, 'REQ001.yml')) + spec = Item(None, os.path.join(FILES, 'REQ001.yml')) self.item = UnknownItem(self.item.uid, spec=spec) self.assertRaises(AttributeError, getattr, self.item, 'path') self.assertRaises(AttributeError, getattr, self.item, 'text') diff --git a/doorstop/core/tests/test_publisher.py b/doorstop/core/tests/test_publisher.py index e1448da29..0e0be651a 100644 --- a/doorstop/core/tests/test_publisher.py +++ b/doorstop/core/tests/test_publisher.py @@ -1,18 +1,26 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Unit tests for the doorstop.core.publisher module.""" # pylint: disable=unused-argument,protected-access +import os import unittest -from unittest.mock import patch, Mock, MagicMock, call from unittest import mock - -import os +from unittest.mock import MagicMock, Mock, call, patch from doorstop.common import DoorstopError from doorstop.core import publisher from doorstop.core.document import Document -from doorstop.core.tests import (FILES, EMPTY, ROOT, MockDataMixIn, - MockItemAndVCS, MockItem, MockDocument) +from doorstop.core.tests import ( + EMPTY, + FILES, + ROOT, + MockDataMixIn, + MockDocument, + MockItem, + MockItemAndVCS, +) class TestModule(MockDataMixIn, unittest.TestCase): @@ -47,30 +55,41 @@ def test_publish_document_html(self, mock_lines, mock_open, mock_makedirs): self.assertIs(path, path2) mock_makedirs.assert_called_once_with(os.path.join(dirpath, Document.ASSETS)) mock_open.assert_called_once_with(path, 'wb') - mock_lines.assert_called_once_with(self.document, '.html', - template=publisher.HTMLTEMPLATE, toc=True, - linkify=False) + mock_lines.assert_called_once_with( + self.document, + '.html', + template=publisher.HTMLTEMPLATE, + toc=True, + linkify=False, + ) @patch('os.path.isdir', Mock(side_effect=[True, False, False, False])) @patch('os.remove') @patch('glob.glob') @patch('builtins.open') @patch('doorstop.core.publisher.publish_lines') - def test_publish_document_deletes_the_contents_of_assets_folder(self, mock_lines, mock_open, mock_glob, mock_rm): + def test_publish_document_deletes_the_contents_of_assets_folder( + self, mock_lines, mock_open, mock_glob, mock_rm + ): """Verify that the contents of an assets directory next to the published file is deleted""" dirpath = os.path.abspath(os.path.join('mock', 'directory')) path = os.path.join(dirpath, 'published.custom') - assets = [os.path.join(dirpath, Document.ASSETS, dir) for dir in ['css', 'logo.png']] + assets = [ + os.path.join(dirpath, Document.ASSETS, dir) for dir in ['css', 'logo.png'] + ] mock_glob.return_value = assets # Act path2 = publisher.publish(self.document, path, '.html') # Assert self.assertIs(path, path2) mock_open.assert_called_once_with(path, 'wb') - mock_lines.assert_called_once_with(self.document, '.html', - template=publisher.HTMLTEMPLATE, - toc=True, - linkify=False) + mock_lines.assert_called_once_with( + self.document, + '.html', + template=publisher.HTMLTEMPLATE, + toc=True, + linkify=False, + ) calls = [call(assets[0]), call(assets[1])] self.assertEqual(calls, mock_rm.call_args_list) @@ -78,13 +97,17 @@ def test_publish_document_deletes_the_contents_of_assets_folder(self, mock_lines @patch('doorstop.core.document.Document.copy_assets') @patch('os.makedirs') @patch('builtins.open') - def test_publish_document_copies_assets(self, mock_open, mock_makedirs, mock_copyassets): + def test_publish_document_copies_assets( + self, mock_open, mock_makedirs, mock_copyassets + ): """Verify that assets are published""" dirpath = os.path.join('mock', 'directory') assets_path = os.path.join(dirpath, 'assets') path = os.path.join(dirpath, 'published.custom') document = MockDocument('/some/path') - mock_open.side_effect = lambda *args, **kw: mock.mock_open(read_data="$body").return_value + mock_open.side_effect = lambda *args, **kw: mock.mock_open( + read_data="$body" + ).return_value # Act path2 = publisher.publish(document, path, '.html') # Assert @@ -94,10 +117,10 @@ def test_publish_document_copies_assets(self, mock_open, mock_makedirs, mock_cop def test_publish_document_unknown(self): """Verify an exception is raised when publishing unknown formats.""" - self.assertRaises(DoorstopError, - publisher.publish, self.document, 'a.a') - self.assertRaises(DoorstopError, - publisher.publish, self.document, 'a.txt', '.a') + self.assertRaises(DoorstopError, publisher.publish, self.document, 'a.a') + self.assertRaises( + DoorstopError, publisher.publish, self.document, 'a.txt', '.a' + ) @patch('os.path.isdir', Mock(return_value=False)) @patch('os.makedirs') @@ -106,7 +129,9 @@ def test_publish_document_unknown(self): def test_publish_tree(self, mock_open, mock_index, mock_makedirs): """Verify a tree can be published.""" dirpath = os.path.join('mock', 'directory') - mock_open.side_effect = lambda *args, **kw: mock.mock_open(read_data="$body").return_value + mock_open.side_effect = lambda *args, **kw: mock.mock_open( + read_data="$body" + ).return_value expected_calls = [call(os.path.join('mock', 'directory', 'MOCK.html'), 'wb')] # Act dirpath2 = publisher.publish(self.mock_tree, dirpath) @@ -122,7 +147,9 @@ def test_publish_tree(self, mock_open, mock_index, mock_makedirs): def test_publish_tree_no_index(self, mock_open, mock_index, mock_makedirs): """Verify a tree can be published.""" dirpath = os.path.join('mock', 'directory') - mock_open.side_effect = lambda *args, **kw: mock.mock_open(read_data="$body").return_value + mock_open.side_effect = lambda *args, **kw: mock.mock_open( + read_data="$body" + ).return_value expected_calls = [call(os.path.join('mock', 'directory', 'MOCK.html'), 'wb')] # Act dirpath2 = publisher.publish(self.mock_tree, dirpath, index=False) @@ -179,8 +206,9 @@ def test_index_tree(self): def test_lines_text_item(self): """Verify text can be published from an item.""" - with patch.object(self.item5, 'find_ref', - Mock(return_value=('path/to/mock/file', 42))): + with patch.object( + self.item5, 'find_ref', Mock(return_value=('path/to/mock/file', 42)) + ): lines = publisher.publish_lines(self.item5, '.txt') text = ''.join(line + '\n' for line in lines) self.assertIn("Reference: path/to/mock/file (line 42)", text) @@ -215,11 +243,15 @@ def test_single_line_heading_to_markdown(self): def test_multi_line_heading_to_markdown(self): """Verify a multi line heading is published as a heading with an attribute equal to the item id""" - item = MockItemAndVCS('path/to/req3.yml', - _file=("links: [sys3]" + '\n' - "text: 'Heading\n\nThis section describes publishing.'" + '\n' - "level: 1.1.0" + '\n' - "normative: false")) + item = MockItemAndVCS( + 'path/to/req3.yml', + _file=( + "links: [sys3]" + '\n' + "text: 'Heading\n\nThis section describes publishing.'" + '\n' + "level: 1.1.0" + '\n' + "normative: false" + ), + ) expected = "## 1.1 Heading {#req3 }\nThis section describes publishing.\n\n" lines = publisher.publish_lines(item, '.md', linkify=True) # Act @@ -230,11 +262,15 @@ def test_multi_line_heading_to_markdown(self): @patch('doorstop.settings.PUBLISH_HEADING_LEVELS', False) def test_multi_line_heading_to_markdown_no_heading_levels(self): """Verify a multi line heading is published as a heading, without level, with an attribute equal to the item id""" - item = MockItemAndVCS('path/to/req3.yml', - _file=("links: [sys3]" + '\n' - "text: 'Heading\n\nThis section describes publishing.'" + '\n' - "level: 1.1.0" + '\n' - "normative: false")) + item = MockItemAndVCS( + 'path/to/req3.yml', + _file=( + "links: [sys3]" + '\n' + "text: 'Heading\n\nThis section describes publishing.'" + '\n' + "level: 1.1.0" + '\n' + "normative: false" + ), + ) expected = "## Heading {#req3 }\nThis section describes publishing.\n\n" lines = publisher.publish_lines(item, '.md', linkify=True) # Act @@ -245,10 +281,12 @@ def test_multi_line_heading_to_markdown_no_heading_levels(self): @patch('doorstop.settings.PUBLISH_CHILD_LINKS', False) def test_lines_text_item_normative(self): """Verify text can be published from an item (normative).""" - expected = ("1.2 req4" + '\n\n' - " This shall..." + '\n\n' - " Reference: Doorstop.sublime-project" + '\n\n' - " Links: sys4" + '\n\n') + expected = ( + "1.2 req4" + '\n\n' + " This shall..." + '\n\n' + " Reference: Doorstop.sublime-project" + '\n\n' + " Links: sys4" + '\n\n' + ) lines = publisher.publish_lines(self.item3, '.txt') # Act text = ''.join(line + '\n' for line in lines) @@ -273,8 +311,9 @@ def test_lines_text_item_with_child_links(self): def test_lines_markdown_item(self): """Verify Markdown can be published from an item.""" - with patch.object(self.item5, 'find_ref', - Mock(return_value=('path/to/mock/file', 42))): + with patch.object( + self.item5, 'find_ref', Mock(return_value=('path/to/mock/file', 42)) + ): lines = publisher.publish_lines(self.item5, '.md') text = ''.join(line + '\n' for line in lines) self.assertIn("> `path/to/mock/file` (line 42)", text) @@ -301,10 +340,12 @@ def test_lines_markdown_item_heading_no_heading_levels(self): @patch('doorstop.settings.PUBLISH_CHILD_LINKS', False) def test_lines_markdown_item_normative(self): """Verify Markdown can be published from an item (normative).""" - expected = ("## 1.2 req4 {#req4 }" + '\n\n' - "This shall..." + '\n\n' - "> `Doorstop.sublime-project`" + '\n\n' - "*Links: sys4*" + '\n\n') + expected = ( + "## 1.2 req4 {#req4 }" + '\n\n' + "This shall..." + '\n\n' + "> `Doorstop.sublime-project`" + '\n\n' + "*Links: sys4*" + '\n\n' + ) # Act lines = publisher.publish_lines(self.item3, '.md', linkify=False) text = ''.join(line + '\n' for line in lines) @@ -333,10 +374,12 @@ def test_lines_markdown_item_without_child_links(self): @patch('doorstop.settings.PUBLISH_CHILD_LINKS', False) def test_lines_markdown_item_without_body_levels(self): """Verify Markdown can be published from an item (no body levels).""" - expected = ("## req4 {#req4 }" + '\n\n' - "This shall..." + '\n\n' - "> `Doorstop.sublime-project`" + '\n\n' - "*Links: sys4*" + '\n\n') + expected = ( + "## req4 {#req4 }" + '\n\n' + "This shall..." + '\n\n' + "> `Doorstop.sublime-project`" + '\n\n' + "*Links: sys4*" + '\n\n' + ) # Act lines = publisher.publish_lines(self.item3, '.md', linkify=False) text = ''.join(line + '\n' for line in lines) @@ -352,7 +395,9 @@ def test_lines_markdown_item_no_ref(self): def test_lines_html_item(self): """Verify HTML can be published from an item.""" - expected = '

1.1 Heading

\n
\n' + expected = ( + '

1.1 Heading

\n
\n' + ) # Act lines = publisher.publish_lines(self.item, '.html') text = ''.join(line + '\n' for line in lines) @@ -371,7 +416,9 @@ def test_lines_html_item_no_heading_levels(self): def test_lines_html_item_linkify(self): """Verify HTML (hyper) can be published from an item.""" - expected = '

1.1 Heading

\n
\n' + expected = ( + '

1.1 Heading

\n
\n' + ) # Act lines = publisher.publish_lines(self.item, '.html', linkify=True) text = ''.join(line + '\n' for line in lines) @@ -428,7 +475,7 @@ def test_toc_no_links_or_heading_levels(self): * 1.2.3 REQ001 * 1.4 REQ003 * 1.6 REQ004 - * 2.1 REQ002 + * 2.1 Plantuml * 2.1 REQ2-001\n''' toc = publisher._table_of_contents_md(self.document, linkify=None) print(toc) @@ -442,7 +489,7 @@ def test_toc_no_links(self): * REQ001 * REQ003 * REQ004 - * REQ002 + * Plantuml * REQ2-001\n''' toc = publisher._table_of_contents_md(self.document, linkify=None) print(toc) @@ -455,7 +502,7 @@ def test_toc(self): * [1.2.3 REQ001](#REQ001) * [1.4 REQ003](#REQ003) * [1.6 REQ004](#REQ004) - * [2.1 REQ002](#REQ002) + * [2.1 Plantuml](#REQ002) * [2.1 REQ2-001](#REQ2-001)\n''' toc = publisher._table_of_contents_md(self.document, linkify=True) self.assertEqual(expected, toc) diff --git a/doorstop/core/tests/test_tree.py b/doorstop/core/tests/test_tree.py index c97819b22..49d5e3615 100644 --- a/doorstop/core/tests/test_tree.py +++ b/doorstop/core/tests/test_tree.py @@ -1,23 +1,21 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +# SPDX-License-Identifier: LGPL-3.0-only """Unit tests for the doorstop.core.tree module.""" -import unittest -from unittest.mock import patch, Mock, MagicMock - +import logging +import operator import os import tempfile -import operator -import logging +import unittest +from unittest.mock import MagicMock, Mock, patch -from doorstop.common import DoorstopError, DoorstopWarning, DoorstopInfo -from doorstop.core.tree import Tree -from doorstop.core.document import Document +from doorstop.common import DoorstopError, DoorstopInfo, DoorstopWarning from doorstop.core.builder import build - -from doorstop.core.tests import FILES, SYS, EMPTY -from doorstop.core.tests import MockDocumentSkip +from doorstop.core.document import Document +from doorstop.core.tests import EMPTY, FILES, SYS, MockDocumentSkip +from doorstop.core.tree import Tree @patch('doorstop.core.document.Document', MockDocumentSkip) @@ -69,19 +67,21 @@ def test_contains(self): def test_draw_utf8(self): """Verify trees structure can be drawn (UTF-8).""" - text = ("a" + '\n' - "│ " + '\n' - "├── b1" + '\n' - "│ │ " + '\n' - "│ └── d" + '\n' - "│ │ " + '\n' - "│ └── e" + '\n' - "│ " + '\n' - "└── b2" + '\n' - " │ " + '\n' - " ├── c1" + '\n' - " │ " + '\n' - " └── c2") + text = ( + "a" + '\n' + "│ " + '\n' + "├── b1" + '\n' + "│ │ " + '\n' + "│ └── d" + '\n' + "│ │ " + '\n' + "│ └── e" + '\n' + "│ " + '\n' + "└── b2" + '\n' + " │ " + '\n' + " ├── c1" + '\n' + " │ " + '\n' + " └── c2" + ) logging.debug('expected:\n%s', text) text2 = self.tree.draw(encoding='UTF-8') logging.debug('actual:\n%s', text2) @@ -89,19 +89,21 @@ def test_draw_utf8(self): def test_draw_cp437(self): """Verify trees structure can be drawn (cp437).""" - text = ("a" + '\n' - "┬ " + '\n' - "├── b1" + '\n' - "│ ┬ " + '\n' - "│ └── d" + '\n' - "│ ┬ " + '\n' - "│ └── e" + '\n' - "│ " + '\n' - "└── b2" + '\n' - " ┬ " + '\n' - " ├── c1" + '\n' - " │ " + '\n' - " └── c2") + text = ( + "a" + '\n' + "┬ " + '\n' + "├── b1" + '\n' + "│ ┬ " + '\n' + "│ └── d" + '\n' + "│ ┬ " + '\n' + "│ └── e" + '\n' + "│ " + '\n' + "└── b2" + '\n' + " ┬ " + '\n' + " ├── c1" + '\n' + " │ " + '\n' + " └── c2" + ) logging.debug('expected:\n%s', text) text2 = self.tree.draw(encoding='cp437') logging.debug('actual:\n%s', text2) @@ -109,19 +111,21 @@ def test_draw_cp437(self): def test_draw_unknown(self): """Verify trees structure can be drawn (unknown).""" - text = ("a" + '\n' - "| " + '\n' - "+-- b1" + '\n' - "| | " + '\n' - "| +-- d" + '\n' - "| | " + '\n' - "| +-- e" + '\n' - "| " + '\n' - "+-- b2" + '\n' - " | " + '\n' - " +-- c1" + '\n' - " | " + '\n' - " +-- c2") + text = ( + "a" + '\n' + "| " + '\n' + "+-- b1" + '\n' + "| | " + '\n' + "| +-- d" + '\n' + "| | " + '\n' + "| +-- e" + '\n' + "| " + '\n' + "+-- b2" + '\n' + " | " + '\n' + " +-- c1" + '\n' + " | " + '\n' + " +-- c2" + ) logging.debug('expected:\n%s', text) text2 = self.tree.draw(encoding='unknown') logging.debug('actual:\n%s', text2) @@ -206,8 +210,7 @@ def setUp(self): def test_palce_empty(self): """Verify a document can be placed in an empty tree.""" tree = build(EMPTY) - doc = MockDocumentSkip.new(tree, - os.path.join(EMPTY, 'temp'), EMPTY, 'TEMP') + doc = MockDocumentSkip.new(tree, os.path.join(EMPTY, 'temp'), EMPTY, 'TEMP') tree._place(doc) # pylint: disable=W0212 self.assertEqual(1, len(tree)) @@ -215,9 +218,9 @@ def test_palce_empty(self): def test_palce_empty_no_parent(self): """Verify a document with parent cannot be placed in an empty tree.""" tree = build(EMPTY) - doc = MockDocumentSkip.new(tree, - os.path.join(EMPTY, 'temp'), EMPTY, 'TEMP', - parent='REQ') + doc = MockDocumentSkip.new( + tree, os.path.join(EMPTY, 'temp'), EMPTY, 'TEMP', parent='REQ' + ) self.assertRaises(DoorstopError, tree._place, doc) # pylint: disable=W0212 def test_documents(self): @@ -241,10 +244,16 @@ def test_validate_no_documents(self): self.assertTrue(tree.validate()) @patch('doorstop.settings.REORDER', False) - @patch('doorstop.core.document.Document.get_issues', - Mock(return_value=[DoorstopError('error'), - DoorstopWarning('warning'), - DoorstopInfo('info')])) + @patch( + 'doorstop.core.document.Document.get_issues', + Mock( + return_value=[ + DoorstopError('error'), + DoorstopWarning('warning'), + DoorstopInfo('info'), + ] + ), + ) def test_validate_document(self): """Verify a document error fails the tree validation.""" self.assertFalse(self.tree.validate()) @@ -282,23 +291,22 @@ def test_new_document(self): def test_new_document_unknown_parent(self): """Verify an exception is raised for an unknown parent.""" temp = tempfile.mkdtemp() - self.assertRaises(DoorstopError, self.tree.create_document, - temp, '_TEST', parent='UNKNOWN') + self.assertRaises( + DoorstopError, self.tree.create_document, temp, '_TEST', parent='UNKNOWN' + ) self.assertFalse(os.path.exists(temp)) @patch('doorstop.core.document.Document.add_item') def test_add_item(self, mock_add_item): """Verify an item can be added to a document.""" self.tree.add_item('REQ') - mock_add_item.assert_called_once_with(number=None, level=None, - reorder=True) + mock_add_item.assert_called_once_with(number=None, level=None, reorder=True) @patch('doorstop.core.document.Document.add_item') def test_add_item_level(self, mock_add): """Verify an item can be added to a document with a level.""" self.tree.add_item('REQ', level='1.2.3') - mock_add.assert_called_once_with(number=None, level='1.2.3', - reorder=True) + mock_add.assert_called_once_with(number=None, level='1.2.3', reorder=True) def test_add_item_unknown_prefix(self): """Verify an exception is raised for an unknown prefix (item).""" @@ -324,25 +332,35 @@ def test_link_items(self, mock_link): self.tree.link_items('req1', 'req2') mock_link.assert_called_once_with('REQ002') + def test_link_items_self_reference(self): + """Verify an exception is raised with a self reference.""" + try: + self.tree.link_items('req1', 'req1') + self.fail() + except DoorstopError as error: + self.assertEqual(str(error), "link would be self reference") + + def test_link_items_cyclic_dependency(self): + """Verify an exception is raised with a cyclic dependency.""" + self.tree.link_items('req1', 'sys2') + msg = "^link would create a cyclic dependency: " "SYS002 -> REQ001 -> SYS002$" + self.assertRaisesRegex(DoorstopError, msg, self.tree.link_items, 'sys2', 'req1') + def test_link_items_unknown_child_prefix(self): """Verify an exception is raised with an unknown child prefix.""" - self.assertRaises(DoorstopError, - self.tree.link_items, 'unknown1', 'req2') + self.assertRaises(DoorstopError, self.tree.link_items, 'unknown1', 'req2') def test_link_items_unknown_child_number(self): """Verify an exception is raised with an unknown child number.""" - self.assertRaises(DoorstopError, - self.tree.link_items, 'req9999', 'req2') + self.assertRaises(DoorstopError, self.tree.link_items, 'req9999', 'req2') def test_link_items_unknown_parent_prefix(self): """Verify an exception is raised with an unknown parent prefix.""" - self.assertRaises(DoorstopError, - self.tree.link_items, 'req1', 'unknown1') + self.assertRaises(DoorstopError, self.tree.link_items, 'req1', 'unknown1') def test_link_items_unknown_parent_number(self): """Verify an exception is raised with an unknown parent prefix.""" - self.assertRaises(DoorstopError, - self.tree.link_items, 'req1', 'req9999') + self.assertRaises(DoorstopError, self.tree.link_items, 'req1', 'req9999') @patch('doorstop.core.item.Item.unlink') def test_unlink_items(self, mock_unlink): @@ -352,27 +370,22 @@ def test_unlink_items(self, mock_unlink): def test_unlink_items_unknown_child_prefix(self): """Verify an exception is raised with an unknown child prefix.""" - self.assertRaises(DoorstopError, - self.tree.unlink_items, 'unknown1', 'req1') + self.assertRaises(DoorstopError, self.tree.unlink_items, 'unknown1', 'req1') def test_unlink_items_unknown_child_number(self): """Verify an exception is raised with an unknown child number.""" - self.assertRaises(DoorstopError, - self.tree.unlink_items, 'req9999', 'req1') + self.assertRaises(DoorstopError, self.tree.unlink_items, 'req9999', 'req1') def test_unlink_items_unknown_parent_prefix(self): """Verify an exception is raised with an unknown parent prefix.""" # Cache miss - self.assertRaises(DoorstopError, - self.tree.unlink_items, 'req3', 'unknown1') + self.assertRaises(DoorstopError, self.tree.unlink_items, 'req3', 'unknown1') # Cache hit - self.assertRaises(DoorstopError, - self.tree.unlink_items, 'req3', 'unknown1') + self.assertRaises(DoorstopError, self.tree.unlink_items, 'req3', 'unknown1') def test_unlink_items_unknown_parent_number(self): """Verify an exception is raised with an unknown parent prefix.""" - self.assertRaises(DoorstopError, - self.tree.unlink_items, 'req3', 'req9999') + self.assertRaises(DoorstopError, self.tree.unlink_items, 'req3', 'req9999') @patch('doorstop.core.editor.launch') def test_edit_item(self, mock_launch): diff --git a/doorstop/core/tests/test_types.py b/doorstop/core/tests/test_types.py index 4b456f26d..32fb2a41b 100644 --- a/doorstop/core/tests/test_types.py +++ b/doorstop/core/tests/test_types.py @@ -1,10 +1,13 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Unit tests for the doorstop.core.types module.""" import unittest + import yaml from doorstop.common import DoorstopError -from doorstop.core.types import Prefix, UID, Text, Level, Stamp, Reference +from doorstop.core.types import UID, Level, Prefix, Reference, Stamp, Text class TestPrefix(unittest.TestCase): @@ -233,8 +236,7 @@ def test_repr(self): """Verify levels can be represented.""" self.assertEqual("Level('1')", repr(self.level_1)) self.assertEqual("Level('1.2')", repr(self.level_1_2)) - self.assertEqual("Level('1.2', heading=True)", - repr(self.level_1_2_heading)) + self.assertEqual("Level('1.2', heading=True)", repr(self.level_1_2_heading)) self.assertEqual("Level('1.2.3')", repr(self.level_1_2_3)) def test_str(self): @@ -399,8 +401,7 @@ def setUp(self): def test_repr(self): """Verify stamps can be represented.""" self.assertEqual("Stamp('abc123')", repr(self.stamp1)) - self.assertEqual("Stamp('2645439971b8090da05c7403320afcfa')", - repr(self.stamp2)) + self.assertEqual("Stamp('2645439971b8090da05c7403320afcfa')", repr(self.stamp2)) self.assertEqual("Stamp(True)", repr(self.stamp3)) self.assertEqual("Stamp(None)", repr(self.stamp4)) self.assertEqual("Stamp(None)", repr(self.stamp5)) diff --git a/doorstop/core/tree.py b/doorstop/core/tree.py index c5a34d2ea..086f8f307 100644 --- a/doorstop/core/tree.py +++ b/doorstop/core/tree.py @@ -1,38 +1,30 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +# SPDX-License-Identifier: LGPL-3.0-only """Representation of a hierarchy of documents.""" import sys from itertools import chain -from doorstop import common +from doorstop import common, settings from doorstop.common import DoorstopError, DoorstopWarning +from doorstop.core import vcs from doorstop.core.base import BaseValidatable -from doorstop.core.types import Prefix, UID from doorstop.core.document import Document -from doorstop.core import vcs -from doorstop import settings +from doorstop.core.types import UID, Prefix UTF8 = 'utf-8' CP437 = 'cp437' ASCII = 'ascii' -BOX = {'end': {UTF8: '│ ', - CP437: '┬ ', - ASCII: '| '}, - 'tee': {UTF8: '├── ', - CP437: '├── ', - ASCII: '+-- '}, - 'bend': {UTF8: '└── ', - CP437: '└── ', - ASCII: '+-- '}, - 'pipe': {UTF8: '│ ', - CP437: '│ ', - ASCII: '| '}, - 'space': {UTF8: ' ', - CP437: ' ', - ASCII: ' '}} +BOX = { + 'end': {UTF8: '│ ', CP437: '┬ ', ASCII: '| '}, + 'tee': {UTF8: '├── ', CP437: '├── ', ASCII: '+-- '}, + 'bend': {UTF8: '└── ', CP437: '└── ', ASCII: '+-- '}, + 'pipe': {UTF8: '│ ', CP437: '│ ', ASCII: '| '}, + 'space': {UTF8: ' ', CP437: ' ', ASCII: ' '}, +} log = common.logger(__name__) @@ -141,8 +133,7 @@ def _place(self, document): if not self.document: # tree is empty if document.parent: - msg = "unknown parent for {}: {}".format(document, - document.parent) + msg = "unknown parent for {}: {}".format(document, document.parent) raise DoorstopError(msg) self.document = document @@ -165,8 +156,7 @@ def _place(self, document): else: break else: - msg = "unknown parent for {}: {}".format(document, - document.parent) + msg = "unknown parent for {}: {}".format(document, document.parent) raise DoorstopError(msg) else: # tree has documents, but no parent specified for document @@ -198,7 +188,9 @@ def vcs(self): # actions ################################################################ # decorators are applied to methods in the associated classes - def create_document(self, path, value, sep=None, digits=None, parent=None): # pylint: disable=R0913 + def create_document( + self, path, value, sep=None, digits=None, parent=None + ): # pylint: disable=R0913 """Create a new document and add it to the tree. :param path: directory path for the new document @@ -215,9 +207,9 @@ def create_document(self, path, value, sep=None, digits=None, parent=None): # p """ prefix = Prefix(value) - document = Document.new(self, - path, self.root, prefix, sep=sep, - digits=digits, parent=parent) + document = Document.new( + self, path, self.root, prefix, sep=sep, digits=digits, parent=parent + ) try: self._place(document) except DoorstopError: @@ -274,6 +266,25 @@ def remove_item(self, value, reorder=True): raise DoorstopError(UID.UNKNOWN_MESSAGE.format(k='', u=uid)) + def check_for_cycle(self, item, cid, path): + """Check if a cyclic dependency would be created. + + :param item: an item on the dependency path + :param cid: the child item's UID + :param path: the path of UIDs from the child item to the item + + :raises: :class:`~doorstop.common.DoorstopError` if the link + would create a cyclic dependency + """ + for did in item.links: + path2 = path + [did] + if did in path: + s = " -> ".join(list(map(str, path2))) + msg = "link would create a cyclic dependency: {}".format(s) + raise DoorstopError(msg) + dep = self.find_item(did, _kind='dependency') + self.check_for_cycle(dep, cid, path2) + # decorators are applied to methods in the associated classes def link_items(self, cid, pid): """Add a new link between two items by UIDs. @@ -293,7 +304,10 @@ def link_items(self, cid, pid): child = self.find_item(cid, _kind='child') # Find parent item parent = self.find_item(pid, _kind='parent') - # Add link + # Add link if it is not a self reference or cyclic dependency + if child is parent: + raise DoorstopError("link would be self reference") + self.check_for_cycle(parent, child.uid, [child.uid, parent.uid]) child.link(parent.uid) return child, parent @@ -443,9 +457,10 @@ def get_issues(self, skip=None, document_hook=None, item_hook=None): yield DoorstopWarning("no documents") # Check each document for document in documents: - for issue in chain(hook(document=document, tree=self), - document.get_issues(skip=skip, - item_hook=item_hook)): + for issue in chain( + hook(document=document, tree=self), + document.get_issues(skip=skip, item_hook=item_hook), + ): # Prepend the document's prefix to yielded exceptions if isinstance(issue, Exception): yield type(issue)("{}: {}".format(document.prefix, issue)) @@ -456,6 +471,7 @@ def get_traceability(self): :return: list of list of :class:`~doorstop.core.item.Item` or `None` """ + def by_uid(row): row2 = [] for item in row: @@ -490,7 +506,9 @@ def _get_prefix_of_children(self, document): children = [c.document.prefix for c in self.children] return children - def _iter_rows(self, item, mapping, parent=True, child=True, row=None): # pylint: disable=R0913 + def _iter_rows( + self, item, mapping, parent=True, child=True, row=None + ): # pylint: disable=R0913 """Generate all traceability row slices. :param item: base :class:`~doorstop.core.item.Item` for slicing @@ -500,6 +518,7 @@ def _iter_rows(self, item, mapping, parent=True, child=True, row=None): # pylin :param row: currently generated row """ + class Row(list): """List type that tracks upper and lower boundaries.""" @@ -524,15 +543,13 @@ def __init__(self, *args, parent=False, child=False, **kwargs): if parent: items = item.parent_items for item2 in items: - yield from self._iter_rows(item2, mapping, - child=False, row=row) + yield from self._iter_rows(item2, mapping, child=False, row=row) if not items: row.parent = True if child: items = item.child_items for item2 in items: - yield from self._iter_rows(item2, mapping, - parent=False, row=row) + yield from self._iter_rows(item2, mapping, parent=False, row=row) if not items: row.child = True @@ -577,7 +594,9 @@ def _draw_line(self): # Build parent prefix string (`getattr` to enable mock testing) prefix = getattr(self.document, 'prefix', '') or str(self.document) # Build children prefix strings - children = ", ".join(c._draw_line() for c in self.children) # pylint: disable=W0212 + children = ", ".join( + c._draw_line() for c in self.children # pylint: disable=protected-access + ) # Format the tree if children: return "{} <- [ {} ]".format(prefix, children) @@ -603,7 +622,10 @@ def _draw_lines(self, encoding, html_links=False): else: base = self._symbol('space', encoding) indent = self._symbol('bend', encoding) - for index, line in enumerate(child._draw_lines(encoding, html_links)): # pylint: disable=W0212 + for index, line in enumerate( + # pylint: disable=protected-access + child._draw_lines(encoding, html_links) + ): if index == 0: yield indent + line else: diff --git a/doorstop/core/types.py b/doorstop/core/types.py index c3abc2c77..8d65741ae 100644 --- a/doorstop/core/types.py +++ b/doorstop/core/types.py @@ -1,14 +1,15 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Common classes and functions for the `doorstop.core` package.""" +import hashlib import os import re -import hashlib import yaml -from doorstop import common +from doorstop import common, settings from doorstop.common import DoorstopError -from doorstop import settings log = common.logger(__name__) @@ -143,8 +144,7 @@ def __eq__(self, other): if not isinstance(other, UID): other = UID(other) try: - return all((self.prefix == other.prefix, - self.number == other.number)) + return all((self.prefix == other.prefix, self.number == other.number)) except DoorstopError: return self.value.lower() == other.value.lower() @@ -189,7 +189,7 @@ def string(self): def check(self): """Verify an UID is valid.""" if self._exc: - raise self._exc + raise self._exc # pylint: disable=raising-bad-type @staticmethod def split_uid(text): @@ -235,8 +235,9 @@ class _Literal(str): @staticmethod def representer(dumper, data): """Return a custom dumper that formats str in the literal style.""" - return dumper.represent_scalar('tag:yaml.org,2002:str', data, - style='|' if data else '') + return dumper.represent_scalar( + 'tag:yaml.org,2002:str', data, style='|' if data else '' + ) yaml.add_representer(_Literal, _Literal.representer) diff --git a/doorstop/core/vcs/__init__.py b/doorstop/core/vcs/__init__.py index 8592f6707..a2ebe4780 100644 --- a/doorstop/core/vcs/__init__.py +++ b/doorstop/core/vcs/__init__.py @@ -1,11 +1,13 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Interfaces to version control systems.""" -import os import logging +import os from doorstop import common from doorstop.common import DoorstopError -from doorstop.core.vcs import git, subversion, veracity, mockvcs, mercurial +from doorstop.core.vcs import git, mercurial, mockvcs, subversion, veracity DEFAULT = mockvcs.WorkingCopy DIRECTORIES = { @@ -38,8 +40,7 @@ def find_root(cwd): if path == parent: msg = "no working copy found from: {}".format(cwd) raise DoorstopError(msg) - else: - path = parent + path = parent log.debug("found working copy: {}".format(path)) return path diff --git a/doorstop/core/vcs/base.py b/doorstop/core/vcs/base.py index 300bc9c6b..ff2da3240 100644 --- a/doorstop/core/vcs/base.py +++ b/doorstop/core/vcs/base.py @@ -1,13 +1,13 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Abstract interface to version control systems.""" -import os import fnmatch +import os import subprocess from abc import ABCMeta, abstractmethod -from doorstop import common -from doorstop import settings - +from doorstop import common, settings log = common.logger(__name__) diff --git a/doorstop/core/vcs/git.py b/doorstop/core/vcs/git.py index a2c99eb15..38db4db06 100644 --- a/doorstop/core/vcs/git.py +++ b/doorstop/core/vcs/git.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Plug-in module to store requirements in a Git repository.""" from doorstop import common diff --git a/doorstop/core/vcs/mercurial.py b/doorstop/core/vcs/mercurial.py index 6e4718f51..32721f931 100644 --- a/doorstop/core/vcs/mercurial.py +++ b/doorstop/core/vcs/mercurial.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Plug-in module to store requirements in a Mercurial repository.""" from doorstop import common diff --git a/doorstop/core/vcs/mockvcs.py b/doorstop/core/vcs/mockvcs.py index e3440293b..425af3e39 100644 --- a/doorstop/core/vcs/mockvcs.py +++ b/doorstop/core/vcs/mockvcs.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Plug-in module to simulate the storage of requirements in a repository.""" import os diff --git a/doorstop/core/vcs/subversion.py b/doorstop/core/vcs/subversion.py index ccf53906f..c1dbc690e 100644 --- a/doorstop/core/vcs/subversion.py +++ b/doorstop/core/vcs/subversion.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Plug-in module to store requirements in a Subversion (1.7) repository.""" import os @@ -32,11 +34,12 @@ def commit(self, message=None): self.call('svn', 'commit', '--message', message) @property - def ignores(self): # pragma: no cover (manual test) + def ignores(self): if self._ignores_cache is None: self._ignores_cache = [] os.chdir(self.path) - for line in self.call('svn', 'pg', '-R', 'svn:ignore', '.', - return_stdout=True).splitlines(): + for line in self.call( + 'svn', 'pg', '-R', 'svn:ignore', '.', return_stdout=True + ).splitlines(): self._ignores_cache.append(line) return self._ignores_cache diff --git a/doorstop/core/vcs/tests/__init__.py b/doorstop/core/vcs/tests/__init__.py index b562659df..0a36d3b5e 100644 --- a/doorstop/core/vcs/tests/__init__.py +++ b/doorstop/core/vcs/tests/__init__.py @@ -1,9 +1,10 @@ -"""Integration tests for the doorstop.core.vcs package.""" +# SPDX-License-Identifier: LGPL-3.0-only -import unittest -from unittest.mock import patch, Mock +"""Integration tests for the doorstop.core.vcs package.""" import os +import unittest +from unittest.mock import Mock, patch from doorstop.common import DoorstopError from doorstop.core import vcs diff --git a/doorstop/core/vcs/tests/test_all.py b/doorstop/core/vcs/tests/test_all.py index 0d718b3ed..16a05e381 100644 --- a/doorstop/core/vcs/tests/test_all.py +++ b/doorstop/core/vcs/tests/test_all.py @@ -1,9 +1,10 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Integration tests for the doorstop.vcs package.""" import unittest from doorstop.core.vcs import load - from doorstop.core.vcs.tests import ROOT diff --git a/doorstop/core/vcs/tests/test_base.py b/doorstop/core/vcs/tests/test_base.py index d7e114ce0..fbe89b404 100644 --- a/doorstop/core/vcs/tests/test_base.py +++ b/doorstop/core/vcs/tests/test_base.py @@ -1,10 +1,12 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Unit tests for the doorstop.vcs.base module.""" import unittest from unittest.mock import patch -from doorstop.core.vcs.base import BaseWorkingCopy from doorstop.core.tests import ROOT +from doorstop.core.vcs.base import BaseWorkingCopy class SampleWorkingCopy(BaseWorkingCopy): diff --git a/doorstop/core/vcs/tests/test_commands.py b/doorstop/core/vcs/tests/test_commands.py index 0c33a5d7b..557e49875 100644 --- a/doorstop/core/vcs/tests/test_commands.py +++ b/doorstop/core/vcs/tests/test_commands.py @@ -1,13 +1,15 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Unit tests for the doorstop.vcs plugin modules.""" import unittest -from unittest.mock import patch, Mock, call +from unittest.mock import Mock, call, patch -from doorstop.core.vcs import load from doorstop.common import DoorstopError +from doorstop.core.vcs import load -class BaseTestCase(): # pylint: disable=R0904 +class BaseTestCase: """Base TestCase for tests that need a working copy.""" DIRECTORY = None @@ -46,7 +48,7 @@ def test_missing_command(self, mock_call): self.assertRaises(DoorstopError, self.add) -@patch('subprocess.call') # pylint: disable=R0904 +@patch('subprocess.call') class TestGit(BaseTestCase, unittest.TestCase): """Tests for the Git plugin.""" @@ -79,12 +81,14 @@ def test_delete(self, mock_call): def test_commit(self, mock_call): """Verify Git can commit files.""" self.commit() - calls = [call(("git", "commit", "--all", "--message", self.message)), - call(("git", "push"))] + calls = [ + call(("git", "commit", "--all", "--message", self.message)), + call(("git", "push")), + ] mock_call.assert_has_calls(calls) -@patch('subprocess.call') # pylint: disable=R0904 +@patch('subprocess.call') class TestSubversion(BaseTestCase, unittest.TestCase): """Tests for the Subversion plugin.""" @@ -93,8 +97,7 @@ class TestSubversion(BaseTestCase, unittest.TestCase): def test_lock(self, mock_call): """Verify Subversion can lock files.""" self.lock() - calls = [call(("svn", "update")), - call(("svn", "lock", self.path))] + calls = [call(("svn", "update")), call(("svn", "lock", self.path))] mock_call.assert_has_calls(calls) def test_edit(self, mock_call): @@ -122,7 +125,7 @@ def test_commit(self, mock_call): mock_call.assert_has_calls(calls) -@patch('subprocess.call') # pylint: disable=R0904 +@patch('subprocess.call') class TestVeracity(BaseTestCase, unittest.TestCase): """Tests for the Veracity plugin.""" @@ -131,8 +134,7 @@ class TestVeracity(BaseTestCase, unittest.TestCase): def test_lock(self, mock_call): """Verify Veracity can lock files.""" self.lock() - calls = [call(("vv", "pull")), - call(("vv", "update"))] + calls = [call(("vv", "pull")), call(("vv", "update"))] mock_call.assert_has_calls(calls) def test_edit(self, mock_call): @@ -156,12 +158,14 @@ def test_delete(self, mock_call): def test_commit(self, mock_call): """Verify Veracity can commit files.""" self.commit() - calls = [call(("vv", "commit", "--message", self.message)), - call(("vv", "push"))] + calls = [ + call(("vv", "commit", "--message", self.message)), + call(("vv", "push")), + ] mock_call.assert_has_calls(calls) -@patch('subprocess.call') # pylint: disable=R0904 +@patch('subprocess.call') class TestMercurial(BaseTestCase, unittest.TestCase): """Tests for the Mercurial plugin.""" @@ -194,6 +198,8 @@ def test_delete(self, mock_call): def test_commit(self, mock_call): """Verify Mercurial can commit files.""" self.commit() - calls = [call(("hg", "commit", "--message", self.message)), - call(("hg", "push"))] + calls = [ + call(("hg", "commit", "--message", self.message)), + call(("hg", "push")), + ] mock_call.assert_has_calls(calls) diff --git a/doorstop/core/vcs/veracity.py b/doorstop/core/vcs/veracity.py index 175f8485c..137f8a267 100644 --- a/doorstop/core/vcs/veracity.py +++ b/doorstop/core/vcs/veracity.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Plug-in module to store requirements in a Veracity repository.""" from doorstop import common diff --git a/doorstop/gui/__init__.py b/doorstop/gui/__init__.py index 309e33d81..ebd3791ea 100644 --- a/doorstop/gui/__init__.py +++ b/doorstop/gui/__init__.py @@ -1 +1,3 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Graphical interface for Doorstop.""" diff --git a/doorstop/gui/application.py b/doorstop/gui/application.py new file mode 100644 index 000000000..0a9a8fb6a --- /dev/null +++ b/doorstop/gui/application.py @@ -0,0 +1,834 @@ +#!/usr/bin/env python +# SPDX-License-Identifier: LGPL-3.0-only + +"""Graphical interface for Doorstop.""" + +import functools +import logging +import sys +from itertools import chain +from unittest.mock import Mock + +from doorstop import common +from doorstop.common import DoorstopError +from doorstop.core import builder, vcs +from doorstop.gui import utilTkinter, widget + +try: + import tkinter as tk + from tkinter import ttk + from tkinter import filedialog +except ImportError as _exc: + sys.stderr.write("WARNING: {}\n".format(_exc)) + tk = Mock() + ttk = Mock() + + +log = common.logger(__name__) + + +def _log(func): + """Log name and arguments.""" + + @functools.wraps(func) + def wrapped(self, *args, **kwargs): + sargs = "{}, {}".format( + ', '.join(repr(a) for a in args), + ', '.join("{}={}".format(k, repr(v)) for k, v in kwargs.items()), + ) + msg = "log: {}: {}".format(func.__name__, sargs.strip(", ")) + if not isinstance(self, ttk.Frame) or not self.ignore: + log.debug(msg.strip()) + return func(self, *args, **kwargs) + + return wrapped + + +class Application(ttk.Frame): + """Graphical application for Doorstop.""" + + def __init__(self, root, cwd, project): + ttk.Frame.__init__(self, root) + + # Create Doorstop variables + self.cwd = cwd + self.tree = None + self.document = None + self.item = None + + # Create string variables + self.stringvar_project = tk.StringVar(value=project or '') + self.stringvar_project.trace('w', self.display_tree) + self.stringvar_document = tk.StringVar() + self.stringvar_document.trace('w', self.display_document) + + # The stringvar_item holds the uid of the main selected item (or empty string if nothing is selected). + self.stringvar_item = tk.StringVar() + self.stringvar_item.trace('w', self.display_item) + + self.stringvar_text = tk.StringVar() + self.stringvar_text.trace('w', self.update_item) + self.intvar_active = tk.IntVar() + self.intvar_active.trace('w', self.update_item) + self.intvar_derived = tk.IntVar() + self.intvar_derived.trace('w', self.update_item) + self.intvar_normative = tk.IntVar() + self.intvar_normative.trace('w', self.update_item) + self.intvar_heading = tk.IntVar() + self.intvar_heading.trace('w', self.update_item) + self.stringvar_link = tk.StringVar() # no trace event + self.stringvar_ref = tk.StringVar() + self.stringvar_ref.trace('w', self.update_item) + self.stringvar_extendedkey = tk.StringVar() + self.stringvar_extendedkey.trace('w', self.display_extended) + self.stringvar_extendedvalue = tk.StringVar() + self.stringvar_extendedvalue.trace('w', self.update_item) + + # Create widget variables + self.combobox_documents = None + self.text_items = None + self.text_item = None + self.listbox_links = None + self.combobox_extended = None + self.text_extendedvalue = None + self.text_parents = None + self.text_children = None + + # Initialize the GUI + self.ignore = False # flag to ignore internal events + frame = self.init(root) + frame.pack(fill=tk.BOTH, expand=1) + + # Start the application + root.after(500, self.find) + + def init(self, root): + """Initialize and return the main frame.""" + # pylint: disable=attribute-defined-outside-init + + # Shared arguments + width_text = 30 + height_text = 10 + height_ext = 5 + + # Shared keyword arguments + kw_f = {'padding': 5} # constructor arguments for frames + kw_gp = {'padx': 2, 'pady': 2} # grid arguments for padded widgets + kw_gs = {'sticky': tk.NSEW} # grid arguments for sticky widgets + kw_gsp = dict( + chain(kw_gs.items(), kw_gp.items()) + ) # grid arguments for sticky padded widgets + + root.bind_all("", lambda arg: widget.adjustFontSize(-1)) + root.bind_all("", lambda arg: widget.adjustFontSize(1)) + root.bind_all("", lambda arg: widget.resetFontSize()) + + # Configure grid + frame = ttk.Frame(root, **kw_f) + frame.rowconfigure(0, weight=0) + frame.rowconfigure(1, weight=1) + frame.columnconfigure(0, weight=2) + frame.columnconfigure(1, weight=1) + frame.columnconfigure(2, weight=1) + frame.columnconfigure(3, weight=2) + + # Create widgets + def frame_project(root): + """Frame for the current project.""" + # Configure grid + frame = ttk.Frame(root, **kw_f) + frame.rowconfigure(0, weight=1) + frame.columnconfigure(0, weight=0) + frame.columnconfigure(1, weight=1) + + # Place widgets + widget.Label(frame, text="Project:").grid(row=0, column=0, **kw_gp) + widget.Entry(frame, textvariable=self.stringvar_project).grid( + row=0, column=1, **kw_gsp + ) + + return frame + + def frame_tree(root): + """Frame for the current document.""" + # Configure grid + frame = ttk.Frame(root, **kw_f) + frame.rowconfigure(0, weight=1) + frame.columnconfigure(0, weight=0) + frame.columnconfigure(1, weight=1) + + # Place widgets + widget.Label(frame, text="Document:").grid(row=0, column=0, **kw_gp) + self.combobox_documents = widget.Combobox( + frame, textvariable=self.stringvar_document, state="readonly" + ) + self.combobox_documents.grid(row=0, column=1, **kw_gsp) + + return frame + + def frame_document(root): + """Frame for current document's outline and items.""" + # Configure grid + frame = ttk.Frame(root, **kw_f) + frame.rowconfigure(0, weight=0) + frame.rowconfigure(1, weight=5) + frame.rowconfigure(2, weight=0) + frame.rowconfigure(3, weight=0) + frame.columnconfigure(0, weight=0) + frame.columnconfigure(1, weight=0) + frame.columnconfigure(2, weight=0) + frame.columnconfigure(3, weight=0) + frame.columnconfigure(4, weight=1) + frame.columnconfigure(5, weight=1) + + @_log + def treeview_outline_treeviewselect(event): + """Handle selecting an item in the tree view.""" + if self.ignore: + return + thewidget = event.widget + curselection = thewidget.selection() + if curselection: + uid = curselection[0] + self.stringvar_item.set(uid) + + @_log + def treeview_outline_delete(event): # pylint: disable=W0613 + """Handle deleting an item in the tree view.""" + if self.ignore: + return + self.remove() + + # Place widgets + widget.Label(frame, text="Outline:").grid( + row=0, column=0, columnspan=4, sticky=tk.W, **kw_gp + ) + widget.Label(frame, text="Items:").grid( + row=0, column=4, columnspan=2, sticky=tk.W, **kw_gp + ) + c_columnId = ("Id",) + self.treeview_outline = widget.TreeView(frame, columns=c_columnId) + for col in c_columnId: + self.treeview_outline.heading(col, text=col) + + # Add a Vertical scrollbar to the Treeview Outline + treeview_outline_verticalScrollBar = widget.ScrollbarV( + frame, command=self.treeview_outline.yview + ) + treeview_outline_verticalScrollBar.grid( + row=1, column=0, columnspan=1, **kw_gs + ) + self.treeview_outline.configure( + yscrollcommand=treeview_outline_verticalScrollBar.set + ) + + self.treeview_outline.bind( + "<>", treeview_outline_treeviewselect + ) + self.treeview_outline.bind("", treeview_outline_delete) + self.treeview_outline.grid(row=1, column=1, columnspan=3, **kw_gsp) + self.text_items = widget.noUserInput_init( + widget.Text(frame, width=width_text, wrap=tk.WORD) + ) + self.text_items.grid(row=1, column=4, columnspan=2, **kw_gsp) + self.text_items_hyperlink = utilTkinter.HyperlinkManager(self.text_items) + widget.Button(frame, text="<", width=0, command=self.left).grid( + row=2, column=0, sticky=tk.EW, padx=(2, 0) + ) + widget.Button(frame, text="v", width=0, command=self.down).grid( + row=2, column=1, sticky=tk.EW + ) + widget.Button(frame, text="^", width=0, command=self.up).grid( + row=2, column=2, sticky=tk.EW + ) + widget.Button(frame, text=">", width=0, command=self.right).grid( + row=2, column=3, sticky=tk.EW, padx=(0, 2) + ) + widget.Button(frame, text="Add Item", command=self.add).grid( + row=2, column=4, sticky=tk.W, **kw_gp + ) + widget.Button(frame, text="Remove Selected Item", command=self.remove).grid( + row=2, column=5, sticky=tk.E, **kw_gp + ) + + return frame + + def frame_item(root): + """Frame for the currently selected item.""" + # Configure grid + frame = ttk.Frame(root, **kw_f) + frame.rowconfigure(0, weight=0) + frame.rowconfigure(1, weight=4) + frame.rowconfigure(2, weight=0) + frame.rowconfigure(3, weight=1) + frame.rowconfigure(4, weight=1) + frame.rowconfigure(5, weight=1) + frame.rowconfigure(6, weight=1) + frame.rowconfigure(7, weight=0) + frame.rowconfigure(8, weight=0) + frame.rowconfigure(9, weight=0) + frame.rowconfigure(10, weight=0) + frame.rowconfigure(11, weight=4) + frame.columnconfigure(0, weight=1, pad=kw_f['padding'] * 2) + frame.columnconfigure(1, weight=1) + + @_log + def text_focusin(_): + """Handle entering a text field.""" + self.ignore = True + + @_log + def text_item_focusout(event): + """Handle updated text text.""" + self.ignore = False + thewidget = event.widget + value = thewidget.get('1.0', tk.END) + self.stringvar_text.set(value) + + @_log + def text_extendedvalue_focusout(event): + """Handle updated extended attributes.""" + self.ignore = False + thewidget = event.widget + value = thewidget.get('1.0', tk.END) + self.stringvar_extendedvalue.set(value) + + # Selected Item + widget.Label(frame, text="Selected Item:").grid( + row=0, column=0, columnspan=3, sticky=tk.W, **kw_gp + ) + self.text_item = widget.Text( + frame, width=width_text, height=height_text, wrap=tk.WORD + ) + self.text_item.bind('', text_focusin) + self.text_item.bind('', text_item_focusout) + self.text_item.grid(row=1, column=0, columnspan=3, **kw_gsp) + + # Column: Properties + self.create_properties_widget(frame).grid( + row=2, rowspan=2, column=0, columnspan=2, sticky=tk.NSEW, **kw_gp + ) + + # Column: Links + self.create_links_widget(frame).grid( + row=4, rowspan=3, column=0, columnspan=2, sticky=tk.NSEW, **kw_gp + ) + + # External Reference + self.create_reference_widget(frame).grid( + row=7, rowspan=2, column=0, columnspan=2, sticky=tk.NSEW, **kw_gp + ) + + widget.Label(frame, text="Extended Attributes:").grid( + row=9, column=0, columnspan=3, sticky=tk.W, **kw_gp + ) + self.combobox_extended = widget.Combobox( + frame, textvariable=self.stringvar_extendedkey + ) + self.combobox_extended.grid(row=10, column=0, columnspan=3, **kw_gsp) + self.text_extendedvalue = widget.Text( + frame, width=width_text, height=height_ext, wrap=tk.WORD + ) + self.text_extendedvalue.bind('', text_focusin) + self.text_extendedvalue.bind('', text_extendedvalue_focusout) + self.text_extendedvalue.grid(row=11, column=0, columnspan=3, **kw_gsp) + + return frame + + def frame_family(root): + """Frame for the parent and child document items.""" + # Configure grid + frame = ttk.Frame(root, **kw_f) + frame.rowconfigure(0, weight=0) + frame.rowconfigure(1, weight=1) + frame.rowconfigure(2, weight=0) + frame.rowconfigure(3, weight=1) + frame.columnconfigure(0, weight=1) + + # Place widgets + widget.Label(frame, text="Linked To:").grid( + row=0, column=0, sticky=tk.W, **kw_gp + ) + self.text_parents = widget.noUserInput_init( + widget.Text(frame, width=width_text, wrap=tk.WORD) + ) + self.text_parents_hyperlink = utilTkinter.HyperlinkManager( + self.text_parents + ) + self.text_parents.grid(row=1, column=0, **kw_gsp) + widget.Label(frame, text="Linked From:").grid( + row=2, column=0, sticky=tk.W, **kw_gp + ) + self.text_children = widget.noUserInput_init( + widget.Text(frame, width=width_text, wrap=tk.WORD) + ) + self.text_children_hyperlink = utilTkinter.HyperlinkManager( + self.text_children + ) + self.text_children.grid(row=3, column=0, **kw_gsp) + + return frame + + # Place widgets + frame_project(frame).grid(row=0, column=0, columnspan=2, **kw_gs) + frame_tree(frame).grid(row=0, column=2, columnspan=2, **kw_gs) + frame_document(frame).grid(row=1, column=0, **kw_gs) + frame_item(frame).grid(row=1, column=1, columnspan=2, **kw_gs) + frame_family(frame).grid(row=1, column=3, **kw_gs) + + return frame + + @_log + def find(self): + """Find the root of the project.""" + if not self.stringvar_project.get(): + try: + path = vcs.find_root(self.cwd) + except DoorstopError as exc: + log.error(exc) + else: + self.stringvar_project.set(path) + + @_log + def browse(self): + """Browse for the root of a project.""" + path = filedialog.askdirectory() + log.debug("path: {}".format(path)) + if path: + self.stringvar_project.set(path) + + @_log + def display_tree(self, *_): + """Display the currently selected tree.""" + # Set the current tree + self.tree = builder.build(root=self.stringvar_project.get()) + log.info("displaying tree...") + + # Display the documents in the tree + values = [ + "{} ({})".format(document.prefix, document.relpath) + for document in self.tree + ] + self.combobox_documents['values'] = values + + # Select the first document + if len(self.tree): # pylint: disable=len-as-condition + self.combobox_documents.current(0) + else: + logging.warning("no documents to display") + + @_log + def display_document(self, *_): + """Display the currently selected document.""" + # Set the current document + index = self.combobox_documents.current() + self.document = list(self.tree)[index] + log.info("displaying document {}...".format(self.document)) + + # Record the currently opened items. + c_openItem = [] + for c_currUID in utilTkinter.getAllChildren(self.treeview_outline): + if self.treeview_outline.item(c_currUID)["open"]: + c_openItem.append(c_currUID) + + # Record the currently selected items. + c_selectedItem = self.treeview_outline.selection() + + # Clear the widgets + self.treeview_outline.delete(*self.treeview_outline.get_children()) + widget.noUserInput_delete(self.text_items, '1.0', tk.END) + self.text_items_hyperlink.reset() + + # Display the items in the document + c_levelsItem = [""] + for item in self.document.items: + theParent = next( + iter(reversed([x for x in c_levelsItem[: item.depth]])), "" + ) + + while len(c_levelsItem) < item.depth: + c_levelsItem.append(item.uid) + c_levelsItem = c_levelsItem[: item.depth] + for x in range(item.depth): + c_levelsItem.append(item.uid) + + # Add the item to the document outline + self.treeview_outline.insert( + theParent, + tk.END, + item.uid, + text=item.level, + values=(item.uid,), + open=item.uid in c_openItem, + ) + + # Add the item to the document text + widget.noUserInput_insert( + self.text_items, tk.END, "{t}".format(t=item.text or item.ref or '???') + ) + widget.noUserInput_insert(self.text_items, tk.END, " [") + widget.noUserInput_insert( + self.text_items, + tk.END, + item.uid, + self.text_items_hyperlink.add( + # pylint: disable=unnecessary-lambda + lambda c_theURL: self.followlink(c_theURL), + item.uid, + ["refLink"], + ), + ) + widget.noUserInput_insert(self.text_items, tk.END, "]\n\n") + + # Set tree view selection + c_selectedItem = [ + x + for x in c_selectedItem + if x in utilTkinter.getAllChildren(self.treeview_outline) + ] + if c_selectedItem: + # Restore selection + self.treeview_outline.selection_set(c_selectedItem) + else: + # Select the first item + for uid in utilTkinter.getAllChildren(self.treeview_outline): + self.stringvar_item.set(uid) + break + else: + logging.warning("no items to display") + self.stringvar_item.set("") + + @_log + def display_item(self, *_): + """Display the currently selected item.""" + try: + self.ignore = True + + # Fetch the current item + uid = self.stringvar_item.get() + if uid == "": + self.item = None + else: + try: + self.item = self.tree.find_item(uid) + except DoorstopError: + pass + log.info("displaying item {}...".format(self.item)) + + if uid != "": + if uid not in self.treeview_outline.selection(): + self.treeview_outline.selection_set((uid,)) + self.treeview_outline.see(uid) + + # Display the item's text + self.text_item.replace( + '1.0', tk.END, "" if self.item is None else self.item.text + ) + + # Display the item's properties + self.stringvar_text.set("" if self.item is None else self.item.text) + self.intvar_active.set(False if self.item is None else self.item.active) + self.intvar_derived.set(False if self.item is None else self.item.derived) + self.intvar_normative.set( + False if self.item is None else self.item.normative + ) + self.intvar_heading.set(False if self.item is None else self.item.heading) + + # Display the item's links + self.listbox_links.delete(0, tk.END) + if self.item is not None: + for uid in self.item.links: + self.listbox_links.insert(tk.END, uid) + self.stringvar_link.set('') + + # Display the item's external reference + self.stringvar_ref.set("" if self.item is None else self.item.ref) + + # Display the item's extended attributes + values = None if self.item is None else self.item.extended + self.combobox_extended['values'] = values or [''] + if self.item is not None: + self.combobox_extended.current(0) + + # Display the items this item links to + widget.noUserInput_delete(self.text_parents, '1.0', tk.END) + self.text_parents_hyperlink.reset() + if self.item is not None: + for uid in self.item.links: + try: + item = self.tree.find_item(uid) + except DoorstopError: + text = "???" + else: + text = item.text or item.ref or '???' + uid = item.uid + + widget.noUserInput_insert( + self.text_parents, tk.END, "{t}".format(t=text) + ) + widget.noUserInput_insert(self.text_parents, tk.END, " [") + widget.noUserInput_insert( + self.text_parents, + tk.END, + uid, + self.text_parents_hyperlink.add( + # pylint: disable=unnecessary-lambda + lambda c_theURL: self.followlink(c_theURL), + uid, + ["refLink"], + ), + ) + widget.noUserInput_insert(self.text_parents, tk.END, "]\n\n") + + # Display the items this item has links from + widget.noUserInput_delete(self.text_children, '1.0', 'end') + self.text_children_hyperlink.reset() + if self.item is not None: + for uid in self.item.find_child_links(): + item = self.tree.find_item(uid) + text = item.text or item.ref or '???' + uid = item.uid + + widget.noUserInput_insert( + self.text_children, tk.END, "{t}".format(t=text) + ) + widget.noUserInput_insert(self.text_children, tk.END, " [") + widget.noUserInput_insert( + self.text_children, + tk.END, + uid, + self.text_children_hyperlink.add( + # pylint: disable=unnecessary-lambda + lambda c_theURL: self.followlink(c_theURL), + uid, + ["refLink"], + ), + ) + widget.noUserInput_insert(self.text_children, tk.END, "]\n\n") + finally: + self.ignore = False + + @_log + def display_extended(self, *_): + """Display the currently selected extended attribute.""" + try: + self.ignore = True + + name = self.stringvar_extendedkey.get() + log.debug("displaying extended attribute '{}'...".format(name)) + self.text_extendedvalue.replace('1.0', tk.END, self.item.get(name, "")) + finally: + self.ignore = False + + @_log + def update_item(self, *_): + """Update the current item from the fields.""" + if self.ignore: + return + if not self.item: + logging.warning("no item selected") + return + + # Update the current item + log.info("updating {}...".format(self.item)) + self.item.auto = False + self.item.text = self.stringvar_text.get() + self.item.active = self.intvar_active.get() + self.item.derived = self.intvar_derived.get() + self.item.normative = self.intvar_normative.get() + self.item.heading = self.intvar_heading.get() + self.item.links = self.listbox_links.get(0, tk.END) + self.item.ref = self.stringvar_ref.get() + name = self.stringvar_extendedkey.get() + if name: + self.item.set(name, self.stringvar_extendedvalue.get()) + self.item.save() + + # Re-select this item + self.display_document() + + @_log + def left(self): + """Dedent the current item's level.""" + self.item.level <<= 1 + self.document.reorder(keep=self.item) + self.display_document() + + @_log + def down(self): + """Increment the current item's level.""" + self.item.level += 1 + self.document.reorder(keep=self.item) + self.display_document() + + @_log + def up(self): + """Decrement the current item's level.""" + self.item.level -= 1 + self.document.reorder(keep=self.item) + self.display_document() + + @_log + def right(self): + """Indent the current item's level.""" + self.item.level >>= 1 + self.document.reorder(keep=self.item) + self.display_document() + + @_log + def add(self): + """Add a new item to the document.""" + logging.info("adding item to {}...".format(self.document)) + if self.item: + level = self.item.level + 1 + else: + level = None + item = self.document.add_item(level=level) + logging.info("added item: {}".format(item)) + # Refresh the document view + self.display_document() + # Set the new selection + self.stringvar_item.set(item.uid) + + @_log + def remove(self): + """Remove the selected item from the document.""" + newSelection = "" + for c_currUID in self.treeview_outline.selection(): + # Find the item which should be selected once the current selection is removed. + for currNeighbourStrategy in ( + self.treeview_outline.next, + self.treeview_outline.prev, + self.treeview_outline.parent, + ): + newSelection = currNeighbourStrategy(c_currUID) + if newSelection != "": + break + # Remove the item + item = self.tree.find_item(c_currUID) + logging.info("removing item {}...".format(item)) + item = self.tree.remove_item(item) + logging.info("removed item: {}".format(item)) + # Set the new selection + self.stringvar_item.set(newSelection) + # Refresh the document view + self.display_document() + + @_log + def link(self): + """Add the specified link to the current item.""" + # Add the specified link to the list + uid = self.stringvar_link.get() + if uid: + self.listbox_links.insert(tk.END, uid) + self.stringvar_link.set('') + + # Update the current item + self.update_item() + + @_log + def unlink(self): + """Remove the currently selected link from the current item.""" + # Remove the selected link from the list (if selected) + index = self.listbox_links.curselection() + if not index: + return + self.listbox_links.delete(index) + + # Update the current item + self.update_item() + + @_log + def followlink(self, uid): + """Display a given uid.""" + # Update the current item + self.ignore = False + self.update_item() + + # Load the good document. + document = self.tree.find_document(uid.prefix) + index = list(self.tree).index(document) + self.combobox_documents.current(index) + self.display_document() + + # load the good Item + self.stringvar_item.set(uid) + + def create_properties_widget(self, parent): + frame = ttk.Frame(parent) + + frame.columnconfigure(0, weight=1) + frame.rowconfigure(0, weight=1) + frame.rowconfigure(1, weight=1) + frame.rowconfigure(2, weight=1) + frame.rowconfigure(3, weight=1) + frame.rowconfigure(4, weight=1) + + widget.Label(frame, text="Properties:").grid(row=0, column=0, sticky=tk.NW) + widget.Checkbutton(frame, text="Active", variable=self.intvar_active).grid( + row=1, column=0, sticky=tk.NW + ) + widget.Checkbutton(frame, text="Derived", variable=self.intvar_derived).grid( + row=2, column=0, sticky=tk.NW + ) + widget.Checkbutton( + frame, text="Normative", variable=self.intvar_normative + ).grid(row=3, column=0, sticky=tk.NW) + widget.Checkbutton(frame, text="Heading", variable=self.intvar_heading).grid( + row=4, column=0, sticky=tk.NW + ) + + return frame + + def create_links_widget(self, parent): + frame = ttk.Frame(parent) + + frame.columnconfigure(0, weight=1) + frame.columnconfigure(1, weight=1) + frame.columnconfigure(2, weight=0) + frame.columnconfigure(3, weight=0) + frame.rowconfigure(0, weight=1) + frame.rowconfigure(1, weight=1) + frame.rowconfigure(2, weight=1) + + width_uid = 10 + widget.Label(frame, text="Links:").grid( + row=0, column=0, columnspan=1, sticky=tk.NW + ) + widget.Entry(frame, textvariable=self.stringvar_link).grid( + row=1, column=0, columnspan=2, sticky=tk.EW + tk.N + ) + widget.Button(frame, text="+", command=self.link).grid( + row=1, column=2, columnspan=1, sticky=tk.EW + tk.N + ) + widget.Button(frame, text="-", command=self.unlink).grid( + row=1, column=3, columnspan=1, sticky=tk.EW + tk.N + ) + self.listbox_links = widget.Listbox(frame, width=width_uid) + self.listbox_links.grid( + row=2, + column=0, + rowspan=2, + columnspan=4, + padx=(3, 0), + pady=(3, 0), + sticky=tk.NSEW, + ) + + return frame + + def create_reference_widget(self, parent): + frame = ttk.Frame(parent) + + frame.columnconfigure(0, weight=1) + frame.rowconfigure(0, weight=1) + frame.rowconfigure(1, weight=1) + + widget.Label(frame, text="External Reference:").grid( + row=0, column=0, sticky=tk.W + ) + widget.Entry(frame, textvariable=self.stringvar_ref).grid( + row=1, column=0, sticky=tk.NSEW + ) + + return frame diff --git a/doorstop/gui/main.py b/doorstop/gui/main.py index 56200f1f4..afd3dc434 100644 --- a/doorstop/gui/main.py +++ b/doorstop/gui/main.py @@ -1,32 +1,27 @@ #!/usr/bin/env python +# SPDX-License-Identifier: LGPL-3.0-only """Graphical interface for Doorstop.""" +import argparse +import functools +import logging import os import sys from unittest.mock import Mock -try: # pragma: no cover (manual test) + +from doorstop import common, settings +from doorstop.common import HelpFormatter, WarningFormatter +from doorstop.gui import application, widget + +try: import tkinter as tk from tkinter import ttk - from tkinter import filedialog -except ImportError as _exc: # pragma: no cover (manual test) +except ImportError as _exc: sys.stderr.write("WARNING: {}\n".format(_exc)) tk = Mock() ttk = Mock() -import argparse -import functools -from itertools import chain -import logging - -from doorstop.gui import widget -from doorstop.gui import utilTkinter - -from doorstop import common -from doorstop.common import HelpFormatter, WarningFormatter, DoorstopError -from doorstop.core import vcs -from doorstop.core import builder -from doorstop import settings log = common.logger(__name__) @@ -38,14 +33,16 @@ def main(args=None): # Shared options debug = argparse.ArgumentParser(add_help=False) debug.add_argument('-V', '--version', action='version', version=VERSION) - debug.add_argument('-v', '--verbose', action='count', default=0, - help="enable verbose logging") + debug.add_argument( + '-v', '--verbose', action='count', default=0, help="enable verbose logging" + ) shared = {'formatter_class': HelpFormatter, 'parents': [debug]} parser = argparse.ArgumentParser(prog=GUI, description=__doc__, **shared) # Build main parser - parser.add_argument('-j', '--project', metavar="PATH", - help="path to the root of the project") + parser.add_argument( + '-j', '--project', metavar="PATH", help="path to the root of the project" + ) # Parse arguments args = parser.parse_args(args=args) @@ -98,33 +95,46 @@ def run(args, cwd, error): """ from doorstop import __project__, __version__ + # Exit if tkinter is not available if isinstance(tk, Mock) or isinstance(ttk, Mock): return error("tkinter is not available") - else: # pragma: no cover (manual test) + else: root = widget.Tk() root.title("{} ({})".format(__project__, __version__)) from sys import platform as _platform - # # Load the icon + # Load the icon if _platform in ("linux", "linux2"): - # linux + # Linux from doorstop.gui import resources - root.tk.call('wm', 'iconphoto', root._w, tk.PhotoImage(data=resources.b64_doorstopicon_png)) # pylint: disable=W0212 + + root.tk.call( + # pylint: disable=protected-access + 'wm', + 'iconphoto', + root._w, + tk.PhotoImage(data=resources.b64_doorstopicon_png), + ) elif _platform == "darwin": - # MAC OS X + # macOS pass # TODO elif _platform in ("win32", "win64"): # Windows from doorstop.gui import resources import base64 import tempfile + try: - with tempfile.TemporaryFile(mode='w+b', suffix=".ico", delete=False) as theTempIconFile: - theTempIconFile.write(base64.b64decode(resources.b64_doorstopicon_ico)) + with tempfile.TemporaryFile( + mode='w+b', suffix=".ico", delete=False + ) as theTempIconFile: + theTempIconFile.write( + base64.b64decode(resources.b64_doorstopicon_ico) + ) theTempIconFile.flush() root.iconbitmap(theTempIconFile.name) finally: @@ -133,7 +143,7 @@ def run(args, cwd, error): except Exception: # pylint: disable=W0703 pass - app = Application(root, cwd, args.project) + app = application.Application(root, cwd, args.project) root.update() root.minsize(root.winfo_width(), root.winfo_height()) @@ -142,612 +152,22 @@ def run(args, cwd, error): return True -def _log(func): # pragma: no cover (manual test) +def _log(func): """Log name and arguments.""" + @functools.wraps(func) def wrapped(self, *args, **kwargs): - sargs = "{}, {}".format(', '.join(repr(a) for a in args), - ', '.join("{}={}".format(k, repr(v)) - for k, v in kwargs.items())) + sargs = "{}, {}".format( + ', '.join(repr(a) for a in args), + ', '.join("{}={}".format(k, repr(v)) for k, v in kwargs.items()), + ) msg = "log: {}: {}".format(func.__name__, sargs.strip(", ")) if not isinstance(self, ttk.Frame) or not self.ignore: log.debug(msg.strip()) return func(self, *args, **kwargs) + return wrapped -class Application(ttk.Frame): # pragma: no cover (manual test), pylint: disable=R0901,R0902 - """Graphical application for Doorstop.""" - - def __init__(self, root, cwd, project): - ttk.Frame.__init__(self, root) - - # Create Doorstop variables - self.cwd = cwd - self.tree = None - self.document = None - self.item = None - - # Create string variables - self.stringvar_project = tk.StringVar(value=project or '') - self.stringvar_project.trace('w', self.display_tree) - self.stringvar_document = tk.StringVar() - self.stringvar_document.trace('w', self.display_document) - - # The stringvar_item holds the uid of the main selected item (or empty string if nothing is selected). - self.stringvar_item = tk.StringVar() - self.stringvar_item.trace('w', self.display_item) - - self.stringvar_text = tk.StringVar() - self.stringvar_text.trace('w', self.update_item) - self.intvar_active = tk.IntVar() - self.intvar_active.trace('w', self.update_item) - self.intvar_derived = tk.IntVar() - self.intvar_derived.trace('w', self.update_item) - self.intvar_normative = tk.IntVar() - self.intvar_normative.trace('w', self.update_item) - self.intvar_heading = tk.IntVar() - self.intvar_heading.trace('w', self.update_item) - self.stringvar_link = tk.StringVar() # no trace event - self.stringvar_ref = tk.StringVar() - self.stringvar_ref.trace('w', self.update_item) - self.stringvar_extendedkey = tk.StringVar() - self.stringvar_extendedkey.trace('w', self.display_extended) - self.stringvar_extendedvalue = tk.StringVar() - self.stringvar_extendedvalue.trace('w', self.update_item) - - # Create widget variables - self.combobox_documents = None - self.text_items = None - self.text_item = None - self.listbox_links = None - self.combobox_extended = None - self.text_extendedvalue = None - self.text_parents = None - self.text_children = None - - # Initialize the GUI - self.ignore = False # flag to ignore internal events - frame = self.init(root) - frame.pack(fill=tk.BOTH, expand=1) - - # Start the application - root.after(500, self.find) - - def init(self, root): - """Initialize and return the main frame.""" - # Shared arguments - width_text = 30 - width_uid = 10 - height_text = 10 - height_ext = 5 - - # Shared keyword arguments - kw_f = {'padding': 5} # constructor arguments for frames - kw_gp = {'padx': 2, 'pady': 2} # grid arguments for padded widgets - kw_gs = {'sticky': tk.NSEW} # grid arguments for sticky widgets - kw_gsp = dict(chain(kw_gs.items(), kw_gp.items())) # grid arguments for sticky padded widgets - - root.bind_all("", lambda arg: widget.adjustFontSize(-1)) - root.bind_all("", lambda arg: widget.adjustFontSize(1)) - root.bind_all("", lambda arg: widget.resetFontSize()) - - # Configure grid - frame = ttk.Frame(root, **kw_f) - frame.rowconfigure(0, weight=0) - frame.rowconfigure(1, weight=1) - frame.columnconfigure(0, weight=2) - frame.columnconfigure(1, weight=1) - frame.columnconfigure(2, weight=1) - frame.columnconfigure(3, weight=2) - - # Create widgets - def frame_project(root): - """Frame for the current project.""" - # Configure grid - frame = ttk.Frame(root, **kw_f) - frame.rowconfigure(0, weight=1) - frame.columnconfigure(0, weight=0) - frame.columnconfigure(1, weight=1) - - # Place widgets - widget.Label(frame, text="Project:").grid(row=0, column=0, **kw_gp) - widget.Entry(frame, textvariable=self.stringvar_project).grid(row=0, column=1, **kw_gsp) - - return frame - - def frame_tree(root): - """Frame for the current document.""" - # Configure grid - frame = ttk.Frame(root, **kw_f) - frame.rowconfigure(0, weight=1) - frame.columnconfigure(0, weight=0) - frame.columnconfigure(1, weight=1) - - # Place widgets - widget.Label(frame, text="Document:").grid(row=0, column=0, **kw_gp) - self.combobox_documents = widget.Combobox(frame, textvariable=self.stringvar_document, state="readonly") - self.combobox_documents.grid(row=0, column=1, **kw_gsp) - - return frame - - def frame_document(root): - """Frame for current document's outline and items.""" - # Configure grid - frame = ttk.Frame(root, **kw_f) - frame.rowconfigure(0, weight=0) - frame.rowconfigure(1, weight=5) - frame.rowconfigure(2, weight=0) - frame.rowconfigure(3, weight=0) - frame.columnconfigure(0, weight=0) - frame.columnconfigure(1, weight=0) - frame.columnconfigure(2, weight=0) - frame.columnconfigure(3, weight=0) - frame.columnconfigure(4, weight=1) - frame.columnconfigure(5, weight=1) - - @_log - def treeview_outline_treeviewselect(event): - """Handle selecting an item in the tree view.""" - if self.ignore: - return - thewidget = event.widget - curselection = thewidget.selection() - if curselection: - uid = curselection[0] - self.stringvar_item.set(uid) - - @_log - def treeview_outline_delete(event): # pylint: disable=W0613 - """Handle deleting an item in the tree view.""" - if self.ignore: - return - self.remove() - - # Place widgets - widget.Label(frame, text="Outline:").grid(row=0, column=0, columnspan=4, sticky=tk.W, **kw_gp) - widget.Label(frame, text="Items:").grid(row=0, column=4, columnspan=2, sticky=tk.W, **kw_gp) - c_columnId = ("Id",) - self.treeview_outline = widget.TreeView(frame, columns=c_columnId) # pylint: disable=W0201 - for col in c_columnId: - self.treeview_outline.heading(col, text=col) - - # Add a Vertical scrollbar to the Treeview Outline - treeview_outline_verticalScrollBar = widget.ScrollbarV(frame, command=self.treeview_outline.yview) - treeview_outline_verticalScrollBar.grid(row=1, column=0, columnspan=1, **kw_gs) - self.treeview_outline.configure(yscrollcommand=treeview_outline_verticalScrollBar.set) - - self.treeview_outline.bind("<>", treeview_outline_treeviewselect) - self.treeview_outline.bind("", treeview_outline_delete) - self.treeview_outline.grid(row=1, column=1, columnspan=3, **kw_gsp) - self.text_items = widget.noUserInput_init(widget.Text(frame, width=width_text, wrap=tk.WORD)) - self.text_items.grid(row=1, column=4, columnspan=2, **kw_gsp) - self.text_items_hyperlink = utilTkinter.HyperlinkManager(self.text_items) # pylint: disable=W0201 - widget.Button(frame, text="<", width=0, command=self.left).grid(row=2, column=0, sticky=tk.EW, padx=(2, 0)) - widget.Button(frame, text="v", width=0, command=self.down).grid(row=2, column=1, sticky=tk.EW) - widget.Button(frame, text="^", width=0, command=self.up).grid(row=2, column=2, sticky=tk.EW) - widget.Button(frame, text=">", width=0, command=self.right).grid(row=2, column=3, sticky=tk.EW, padx=(0, 2)) - widget.Button(frame, text="Add Item", command=self.add).grid(row=2, column=4, sticky=tk.W, **kw_gp) - widget.Button(frame, text="Remove Selected Item", command=self.remove).grid(row=2, column=5, sticky=tk.E, **kw_gp) - - return frame - - def frame_item(root): - """Frame for the currently selected item.""" - # Configure grid - frame = ttk.Frame(root, **kw_f) - frame.rowconfigure(0, weight=0) - frame.rowconfigure(1, weight=4) - frame.rowconfigure(2, weight=0) - frame.rowconfigure(3, weight=1) - frame.rowconfigure(4, weight=1) - frame.rowconfigure(5, weight=1) - frame.rowconfigure(6, weight=1) - frame.rowconfigure(7, weight=0) - frame.rowconfigure(8, weight=0) - frame.rowconfigure(9, weight=0) - frame.rowconfigure(10, weight=0) - frame.rowconfigure(11, weight=4) - frame.columnconfigure(0, weight=0, pad=kw_f['padding'] * 2) - frame.columnconfigure(1, weight=1) - frame.columnconfigure(2, weight=1) - - @_log - def text_focusin(_): - """Handle entering a text field.""" - self.ignore = True - - @_log - def text_item_focusout(event): - """Handle updated text text.""" - self.ignore = False - thewidget = event.widget - value = thewidget.get('1.0', tk.END) - self.stringvar_text.set(value) - - @_log - def text_extendedvalue_focusout(event): - """Handle updated extended attributes.""" - self.ignore = False - thewidget = event.widget - value = thewidget.get('1.0', tk.END) - self.stringvar_extendedvalue.set(value) - - # Place widgets - widget.Label(frame, text="Selected Item:").grid(row=0, column=0, columnspan=3, sticky=tk.W, **kw_gp) - self.text_item = widget.Text(frame, width=width_text, height=height_text, wrap=tk.WORD) - self.text_item.bind('', text_focusin) - self.text_item.bind('', text_item_focusout) - self.text_item.grid(row=1, column=0, columnspan=3, **kw_gsp) - widget.Label(frame, text="Properties:").grid(row=2, column=0, sticky=tk.W, **kw_gp) - widget.Label(frame, text="Links:").grid(row=2, column=1, columnspan=2, sticky=tk.W, **kw_gp) - widget.Checkbutton(frame, text="Active", variable=self.intvar_active).grid(row=3, column=0, sticky=tk.W, **kw_gp) - self.listbox_links = widget.Listbox(frame, width=width_uid, height=6) - self.listbox_links.grid(row=3, column=1, rowspan=4, **kw_gsp) - widget.Entry(frame, width=width_uid, textvariable=self.stringvar_link).grid(row=3, column=2, sticky=tk.EW + tk.N, **kw_gp) - widget.Checkbutton(frame, text="Derived", variable=self.intvar_derived).grid(row=4, column=0, sticky=tk.W, **kw_gp) - widget.Button(frame, text="<< Link Item", command=self.link).grid(row=4, column=2, **kw_gp) - widget.Checkbutton(frame, text="Normative", variable=self.intvar_normative).grid(row=5, column=0, sticky=tk.W, **kw_gp) - widget.Checkbutton(frame, text="Heading", variable=self.intvar_heading).grid(row=6, column=0, sticky=tk.W, **kw_gp) - widget.Button(frame, text=">> Unlink Item", command=self.unlink).grid(row=6, column=2, **kw_gp) - widget.Label(frame, text="External Reference:").grid(row=7, column=0, columnspan=3, sticky=tk.W, **kw_gp) - widget.Entry(frame, width=width_text, textvariable=self.stringvar_ref).grid(row=8, column=0, columnspan=3, **kw_gsp) - widget.Label(frame, text="Extended Attributes:").grid(row=9, column=0, columnspan=3, sticky=tk.W, **kw_gp) - self.combobox_extended = widget.Combobox(frame, textvariable=self.stringvar_extendedkey) - self.combobox_extended.grid(row=10, column=0, columnspan=3, **kw_gsp) - self.text_extendedvalue = widget.Text(frame, width=width_text, height=height_ext, wrap=tk.WORD) - self.text_extendedvalue.bind('', text_focusin) - self.text_extendedvalue.bind('', text_extendedvalue_focusout) - self.text_extendedvalue.grid(row=11, column=0, columnspan=3, **kw_gsp) - - return frame - - def frame_family(root): - """Frame for the parent and child document items.""" - # Configure grid - frame = ttk.Frame(root, **kw_f) - frame.rowconfigure(0, weight=0) - frame.rowconfigure(1, weight=1) - frame.rowconfigure(2, weight=0) - frame.rowconfigure(3, weight=1) - frame.columnconfigure(0, weight=1) - - # Place widgets - widget.Label(frame, text="Linked To:").grid(row=0, column=0, sticky=tk.W, **kw_gp) - self.text_parents = widget.noUserInput_init(widget.Text(frame, width=width_text, wrap=tk.WORD)) - self.text_parents_hyperlink = utilTkinter.HyperlinkManager(self.text_parents) # pylint: disable=W0201 - self.text_parents.grid(row=1, column=0, **kw_gsp) - widget.Label(frame, text="Linked From:").grid(row=2, column=0, sticky=tk.W, **kw_gp) - self.text_children = widget.noUserInput_init(widget.Text(frame, width=width_text, wrap=tk.WORD)) - self.text_children_hyperlink = utilTkinter.HyperlinkManager(self.text_children) # pylint: disable=W0201 - self.text_children.grid(row=3, column=0, **kw_gsp) - - return frame - - # Place widgets - frame_project(frame).grid(row=0, column=0, columnspan=2, **kw_gs) - frame_tree(frame).grid(row=0, column=2, columnspan=2, **kw_gs) - frame_document(frame).grid(row=1, column=0, **kw_gs) - frame_item(frame).grid(row=1, column=1, columnspan=2, **kw_gs) - frame_family(frame).grid(row=1, column=3, **kw_gs) - - return frame - - @_log - def find(self): - """Find the root of the project.""" - if not self.stringvar_project.get(): - try: - path = vcs.find_root(self.cwd) - except DoorstopError as exc: - log.error(exc) - else: - self.stringvar_project.set(path) - - @_log - def browse(self): - """Browse for the root of a project.""" - path = filedialog.askdirectory() - log.debug("path: {}".format(path)) - if path: - self.stringvar_project.set(path) - - @_log - def display_tree(self, *_): - """Display the currently selected tree.""" - # Set the current tree - self.tree = builder.build(root=self.stringvar_project.get()) - log.info("displaying tree...") - - # Display the documents in the tree - values = ["{} ({})".format(document.prefix, document.relpath) - for document in self.tree] - self.combobox_documents['values'] = values - - # Select the first document - if len(self.tree): # pylint: disable=len-as-condition - self.combobox_documents.current(0) - else: - logging.warning("no documents to display") - - @_log - def display_document(self, *_): - """Display the currently selected document.""" - # Set the current document - index = self.combobox_documents.current() - self.document = list(self.tree)[index] - log.info("displaying document {}...".format(self.document)) - - # Record the currently opened items. - c_openItem = [] - for c_currUID in utilTkinter.getAllChildren(self.treeview_outline): - if self.treeview_outline.item(c_currUID)["open"]: - c_openItem.append(c_currUID) - - # Record the currently selected items. - c_selectedItem = self.treeview_outline.selection() - - # Clear the widgets - self.treeview_outline.delete(*self.treeview_outline.get_children()) - widget.noUserInput_delete(self.text_items, '1.0', tk.END) - self.text_items_hyperlink.reset() - - # Display the items in the document - c_levelsItem = [""] - for item in self.document.items: - theParent = next(iter(reversed([x for x in c_levelsItem[:item.depth]])), "") - - while len(c_levelsItem) < item.depth: - c_levelsItem.append(item.uid) - c_levelsItem = c_levelsItem[:item.depth] - for x in range(item.depth): - c_levelsItem.append(item.uid) - - # Add the item to the document outline - self.treeview_outline.insert(theParent, tk.END, item.uid, text=item.level, values=(item.uid,), open=item.uid in c_openItem) - - # Add the item to the document text - widget.noUserInput_insert(self.text_items, tk.END, "{t}".format(t=item.text or item.ref or '???')) - widget.noUserInput_insert(self.text_items, tk.END, " [") - widget.noUserInput_insert(self.text_items, tk.END, item.uid, self.text_items_hyperlink.add(lambda c_theURL: self.followlink(c_theURL), item.uid, ["refLink"])) # pylint: disable=W0108 - widget.noUserInput_insert(self.text_items, tk.END, "]\n\n") - - # Set tree view selection - c_selectedItem = [x for x in c_selectedItem if x in utilTkinter.getAllChildren(self.treeview_outline)] - if c_selectedItem: - # Restore selection - self.treeview_outline.selection_set(c_selectedItem) - else: - # Select the first item - for uid in utilTkinter.getAllChildren(self.treeview_outline): - self.stringvar_item.set(uid) - break - else: - logging.warning("no items to display") - self.stringvar_item.set("") - - @_log - def display_item(self, *_): - """Display the currently selected item.""" - try: - self.ignore = True - - # Fetch the current item - uid = self.stringvar_item.get() - if uid == "": - self.item = None - else: - try: - self.item = self.tree.find_item(uid) - except DoorstopError: - pass - log.info("displaying item {}...".format(self.item)) - - if uid != "": - if uid not in self.treeview_outline.selection(): - self.treeview_outline.selection_set((uid, )) - self.treeview_outline.see(uid) - - # Display the item's text - self.text_item.replace('1.0', tk.END, "" if self.item is None else self.item.text) - - # Display the item's properties - self.stringvar_text.set("" if self.item is None else self.item.text) - self.intvar_active.set(False if self.item is None else self.item.active) - self.intvar_derived.set(False if self.item is None else self.item.derived) - self.intvar_normative.set(False if self.item is None else self.item.normative) - self.intvar_heading.set(False if self.item is None else self.item.heading) - - # Display the item's links - self.listbox_links.delete(0, tk.END) - if self.item is not None: - for uid in self.item.links: - self.listbox_links.insert(tk.END, uid) - self.stringvar_link.set('') - - # Display the item's external reference - self.stringvar_ref.set("" if self.item is None else self.item.ref) - - # Display the item's extended attributes - values = None if self.item is None else self.item.extended - self.combobox_extended['values'] = values or [''] - if self.item is not None: - self.combobox_extended.current(0) - - # Display the items this item links to - widget.noUserInput_delete(self.text_parents, '1.0', tk.END) - self.text_parents_hyperlink.reset() - if self.item is not None: - for uid in self.item.links: - try: - item = self.tree.find_item(uid) - except DoorstopError: - text = "???" - else: - text = item.text or item.ref or '???' - uid = item.uid - - widget.noUserInput_insert(self.text_parents, tk.END, "{t}".format(t=text)) - widget.noUserInput_insert(self.text_parents, tk.END, " [") - widget.noUserInput_insert(self.text_parents, tk.END, uid, self.text_parents_hyperlink.add(lambda c_theURL: self.followlink(c_theURL), uid, ["refLink"])) # pylint: disable=W0108 - widget.noUserInput_insert(self.text_parents, tk.END, "]\n\n") - - # Display the items this item has links from - widget.noUserInput_delete(self.text_children, '1.0', 'end') - self.text_children_hyperlink.reset() - if self.item is not None: - for uid in self.item.find_child_links(): - item = self.tree.find_item(uid) - text = item.text or item.ref or '???' - uid = item.uid - - widget.noUserInput_insert(self.text_children, tk.END, "{t}".format(t=text)) - widget.noUserInput_insert(self.text_children, tk.END, " [") - widget.noUserInput_insert(self.text_children, tk.END, uid, self.text_children_hyperlink.add(lambda c_theURL: self.followlink(c_theURL), uid, ["refLink"])) # pylint: disable=W0108 - widget.noUserInput_insert(self.text_children, tk.END, "]\n\n") - finally: - self.ignore = False - - @_log - def display_extended(self, *_): - """Display the currently selected extended attribute.""" - try: - self.ignore = True - - name = self.stringvar_extendedkey.get() - log.debug("displaying extended attribute '{}'...".format(name)) - self.text_extendedvalue.replace('1.0', tk.END, self.item.get(name, "")) - finally: - self.ignore = False - - @_log - def update_item(self, *_): - """Update the current item from the fields.""" - if self.ignore: - return - if not self.item: - logging.warning("no item selected") - return - - # Update the current item - log.info("updating {}...".format(self.item)) - self.item.auto = False - self.item.text = self.stringvar_text.get() - self.item.active = self.intvar_active.get() - self.item.derived = self.intvar_derived.get() - self.item.normative = self.intvar_normative.get() - self.item.heading = self.intvar_heading.get() - self.item.links = self.listbox_links.get(0, tk.END) - self.item.ref = self.stringvar_ref.get() - name = self.stringvar_extendedkey.get() - if name: - self.item.set(name, self.stringvar_extendedvalue.get()) - self.item.save() - - # Re-select this item - self.display_document() - - @_log - def left(self): - """Dedent the current item's level.""" - self.item.level <<= 1 - self.document.reorder(keep=self.item) - self.display_document() - - @_log - def down(self): - """Increment the current item's level.""" - self.item.level += 1 - self.document.reorder(keep=self.item) - self.display_document() - - @_log - def up(self): - """Decrement the current item's level.""" - self.item.level -= 1 - self.document.reorder(keep=self.item) - self.display_document() - - @_log - def right(self): - """Indent the current item's level.""" - self.item.level >>= 1 - self.document.reorder(keep=self.item) - self.display_document() - - @_log - def add(self): - """Add a new item to the document.""" - logging.info("adding item to {}...".format(self.document)) - if self.item: - level = self.item.level + 1 - else: - level = None - item = self.document.add_item(level=level) - logging.info("added item: {}".format(item)) - # Refresh the document view - self.display_document() - # Set the new selection - self.stringvar_item.set(item.uid) - - @_log - def remove(self): - """Remove the selected item from the document.""" - newSelection = "" - for c_currUID in self.treeview_outline.selection(): - # Find the item which should be selected once the current selection is removed. - for currNeighbourStrategy in (self.treeview_outline.next, self.treeview_outline.prev, self.treeview_outline.parent): - newSelection = currNeighbourStrategy(c_currUID) - if newSelection != "": - break - # Remove the item - item = self.tree.find_item(c_currUID) - logging.info("removing item {}...".format(item)) - item = self.tree.remove_item(item) - logging.info("removed item: {}".format(item)) - # Set the new selection - self.stringvar_item.set(newSelection) - # Refresh the document view - self.display_document() - - @_log - def link(self): - """Add the specified link to the current item.""" - # Add the specified link to the list - uid = self.stringvar_link.get() - if uid: - self.listbox_links.insert(tk.END, uid) - self.stringvar_link.set('') - - # Update the current item - self.update_item() - - @_log - def unlink(self): - """Remove the currently selected link from the current item.""" - # Remove the selected link from the list - index = self.listbox_links.curselection() - self.listbox_links.delete(index) - - # Update the current item - self.update_item() - - @_log - def followlink(self, uid): - """Display a given uid.""" - # Update the current item - self.ignore = False - self.update_item() - - # Load the good document. - document = self.tree.find_document(uid.prefix) - index = list(self.tree).index(document) - self.combobox_documents.current(index) - self.display_document() - - # load the good Item - self.stringvar_item.set(uid) - - -if __name__ == '__main__': # pragma: no cover (manual test) +if __name__ == '__main__': sys.exit(main()) diff --git a/doorstop/gui/resources.py b/doorstop/gui/resources.py index 1473639b7..8635cf516 100644 --- a/doorstop/gui/resources.py +++ b/doorstop/gui/resources.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +# SPDX-License-Identifier: LGPL-3.0-only # This is the doorstop icon in ICO format, base64 encoded b64_doorstopicon_ico = "AAABAAEAAAAAAAEAIABsFQAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAEAAAABAAgEAAAA9ntg7QAAFTNJREFUeNrtnWd8VHXWgJ8U0ikCkSJVkGpAZRGQFmlCCIqAvCBCAMHKu/AuqCvssrAUXVHUjWIHpEkTWIEFVBBBQEQ6hFRCAgkJLb2XeT/MdZi5k7mTOiU5z/mSD5lkfvec+7+nXxAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEoWy4ySWoVO5lFJ1JIlsuRc3Dn6kcJYtMfmYqjeSC1Kw7fwo/koVOkWwO8BJN5cLUBOozkQPkGpT/h+RxlJdpLheoOtOAcXxPppny7xrBMWaIEVRPavMMu8m2qPw/JJ/jzBQjqF7UYyS7SDVScwFRJGucBL8zi/vlwlUHfAlmG+lG6i3iLK/RmcGs4oZFIyjkFHNoJRfQmfFjGFu5o1L+G4Z724f+rOK6xuPgNHNoJxfSGfFhEBu4baTOYiKZZ6ZOLx5nJTctGkERZ3iTNrjIJXUevBjAWm6ZKDKSt3kQVwu/P5g1JGk8Di4yjw5yYZ0Bb/qzSqXMyyyliwXl3/3c46zUcAyLOM982ks63pGpRT++UDl2cSznoVKqzZNAVmo4hkVEsICO8jhwRDzoxackmCgsnlC6U6tMf8eHQLMTxNQILrGEB3GXS+44uNGDUJXyr7OCXuVUk/4kuKmRLopmiUWPQrAp7nTjfa6YqCeRVfTFs0J/11qIWEQ0b5f64SJU0Z3fhXe5bKKY23zNgAoq/+5J8LimT6Ajmnd5WB4H9sCVAJYQY6KOO2xkMD6V+n+8rZwEOq7wHn8SI7Ct8juwiAgTNaSyjWBqV8n/87ISIuq4zL/phoeoxhbcz1zCKTa6/Jns4KkqUv5dIwi0YgRX+ZBeZYw5hDLSlrmcodDosmewh2eob5P/7m0lRNQRzyf0xEsUVRW04jXOUmR0uXP4ngncY9NvYT1ETOQTestJULm0ZjanKTC6zNn8xETutcu38bHqGCYQymNyElQOTZnJCZNjP58jTLeT8u+eBHrHsMiiEVznCwLxlsRxRWjMy/xGnkll7iT/RzOH+HaePEWYZoNZMv+gjqixfNzLC/xk0r9bwGn+4jCtWo0IYYdJu4m6iBzBW/SQ4LB8F3cqv5gov4jTvEYLh/h2LrRjJkc1WkyLOM082kmiuDz4M4kDJhe3iIu8yQMO8e08CGAR5018ElPJ5giv0lae/eXhHp5lv+rOCmcB7R3icnoRSChXTJJQppLJXiabuKf3SDRQWhrwDHtM+nd1RLPIQcqu9Qlig2bod5NtPE1Do880ZzoHOCEN5tapzSh2qWZ24ljGQw6hfH9C2KUyTbW3v5qh+Bo+4UI7XuM4+ehIoo8oWIu6jGC7ydiGjjj+TTcHyKa50IZXOWI0SFpSIWgFvY1qkB50Yz4XDWmrNMaKki3hyzC2kKZKpX5EDwcor7rTmYWq/KPa149kKd2MfH0fAllBnCocnCWKLgkfhrFRFUdf50t6VVI7R0Xwog/LuayR5yvgJPNMuoTv4Sm2lNg+8o4oW4232diGjht8TaADeMx1CGKdqsNQHegdZQatjT7ThOfYbTjJihX54/fXVHKTipPjyUBWq8Y27rCBAQ6g/EY8yw5SNJSfyV6mmGwUacssjimha3EJouMHO9ctHAgPerNSFUylspVh+Nn9u7XgZQ5punup7GC0UaDnTicWcIYCC6r/wwDOSyAIUIvHzJq3M9jOmCru5bGOK+2YowRtluQGa3jaqLDjSR+WEakc9MWaBnCLLnLnP8rHXFUpfycjqWfnb+ZFD94jTMPX1xHLRyaBXj2GsIZEk8FTy+rXkcegmqx8Nx7hA5Xyc9jDeLsr34+hfMU1DdUXE8VbJhmJhkxgmyprYf4pUzewkKk1V/kPsVTVuZ/NjzxnkjK1B/6MYYcqDlGXc8+xiE5GGYmWvMghjR1DaiO4+/OCmqn8B/kX0arlK4d53u4+cTNe5gdNRebxO7NpZyhBufMAb/CbppegJatq3hhZZxYRbVI3y+cIL9HYrt/KhQ68zkmTHiPzKP8AL3Gf4TO1eJR3iNb0EqzJjzWpSdSV9izkokr5J5lh50YuL7rxHhc1sns6UtnNeCMjrU1/PlOldssjp2pOJqAd81TKL+IMs+28eM2XQWbD42q5xUaepK7hM/UYzRaNlq+ySDQB1V/1LrTlTc6Z9MoUcoH5tLXr9/JnFN9qzvToSGAlA4zyEfcxiX2qIlVFJJnB1V39LXmNcybHazHh/M3O65WaMo19mtk9HbF8QD9D66YLbZjN0RKWyVZEcniueit/JidVHnI4i+hoR9/XlfbM4QQ5moHeeRbRxeCgudOVhVwot6+vJXOqq/KbMIPfVB7yZZbQxY53vgcPsYQLmu5eHidNdgB605ePVOPmlSnLq+MA+X28yHHVYXmZ9+26N8OHQD4hXqNxU0c2B3mJ5gYT9WMo6614CRWVzXaveFQyjZjKIVU8fY0PedSOyq/PSL6xosg09jDBUM715H6eY5vmsGflyC/VKRD0ZzI/q+7863xGDzumOxozme/J0FTCbTYx0hDoeTGIFcSXYoN4ZciV6rJ32J/xJm/b0KEjiZX0s1s7hxttmcNRK75+Al8y0NB1UJ9gtmpu/6lsSeMR51d+XcaxV+VX32EtgXbr4nOjCwsJ05jT+SPQ62kI9BoxlT1WzKXyJYcnnVv59zCK3are+NtsYojdenl86ccKYjTdvSIusZiuBuW3ZSbHNUPDqpICZjiv8v0YyQ5V9SyNrYwwGoWwLbUZzlor7l4B5/gbnZRchDsBLLYSGlatOGlvcG1GmLVJpvMfnrTb3Pu9PMteKzn6XI4wi9ZKoOdJX0JVyyRtLxudT/m+BLFZpfxM9jLWqGRiS1xpyQz2W3l+Z/IjUw3l3DqMYL3mYidbyQHnygR4M4RvVPdZDvtsvpTprvK78g/Oa1bydaSwg2dooHzGn/HsqcSSTkVLwq2dRfk+DGCtKkjK5hCT8bfT9+nBB4YOXEuSxHqCDGdTS/7MIRvF+KWTWLo7g/Jr0d9sDVoBh5luJ+X7EcQaq2naq3xKfyUT4UIn5mqucLCPpDLM0ZXvRQ8+Nml31pHLr7xsp16eJozjO9UMkXmgF8EyuiuZCA/+xAdEWDkr7CWTHFn57vTgY1XPTCGnmGmnd+o251V+thKxF3KOvxsCPW8G8olmk7e95U3HVX53QolXXdwzzLaL2+JGF+ZxykpLRg7HmGVoN7mX0Wy3aWq3PLLCAeaeS7jcD7GcWNWxGsabtAG8GM67DLdZAOPJoywn0sqFzOYA0wwnUxNe4Ae7ZPfKKjsdLRB0pQv/UilfRzgLaQd4MlTJAqSyk7FVvpC5Dk+w0uoRnsIuQhSH1IWOvM7pSm7fqjo5Y/dxGJM7vyNvqXbuFxPLMgJwwYtBquUN2eyrwt28/oxhm+acjg4dN1hHsJKB9KIrywhzUHevZEl0lEyAC51YaBZXx7KMzrgCDfmwxOm3XI7wYqWPdrTkJX6yWp3TB3reyoOiP5+rohVnkDuOsC7KlXbMN7tz4vg3D+NFG1qgf+3qagsuVR6/Gb19t2K404E3OGElu1dEFMt4VHGgGjKCzZrr2hxXshhvb/W3YS5hqopYIh/RDXc6sJRYzig7Od0J5BMLd1kx55lPQIWav2rRnXeIslKdKyKMBYZArxHT+MGhsntlkwJet6/y53BW1b+byJf0wotOLDb43voaegD6V7J9oJrtvSuX+ZCe5Qps6vI4n1sduMrjd/5KO1zRN3nPslMlvzIl1F7Kb85fOKW6226zikA8aM1iosy+agzLeRgXoCvvWFRWEl+VsR+oHmNKMXCVy2FeVVZDu9OFpYTbsZJfebLFHrvDWzKDE6phh9usIxBvwJWJ/G7Bl75GKP3wwIWOLCbcom++vpQ7flowhR+sVufS2MtExdX0oT+hxDmVr68lP5msl7IBjXmFo6pjP5WNPEFDWiuFXRdaMZtzFlyxW6wlEF+gI3MJt1BiSWMHozUKxW604XUrW3j0fvJWRikZhzoE842VSoCzyXna2075TZjOEVWSJJ1vCaIhfVnJVfbzvKFxoikvccjCDHwK3/E/1ANaMZeTFg7jLA4wmSYlunv/5JLV+fpEVjNYOUmaMNGBKvmVJ9fpaRvl+zOFg2bK381ImvAYXxq8+xyO8L+G0e3GTLZYQ89iP8/TGP0k4BkL6szjV0KMnnO+9CpFM1YxsYTSRynntmKmmeFWBymmmDyCql75DZjEfpW/nMUexuFPbz4zq63nc5LXeEApq9RnMnssXP58jjODxkBT5qhe5PTHo2Abwcp4SD1Gsd7qzE0xYSylK+6ACwH8nQsV2sXh6DKtqpU/jn1m22+u8TL3AM352sLsTAHn+RsBiuoa8Az7LGzCyucUr9MWaMY0fjE6L67yKf2UckdjJlr8C8b/9Sxv0FHJJ9zHEtVSmeoo/6zKiWk3ni8xdXOH1QzHD6jPs3xvIe2qrwQ8ohiBH0+z3cIipWLCWUwnXKhLCPvJ5Rof0F1RZBtmcdBqxJ7HCWbTRvnm9/EC+6rhsW8uq6syEHShAcPZUqLnfIedjKE+0ICn2WbBuy4mjq8MI1R1ecLCX9Ohn7d5DE/uZajyCvVaPMyCUszXp7GPEKXLyI3O/JXfa4Tydeg4VPWzFL4EmzVz6yWD3UyiPuBDsFnP7125yUaeMlTdgths4TAvZI/hHvaiLx+blZZLiii2MEYJGD0IYInF/EJ1klzSSeQYa3jFNqkgP4LYXGJ5NYvDTKcp4MMQ1lnsmE9lJ2OU+rUvg1mr+s1s9vMqzXED6jGcdaWYr0/ka4YoXoIfg/icK9Uiv2f5MRfHb2znPabTn9Y0xKeq12Z0YYRhAasfw9lc4l2eyxH+TAv0L0D9yqIRZPC9IbL3oB8rFA8jnT2GBpFGTGS31dRuMfGE0lcJ9Oozki02mMm3h+Rwm3iOs4Z5jOZRWti2DWwUN9jFOEP3ji9BbCrxcZDPOcWb96AnKyx24uRyhBlK+bcW3QllIyOVu7gNM0ziAEvKv8TbdFWcy2ZMYY+VOX7nkwISOcZm3uUFBtCCunjZZ0XOUFLQkc5exhv6+OtYfBwUco6FPIgrtehh1hlobCwnmUdHPAF3vNGPWi7krFV3L5+LLFLaTFxpw2yOOX1F7+6tkUwUh/mUvzCCbrRQ2lXsSl9DGJjFD0w0DEnpT4LUEu/PGN7hIcCFh1luse5XxF46GR4G71sZy9bfFceZo5wdbgSwgAsON6xRVikinxv8yib+xYsMoCU+jrX86RGTPVcZ/MRkQwtXHYZZCOqKiSGUXnjiziO8S7hKufmcUabs6/IE60rRjJXJT0xXyrm+9OFjYpzY3SsgiUsc5AtmM5wuNHPcMc8OXDA7qA4wzVCE9GW4hRBR/777vtQCOjLfsFM3n+PMpAVQnwlm7/crSdLZybOG+GEQqxy+X19X4uMxh5ucYCOLmcYA2la9B18ZNOdEiZ7pYaYbxrtqW/QJdFznK6Ui14a5nOSwsje7JVPZX4pN+clsIliJ8hszgd1OVs4t4gYX+JGVzOFJOtOE2s71EugGHLbojv3CK4ZirZ/GSZDCFkZQB2hFI9zowHyzhpKSJIEvGKi8WKUZr3DQSbr3isjkJmfZxGJCeJz2dtt7YhXrtujFdxprifM5y1r+Q7xiBP0IYXCJbRzpHGY1B2nBOEbTymoBI5pdbOAs+bgQQBBj6WyP5qcykUY8CSRzmQgiSCCbXIoc+yuX5jCawCw6awQkBZxkDbu4qjwO+hHCoBKNII0wGlltAC8kki1s5wJFeBLAeEbQ1kGPzWIyyOQWUURwiWskkUyqMx3wLqX6nUaMYDyPaWSgCrjAOnZwGQBf+hPCE+VY+1LAabayjRjAh348TbCdZom11Z5FPPHEcYUIokgkh3yKnUnxZTEAPY15gsl013iaFXKBDewgGh1Qhz5MYUAZ5v8yOcV69hEHNKQfE+njSJNvpJJKCvGcJ5xYbpBCinMqvXwGANCQICYaGq1KvjfC2MBmLqMDfAlkGgNLEeNmcpBN7OE20IQgxtLHAd6Oq6OQPBKJ4TKxRBNHHBkUoXN2tZfXAAD8Gchkems0bRcRzia2Eo4OqEcgIfQ1ZBDNucFRNvAjKUB7RjCWB+2cAk3nFrdIIIxIIrhBOukUVB+lV8wAAOozhOeU1m5L984lvmUDkRQDvjzOJAaX8FrHJHaxiSPk4E4nxjLSbq+EyCePW0QQQyRxXOUatx3dg7efAeiNYACT6K+x3FFHFBv5losUoQ8RJzPQyCeIYTfrOUse3vRkLMNpZnNfP4frJHOdK0QSQSxpZJNf/dVeGQYAUJfBPE8vTW8/ko18QzSFgB/9CWEIfsSwke2co5i69GYCA20435JNNmnEEkEYcVwniRsU1iSlV54B6I1AH/dbNoJirrCFjZxXToIh3M9/CQMaMYRx9LbJltB8krhKHHGEE0k86eRV1+e6bQ1AbwS9mcwADUcPYtjJOi6QB4ArLRjOOLpVqbuXTTrpJHCRcGJI4iY3a9YBb0t8CGKLldGrBGYqvz2UM1VUyS8in5ucYDNLmUgfmuJlx1fO1JAT4O5J0JNJDNLY+/NfRpEHzOL9Sv3PudwhhWQuEUE4SaSRSrqo1zqV23uSxj5+pjcTCbbwOPDChzwgjbwKtzYWUkA6MVwmiljiiCOZvOqUpHE+A9Dfi/s5xkpCGFbCXK8PPqQAGeU2AB23ucF1orhEBFdIIYssUaTjGABANoc4Tg8m8KRqA5i3kuDNKJMzVkAuWVwlgkvEc50EEuWAd2QDAMjjEMf5mikEGxmBj+L1ZyjRgBbF3CaBa1wlgmgiuU0u2aIyZzEAvREc5RRfM44nlY0BPsoJkGnhBCgkgwxuEkYEUSSQTBKZoiZnNQCAXH7hV9byLE/THD/FANJNDKCYdK4QTzwxRBFJMrkUiDtXPQxAf18f5xRrGU+wUkrOJJcsbnGLcOXJfos7pInSbY1tiy9u/IlkrgAejKaAGOJIr171dUEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBKHa8f8/04iolNoFlwAAAABJRU5ErkJggg==" diff --git a/doorstop/gui/tests/__init__.py b/doorstop/gui/tests/__init__.py index 6e9982162..7d0ed3aa0 100644 --- a/doorstop/gui/tests/__init__.py +++ b/doorstop/gui/tests/__init__.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Package for the doorstop.gui tests.""" ENV = 'TEST_INTEGRATION' # environment variable to enable integration tests diff --git a/doorstop/gui/tests/test_all.py b/doorstop/gui/tests/test_all.py index cd088d6bc..6349175aa 100644 --- a/doorstop/gui/tests/test_all.py +++ b/doorstop/gui/tests/test_all.py @@ -1,13 +1,14 @@ -"""Integration tests for the doorstop.cli package.""" +# SPDX-License-Identifier: LGPL-3.0-only -import unittest -from unittest.mock import patch, Mock +"""Integration tests for the doorstop.cli package.""" -import sys import imp +import sys +import unittest +from unittest.mock import Mock, patch -from doorstop.gui.main import main from doorstop.gui import main as gui +from doorstop.gui.main import main class TestMain(unittest.TestCase): diff --git a/doorstop/gui/utilTkinter.py b/doorstop/gui/utilTkinter.py index 959ea2f96..e4c0b9c88 100644 --- a/doorstop/gui/utilTkinter.py +++ b/doorstop/gui/utilTkinter.py @@ -1,10 +1,12 @@ #!/usr/bin/env python +# SPDX-License-Identifier: LGPL-3.0-only import sys from unittest.mock import Mock -try: # pragma: no cover (manual test) + +try: import tkinter as tk -except ImportError as _exc: # pragma: no cover (manual test) +except ImportError as _exc: sys.stderr.write("WARNING: {}\n".format(_exc)) tk = Mock() ttk = Mock() diff --git a/doorstop/gui/widget.py b/doorstop/gui/widget.py index aad72fc53..b5074fe37 100644 --- a/doorstop/gui/widget.py +++ b/doorstop/gui/widget.py @@ -1,14 +1,16 @@ #!/usr/bin/env python +# SPDX-License-Identifier: LGPL-3.0-only """Graphical Widget creator and controller for doorstop.""" import sys from unittest.mock import Mock -try: # pragma: no cover (manual test) + +try: import tkinter as tk from tkinter import ttk from tkinter import font -except ImportError as _exc: # pragma: no cover (manual test) +except ImportError as _exc: sys.stderr.write("WARNING: {}\n".format(_exc)) tk = Mock() ttk = Mock() @@ -41,7 +43,7 @@ # # Widget -class _Listbox2(tk.Listbox): # pragma: no cover (manual test), pylint: disable=R0901 +class _Listbox2(tk.Listbox): """Listbox class with automatic width adjustment.""" def autowidth(self, maxwidth=250): @@ -108,12 +110,16 @@ def TreeView(parent, *args, **kwargs): def ScrollbarH(parent, *args, **kwargs): - result = ttk.Scrollbar(parent, *args, orient="horizontal", style="ds.Horizontal.TScrollbar", **kwargs) + result = ttk.Scrollbar( + parent, *args, orient="horizontal", style="ds.Horizontal.TScrollbar", **kwargs + ) return result def ScrollbarV(parent, *args, **kwargs): - result = ttk.Scrollbar(parent, *args, orient="vertical", style="ds.Vertical.TScrollbar", **kwargs) + result = ttk.Scrollbar( + parent, *args, orient="vertical", style="ds.Vertical.TScrollbar", **kwargs + ) return result @@ -181,7 +187,9 @@ def Tk(): # Style for Progressbar global styleDsHorizontalTProgressbar styleDsHorizontalTProgressbar = ttk.Style() - styleDsHorizontalTProgressbar.configure('ds.Horizontal.TProgressbar', font=fontNormal) + styleDsHorizontalTProgressbar.configure( + 'ds.Horizontal.TProgressbar', font=fontNormal + ) global styleDsVerticalTProgressbar styleDsVerticalTProgressbar = ttk.Style() styleDsVerticalTProgressbar.configure('ds.Vertical.TProgressbar', font=fontNormal) @@ -230,6 +238,7 @@ def Tk(): result.option_add('*TCombobox*Listbox.font', fontNormal) return result + # Manage font size. diff --git a/doorstop/server/__init__.py b/doorstop/server/__init__.py index bbb8c1e21..46c1c3f06 100644 --- a/doorstop/server/__init__.py +++ b/doorstop/server/__init__.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Web interface for Doorstop.""" from .client import check, get_next_number diff --git a/doorstop/server/client.py b/doorstop/server/client.py index acfae3cd3..43d176272 100644 --- a/doorstop/server/client.py +++ b/doorstop/server/client.py @@ -1,13 +1,13 @@ #!/usr/bin/env python +# SPDX-License-Identifier: LGPL-3.0-only """REST client to request item numbers.""" import requests -from doorstop import common +from doorstop import common, settings from doorstop.common import DoorstopError from doorstop.server import utilities -from doorstop import settings log = common.logger(__name__) @@ -59,8 +59,9 @@ def get_next_number(prefix): return number -if __name__ == '__main__': # pragma: no cover (manual test) +if __name__ == '__main__': import sys + if len(sys.argv) != 2: exit("Usage: {} ".format(sys.argv[0])) print(get_next_number(sys.argv[-1])) diff --git a/doorstop/server/main.py b/doorstop/server/main.py index 576bc14bf..b422bd02b 100644 --- a/doorstop/server/main.py +++ b/doorstop/server/main.py @@ -1,21 +1,21 @@ #!/usr/bin/env python +# SPDX-License-Identifier: LGPL-3.0-only """REST server to display content and reserve item numbers.""" -import os -from collections import defaultdict -import webbrowser import argparse import logging +import os +import webbrowser +from collections import defaultdict import bottle -from bottle import get, post, request, hook, response, template +from bottle import get, hook, post, request, response, template -from doorstop import common, build, publisher +from doorstop import build, common, publisher, settings from doorstop.common import HelpFormatter -from doorstop.server import utilities -from doorstop import settings from doorstop.core import vcs +from doorstop.server import utilities log = common.logger(__name__) @@ -36,21 +36,32 @@ def main(args=None): shared = {'formatter_class': HelpFormatter, 'parents': [debug]} # Build main parser - parser = argparse.ArgumentParser(prog=SERVER, description=__doc__, - **shared) + parser = argparse.ArgumentParser(prog=SERVER, description=__doc__, **shared) cwd = os.getcwd() - parser.add_argument('-j', '--project', default=None, - help="path to the root of the project") - parser.add_argument('-P', '--port', metavar='NUM', type=int, - default=settings.SERVER_PORT, - help="use a custom port for the server") - parser.add_argument('-H', '--host', default='127.0.0.1', - help="IP address to listen") - parser.add_argument('-w', '--wsgi', action='store_true', - help="Run as a WSGI process") - parser.add_argument('-b', '--baseurl', default='', - help="Base URL this is served at (Usually only necessary for WSGI)") + parser.add_argument( + '-j', '--project', default=None, help="path to the root of the project" + ) + parser.add_argument( + '-P', + '--port', + metavar='NUM', + type=int, + default=settings.SERVER_PORT, + help="use a custom port for the server", + ) + parser.add_argument( + '-H', '--host', default='127.0.0.1', help="IP address to listen" + ) + parser.add_argument( + '-w', '--wsgi', action='store_true', help="Run as a WSGI process" + ) + parser.add_argument( + '-b', + '--baseurl', + default='', + help="Base URL this is served at (Usually only necessary for WSGI)", + ) # Parse arguments args = parser.parse_args(args=args) @@ -59,8 +70,9 @@ def main(args=None): args.project = vcs.find_root(cwd) # Configure logging - logging.basicConfig(format=settings.VERBOSE_LOGGING_FORMAT, - level=settings.VERBOSE_LOGGING_LEVEL) + logging.basicConfig( + format=settings.VERBOSE_LOGGING_FORMAT, level=settings.VERBOSE_LOGGING_LEVEL + ) # Run the program run(args, os.getcwd(), parser.error) @@ -79,8 +91,9 @@ def run(args, cwd, _): tree.load() host = args.host port = args.port or settings.SERVER_PORT - bottle.TEMPLATE_PATH.insert(0, os.path.join(os.path.dirname(__file__), - '..', 'views')) + bottle.TEMPLATE_PATH.insert( + 0, os.path.join(os.path.dirname(__file__), '..', 'views') + ) # If you started without WSGI, the base will be '/'. if args.baseurl == '' and not args.wsgi: @@ -97,8 +110,7 @@ def run(args, cwd, _): url = utilities.build_url(host=host, port=port) webbrowser.open(url) if not args.wsgi: - bottle.run(app=app, host=host, port=port, - debug=args.debug, reloader=args.debug) + bottle.run(app=app, host=host, port=port, debug=args.debug, reloader=args.debug) @hook('before_request') @@ -108,7 +120,7 @@ def strip_path(): @hook('after_request') -def enable_cors(): # pragma: no cover (manual test) +def enable_cors(): """Allow a webserver running on the same machine to access data.""" response.headers['Access-Control-Allow-Origin'] = '*' @@ -209,8 +221,9 @@ def get_attr(prefix, uid, name): @get('/assets/doorstop/') def get_assets(filename): """Serve static files. Mainly used to serve CSS files and javascript.""" - public_dir = os.path.join(os.path.dirname(__file__), - '..', 'core', 'files', 'assets', 'doorstop') + public_dir = os.path.join( + os.path.dirname(__file__), '..', 'core', 'files', 'assets', 'doorstop' + ) return bottle.static_file(filename, root=public_dir) @@ -227,5 +240,5 @@ def post_numbers(prefix): return str(number) -if __name__ == '__main__': # pragma: no cover (manual test) +if __name__ == '__main__': main() diff --git a/doorstop/server/tests/__init__.py b/doorstop/server/tests/__init__.py index 8cce51fdc..bf9506825 100644 --- a/doorstop/server/tests/__init__.py +++ b/doorstop/server/tests/__init__.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Package for the doorstop.server tests.""" ENV = 'TEST_INTEGRATION' # environment variable to enable integration tests diff --git a/doorstop/server/tests/test_all.py b/doorstop/server/tests/test_all.py index 96ff5ad0f..51cfdcb23 100644 --- a/doorstop/server/tests/test_all.py +++ b/doorstop/server/tests/test_all.py @@ -1,15 +1,16 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Integration tests for the doorstop.server package.""" +import logging import os import time import unittest -from unittest.mock import patch from multiprocessing import Process -import logging +from unittest.mock import patch from doorstop import server from doorstop.server import main - from doorstop.server.tests import ENV, REASON diff --git a/doorstop/server/tests/test_client.py b/doorstop/server/tests/test_client.py index 478f2a24b..11a58fcce 100644 --- a/doorstop/server/tests/test_client.py +++ b/doorstop/server/tests/test_client.py @@ -1,7 +1,9 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Unit tests for the doorstop.server.client module.""" import unittest -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch import requests diff --git a/doorstop/server/tests/test_server.py b/doorstop/server/tests/test_server.py index 9f057a58b..4fe571458 100644 --- a/doorstop/server/tests/test_server.py +++ b/doorstop/server/tests/test_server.py @@ -1,8 +1,9 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Unit tests for the doorstop.server.main module.""" import unittest -from unittest.mock import patch, Mock, MagicMock - +from unittest.mock import MagicMock, Mock, patch from doorstop.server import main as server @@ -136,17 +137,17 @@ def test_get_documents(self): def test_get_document(self): """Verify `/document/PREFIX` works (JSON).""" data = server.get_document('prefix') - self.assertEqual({'UID': {'links': ['UID3', 'UID4'], - 'text': 'TEXT'}, - 'UID2': {}}, data) + self.assertEqual( + {'UID': {'links': ['UID3', 'UID4'], 'text': 'TEXT'}, 'UID2': {}}, data + ) def test_get_all_documents(self): """Verify `/documents/all` works (JSON).""" data = server.get_all_documents() - expected = {'PREFIX': {'UID': {'links': ['UID3', 'UID4'], - 'text': 'TEXT'}, - 'UID2': {}}, - 'PREFIX2': {}} + expected = { + 'PREFIX': {'UID': {'links': ['UID3', 'UID4'], 'text': 'TEXT'}, 'UID2': {}}, + 'PREFIX2': {}, + } self.assertEqual(expected, data) def test_get_items(self): @@ -157,8 +158,7 @@ def test_get_items(self): def test_get_item(self): """Verify `/document/PREFIX/items/UID` works (JSON).""" data = server.get_item('prefix', 'uid') - self.assertEqual({'data': {'links': ['UID3', 'UID4'], - 'text': 'TEXT'}}, data) + self.assertEqual({'data': {'links': ['UID3', 'UID4'], 'text': 'TEXT'}}, data) def test_get_attrs(self): """Verify `/document/PREFIX/items/UID/attrs` works (JSON).""" diff --git a/doorstop/server/utilities.py b/doorstop/server/utilities.py index 0a8b14dd9..88e7f67ba 100644 --- a/doorstop/server/utilities.py +++ b/doorstop/server/utilities.py @@ -1,7 +1,8 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Shared functions for the `doorstop.server` package.""" -from doorstop import common -from doorstop import settings +from doorstop import common, settings log = common.logger(__name__) @@ -12,7 +13,7 @@ class StripPathMiddleware: # pylint: disable=R0903 def __init__(self, app): self.app = app - def __call__(self, e, h): # pragma: no cover (integration test) + def __call__(self, e, h): e['PATH_INFO'] = e['PATH_INFO'].rstrip('/') return self.app(e, h) @@ -32,7 +33,7 @@ def build_url(host=None, port=None, path=None): return url -def json_response(request): # pragma: no cover (integration test) +def json_response(request): """Determine if the request's response should be JSON.""" if request.query.get('format') == 'json': return True diff --git a/doorstop/settings.py b/doorstop/settings.py index 74296c02f..814bb4584 100644 --- a/doorstop/settings.py +++ b/doorstop/settings.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Settings for the Doorstop package.""" import logging @@ -6,7 +8,9 @@ DEFAULT_LOGGING_FORMAT = "%(message)s" LEVELED_LOGGING_FORMAT = "%(levelname)s: %(message)s" VERBOSE_LOGGING_FORMAT = "[%(levelname)-8s] %(message)s" -VERBOSE2_LOGGING_FORMAT = "[%(levelname)-8s] (%(name)s @%(lineno)4d) %(message)s" # pylint: disable=C0301 +VERBOSE2_LOGGING_FORMAT = ( + "[%(levelname)-8s] (%(name)s @%(lineno)4d) %(message)s" +) # pylint: disable=C0301 QUIET_LOGGING_LEVEL = logging.WARNING TIMED_LOGGING_FORMAT = "%(asctime)s" + ' ' + VERBOSE_LOGGING_FORMAT DEFAULT_LOGGING_LEVEL = logging.WARNING @@ -46,6 +50,7 @@ PUBLISH_CHILD_LINKS = True # include child links when publishing PUBLISH_BODY_LEVELS = True # include levels on non-header items PUBLISH_HEADING_LEVELS = True # include levels on header items +ENABLE_HEADERS = True # use headers if defined # Version control settings ADDREMOVE_FILES = True # automatically add/remove new/changed files @@ -58,6 +63,3 @@ # Server settings SERVER_HOST = None # '' = server not specified, None = no server in use SERVER_PORT = 7867 - -# Beta Features -ENABLE_HEADERS = False diff --git a/doorstop/views/base.tpl b/doorstop/views/base.tpl index a9960c61b..f689b9754 100644 --- a/doorstop/views/base.tpl +++ b/doorstop/views/base.tpl @@ -8,6 +8,12 @@ {{! ''%(baseurl+'assets/doorstop/'+stylesheet) if stylesheet else "" }} + + {{! '

Navigation: HomeDocuments'.format(baseurl) if navigation else ''}} diff --git a/mkdocs.yml b/mkdocs.yml index 0805ed688..164a13a41 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,23 +1,30 @@ site_name: Doorstop site_description: Requirements management using version control. site_author: Jace Browning + repo_url: https://github.com/jacebrowning/doorstop +edit_uri: https://github.com/jacebrowning/doorstop/edit/develop/docs theme: readthedocs extra_css: [] extra_javascript: [] -pages: +nav: - Home: index.md +- Setup: setup.md - Command-line Interface: - Creating Documents: cli/creation.md - Reordering Documents: cli/reordering.md - Validating Requirements: cli/validation.md - Publishing Documents: cli/publishing.md - Importing and Exporting: cli/interchange.md -- Graphical Interface: gui/coming-soon.md +- Desktop Interface: gui/overview.md +- Web Interface: web.md - Scripting Interface: api/scripting.md -- Item reference: reference/items.md +- Reference: + - Tree: reference/tree.md + - Document: reference/document.md + - Item: reference/item.md - Examples: examples.md - About: - Release Notes: about/changelog.md diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 000000000..38dc5cc8b --- /dev/null +++ b/poetry.lock @@ -0,0 +1,658 @@ +[[package]] +category = "dev" +description = "Python graph (network) package" +name = "altgraph" +optional = false +python-versions = "*" +version = "0.16.1" + +[[package]] +category = "dev" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +marker = "python_version >= \"3.6\" and python_version < \"4.0\"" +name = "appdirs" +optional = false +python-versions = "*" +version = "1.4.3" + +[[package]] +category = "dev" +description = "An abstract syntax tree for Python with inference support." +name = "astroid" +optional = false +python-versions = ">=3.4.*" +version = "2.2.5" + +[package.dependencies] +lazy-object-proxy = "*" +six = "*" +typed-ast = ">=1.3.0" +wrapt = "*" + +[[package]] +category = "dev" +description = "Classes Without Boilerplate" +marker = "python_version >= \"3.6\" and python_version < \"4.0\"" +name = "attrs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "19.1.0" + +[[package]] +category = "dev" +description = "A backport of the get_terminal_size function from Python 3.3's shutil." +name = "backports.shutil-get-terminal-size" +optional = false +python-versions = "*" +version = "1.0.0" + +[[package]] +category = "dev" +description = "The uncompromising code formatter." +marker = "python_version >= \"3.6\" and python_version < \"4.0\"" +name = "black" +optional = false +python-versions = ">=3.6" +version = "19.3b0" + +[package.dependencies] +appdirs = "*" +attrs = ">=18.1.0" +click = ">=6.5" +toml = ">=0.9.4" + +[[package]] +category = "main" +description = "Fast and simple WSGI-framework for small web-applications." +name = "bottle" +optional = false +python-versions = "*" +version = "0.12.13" + +[[package]] +category = "main" +description = "Python package for providing Mozilla's CA Bundle." +name = "certifi" +optional = false +python-versions = "*" +version = "2019.6.16" + +[[package]] +category = "main" +description = "Universal encoding detector for Python 2 and 3" +name = "chardet" +optional = false +python-versions = "*" +version = "3.0.4" + +[[package]] +category = "dev" +description = "Composable command line interface toolkit" +name = "click" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "7.0" + +[[package]] +category = "dev" +description = "Cross-platform colored terminal text." +name = "colorama" +optional = false +python-versions = "*" +version = "0.3.9" + +[[package]] +category = "dev" +description = "plugin core for use by pytest-cov, nose-cov and nose2-cov" +name = "cov-core" +optional = false +python-versions = "*" +version = "1.15.0" + +[package.dependencies] +coverage = ">=3.6" + +[[package]] +category = "main" +description = "Code coverage measurement for Python" +name = "coverage" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, <4" +version = "4.5.4" + +[[package]] +category = "dev" +description = "A place to track your code coverage metrics." +name = "coveragespace" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.1" + +[package.dependencies] +"backports.shutil-get-terminal-size" = ">=1.0,<2.0" +colorama = ">=0.3,<0.4" +coverage = ">=4.0,<5.0" +docopt = ">=0.6,<0.7" +requests = ">=2.0,<3.0" + +[[package]] +category = "dev" +description = "Pythonic argument parser, that will make you smile" +name = "docopt" +optional = false +python-versions = "*" +version = "0.6.2" + +[[package]] +category = "main" +description = "An implementation of lxml.xmlfile for the standard library" +name = "et-xmlfile" +optional = false +python-versions = "*" +version = "1.0.1" + +[[package]] +category = "dev" +description = "Expecter Gadget, a better expectation (assertion) library" +name = "expecter" +optional = false +python-versions = "*" +version = "0.3.0" + +[[package]] +category = "main" +description = "A comprehensive HTTP client library." +name = "httplib2" +optional = false +python-versions = "*" +version = "0.13.1" + +[[package]] +category = "main" +description = "Internationalized Domain Names in Applications (IDNA)" +name = "idna" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.8" + +[[package]] +category = "dev" +description = "A Python utility / library to sort Python imports." +name = "isort" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "4.3.21" + +[[package]] +category = "main" +description = "Julian dates from proleptic Gregorian and Julian calendars." +name = "jdcal" +optional = false +python-versions = "*" +version = "1.4.1" + +[[package]] +category = "dev" +description = "A small but fast and easy to use stand-alone template engine written in pure python." +name = "jinja2" +optional = false +python-versions = "*" +version = "2.10.1" + +[package.dependencies] +MarkupSafe = ">=0.23" + +[[package]] +category = "dev" +description = "A fast and thorough lazy object proxy." +name = "lazy-object-proxy" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.4.1" + +[[package]] +category = "dev" +description = "Python LiveReload is an awesome tool for web developers" +name = "livereload" +optional = false +python-versions = "*" +version = "2.6.1" + +[package.dependencies] +six = "*" +tornado = "*" + +[[package]] +category = "dev" +description = "Thread-based interface to file system observation primitives." +marker = "sys_platform == \"darwin\"" +name = "macfsevents" +optional = false +python-versions = "*" +version = "0.8.1" + +[[package]] +category = "main" +description = "Python implementation of Markdown." +name = "markdown" +optional = false +python-versions = "*" +version = "2.6.11" + +[[package]] +category = "dev" +description = "Safely add untrusted strings to HTML/XML markup." +name = "markupsafe" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.1.1" + +[[package]] +category = "dev" +description = "McCabe checker, plugin for flake8" +name = "mccabe" +optional = false +python-versions = "*" +version = "0.6.1" + +[[package]] +category = "main" +description = "Python-Markdown extension to wrap the document logical sections (as implied by h1-h6 headings)" +name = "mdx-outline" +optional = false +python-versions = "*" +version = "1.3.0" + +[package.dependencies] +Markdown = ">=2.0" + +[[package]] +category = "dev" +description = "Project documentation with Markdown." +name = "mkdocs" +optional = false +python-versions = ">=2.7.9,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.0.4" + +[package.dependencies] +Jinja2 = ">=2.7.1" +Markdown = ">=2.3.1" +PyYAML = ">=3.10" +click = ">=3.3" +livereload = ">=2.5.1" +tornado = ">=5.0" + +[[package]] +category = "dev" +description = "Optional static typing for Python" +name = "mypy" +optional = false +python-versions = "*" +version = "0.720" + +[package.dependencies] +mypy-extensions = ">=0.4.0,<0.5.0" +typed-ast = ">=1.4.0,<1.5.0" +typing-extensions = ">=3.7.4" + +[[package]] +category = "dev" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +name = "mypy-extensions" +optional = false +python-versions = "*" +version = "0.4.1" + +[[package]] +category = "dev" +description = "nose extends unittest to make testing easier" +name = "nose" +optional = false +python-versions = "*" +version = "1.3.7" + +[[package]] +category = "dev" +description = "Nose plugin for capturing stderr." +name = "nose-capturestderr" +optional = false +python-versions = "*" +version = "1.2" + +[package.dependencies] +nose = ">=0.11.1" + +[[package]] +category = "dev" +description = "nose plugin for coverage reporting, including subprocesses and multiprocessing" +name = "nose-cov" +optional = false +python-versions = "*" +version = "1.6" + +[package.dependencies] +cov-core = ">=1.6" +nose = ">=0.11.4" + +[[package]] +category = "dev" +description = "A nose plugin to show skipped tests and their messages" +name = "nose-show-skipped" +optional = false +python-versions = "*" +version = "0.1" + +[package.dependencies] +nose = "*" + +[[package]] +category = "main" +description = "A Python library to read/write Excel 2010 xlsx/xlsm files" +name = "openpyxl" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.6.2" + +[package.dependencies] +et_xmlfile = "*" +jdcal = "*" + +[[package]] +category = "main" +description = "" +name = "plantuml" +optional = false +python-versions = "*" +version = "0.2.1" + +[package.dependencies] +httplib2 = "*" + +[[package]] +category = "main" +description = "A PlantUML plugin for Markdown" +name = "plantuml-markdown" +optional = false +python-versions = "*" +version = "3.1.2" + +[package.dependencies] +Markdown = "*" +plantuml = "*" + +[[package]] +category = "dev" +description = "Python docstring style checker" +name = "pydocstyle" +optional = false +python-versions = "*" +version = "4.0.0" + +[package.dependencies] +snowballstemmer = "*" + +[[package]] +category = "main" +description = "Cache lines and file information which are generally Python programs" +name = "pyficache" +optional = false +python-versions = "*" +version = "0.3.1" + +[package.dependencies] +coverage = "*" +pygments = ">=2.0" + +[[package]] +category = "main" +description = "Pygments is a syntax highlighting package written in Python." +name = "pygments" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.4.2" + +[[package]] +category = "dev" +description = "PyInstaller bundles a Python application and all its dependencies into a single package." +name = "pyinstaller" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.5" + +[package.dependencies] +altgraph = "*" +setuptools = "*" + +[[package]] +category = "dev" +description = "python code static checker" +name = "pylint" +optional = false +python-versions = ">=3.4.*" +version = "2.3.1" + +[package.dependencies] +astroid = ">=2.2.0,<3" +colorama = "*" +isort = ">=4.2.5,<5" +mccabe = ">=0.6,<0.7" + +[[package]] +category = "dev" +description = "Python Wrapper for Mac OS 10.10 Notification Center" +marker = "sys_platform == \"darwin\"" +name = "pync" +optional = false +python-versions = "*" +version = "2.0.3" + +[package.dependencies] +python-dateutil = ">=2.0" + +[[package]] +category = "dev" +description = "Extensions to the standard Python datetime module" +marker = "sys_platform == \"darwin\"" +name = "python-dateutil" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.8.0" + +[package.dependencies] +six = ">=1.5" + +[[package]] +category = "main" +description = "Math extension for Python-Markdown" +name = "python-markdown-math" +optional = false +python-versions = "*" +version = "0.6" + +[[package]] +category = "dev" +description = "console colouring for python" +name = "python-termstyle" +optional = false +python-versions = "*" +version = "0.1.10" + +[package.dependencies] +setuptools = "*" + +[[package]] +category = "main" +description = "YAML parser and emitter for Python" +name = "pyyaml" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "5.1.2" + +[[package]] +category = "main" +description = "Python HTTP for Humans." +name = "requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.22.0" + +[package.dependencies] +certifi = ">=2017.4.17" +chardet = ">=3.0.2,<3.1.0" +idna = ">=2.5,<2.9" +urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" + +[[package]] +category = "dev" +description = "Python 2 and 3 compatibility utilities" +name = "six" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*" +version = "1.12.0" + +[[package]] +category = "dev" +description = "An automatic test runner. Supports nose out of the box." +name = "sniffer" +optional = false +python-versions = "*" +version = "0.4.1" + +[package.dependencies] +colorama = "*" +nose = "*" +python-termstyle = "*" + +[[package]] +category = "dev" +description = "This package provides 23 stemmers for 22 languages generated from Snowball algorithms." +name = "snowballstemmer" +optional = false +python-versions = "*" +version = "1.9.0" + +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +marker = "python_version >= \"3.6\" and python_version < \"4.0\"" +name = "toml" +optional = false +python-versions = "*" +version = "0.10.0" + +[[package]] +category = "dev" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +name = "tornado" +optional = false +python-versions = ">= 3.5" +version = "6.0.3" + +[[package]] +category = "dev" +description = "a fork of Python 2 and 3 ast modules with type comment support" +name = "typed-ast" +optional = false +python-versions = "*" +version = "1.4.0" + +[[package]] +category = "dev" +description = "Type Hints for Python" +name = "typing" +optional = false +python-versions = "*" +version = "3.7.4" + +[[package]] +category = "dev" +description = "Backported and Experimental Type Hints for Python 3.5+" +name = "typing-extensions" +optional = false +python-versions = "*" +version = "3.7.4" + +[package.dependencies] +typing = ">=3.7.4" + +[[package]] +category = "main" +description = "HTTP library with thread-safe connection pooling, file post, and more." +name = "urllib3" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" +version = "1.25.3" + +[[package]] +category = "dev" +description = "Module for decorators, wrappers and monkey patching." +name = "wrapt" +optional = false +python-versions = "*" +version = "1.11.2" + +[metadata] +content-hash = "2651989996a4b4edc910deeaaa8f42d812ad4b930dab37f3ad876b5ffb2475ce" +python-versions = "^3.5" + +[metadata.hashes] +altgraph = ["d6814989f242b2b43025cba7161fc1b8fb487a62cd49c49245d6fd01c18ac997", "ddf5320017147ba7b810198e0b6619bd7b5563aa034da388cea8546b877f9b0c"] +appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"] +astroid = ["6560e1e1749f68c64a4b5dee4e091fce798d2f0d84ebe638cf0e0585a343acf4", "b65db1bbaac9f9f4d190199bb8680af6f6f84fd3769a5ea883df8a91fe68b4c4"] +attrs = ["69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", "f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"] +"backports.shutil-get-terminal-size" = ["0975ba55054c15e346944b38956a4c9cbee9009391e41b86c68990effb8c1f64", "713e7a8228ae80341c70586d1cc0a8caa5207346927e23d09dcbcaf18eadec80"] +black = ["09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf", "68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c"] +bottle = ["39b751aee0b167be8dffb63ca81b735bbf1dd0905b3bc42761efedee8f123355"] +certifi = ["046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", "945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695"] +chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] +click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] +colorama = ["463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda", "48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1"] +cov-core = ["4a14c67d520fda9d42b0da6134638578caae1d374b9bb462d8de00587dba764c"] +coverage = ["08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", "0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", "141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", "19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", "23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", "245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", "331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", "386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", "3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", "60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", "63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", "6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", "6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", "7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", "826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", "93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", "9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", "af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", "bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", "bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", "c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", "dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", "df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", "e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", "e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", "e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", "eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", "eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", "ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", "efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", "fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", "ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025"] +coveragespace = ["498b54ec158a19e1f5647da681dc77fd9d17df11ecff1253d60ac7970209f6e5", "7c5ce4641e0f995b9be0e8b53401fd7b6d17db1b8c23bfd06f0c845ad0de5b5f"] +docopt = ["49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"] +et-xmlfile = ["614d9722d572f6246302c4491846d2c393c199cfa4edc9af593437691683335b"] +expecter = ["4d2cab9d9c80620456231106b989c9a6c70f8f7f3a9725a6644097bd3017705a"] +httplib2 = ["6901c8c0ffcf721f9ce270ad86da37bc2b4d32b8802d4a9cec38274898a64044", "cf6f9d5876d796539ec922a2c9b9a7cad9bfd90f04badcdc3bcfa537168052c3"] +idna = ["c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"] +isort = ["54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", "6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"] +jdcal = ["1abf1305fce18b4e8aa248cf8fe0c56ce2032392bc64bbd61b5dff2a19ec8bba", "472872e096eb8df219c23f2689fc336668bdb43d194094b5cc1707e1640acfc8"] +jinja2 = ["065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", "14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"] +lazy-object-proxy = ["159a745e61422217881c4de71f9eafd9d703b93af95618635849fe469a283661", "23f63c0821cc96a23332e45dfaa83266feff8adc72b9bcaef86c202af765244f", "3b11be575475db2e8a6e11215f5aa95b9ec14de658628776e10d96fa0b4dac13", "3f447aff8bc61ca8b42b73304f6a44fa0d915487de144652816f950a3f1ab821", "4ba73f6089cd9b9478bc0a4fa807b47dbdb8fad1d8f31a0f0a5dbf26a4527a71", "4f53eadd9932055eac465bd3ca1bd610e4d7141e1278012bd1f28646aebc1d0e", "64483bd7154580158ea90de5b8e5e6fc29a16a9b4db24f10193f0c1ae3f9d1ea", "6f72d42b0d04bfee2397aa1862262654b56922c20a9bb66bb76b6f0e5e4f9229", "7c7f1ec07b227bdc561299fa2328e85000f90179a2f44ea30579d38e037cb3d4", "7c8b1ba1e15c10b13cad4171cfa77f5bb5ec2580abc5a353907780805ebe158e", "8559b94b823f85342e10d3d9ca4ba5478168e1ac5658a8a2f18c991ba9c52c20", "a262c7dfb046f00e12a2bdd1bafaed2408114a89ac414b0af8755c696eb3fc16", "acce4e3267610c4fdb6632b3886fe3f2f7dd641158a843cf6b6a68e4ce81477b", "be089bb6b83fac7f29d357b2dc4cf2b8eb8d98fe9d9ff89f9ea6012970a853c7", "bfab710d859c779f273cc48fb86af38d6e9210f38287df0069a63e40b45a2f5c", "c10d29019927301d524a22ced72706380de7cfc50f767217485a912b4c8bd82a", "dd6e2b598849b3d7aee2295ac765a578879830fb8966f70be8cd472e6069932e", "e408f1eacc0a68fed0c08da45f31d0ebb38079f043328dce69ff133b95c29dc1"] +livereload = ["78d55f2c268a8823ba499305dcac64e28ddeb9a92571e12d543cd304faf5817b", "89254f78d7529d7ea0a3417d224c34287ebfe266b05e67e51facaf82c27f0f66"] +macfsevents = ["1324b66b356051de662ba87d84f73ada062acd42b047ed1246e60a449f833e10"] +markdown = ["9ba587db9daee7ec761cfc656272be6aabe2ed300fece21208e4aab2e457bc8f", "a856869c7ff079ad84a3e19cd87a64998350c2b94e9e08e44270faef33400f81"] +markupsafe = ["00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", "b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", "ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"] +mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] +mdx-outline = ["eb678ec5800b053cc63d70ff7dc40228bbe7c2d3e6180a3c6b72bfb09081a4d0"] +mkdocs = ["17d34329aad75d5de604b9ed4e31df3a4d235afefdc46ce7b1964fddb2e1e939", "8cc8b38325456b9e942c981a209eaeb1e9f3f77b493ad755bfef889b9c8d356a"] +mypy = ["0107bff4f46a289f0e4081d59b77cef1c48ea43da5a0dbf0005d54748b26df2a", "07957f5471b3bb768c61f08690c96d8a09be0912185a27a68700f3ede99184e4", "10af62f87b6921eac50271e667cc234162a194e742d8e02fc4ddc121e129a5b0", "11fd60d2f69f0cefbe53ce551acf5b1cec1a89e7ce2d47b4e95a84eefb2899ae", "15e43d3b1546813669bd1a6ec7e6a11d2888db938e0607f7b5eef6b976671339", "352c24ba054a89bb9a35dd064ee95ab9b12903b56c72a8d3863d882e2632dc76", "437020a39417e85e22ea8edcb709612903a9924209e10b3ec6d8c9f05b79f498", "49925f9da7cee47eebf3420d7c0e00ec662ec6abb2780eb0a16260a7ba25f9c4", "6724fcd5777aa6cebfa7e644c526888c9d639bd22edd26b2a8038c674a7c34bd", "7a17613f7ea374ab64f39f03257f22b5755335b73251d0d253687a69029701ba", "cdc1151ced496ca1496272da7fc356580e95f2682be1d32377c22ddebdf73c91"] +mypy-extensions = ["37e0e956f41369209a3d5f34580150bcacfabaa57b33a15c0b25f4b5725e0812", "b16cabe759f55e3409a7d231ebd2841378fb0c27a5d1994719e340e4f429ac3e"] +nose = ["9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", "dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", "f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98"] +nose-capturestderr = ["3a9d3986f44490a1286d9eacd66879dbb059b575f6660a228b2051a8617a89ab"] +nose-cov = ["8bec0335598f1cc69e3262cc50d7678c1a6010fa44625ce343c4ec1500774412"] +nose-show-skipped = ["a202f9c4b35107e9e1d6d8438eff4a930cb31a7e17517a69b319448f136815ce"] +openpyxl = ["1d2af392cef8c8227bd2ac3ebe3a28b25aba74fd4fa473ce106065f0b73bfe2e"] +plantuml = ["a117593c4864fce120e659db44e7235c34e7b3930c0fed421d659cfebf2ddabf"] +plantuml-markdown = ["47d591040c8416265f3babd071cf039686d0f3a4e313d1c31dc38fa3c6f2ff70", "d0163fb49e605b146a22febd0b972ed070980f84b4be1320ad9cdd8fa67ad8d4"] +pydocstyle = ["58c421dd605eec0bce65df8b8e5371bb7ae421582cdf0ba8d9435ac5b0ffc36a"] +pyficache = ["1b285d0ff7e2463b92dca5e3f813dd35229d85b3f2928c39ca5aa0b8403d4b04", "1ef1c7d56b7d926339a12d003e6a1e908c0a71fd4c1af8a7782471cc3b418ff6", "3bafda326b51729b3e7ea3929b0c72b858d449f14bbbdbf69f3d19fac47c2a5f", "67e35c7d996190d2ecf20333b04ebe7ec0eb8f107d9603c798ec36468dc12f3f", "87a48acb7a587798fcc9f430256ed72bcbe60c726ae7ce7eaab9f462d6f86cf5", "d20906865be805f0b86a6082dbf753a89c5a05c0d582420d8d9a480b9a33d21f"] +pygments = ["71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", "881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"] +pyinstaller = ["ee7504022d1332a3324250faf2135ea56ac71fdb6309cff8cd235de26b1d0a96"] +pylint = ["5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09", "723e3db49555abaf9bf79dc474c6b9e2935ad82230b10c1138a71ea41ac0fff1"] +pync = ["38b9e61735a3161f9211a5773c5f5ea698f36af4ff7f77fa03e8d1ff0caa117f"] +python-dateutil = ["7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", "c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"] +python-markdown-math = ["c68d8cb9695cb7b435484403dc18941d1bad0ff148e4166d9417046a0d5d3022", "d443e264cf063623a5f02b0c9730867e5172b31d49967da424ed3457c25b2848"] +python-termstyle = ["6faf42ba42f2826c38cf70dacb3ac51f248a418e48afc0e36593df11cf3ab1d2", "f42a6bb16fbfc5e2c66d553e7ad46524ea833872f75ee5d827c15115fafc94e2"] +pyyaml = ["0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", "01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", "5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", "5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", "7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", "7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", "87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", "9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", "a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", "b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", "b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", "bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", "f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"] +requests = ["11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"] +six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] +sniffer = ["b37665053fb83d7790bf9e51d616c11970863d14b5ea5a51155a4e95759d1529", "f120843fe152d0e380402fc11313b151e2044c47fdd36895de2efedc8624dbb8"] +snowballstemmer = ["9f3b9ffe0809d174f7047e121431acf99c89a7040f0ca84f94ba53a498e6d0c9"] +toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] +tornado = ["349884248c36801afa19e342a77cc4458caca694b0eda633f5878e458a44cb2c", "398e0d35e086ba38a0427c3b37f4337327231942e731edaa6e9fd1865bbd6f60", "4e73ef678b1a859f0cb29e1d895526a20ea64b5ffd510a2307b5998c7df24281", "559bce3d31484b665259f50cd94c5c28b961b09315ccd838f284687245f416e5", "abbe53a39734ef4aba061fca54e30c6b4639d3e1f59653f0da37a0003de148c7", "c845db36ba616912074c5b1ee897f8e0124df269468f25e4fe21fe72f6edd7a9", "c9399267c926a4e7c418baa5cbe91c7d1cf362d505a1ef898fde44a07c9dd8a5"] +typed-ast = ["18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", "262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", "2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", "354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", "4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", "630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", "66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", "71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", "95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", "bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", "cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", "d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", "d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", "d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", "ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"] +typing = ["38566c558a0a94d6531012c8e917b1b8518a41e418f7f15f00e129cc80162ad3", "53765ec4f83a2b720214727e319607879fec4acde22c4fbb54fa2604e79e44ce", "84698954b4e6719e912ef9a42a2431407fe3755590831699debda6fba92aac55"] +typing-extensions = ["2ed632b30bb54fc3941c382decfd0ee4148f5c591651c9272473fea2c6397d95", "b1edbbf0652660e32ae780ac9433f4231e7339c7f9a8057d0f042fcbcea49b87", "d8179012ec2c620d3791ca6fe2bf7979d979acdbef1fca0bc56b37411db682ed"] +urllib3 = ["b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1", "dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232"] +wrapt = ["565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..70e2a9a15 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,100 @@ +[tool.poetry] + +name = "doorstop" +version = "1.6" +description = "Requirements management using version control." + +license = "LGPLv3" + +authors = ["Jace Browning "] + +readme = "README.md" + +homepage = "https://pypi.org/project/Doorstop" +documentation = "https://doorstop.readthedocs.io" +repository = "https://github.com/jacebrowning/doorstop" + +keywords = [ + "requirements-management", + "version-control", + "documentation", + "traceability", + "markdown", + "certification", + "html", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Topic :: Software Development :: Documentation", + "Topic :: Text Editors :: Documentation", + "Topic :: Text Processing :: Markup", +] + +[tool.poetry.dependencies] + +python = "^3.5" + +PyYAML = "^5.1" +Markdown = "^2" +bottle = "0.12.13" +requests = "^2" +pyficache = "0.3.1" +mdx_outline = "^1.3.0" +python-markdown-math = "0.6" +plantuml-markdown = "^3.0" +openpyxl = "^2.6" + +[tool.poetry.dev-dependencies] + +# Formatters +black = { version = "19.3b0", python = "^3.6" } +isort = "4.3.21" + +# Linters +mypy = "*" +pydocstyle = "*" +pylint = "^2.0" + +# Testing +nose = "^1.3" +nose-cov = "*" +nose-capturestderr = "*" +nose-show-skipped = "*" +expecter = "*" + +# Reports +coveragespace = "*" + +# Documentation +mkdocs = "^1.0" +pygments = "*" + +# Tooling +pyinstaller = "*" +sniffer = "*" +MacFSEvents = { version = "*", platform = "darwin" } +pync = { version = "*", platform = "darwin" } + +[tool.poetry.scripts] + +doorstop = "doorstop.cli.main:main" +doorstop-gui = "doorstop.gui.main:main" +doorstop-server = "doorstop.server.main:main" + +[tool.black] + +target-version = ["py36", "py37"] +skip-string-normalization = true + +[build-system] + +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" diff --git a/reqs/REQ002.yml b/reqs/REQ002.yml index 2ed9eb804..a9204e0ec 100644 --- a/reqs/REQ002.yml +++ b/reqs/REQ002.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 2.0 links: [] normative: false diff --git a/reqs/REQ006.yml b/reqs/REQ006.yml index 3a90d6b95..09bffc747 100644 --- a/reqs/REQ006.yml +++ b/reqs/REQ006.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 3.0 links: [] normative: false diff --git a/reqs/REQ010.yml b/reqs/REQ010.yml index 8d68520af..c582c564e 100644 --- a/reqs/REQ010.yml +++ b/reqs/REQ010.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 4.0 links: [] normative: false diff --git a/reqs/REQ018.yml b/reqs/REQ018.yml index 9057aca38..f2b366d2d 100644 --- a/reqs/REQ018.yml +++ b/reqs/REQ018.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.0 links: [] normative: false diff --git a/reqs/tutorial/TUT001.yml b/reqs/tutorial/TUT001.yml index 17114e10c..37d617a75 100644 --- a/reqs/tutorial/TUT001.yml +++ b/reqs/tutorial/TUT001.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.1 links: - REQ003: 1f33605bbc5d1a39c9a6441b91389e88 diff --git a/reqs/tutorial/TUT002.yml b/reqs/tutorial/TUT002.yml index 2960cc591..7d8dbfaec 100644 --- a/reqs/tutorial/TUT002.yml +++ b/reqs/tutorial/TUT002.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.2 links: - REQ003: 1f33605bbc5d1a39c9a6441b91389e88 diff --git a/reqs/tutorial/TUT003.yml b/reqs/tutorial/TUT003.yml index 93b727ecb..927969d3f 100644 --- a/reqs/tutorial/TUT003.yml +++ b/reqs/tutorial/TUT003.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.0 links: [] normative: false diff --git a/reqs/tutorial/TUT004.yml b/reqs/tutorial/TUT004.yml index 84339b7c4..e07acba6f 100644 --- a/reqs/tutorial/TUT004.yml +++ b/reqs/tutorial/TUT004.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.3 links: - REQ003: 1f33605bbc5d1a39c9a6441b91389e88 diff --git a/reqs/tutorial/TUT005.yml b/reqs/tutorial/TUT005.yml index 1af105b10..292bb5994 100644 --- a/reqs/tutorial/TUT005.yml +++ b/reqs/tutorial/TUT005.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 2.0 links: [] normative: false diff --git a/reqs/tutorial/TUT008.yml b/reqs/tutorial/TUT008.yml index f77bc77fe..74df50419 100644 --- a/reqs/tutorial/TUT008.yml +++ b/reqs/tutorial/TUT008.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.4 links: - REQ003: 1f33605bbc5d1a39c9a6441b91389e88 diff --git a/reqs/tutorial/TUT009.yml b/reqs/tutorial/TUT009.yml index 442ef3f38..c7d252b63 100644 --- a/reqs/tutorial/TUT009.yml +++ b/reqs/tutorial/TUT009.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 2.1 links: - REQ007: 1b2201126b830e4ea9f57c77dbd6a38e diff --git a/reqs/tutorial/TUT010.yml b/reqs/tutorial/TUT010.yml index 377f74a78..abc946c38 100644 --- a/reqs/tutorial/TUT010.yml +++ b/reqs/tutorial/TUT010.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 2.2 links: - REQ007: 1b2201126b830e4ea9f57c77dbd6a38e diff --git a/reqs/tutorial/TUT011.yml b/reqs/tutorial/TUT011.yml index 3119b3f0b..2292051d6 100644 --- a/reqs/tutorial/TUT011.yml +++ b/reqs/tutorial/TUT011.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 3.0 links: [] normative: false diff --git a/reqs/tutorial/TUT012.yml b/reqs/tutorial/TUT012.yml index e487b34d9..77c91b485 100644 --- a/reqs/tutorial/TUT012.yml +++ b/reqs/tutorial/TUT012.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 3.2 links: - REQ016: cd08e04c45e8f91de19227a183f78777 diff --git a/reqs/tutorial/TUT013.yml b/reqs/tutorial/TUT013.yml index 8774fbef3..bbc9c7c1b 100644 --- a/reqs/tutorial/TUT013.yml +++ b/reqs/tutorial/TUT013.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 3.3 links: - REQ016: cd08e04c45e8f91de19227a183f78777 diff --git a/reqs/tutorial/TUT014.yml b/reqs/tutorial/TUT014.yml index 184f7a5b4..3acc468f7 100644 --- a/reqs/tutorial/TUT014.yml +++ b/reqs/tutorial/TUT014.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 4.0 links: [] normative: false diff --git a/reqs/tutorial/TUT015.yml b/reqs/tutorial/TUT015.yml index a66c3dbe4..b7af3ff83 100644 --- a/reqs/tutorial/TUT015.yml +++ b/reqs/tutorial/TUT015.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 4.1 links: - REQ017: 4a5e03f8a2525f73633f078aa67e29e7 diff --git a/reqs/tutorial/TUT016.yml b/reqs/tutorial/TUT016.yml index 5ccb656a6..01e725f65 100644 --- a/reqs/tutorial/TUT016.yml +++ b/reqs/tutorial/TUT016.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 3.1 links: - REQ016: cd08e04c45e8f91de19227a183f78777 diff --git a/reqs/tutorial/TUT017.yml b/reqs/tutorial/TUT017.yml index bebd45b6b..97e71a00d 100644 --- a/reqs/tutorial/TUT017.yml +++ b/reqs/tutorial/TUT017.yml @@ -1,11 +1,12 @@ active: true derived: false +header: '' level: 1.5 links: - REQ004: 94f4db8d1a50ab62ee0edec1e28c0afb normative: true ref: '' -reviewed: 98ba42c551fec65d9796fee72d15f844 +reviewed: 6c611696a7b09ca26f613ecad4d3fb1f text: | ### Headings 3 @@ -57,3 +58,38 @@ text: | --- | --- | --- *Still* | `renders` | **nicely** 1 | 2 | 3 + + ### UML Diagrams + + [PlantUML-Guide](http://plantuml.com/guide) explains the syntax for + all supported diagram types. Here you see an exemplary state chart diagram: + + ```plantuml format="png" alt="State Diagram Loading" title="State Diagram" + @startuml + scale 600 width + + [*] -> State1 + State1 --> State2 : Succeeded + State1 --> [*] : Aborted + State2 --> State3 : Succeeded + State2 --> [*] : Aborted + state State3 { + state "Accumulate Enough Data\nLong State Name" as long1 + long1 : Just a test + [*] --> long1 + long1 --> long1 : New Data + long1 --> ProcessData : Enough Data + } + State3 --> State3 : Failed + State3 --> [*] : Succeeded / Save Result + State3 --> [*] : Aborted + + @enduml + ``` + + ### Math LaTex + + You can use Math LaTex expressions as `$$k_{n+1} = n^2 + k_n^2 - k_{n-1}$$` + which is rendered like this: + + $$k_{n+1} = n^2 + k_n^2 - k_{n-1}$$ diff --git a/reqs/tutorial/TUT018.yml b/reqs/tutorial/TUT018.yml index 0b58f4ef2..3644738cd 100644 --- a/reqs/tutorial/TUT018.yml +++ b/reqs/tutorial/TUT018.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.6.0 links: [] normative: false diff --git a/reqs/tutorial/TUT019.yml b/reqs/tutorial/TUT019.yml index 9a39f20e6..3ce6966ff 100644 --- a/reqs/tutorial/TUT019.yml +++ b/reqs/tutorial/TUT019.yml @@ -1,5 +1,6 @@ active: true derived: false +header: '' level: 1.6.1 links: - REQ004: 94f4db8d1a50ab62ee0edec1e28c0afb diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..36151eaf4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +poetry +sappy diff --git a/runtime.txt b/runtime.txt index c91e43be5..9fbd3bf0a 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -python-3.6.1 +python-3.6.8 diff --git a/scent.py b/scent.py index 0a05f56c4..01ed57f9a 100644 --- a/scent.py +++ b/scent.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: LGPL-3.0-only + """Configuration file for sniffer.""" # pylint: disable=superfluous-parens,bad-continuation @@ -27,7 +29,6 @@ class Options: (('make', 'check'), "Static Analysis", True), (('make', 'demo'), "Run Demo", False), (('make', 'docs'), None, True), - (('make', 'dist'), None, True), ] diff --git a/setup.py b/setup.py deleted file mode 100644 index d0bce71dd..000000000 --- a/setup.py +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env python - -import os -import sys - -import setuptools - - -PACKAGE_NAME = 'doorstop' -MINIMUM_PYTHON_VERSION = '3.4' - - -def check_python_version(): - """Exit when the Python version is too low.""" - if sys.version < MINIMUM_PYTHON_VERSION: - sys.exit("Python {0}+ is required.".format(MINIMUM_PYTHON_VERSION)) - - -def read_package_variable(key, filename='__init__.py'): - """Read the value of a variable from the package without importing.""" - module_path = os.path.join(PACKAGE_NAME, filename) - with open(module_path) as module: - for line in module: - parts = line.strip().split(' ', 2) - if parts[:-1] == [key, '=']: - return parts[-1].strip("'") - sys.exit("'%s' not found in '%s'", key, module_path) - - -def build_description(): - """Build a description for the project from documentation files.""" - try: - readme = open("README.rst").read() - changelog = open("CHANGELOG.rst").read() - except IOError: - return "" - else: - return readme + '\n' + changelog - - -check_python_version() - -setuptools.setup( - name=read_package_variable('__project__'), - version=read_package_variable('__version__'), - - description=read_package_variable('DESCRIPTION'), - url='http://doorstop.readthedocs.io/', - author='Jace Browning', - author_email='jacebrowning@gmail.com', - - packages=setuptools.find_packages(), - package_data={'doorstop.core': ['files/*.html', 'files/*.css', 'files/assets/doorstop/*'], - 'doorstop': ['views/*.tpl']}, - - entry_points={ - 'console_scripts': [ - read_package_variable('CLI') + ' = doorstop.cli.main:main', - read_package_variable('GUI') + ' = doorstop.gui.main:main', - read_package_variable('SERVER') + ' = doorstop.server.main:main', - ] - }, - - long_description=build_description(), - license='LGPL', - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Topic :: Software Development :: Documentation', - 'Topic :: Text Editors :: Documentation', - 'Topic :: Text Processing :: Markup', - ], - - install_requires=[ - "PyYAML >= 3.10, < 4", - "Markdown >= 2, < 3", - "openpyxl >= 2.1, < 2.2", - "bottle == 0.12.13", - "requests >= 2, < 3", - "pyficache == 0.3.1", - "mdx_outline >= 1.3.0, < 2", - ], -)