commit 4b51d678bba6576532a47dab9991c127dc8e7853 Author: error Date: Tue Jan 3 14:42:54 2023 +0100 Version 1.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cdb3c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,164 @@ + +3.0 KiB +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ce51510 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2023 Error + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d324436 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# RFCartography + +Visualize relations between RFCs + +## Set Up + +Clone this repository and install dependencies, e.g. using `pip install -r requirements.txt`. +Place the index file (can be downloaded [here](https://www.rfc-editor.org/rfc-index.xml)) in the instance folder create a configuration in that location (see `Configuration`). + +Afterwards, RFCartography can be run by executing + + flask --app rfcartography run + +Note: Use `flask run` only for local development. +If you want to deploy it to a production server, use a WSGI server instead, as explained in the [Flask documentation](https://flask.palletsprojects.com/en/latest/deploying/). + +Hint: Generating large graphs takes a lot of time. +It is highly recommended to cache responses to improve response times. + +## Configuration + +RFCartography (or rather Flask, the framework it's based on) searches for the configuration in the `instance` directory. +To configure your instance, create a `config.py` file in this directory. + +### Flask Configuration Parameters + + - `SERVER_NAME`: Set the servers name, e.g. it's URL or address. RFCartography requires this value to be set correctly. If unset, it will default to `localhost`. + +For further generic configuration parameters, please refer to the [Flask documentation](https://flask.palletsprojects.com/en/latest/config/). + +### RFCartography Specific Configuration Parameters + + - `INDEX_FILE`: XML file containing the RFC index, usually downloaded from [here](https://www.rfc-editor.org/rfc-index.xml) + - `NAMESPACE`: XML namespace of the RFC index, defaults to `http://www.rfc-editor.org/rfc-index` + - `DEPTH_DEFAULT`: Depth limit to be used for requests w/o specified depth limit, defaults to 8 + - `IMPRINT`: Content that shall be displayed on the imprint page, see `Custom Content` for the syntax + - `PRIVACY`: Content that shall be displayed on the privacy page, see `Custom Content` for the syntax + +### Custom Content + +The content of the imprint and the privacy pages can be configured with the config file. +The corresponding parameters take a list of tuples of strings. +The first item of each tuple will be displayed as a headline, all following items as paragraphs. + +Example: + + PRIVACY = [('Privacy Statement', 'This website only processes data that is necessary in order to fulfill the user\'s request, e.g. the user\'s IP address. It does not generate access logs. Personal data is discarded once the request was served.')] + +Additional line breaks within a paragraph can be inserted by using a tuple of multiple strings instead of a string as a paragraph, e.g.: + + IMPRINT = [('E-Mail', 'My mail addresses are', ('example[at]not-a-real-mail-address.net', 'another-example[at]not-a-real-mail-address.net', 'one-more.example[at]not-a-real-mail-address.net'))] + +## Maintenance + +Remember to regularly update the rfc index file. + +## Tests + +Unittests a place in the `tests` directory. +Run all tests by executing: + + python -m unittest discover tests + +If you have coverage.py installed, you can check the coverage by executing: + + python -m coverage run -m unittest discover tests + python -m coverage report diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..65a3ae9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask +defusedxml +networkx[default] diff --git a/rfcartography/__init__.py b/rfcartography/__init__.py new file mode 100644 index 0000000..0965a6d --- /dev/null +++ b/rfcartography/__init__.py @@ -0,0 +1,48 @@ +from flask import Flask +from rfcartography.index_parser import IndexParser +from rfcartography.rfcartographer import RFCartographer +from rfcartography.routing import register_routers +from rfcartography.errors import register_errorhandlers + + +META = {'NAME': "RFCartography", + 'VERSION': "1.0.0", + 'SOURCE': "https://git.undefinedbehavior.de/undef/RFCartography"} + +def create_app(test_config: dict = None) -> Flask: + """set up the flask application""" + # create app + app = Flask(import_name=__name__, + instance_relative_config=True) + + # load configuration + if test_config is None: + app.config.from_pyfile(filename='config.py') + else: + app.config.from_mapping(mapping=test_config) + + # apply default config values where missing + if app.config['SERVER_NAME'] is None: + app.config['SERVER_NAME'] = 'localhost' + if not 'NAMESPACE' in app.config: + app.config['NAMESPACE'] = 'http://www.rfc-editor.org/rfc-index' + if not 'DEPTH_DEFAULT' in app.config: + app.config['DEPTH_DEFAULT'] = 8 + app.config['META'] = META + + # import rfc index data + try: + with app.open_instance_resource(app.config['INDEX_FILE']) as rfc_index: + xml: str = rfc_index.read() + except: + print('Error: INDEX_FILE could no be opened.\nExiting...') + exit(1) + + # set up the RFCartographer + parser: IndexParser = IndexParser(xml, app.config['NAMESPACE']) + app.cartographer: RFCartographer = RFCartographer(parser.get_index()) + + # register request handlers + register_errorhandlers(app) + register_routers(app) + return app diff --git a/rfcartography/details.py b/rfcartography/details.py new file mode 100644 index 0000000..1ccff7f --- /dev/null +++ b/rfcartography/details.py @@ -0,0 +1,43 @@ +from flask import Blueprint, render_template, current_app, abort +from rfcartography.index_parser import Document, NotIssued, DocType, Month + + +details: Blueprint = Blueprint('details', __name__) + +@details.route('/RFC', methods=['GET']) +def show_rfc(num: int) -> tuple[str, int]: + """handle requests for the details page for RFCs""" + rfc: Document = current_app.cartographer.get_document(DocType.RFC, num) + if rfc is None: + abort(404) + elif isinstance(rfc, NotIssued): + content: dict = {'title': rfc.docID(), + 'content': [('Not Issued', 'This RFC number was retained as a place holder, but never issued.')]} + return render_template('generic.html', **content), 200 + else: + url: str = "http://" + current_app.config['SERVER_NAME'] + if url[-1] != '/': + url = url + '/' + if rfc.pub_date is not None: + date: str = f"{Month(rfc.pub_date.month).name} {rfc.pub_date.year}" + else: + date: str = "" + context: dict = {'rfc': rfc, + 'url': url, + 'date': date} + return render_template('rfc.html', **context), 200 + +@details.route('/STD', methods=['GET'], defaults={'doctype': DocType.STD}) +@details.route('/BCP', methods=['GET'], defaults={'doctype': DocType.BCP}) +@details.route('/FYI', methods=['GET'], defaults={'doctype': DocType.FYI}) +def show_details(num: int, doctype: DocType) -> tuple[str, int]: + """handle requests for the details page for STDs, BCPs and FYIs""" + doc: Document = current_app.cartographer.get_document(doctype, num) + if doc is None: + abort(404) + url: str = "http://" + current_app.config['SERVER_NAME'] + if url[-1] != '/': + url = url + '/' + context: dict = {'doc': doc, + 'url': url} + return render_template('details.html', **context), 200 diff --git a/rfcartography/errors.py b/rfcartography/errors.py new file mode 100644 index 0000000..a62257d --- /dev/null +++ b/rfcartography/errors.py @@ -0,0 +1,29 @@ +from flask import Flask, render_template + + +def register_errorhandlers(app: Flask) -> None: + @app.errorhandler(400) + def bad_request(e) -> tuple: + content: dict = {'title': 'Bad Request', + 'content': [('HTTP Status 400', 'The request is malformed and cannot be processed.')]} + return render_template('generic.html', **content), 400 + + @app.errorhandler(404) + def not_found(e) -> tuple: + content: dict = {'title': 'Not Found', + 'content': [('HTTP Status 404', 'The requsted ressource was not found.')]} + return render_template('generic.html', **content), 404 + + @app.errorhandler(405) + def method_not_allowed(e) -> tuple: + content: dict = {'title': 'Method Not Allowed', + 'content': [('HTTP Status 405', 'The requested method is not allowed for this ressource.')]} + return render_template('generic.html', **content), 405 + + @app.errorhandler(500) + def internal_server_error(e) -> tuple: + content: dict = {'title': 'Internal Server Error', + 'content': [('HTTP Status 500', 'The request cannot be answered due to an internal server error.')]} + return render_template('generic.html', **content), 500 + + return diff --git a/rfcartography/index_parser.py b/rfcartography/index_parser.py new file mode 100644 index 0000000..01f0c13 --- /dev/null +++ b/rfcartography/index_parser.py @@ -0,0 +1,514 @@ +from enum import Enum, auto +from abc import ABC, abstractmethod +from datetime import date +from xml.etree.ElementTree import Element +from defusedxml.ElementTree import fromstring + + +class DocType(Enum): + RFC = 1 + STD = 2 + BCP = 3 + FYI = 4 + NIC = 5 + IEN = 6 + RTR = 7 + + def docID(self, + num: int) -> str: + if self.value < 5: # RFC, STD, BCP, FYI + return f"{self.name}{str(num).rjust(4, '0')}" + else: # NIC, IEN, RTR + return f"{self.name}{num}" + + +class Status(Enum): + INTERNET_STANDARD = auto() + DRAFT_STANDARD = auto() + PROPOSED_STANDARD = auto() + UNKNOWN = auto() + BEST_CURRENT_PRACTICE = auto() + FOR_YOUR_INFORMATION = auto() + EXPERIMENTAL = auto() + HISTORIC = auto() + INFORMATIONAL = auto() + + +class FileFormat(Enum): + ASCII = auto() + PS = auto() + PDF = auto() + TGZ = auto() + HTML = auto() + XML = auto() + TEXT = auto() + + +class Stream(Enum): + IETF = auto() + IAB = auto() + IRTF = auto() + INDEPENDENT = auto() + Editorial = auto() + Legacy = auto() + + +class Month(Enum): + January = 1 + February = 2 + March = 3 + April = 4 + May = 5 + June = 6 + July = 7 + August = 8 + September = 9 + October = 10 + November = 11 + December = 12 + + +class Author: + def __init__(self, + name: str, + title: str = "", + organization: str = "", + org_abbrev: str = ""): + self.name: str = name + self.title: str = title + self.organization: str = organization + self.org_abbrev: str = org_abbrev + return + + +class Document(ABC): + def __init__(self, + type: DocType, + number: int, + title: str = "", + is_also: list['Document'] = []): + self.type: DocType = type + self.number: int = number + self.title: str = title + self.is_also: list['Document'] = is_also + return + + def docID(self) -> str: + return self.type.docID(self.number) + + @abstractmethod + def update(self, **kwargs) -> 'Document': + pass + + @abstractmethod + def get_references(self) -> list[tuple[str, 'Document']]: + pass + + +class RFC(Document): + def __init__(self, + number: int, + title: str = "", + authors: list[Author] = [], + pub_date: date = None, + current_status: Status = Status.UNKNOWN, + pub_status: Status = Status.UNKNOWN, + format: list[FileFormat] = [], + page_count: int = None, + keywords: list[str] = [], + abstract: list[str] = [], + draft: str = "", + notes: str = "", + obsoletes: list[Document] = [], + obsoleted_by: list[Document] = [], + updates: list[Document] = [], + updated_by: list[Document] = [], + is_also: list[Document] = [], + see_also: list[Document] = [], + stream: Stream = None, + area: str = "", + wg_acronym: str = "", + errata_url: str = "", + doi: str = ""): + super().__init__(DocType.RFC, number, title, is_also) + self.authors: list[Author] = authors + self.pub_date: date = pub_date + self.format: list[FileFormat] = format + self.page_count: int = page_count + self.keywords: list[str] = keywords + self.abstract: list[str] = abstract + self.draft: str = draft + self.notes: str = notes + self.obsoletes: list[Document] = obsoletes + self.obsoleted_by: list[Document] = obsoleted_by + self.updates: list[Document] = updates + self.updated_by: list[Document] = updated_by + self.see_also: list[Document] = see_also + self.current_status: Status = current_status + self.pub_status: Status = pub_status + self.stream: Stream = stream + self.area: str = area + self.wg_acronym: str = wg_acronym + self.errata_url: str = errata_url + self.doi: str = doi + return + + def update(self, **kwargs) -> Document: + if 'title' in kwargs: + self.title = kwargs['title'] + if 'authors' in kwargs: + self.authors = kwargs['authors'] + if 'pub_date' in kwargs: + self.pub_date = kwargs["pub_date"] + if 'current_status' in kwargs: + self.current_status = kwargs["current_status"] + if 'pub_status' in kwargs: + self.pub_status = kwargs["pub_status"] + if 'format' in kwargs: + self.format = kwargs["format"] + if 'page_count' in kwargs: + self.page_count = kwargs["page_count"] + if 'keywords' in kwargs: + self.keywords = kwargs["keywords"] + if 'abstract' in kwargs: + self.abstract = kwargs["abstract"] + if 'draft' in kwargs: + self.draft = kwargs["draft"] + if 'notes' in kwargs: + self.notes = kwargs["notes"] + if 'obsoletes' in kwargs: + self.obsoletes = kwargs["obsoletes"] + if 'obsoleted_by' in kwargs: + self.obsoleted_by = kwargs["obsoleted_by"] + if 'updates' in kwargs: + self.updates = kwargs["updates"] + if 'updated_by' in kwargs: + self.updated_by = kwargs["updated_by"] + if 'is_also' in kwargs: + self.is_also = kwargs["is_also"] + if 'see_also' in kwargs: + self.see_also = kwargs["see_also"] + if 'stream' in kwargs: + self.stream = kwargs["stream"] + if 'area' in kwargs: + self.area = kwargs["area"] + if 'wg_acronym' in kwargs: + self.wg_acronym = kwargs["wg_acronym"] + if 'errata_url' in kwargs: + self.errata_url = kwargs["errata_url"] + if 'doi' in kwargs: + self.doi = kwargs["doi"] + return self + + def get_references(self) -> list[tuple[str, Document]]: + reftypes: list[str] = ["obsoletes"]*len(self.obsoletes)\ + + ["obsoleted by"]*len(self.obsoleted_by)\ + + ["updates"]*len(self.updates)\ + + ["updated by"]*len(self.updated_by)\ + + ["is also"]*len(self.is_also)\ + + ["see also"]*len(self.see_also) + refs: list[Document] = self.obsoletes \ + + self.obsoleted_by \ + + self.updates \ + + self.updated_by \ + + self.is_also \ + + self.see_also + return list(zip(reftypes, refs)) + + +class NotIssued(Document): + def __init__(self, + number: int): + super().__init__(DocType.RFC, number) + return + + def update(self, **kwargs) -> Document: + return self + + def get_references(self) -> list[tuple[str, Document]]: + return [] + + +class STD(Document): + def __init__(self, + number: int, + title: str = "", + is_also: list[Document] = []): + super().__init__(DocType.STD, number, title, is_also) + return + + def update(self, **kwargs) -> Document: + if 'title' in kwargs: + self.title = kwargs['title'] + if 'is_also' in kwargs: + self.is_also = kwargs['is_also'] + return self + + def get_references(self) -> list[tuple[str, Document]]: + return list(zip(["is also"]*len(self.is_also), self.is_also)) + + +class BCP(Document): + def __init__(self, + number: int, + title: str = "", + is_also: list[Document] = []): + super().__init__(DocType.BCP, number, title, is_also) + return + + def update(self, **kwargs) -> Document: + if 'title' in kwargs: + self.title = kwargs['title'] + if 'is_also' in kwargs: + self.is_also = kwargs['is_also'] + return self + + def get_references(self) -> list[tuple[str, Document]]: + return list(zip(["is also"]*len(self.is_also), self.is_also)) + + +class FYI(Document): + def __init__(self, + number: int, + title: str = "", + is_also: list[Document] = []): + super().__init__(DocType.FYI, number, title, is_also) + return + + def update(self, **kwargs) -> Document: + if 'title' in kwargs: + self.title = kwargs['title'] + if 'is_also' in kwargs: + self.is_also = kwargs['is_also'] + return self + + def get_references(self) -> list[tuple[str, Document]]: + return list(zip(["is also"]*len(self.is_also), self.is_also)) + + +class NIC(Document): + def __init__(self, + number: int): + super().__init__(DocType.NIC, number) + return + + def update(self, **kwargs) -> Document: + return self + + def get_references(self) -> list[tuple[str, Document]]: + return [] + + +class IEN(Document): + def __init__(self, + number: int): + super().__init__(DocType.IEN, number) + return + + def update(self, **kwargs) -> Document: + return self + + def get_references(self) -> list[tuple[str, Document]]: + return [] + + +class RTR(Document): + def __init__(self, + number: int): + super().__init__(DocType.RTR, number) + return + + def update(self, **kwargs) -> Document: + return self + + def get_references(self) -> list[tuple[str, Document]]: + return [] + + +class IndexParser: + def __init__(self, + xml: str, + namespace: str = "http://www.rfc-editor.org/rfc-index"): + def _get_reflist(container: Element | None) -> list[Document]: + reflist: list[Document] = [] + if container is not None: + for ref in container.findall(f"{{{namespace}}}doc-id"): + ref_type: str = DocType[ref.text[:3]] + ref_num: int = int(ref.text[3:]) + if ref_num not in self.index[ref_type]: + if ref_type == DocType.RFC: + self.index[DocType.RFC][ref_num] = RFC(ref_num) + elif ref_type == DocType.STD: + self.index[DocType.STD][ref_num] = STD(ref_num) + elif ref_type == DocType.BCP: + self.index[DocType.BCP][ref_num] = BCP(ref_num) + elif ref_type == DocType.FYI: + self.index[DocType.FYI][ref_num] = FYI(ref_num) + elif ref_type == DocType.NIC: + self.index[DocType.NIC][ref_num] = NIC(ref_num) + elif ref_type == DocType.IEN: + self.index[DocType.IEN][ref_num] = IEN(ref_num) + else: # ref_type == DocType.RTR + self.index[DocType.RTR][ref_num] = RTR(ref_num) + reflist.append(self.index[ref_type][ref_num]) + return reflist + + self.index: dict[DocType: dict[int, Document]] = {DocType.RFC: {}, + DocType.STD: {}, + DocType.BCP: {}, + DocType.FYI: {}, + DocType.NIC: {}, + DocType.IEN: {}, + DocType.RTR: {}} + + root: Element = fromstring(xml) + for child in root: + if child.tag == f"{{{namespace}}}rfc-entry": + docID: str = child.findtext(f"{{{namespace}}}doc-id") + number: int = int(docID[3:]) + title: str = child.findtext(f"{{{namespace}}}title") + authors: list[Author] = [] + for author in child.findall(f"{{{namespace}}}author"): + name: str = author.findtext(f"{{{namespace}}}name") + auth_title: str = author.findtext(f"{{{namespace}}}title", "") + org: str = author.findtext(f"{{{namespace}}}organization", "") + org_abbrev: str = author.findtext(f"{{{namespace}}}org-abbrev", "") + authors.append(Author(name, auth_title, org, org_abbrev)) + tmp: Element | None = child.find(f"{{{namespace}}}date") + pub_year: int = int(tmp.findtext(f"{{{namespace}}}year")) + pub_month: int = Month[tmp.findtext(f"{{{namespace}}}month")].value + pub_day: int = int(tmp.findtext(f"{{{namespace}}}day", "1")) + pub_date: date = date(pub_year, pub_month, pub_day) + format: list[FileFormat] = [] + tmp = child.find(f"{{{namespace}}}format") + if tmp is not None: + for file_format in tmp.findall(f"{{{namespace}}}file-format"): + format.append(FileFormat[file_format.text]) + page_count: int = int(child.findtext(f"{{{namespace}}}page-count", "-1")) + if page_count < 0: + page_count = None + keywords: list[str] = [] + tmp = child.find(f"{{{namespace}}}keywords") + if tmp is not None: + for kw in tmp.findall(f"{{{namespace}}}kw"): + keywords.append(kw.text) + abstract: list[str] = [] + tmp = child.find(f"{{{namespace}}}abstract") + if tmp is not None: + for p in tmp.findall(f"{{{namespace}}}p"): + abstract.append(p.text) + draft: str = child.findtext(f"{{{namespace}}}draft", "") + notes: str = child.findtext(f"{{{namespace}}}notes", "") + tmp = child.find(f"{{{namespace}}}obsoletes") + obsoletes: list[Document] = _get_reflist(tmp) + tmp = child.find(f"{{{namespace}}}obsoleted-by") + obsoleted_by: list[Document] = _get_reflist(tmp) + tmp = child.find(f"{{{namespace}}}updates") + updates: list[Document] = _get_reflist(tmp) + tmp = child.find(f"{{{namespace}}}updated-by") + updated_by: list[Document] = _get_reflist(tmp) + tmp = child.find(f"{{{namespace}}}is-also") + is_also: list[Document] = _get_reflist(tmp) + tmp = child.find(f"{{{namespace}}}see-also") + see_also: list[Document] = _get_reflist(tmp) + current_status: Status = Status[child.findtext(f"{{{namespace}}}current-status").replace(" ", "_")] + pub_status: Status = Status[child.findtext(f"{{{namespace}}}publication-status").replace(" ", "_")] + stream: Stream = None + tmp = child.find(f"{{{namespace}}}stream") + if tmp is not None: + stream = Stream[tmp.text] + area: str = child.findtext(f"{{{namespace}}}area", "") + wg_acronym: str = child.findtext(f"{{{namespace}}}wg_acronym", "") + errata_url: str = child.findtext(f"{{{namespace}}}errata-url", "") + doi: str = child.findtext(f"{{{namespace}}}doi", "") + if number in self.index[DocType.RFC]: + self.index[DocType.RFC][number].update(title=title, + authors=authors, + pub_date=pub_date, + current_status=current_status, + pub_status=pub_status, + format=format, + page_count=page_count, + keywords=keywords, + abstract=abstract, + draft=draft, + notes=notes, + obsoletes=obsoletes, + obsoleted_by=obsoleted_by, + updates=updates, + updated_by=updated_by, + is_also=is_also, + see_also=see_also, + stream=stream, + area=area, + wg_acronym=wg_acronym, + errata_url=errata_url, + doi=doi) + else: + self.index[DocType.RFC][number] = RFC(number, + title, + authors, + pub_date, + current_status, + pub_status, + format, + page_count, + keywords, + abstract, + draft, + notes, + obsoletes, + obsoleted_by, + updates, + updated_by, + is_also, + see_also, + stream, + area, + wg_acronym, + errata_url, + doi) + continue + elif child.tag == f"{{{namespace}}}rfc-not-issued-entry": + docID: str = child.findtext(f"{{{namespace}}}doc-id") + number: int = int(docID[3:]) + if number not in self.index[DocType.RFC]: + self.index[DocType.RFC][number] = NotIssued(number) + continue + elif child.tag == f"{{{namespace}}}std-entry": + docID: str = child.findtext(f"{{{namespace}}}doc-id") + number: int = int(docID[3:]) + title: str = child.findtext(f"{{{namespace}}}title") + alias: Element = child.find(f"{{{namespace}}}is-also") + is_also: list[Document] = _get_reflist(alias) + if number in self.index[DocType.STD]: + self.index[DocType.STD][number].update(title=title, is_also=is_also) + else: + self.index[DocType.STD][number] = STD(number, title, is_also) + continue + elif child.tag == f"{{{namespace}}}bcp-entry": + docID: str = child.findtext(f"{{{namespace}}}doc-id") + number: int = int(docID[3:]) + title: str = child.findtext(f"{{{namespace}}}title", "") + alias: Element = child.find(f"{{{namespace}}}is-also") + is_also: list[Document] = _get_reflist(alias) + if number in self.index[DocType.BCP]: + self.index[DocType.BCP][number].update(title=title, is_also=is_also) + else: + self.index[DocType.BCP][number] = BCP(number, title, is_also) + continue + elif child.tag == f"{{{namespace}}}fyi-entry": + docID: str = child.findtext(f"{{{namespace}}}doc-id") + number: int = int(docID[3:]) + title: str = child.findtext(f"{{{namespace}}}title", "") + alias: Element = child.find(f"{{{namespace}}}is-also") + is_also: list[Document] = _get_reflist(alias) + if number in self.index[DocType.FYI]: + self.index[DocType.FYI][number].update(title=title, is_also=is_also) + else: + self.index[DocType.FYI][number] = FYI(number, title, is_also) + continue + return + + def get_index(self) -> dict[DocType: dict[int, Document]]: + return self.index diff --git a/rfcartography/rfcartographer.py b/rfcartography/rfcartographer.py new file mode 100644 index 0000000..7b4f6db --- /dev/null +++ b/rfcartography/rfcartographer.py @@ -0,0 +1,172 @@ +from networkx import MultiDiGraph, kamada_kawai_layout, \ + draw_networkx_nodes, draw_networkx_edges +from matplotlib.pyplot import figure, close +from matplotlib.figure import Figure +from matplotlib.layout_engine import TightLayoutEngine +from io import StringIO +from math import sqrt +from rfcartography.index_parser import Document, DocType + + +class RFCMap: + def __init__(self, + graph: MultiDiGraph, + nodes: dict[DocType, list[Document]], + edges: dict[str, tuple[Document, Document]], + url_base: str, + node_color: dict[DocType, str] = {DocType.RFC: '#2072b1', + DocType.STD: '#c21a7e', + DocType.BCP: '#6d388d', + DocType.FYI: '#8bbd3e', + DocType.NIC: '#efe50b', + DocType.IEN: '#f28f20', + DocType.RTR: '#e32326'}, + edge_style: dict[str, tuple[str, str]]= {'obsoletes': ('dashed', '#607d8d'), + 'obsoleted by': ('dashed', '#303c50'), + 'updates': ('dashdot', '#607d8d'), + 'updated by': ('dashdot', '#303c50'), + 'is also': ('solid', '#132e41'), + 'see also': ('dotted', '#008e90')}): + self.graph: MultiDiGraph = graph + self.nodes: dict[DocType, list[Document]] = nodes + self.edges: dict[str, tuple[Document, Document]] = edges + self.node_color: dict[DocType, str] = node_color + self.edge_style: dict[str, tuple[str, str]] = edge_style + self.url_base: str = url_base + self.position: dict = kamada_kawai_layout(self.graph) + return + + def set_node_color(self, + doctype: DocType, + color: str) -> None: + self.node_color[doctype] = color + return + + def set_edge_style(self, + reftype: str, + style: tuple[str, str]) -> None: + self.edge_style[reftype] = style + return + + def set_url_base(self, + url_base: str) -> None: + self.url_base = url_base + return + + def get_node_colors(self) -> dict[DocType, str]: + return self.node_color + + def get_edge_styles(self) -> dict[str, tuple[str, str]]: + return self.edge_style + + def get_url_base(self) -> str: + return self.url_base + + def get_node_count(self) -> int: + return self.graph.number_of_nodes() + + def get_edge_count(self) -> int: + return self.graph.size() + + def draw(self) -> str: + nodes: list[Document] = [] + node_colors: list[str] = [] + urls: list[str] = [] + + for doctype in self.nodes: + nodes = nodes + self.nodes[doctype] + node_colors = node_colors + [self.node_color[doctype]]*len(self.nodes[doctype]) + for node in self.nodes[doctype]: + urls.append(f"{self.url_base}{node.docID()}") + + edge_rad: dict[float] = {'obsoletes': 0.1 if len(self.edges['obsoleted by']) > 0 else 0, + 'obsoleted by': 0.1 if len(self.edges['obsoletes']) > 0 else 0, + 'updates': 0.1 if len(self.edges['updated by']) > 0 else 0, + 'updated by': 0.1 if len(self.edges['updates']) > 0 else 0, + 'is also': 0, + 'see also': 0} + size: float = sqrt(len(nodes)) + fig: Figure = figure(figsize=(size, size), layout=TightLayoutEngine(pad=0.2)) + fig.clear() + + draw_networkx_nodes(self.graph, self.position, + nodelist=nodes, + node_size=128, + node_color=node_colors, + node_shape="o").set_urls(urls) + + for reftype in self.edges: + draw_networkx_edges(self.graph, self.position, + edgelist=self.edges[reftype], + connectionstyle=f"arc3,rad={edge_rad[reftype]}", + style=self.edge_style[reftype][0], + edge_color=self.edge_style[reftype][1]) + + svg: StringIO = StringIO() + fig.savefig(svg, format='svg') + svg.seek(0) + close(fig) + return svg.read().removeprefix('\n\n') + + +class RFCartographer: + def __init__(self, + index: dict[DocType: dict[int, Document]]): + self.index: dict[DocType: dict[int, Document]] = index + return + + def get_document(self, + doctype: DocType, + number: int) -> Document | None: + return self.index[doctype].get(number, None) + + def map_subnet(self, + core: Document, + url: str, + max_depth: int = 0, + node_color: dict[DocType, str] = None, + edge_style: dict[str, tuple[str, str]] = None, + node_types: list[DocType] = []) -> RFCMap: + """generate a map for the subnet core belongs to""" + if node_types == []: + node_types = [DocType.RFC, DocType.STD, DocType.BCP, + DocType.FYI, DocType.NIC, DocType.IEN, DocType.RTR] + nodes: dict = {DocType.RFC: [], + DocType.STD: [], + DocType.BCP: [], + DocType.FYI: [], + DocType.NIC: [], + DocType.IEN: [], + DocType.RTR: []} + edges: dict = {'obsoletes': [], + 'obsoleted by': [], + 'updates': [], + 'updated by': [], + 'is also': [], + 'see also': []} + params: dict[str, dict] = {} + if node_color is not None: + params['node_color'] = node_color + if edge_style is not None: + params['edge_style'] = edge_style + todo: list[tuple[Document, int]] = [(core, 0)] + done: list[Document] = [] + graph: MultiDiGraph = MultiDiGraph() + graph.add_node(core) + nodes[core.type].append(core) + + while len(todo) > 0: + node: tuple[Document, int] = todo.pop(0) + if node[0] not in done: + done.append(node[0]) + if node[1] < max_depth or max_depth <= 0: + for neighbor in node[0].get_references(): + if not neighbor[1].type in node_types: + continue + if not graph.has_node(neighbor[1]): + graph.add_node(neighbor[1]) + nodes[neighbor[1].type].append(neighbor[1]) + graph.add_edge(node[0], neighbor[1], reftype=neighbor[0]) + edges[neighbor[0]].append((node[0], neighbor[1])) + todo.append((neighbor[1], node[1]+1)) + return RFCMap(graph, nodes, edges, url, **params) diff --git a/rfcartography/routing.py b/rfcartography/routing.py new file mode 100644 index 0000000..0c6fd40 --- /dev/null +++ b/rfcartography/routing.py @@ -0,0 +1,35 @@ +from flask import Flask, Blueprint, redirect, url_for, render_template, abort +from werkzeug.wrappers import Response +from rfcartography.search import search +from rfcartography.details import details + + +def register_routers(app: Flask) -> None: + @app.route('/imprint') + def imprint() -> tuple[str, int]: + if not 'IMPRINT' in app.config: + abort(404) + content: dict = {'title': 'Imprint', + 'content': app.config['IMPRINT']} + return render_template('generic.html', **content), 200 + + @app.route('/privacy') + def privacy() -> tuple[str, int]: + if not 'PRIVACY' in app.config: + abort(404) + content: dict = {'title': 'Privacy', + 'content': app.config['PRIVACY']} + return render_template('generic.html', **content), 200 + + # answer favicon requests + @app.route('/favicon.ico') + def favicon() -> Response: + return redirect(url_for('static', filename='favicon.svg')) + + static = Blueprint('static', + __name__, + static_folder='static') + app.register_blueprint(static) + app.register_blueprint(search) + app.register_blueprint(details) + return diff --git a/rfcartography/search.py b/rfcartography/search.py new file mode 100644 index 0000000..d84b3e8 --- /dev/null +++ b/rfcartography/search.py @@ -0,0 +1,119 @@ +from flask import Blueprint, abort, request, current_app, render_template +from rfcartography.index_parser import DocType, Document + + +def validate_type(user_input: str | None) -> DocType: + """check if the given input is a DocType + return the DocType if it is valid + abort with HTTP Status 400 if it isn't valid""" + if user_input is None: + abort(400) + try: + doctype: DocType = DocType[user_input.upper()] + except KeyError: + abort(400) + return doctype + +def validate_int(user_input: str | None) -> int: + """check if the given input is a integer + return the int if it is valid + abort with HTTP Status 400 if it isn't valid""" + if user_input is None: + abort(400) + try: + i: int = int(user_input) + except ValueError: + abort(400) + return i + +def validate_color(user_input: str) -> str: + """check if the given user input is a valid color + return the string if it is valid + abort with HTTP Status 400 if it isn't valid""" + if user_input[0] != '#' or len(user_input) != 7: + abort(400) + for i in range(1,7): + if user_input[i] not in '0123456789abcdefABCDEF': + abort(400) + return user_input + +def validate_linestyle(user_input: str) -> str: + """check if the given user input is a valid linestyle + return the string if it is valid + abort with HTTP Status 400 if it isn't valid""" + if user_input in ['solid', 'dashed', 'dashdot', 'dotted', 'none']: + return user_input + else: + abort(400) + +search: Blueprint = Blueprint('search', __name__) + +@search.route('/', methods=['GET']) +def provide_searchform() -> tuple[str, int]: + """handle requests for the search form""" + return render_template('search.html'), 200 + +@search.route('/map', methods= ['GET']) +def handle_search_request() -> tuple[str, int]: + """handle search requests""" + params: dict = {} + + doctype: DocType = validate_type(request.args.get('type', None)) + num: int = validate_int(request.args.get('num', None)) + depth: int = validate_int(request.args.get('depth', current_app.config['DEPTH_DEFAULT'])) + nodes: list[DocType] = request.args.getlist('nodes_enabled', validate_type) + if nodes == []: + nodes = [DocType.RFC, DocType.STD, DocType.BCP, DocType.FYI, + DocType.NIC, DocType.IEN, DocType.RTR] + params['node_types'] = nodes + + node_colors: dict[DocType, str | None] = {DocType.RFC: request.args.get('rfc_color', None), + DocType.STD: request.args.get('std_color', None), + DocType.BCP: request.args.get('bcp_color', None), + DocType.FYI: request.args.get('fyi_color', None), + DocType.NIC: request.args.get('nic_color', None), + DocType.IEN: request.args.get('ien_color', None), + DocType.RTR: request.args.get('rtr_color', None)} + if not all(color is None for color in node_colors.values()): + for nodetype in node_colors: + node_colors[nodetype] = validate_color(node_colors[nodetype]) + params['node_color'] = node_colors + + edge_style: dict[str, tuple[str, str]] = \ + {'obsoletes': (request.args.get('obsoletes_style', None), + request.args.get('obsoletes_color', None)), + 'obsoleted by': (request.args.get('obsoleted_by_style', None), + request.args.get('obsoleted_by_color', None)), + 'updates': (request.args.get('updates_style', None), + request.args.get('updates_color', None)), + 'updated by': (request.args.get('updated_by_style', None), + request.args.get('updated_by_color', None)), + 'is also': (request.args.get('is_also_style', None), + request.args.get('is_also_color', None)), + 'see also': (request.args.get('see_also_style', None), + request.args.get('see_also_color', None))} + if not all(arg is None for edge_type in edge_style.values() for arg in edge_type): + for edge_type in edge_style: + edge_style[edge_type] = (validate_linestyle(edge_style[edge_type][0]), + validate_color(edge_style[edge_type][1])) + params['edge_style'] = edge_style + + url: str = "http://" + current_app.config['SERVER_NAME'] + if url[-1] != '/': + url = url + '/' + + doc: Document = current_app.cartographer.get_document(doctype, num) + if doc is None: + abort(404) + else: + rfc_map: RFCMap = current_app.cartographer.map_subnet(doc, url, depth, **params) + content: dict = {'core_node_id': doc.docID(), + 'map': rfc_map.draw(), + 'nodes': nodes, + 'node_colors': rfc_map.get_node_colors(), + 'edge_style': rfc_map.get_edge_styles()} + if not doctype in nodes: + content['nodes'].append(doctype) + return render_template('map.html', **content), 200 + + diff --git a/rfcartography/static/css/map.css b/rfcartography/static/css/map.css new file mode 100644 index 0000000..89cc615 --- /dev/null +++ b/rfcartography/static/css/map.css @@ -0,0 +1,47 @@ +main h1 + svg { + width: 70%; + height: auto; +} + +main h1 + svg + div { + width: 30%; + float: right; + padding: 1rem; +} + +main div table { + margin-bottom: 1rem; +} + +main div table tr td:first-of-type { + height: 2rem; + width: 3rem; + padding-right: 1rem; + vertical-align: middle; +} + +main div table tr td:nth-of-type(2) { + height: 2rem; +} + +.node { + width: 1.5rem; + height: auto; +} + +.edge { + width: 3rem; + height: auto; + vertical-align: text-top; +} + +@media (max-width: 860px) { + main h1 + svg { + width: 100%; + } + + main h1 + svg + div { + width: 100%; + float: none; + } +} diff --git a/rfcartography/static/css/reset.css b/rfcartography/static/css/reset.css new file mode 100644 index 0000000..e29c0f5 --- /dev/null +++ b/rfcartography/static/css/reset.css @@ -0,0 +1,48 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} diff --git a/rfcartography/static/css/rfcartography.css b/rfcartography/static/css/rfcartography.css new file mode 100644 index 0000000..b295348 --- /dev/null +++ b/rfcartography/static/css/rfcartography.css @@ -0,0 +1,290 @@ +@font-face { + font-family: 'Courier Prime'; + font-style: normal; + font-weight: normal; + font-stretch: normal; + font-display: swap; + src: url('/static/fonts/CourierPrime-Regular.ttf') format('truetype'), + local('Courier Prime'); +} + +@font-face { + font-family: 'Courier Prime'; + font-style: normal; + font-weight: bold; + font-stretch: normal; + font-display: swap; + src: url('/static/fonts/CourierPrime-Bold.ttf') format('truetype'), + local('Courier Prime'); +} + +@font-face { + font-family: 'Courier Prime'; + font-style: italic; + font-weight: normal; + font-stretch: normal; + font-display: swap; + src: url('/static/fonts/CourierPrime-Italic.ttf') format('truetype'), + local('Courier Prime'); +} + +@font-face { + font-family: 'Courier Prime'; + font-style: italic; + font-weight: bold; + font-stretch: normal; + font-display: swap; + src: url('/static/fonts/CourierPrime-BoldItalic.ttf') format('truetype'), + local('Courier Prime'); +} + +:root { + --background-body: #FAFAFA; + --background-item: #FFFFFF; + --accent-blue: #2072b1; + --accent-red: #c21a7e; + --text: #000000; + --gradient: linear-gradient(45deg, #2072b1 10%, #c21a7e 90%); + --body-width: 80vw; +} + +@media (max-width: 860px) { + :root { + --body-width: 100vw; + } +} + +/* consider padding and borders for width and height */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +@media (prefers-reduced-motion: no-preference) { + html { + scroll-behavior: smooth; + } +} + +html { + font-size: 1rem; +} + +body { + font-family: 'Courier Prime', monospace; + line-height: 1.8; + max-width: var(--body-width); + min-height: 100vh; + overflow-x: hidden; + background-color: var(--background-body); + text-rendering: optimizeLegibility; + margin: auto; +} + +header +{ + height: 4em; + width: 100%; + padding: 1ch; + border-bottom-width: 3px; + border-bottom-style: solid; + border-bottom-color: var(--accent-blue); + border-image: var(--gradient); + border-image-slice: 1; + display: flex; + align-items: center; + margin-bottom: 1.5em; +} + +header img +{ + height: 3ch; + padding-right: 1ch; +} + +header a { + text-decoration: none; +} + +header a h1 { + font-size: 2ch; +} + +header a h1 span:nth-of-type(1) { + color: var(--accent-red) +} + +header a h1 span:nth-of-type(2) { + color: var(--accent-blue) +} + +header a:hover h1 span:nth-of-type(1) { + color: var(--accent-blue) +} + +header a:hover h1 span:nth-of-type(2) { + color: var(--accent-red) +} + +header label { + display: none; +} + +header nav { + flex-grow: 1; +} + +header form { + display: block; + text-align: right; + font-size: 0; + min-width: 14rem; +} + +header select { + border-width: 2px; + border-style: solid; + border-color: var(--accent-blue); + border-top-left-radius: 1ch; + border-bottom-left-radius: 1ch; + height: 2rem; + width: 4rem; + background-color: var(--background-item); + text-align: right; + font-family: 'Courier Prime', monospace; + vertical-align: middle; +} + +header input[type=number] { + border-width: 2px; + border-style: solid; + border-color: var(--accent-blue); + height: 2rem; + width: 8rem; + background-color: var(--background-item); + font-family: 'Courier Prime', monospace; + -webkit-appearance: none; + -moz-appearance: textfield; + vertical-align: middle +} + +header input[type=number]:focus { + border-color: var(--accent-red); + outline: none; +} + +header input[type=submit] { + border-width: 2px; + border-style: solid; + border-color: var(--accent-blue); + border-top-right-radius: 1ch; + border-bottom-right-radius: 1ch; + height: 2rem; + width: 2rem; + background-color: var(--accent-blue); + color: var(--background-item); + font-weight: bold; + vertical-align: middle; +} + +header input[type=submit]:hover { + background-color: var(--background-item); + border-color: var(--accent-blue); + color: var(--accent-blue); +} + +header input[type=submit]:focus { + background-color: var(--background-item); + border-color: var(--accent-red); + color: var(--accent-red); +} + +main { + min-height: 50vh; + padding: 1em; +} + +main h1 { + font-size: 1.4rem; + font-weight: bold; + color: var(--accent-blue); + margin-bottom: 0.5em; + margin-top: 0.5em; +} + +main h2 { + font-size: 1.2rem; + font-weight: bold; + margin-bottom: 0.5em; + margin-top: 0.5em; +} + +main p { + margin-bottom: 0.5em; +} + +main a { + text-decoration: none; + color: var(--accent-blue); +} + +main a:hover { + text-decoration: none; + color: var(--accent-red); +} + +main a:active { + text-decoration: underline; +} + +main dl dt { + font-weight: bold; +} + +main dl dd { + padding-left: 2rem; +} + +footer { + width: 100%; + padding: 1ch; + border-top-width: 3px; + border-top-style: solid; + border-top-color: var(--accent-blue); + border-image: var(--gradient); + border-image-slice: 1; + margin-top: 2ch; + clear: both; +} + +footer ul { + display: flex; + align-items: center; + justify-content: center; + color: var(--accent-blue); + font-size: 0.8rem; +} + +footer ul li { + padding-left: 1ch; + padding-right: 1ch; +} + +footer ul li:nth-child(2):before { + content: "::"; +} + +footer ul li:nth-child(2):after { + content: "::"; +} + +footer ul li a { + text-decoration: none; + color: var(--accent-blue); +} + +footer ul li a:hover { + text-decoration: none; + color: var(--accent-red); +} diff --git a/rfcartography/static/css/search.css b/rfcartography/static/css/search.css new file mode 100644 index 0000000..d7ab29c --- /dev/null +++ b/rfcartography/static/css/search.css @@ -0,0 +1,152 @@ +main form { + width: 100%; + display: flex; + flex-wrap: wrap; +} + +main form fieldset { + padding: 0.5rem; +} + +main form fieldset input[type=number] { + border-width: 2px; + border-style: solid; + border-color: var(--accent-blue); + height: 2rem; + background-color: var(--background-item); + font-family: 'Courier Prime', monospace; + -webkit-appearance: none; + -moz-appearance: textfield; + vertical-align: middle; + padding-left: 0.5ch; + padding-right: 0.5ch; +} + +main form fieldset input[type=number]:focus { + border-color: var(--accent-red); + outline: none; +} + +main form fieldset input[type=number]:first-of-type { + font-size: 1rem; + width: calc(100% - 18ch - 4rem); +} + +main form fieldset select { + border-width: 2px; + border-style: solid; + border-color: var(--accent-blue); + font-size: 1rem; + height: 2rem; + background-color: var(--background-item); + text-align: right; + font-family: 'Courier Prime', monospace; + vertical-align: middle; +} + +main form fieldset:first-of-type { + min-width: 100%; + font-size: 0rem; +} + +main form fieldset:first-of-type label{ + font-size: 1rem; + padding: 1ch; +} + +main form fieldset:first-of-type select { + border-top-left-radius: 1ch; + border-bottom-left-radius: 1ch; + width: 18ch; +} + +main form fieldset:first-of-type input[type=submit] { + border-width: 2px; + border-style: solid; + border-color: var(--accent-blue); + border-top-right-radius: 1ch; + border-bottom-right-radius: 1ch; + height: 2rem; + width: 4rem; + background-color: var(--accent-blue); + color: var(--background-item); + font-size: 1rem; + font-weight: bold; + vertical-align: middle; +} + +main form fieldset:first-of-type input[type=submit]:hover { + background-color: var(--background-item); + border-color: var(--accent-blue); + color: var(--accent-blue); +} + +main form fieldset:first-of-type input[type=submit]:focus { + background-color: var(--background-item); + border-color: var(--accent-red); + color: var(--accent-red); +} + +main form fieldset:not(:first-of-type) { + min-width: 20rem; + border-style: solid; + border-width: 2px; + border-radius: 1rem; + border-color: var(--accent-blue); + border-image: var(--gradient); + border-image-slice: 1; + flex-grow: 1; + margin: 0.5rem; + text-align: center; +} + +main form fieldset:not(:first-of-type) legend { + padding: 0.5rem; +} + +main form fieldset:not(:first-of-type) div { + display: inline-block; + vertical-align: middle; +} + +main form fieldset:not(:first-of-type) div:nth-of-type(1) { + padding-right: 1rem; +} + +main form fieldset:not(:first-of-type) div:nth-of-type(2) { + height: 100%; +} + +main form fieldset #depth { + text-align: right; + font-size: 1rem; + border-radius: 1ch; + width: 8rem; +} + +main form fieldset:not(:first-of-type) input[type=color] { + width: 2rem; + vertical-align: middle; + border-radius: 0.5rem; +} + +main form fieldset:not(:first-of-type) select { + border-radius: 1ch; + width: 12ch; +} + +main form fieldset:not(:first-of-type) label { + display: inline-block; + vertical-align: middle; + text-align: left; +} + +main form fieldset:nth-of-type(4) div label, +main form fieldset:nth-of-type(5) div label { + width: 4ch; +} + +main form fieldset:nth-of-type(6) div label, +main form fieldset:nth-of-type(7) div label { + width: 13ch; +} diff --git a/rfcartography/static/favicon.svg b/rfcartography/static/favicon.svg new file mode 100644 index 0000000..5bd7eed --- /dev/null +++ b/rfcartography/static/favicon.svg @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/rfcartography/static/fonts/CourierPrime-Bold.ttf b/rfcartography/static/fonts/CourierPrime-Bold.ttf new file mode 100644 index 0000000..7e6b222 Binary files /dev/null and b/rfcartography/static/fonts/CourierPrime-Bold.ttf differ diff --git a/rfcartography/static/fonts/CourierPrime-BoldItalic.ttf b/rfcartography/static/fonts/CourierPrime-BoldItalic.ttf new file mode 100644 index 0000000..2e70ab7 Binary files /dev/null and b/rfcartography/static/fonts/CourierPrime-BoldItalic.ttf differ diff --git a/rfcartography/static/fonts/CourierPrime-Italic.ttf b/rfcartography/static/fonts/CourierPrime-Italic.ttf new file mode 100644 index 0000000..15d9463 Binary files /dev/null and b/rfcartography/static/fonts/CourierPrime-Italic.ttf differ diff --git a/rfcartography/static/fonts/CourierPrime-Regular.ttf b/rfcartography/static/fonts/CourierPrime-Regular.ttf new file mode 100644 index 0000000..4af1ff5 Binary files /dev/null and b/rfcartography/static/fonts/CourierPrime-Regular.ttf differ diff --git a/rfcartography/static/fonts/OFL.txt b/rfcartography/static/fonts/OFL.txt new file mode 100644 index 0000000..8f1b147 --- /dev/null +++ b/rfcartography/static/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2015 The Courier Prime Project Authors (https://github.com/quoteunquoteapps/CourierPrime). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/rfcartography/templates/base.html b/rfcartography/templates/base.html new file mode 100644 index 0000000..80ff771 --- /dev/null +++ b/rfcartography/templates/base.html @@ -0,0 +1,64 @@ + + + + {% block head %} + + + + {% block head_title %} + RFCartography + {% endblock head_title %} + + + + + {% endblock head %} + + +
+ RFCartography Logo + +

+ RFCartography +

+
+ +
+
+ {% block main %} + {% endblock %} +
+ + + diff --git a/rfcartography/templates/details.html b/rfcartography/templates/details.html new file mode 100644 index 0000000..4ee1e5c --- /dev/null +++ b/rfcartography/templates/details.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{%- block head_title %} +{{ doc.docID() }} :: RFCartography +{% endblock head_title %} + +{%- block main %} +

+ {{ doc.docID() }} +

+
+ {% if doc.title != "" %} +
+ Title +
+
+ {{ doc.title }} +
+ {% endif %} + {% for ref in doc.is_also %} + {% if loop.first %} +
+ a.k.a. +
+
+ {% endif %} + + {{ ref.docID() }} + {% if not loop.last %}, + {% else %} +
+ {% endif %} + {% endfor %} +
+
+ + RFC-Editor + +{% endblock main %} diff --git a/rfcartography/templates/generic.html b/rfcartography/templates/generic.html new file mode 100644 index 0000000..bc8eb49 --- /dev/null +++ b/rfcartography/templates/generic.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{%- block head_title %} +{{ title }} :: RFCartography +{% endblock head_title %} + +{%- block main %} +

+ {{ title }} +

+{% for chapter in content %} + {% for paragraph in chapter %} + {% if loop.first %} +

+ {{ paragraph }} +

+ {% else %} +

+ {% if paragraph is string %} + {{ paragraph }} + {% else %} + {% for line in paragraph %} + {{ line }} +
+ {% endfor %} + {% endif %} +

+ {% endif %} + {% endfor %} +{% endfor %} +{% endblock main %} diff --git a/rfcartography/templates/map.html b/rfcartography/templates/map.html new file mode 100644 index 0000000..45d0095 --- /dev/null +++ b/rfcartography/templates/map.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} + +{% block head %} + {{ super() }} + +{% endblock %} + +{%- block head_title %} +Map for {{ core_node_id }} :: RFCartography +{% endblock head_title %} + +{%- block main %} +

+ Map of {{ core_node_id }} and its Environment +

+{{ map|safe }} +
+

+ Legend +

+

+ Nodes: +

+ + {% for nodetype in nodes %} + + + + + {% endfor %} +
+ + + + + {{ nodetype.name }} +
+

+ Edges: +

+ + {% for edge in edge_style.keys() %} + + {% if edge_style[edge][0] == "solid" %} + + + {% elif edge_style[edge][0] == "dotted" %} + + + {% elif edge_style[edge][0] == "dashdot" %} + + + {% elif edge_style[edge][0] == "dashed" %} + + + {% else %} + + + {% endif %} + + {% endfor %} +
+ + + + + + {{ edge }} + + + + + + + {{ edge }} + + + + + + + {{ edge }} + + + + + + + {{ edge }} + + + + + {{ edge }} +
+
+{% endblock main %} diff --git a/rfcartography/templates/rfc.html b/rfcartography/templates/rfc.html new file mode 100644 index 0000000..1adf804 --- /dev/null +++ b/rfcartography/templates/rfc.html @@ -0,0 +1,224 @@ +{% extends "base.html" %} + +{%- block head_title %} +{{ rfc.docID() }} :: RFCartography +{% endblock head_title %} + +{%- block main %} +

+ {{ rfc.docID() }} +

+
+
+ Title +
+
+ {{ rfc.title }} +
+ {% for ref in rfc.is_also %} + {% if loop.first %} +
+ a.k.a. +
+
+ {% endif %} + + {{ ref.docID() }} + {% if not loop.last %}, + {% else %} +
+ {% endif %} + {% endfor %} +
+ Authors +
+
+ {% for author in rfc.authors %} + {{ author.title }} {{ author.name }} {% if not author.organization == "" %}({{ author.organization }}){% endif %}{% if not loop.last %}
{% endif %} + {% endfor %} +
+ {% if date != "" %} +
+ Publication Date +
+
+ {{ date }} +
+ {% endif %} +
+ Available as +
+
+ {% for format in rfc.format %} + {{ format.name }}{% if not loop.last %}, {% endif %} + {% endfor %} +
+ {% if rfc.page_count is not none %} +
+ Pages +
+
+ {{ rfc.page_count }} +
+ {% endif %} + {% for kw in rfc.keywords %} + {% if loop.first %} +
+ Keywords +
+
+ {% endif %} + {{ kw }}{% if not loop.last %}, {% endif %} + {% if loop.last %} +
+ {% endif %} + {% endfor %} + {% for p in rfc.abstract %} + {% if loop.first %} +
+ Abstract +
+
+ {% endif %} +

+ {{ p }} +

+ {% if loop.last %} +
+ {% endif %} + {% endfor %} + {% if rfc.draft != "" %} +
+ Draft +
+
+ {{ rfc.draft }} +
+ {% endif %} + {% if rfc.notes != "" %} +
+ Notes +
+
+ {{ rfc.notes }} +
+ {% endif %} + {% for ref in rfc.obsoletes %} + {% if loop.first %} +
+ Obsoletes +
+
+ {% endif %} + + {{ ref.docID() }} + {% if not loop.last %}, + {% else %} +
+ {% endif %} + {% endfor %} + {% for ref in rfc.obsoleted_by %} + {% if loop.first %} +
+ Obsoleted by +
+
+ {% endif %} + + {{ ref.docID() }} + {% if not loop.last %}, + {% else %} +
+ {% endif %} + {% endfor %} + {% for ref in rfc.updates %} + {% if loop.first %} +
+ Updates +
+
+ {% endif %} + + {{ ref.docID() }} + {% if not loop.last %}, + {% else %} +
+ {% endif %} + {% endfor %} + {% for ref in rfc.updated_by %} + {% if loop.first %} +
+ Updated by +
+
+ {% endif %} + + {{ ref.docID() }} + {% if not loop.last %}, + {% else %} +
+ {% endif %} + {% endfor %} + {% for ref in rfc.see_also %} + {% if loop.first %} +
+ See also +
+
+ {% endif %} + + {{ ref.docID() }} + {% if not loop.last %}, + {% else %} +
+ {% endif %} + {% endfor %} +
+ Status +
+
+ {{ rfc.current_status.name }} {% if rfc.current_status != rfc.pub_status %}(originally published as {{ rfc.pub_status.name }}){% endif %} +
+ {% if rfc.stream is not none %} +
+ Stream +
+
+ {{ rfc.stream.name }} +
+ {% endif %} + {% if rfc.area != "" %} +
+ Area +
+
+ {{ rfc.area }} +
+ {% endif %} + {% if rfc.wg_acronym != "" %} +
+ Working Group +
+
+ {{ rfc.wg_acronym }} +
+ {% endif %} + {% if rfc.doi != "" %} +
+ DOI +
+
+ {{ rfc.doi }} +
+ {% endif %} +
+
+ + RFC-Editor + + {% if rfc.errata_url != "" %} | + + Errata + + {% endif %} +{% endblock main %} diff --git a/rfcartography/templates/search.html b/rfcartography/templates/search.html new file mode 100644 index 0000000..c095f25 --- /dev/null +++ b/rfcartography/templates/search.html @@ -0,0 +1,158 @@ +{% extends "base.html" %} + +{% block head %} + {{ super() }} + +{% endblock %} + +{%- block head_title %} +RFCartography +{% endblock head_title %} + +{%- block main %} +

+ Generate Maps of RFCs and their Relations +

+ +{% endblock main %} diff --git a/tests/test_details.py b/tests/test_details.py new file mode 100644 index 0000000..8580d73 --- /dev/null +++ b/tests/test_details.py @@ -0,0 +1,91 @@ +from unittest import TestCase +from flask import Flask +from rfcartography import create_app +from rfcartography.index_parser import RFC, NotIssued, STD, BCP, FYI, DocType +from rfcartography.rfcartographer import RFCartographer + + +class TestDetailsPages(TestCase): + def setUp(self): + """create an app object for testing purposes""" + self.app:Flask = create_app() + self.app.config.update({'TESTING': True}) + + rfc42: RFC = RFC(42) + rfc23: NotIssued = NotIssued(23) + std42: STD = STD(42) + bcp42: BCP = BCP(42) + fyi42: FYI = FYI(42) + index: dict[DocType: dict[int, Document]] = {DocType.RFC: {42: rfc42, + 23: rfc23}, + DocType.STD: {42: std42}, + DocType.BCP: {42: bcp42}, + DocType.FYI: {42: fyi42}, + DocType.NIC: {}, + DocType.IEN: {}, + DocType.RTR: {}} + self.app.cartographer = RFCartographer(index) + return + + def test_rfc_details(self): + """testing the details page for RFCs""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/RFC0042') + self.assertEqual(response.status, '200 OK') + return + + def test_rfc_not_issued_details(self): + """testing the details page for not issued RFCs""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/RFC0023') + self.assertEqual(response.status, '200 OK') + return + + def test_not_existing_rfc_details(self): + """testing the details page requests for not existing RFCs""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/RFC0666') + self.assertEqual(response.status, '404 NOT FOUND') + return + + def test_std_details(self): + """testing the details page for STDs""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/STD0042') + self.assertEqual(response.status, '200 OK') + return + + def test_not_existing_std_details(self): + """testing the details page requests for not existing STDs""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/STD0666') + self.assertEqual(response.status, '404 NOT FOUND') + return + + def test_bcp_details(self): + """testing the details page for BCPs""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/BCP0042') + self.assertEqual(response.status, '200 OK') + return + + def test_not_existing_bcp_details(self): + """testing the details page requests for not existing BCPs""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/BCP0666') + self.assertEqual(response.status, '404 NOT FOUND') + return + + def test_fyi_details(self): + """testing the details page for FYIs""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/FYI0042') + self.assertEqual(response.status, '200 OK') + return + + def test_not_existing_fyi_details(self): + """testing the details page requests for not existing FYIs""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/FYI0666') + self.assertEqual(response.status, '404 NOT FOUND') + return diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..590ba88 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,34 @@ +from unittest import TestCase +from flask import Flask +from flask.testing import FlaskClient +from werkzeug.test import TestResponse +from rfcartography import create_app + + +class TestErrorHandling(TestCase): + def setUp(self): + """create an app object for testing purposes""" + self.app:Flask = create_app() + self.app.config.update({"TESTING": True}) + return + + def test_400(self): + """testing handling of malformed requests""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/map?type=foo&num=bar') + self.assertEqual(response.status, '400 BAD REQUEST') + return + + def test_404(self): + """testing handling of requests for non-existing ressources""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/foobar') + self.assertEqual(response.status, '404 NOT FOUND') + return + + def test_405(self): + """testing handling of invalid request methods""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.post('/') + self.assertEqual(response.status, '405 METHOD NOT ALLOWED') + return diff --git a/tests/test_index_parser.py b/tests/test_index_parser.py new file mode 100644 index 0000000..28de27d --- /dev/null +++ b/tests/test_index_parser.py @@ -0,0 +1,715 @@ +from unittest import TestCase +from rfcartography.index_parser import Author, Status, FileFormat, RFC, NotIssued,\ + STD, BCP, FYI, NIC, IEN, RTR, IndexParser,\ + DocType, Stream, Document +from datetime import date + + +class TestDocumentClasses(TestCase): + def test_rfc_id(self): + """testing docID for RFCs""" + rfc: RFC = RFC(791, + "Internet Protocol", + Author("J. Postel"), + date(1981, 9, 1), + Status.INTERNET_STANDARD, + Status.INTERNET_STANDARD) + self.assertEqual(rfc.docID(), 'RFC0791') + return + + def test_not_issued_id(self): + """testing docID for not issued RFCs""" + not_issued: NotIssued = NotIssued(715) + self.assertEqual(not_issued.docID(), 'RFC0715') + return + + def test_std_id(self): + """testing docID for STDs""" + std: STD = STD(6, "User Datagram Protocol") + self.assertEqual(std.docID(), 'STD0006') + return + + def test_bcp_id(self): + """testing docID for BCPs""" + bcp: BCP = BCP(200, "IAB and IESG Statement on Cryptographic Technology and the Internet") + self.assertEqual(bcp.docID(), 'BCP0200') + return + + def test_fyi_id(self): + """testing docID for FYIs""" + fyi: FYI = FYI(5, "Choosing a name for your computer") + self.assertEqual(fyi.docID(), 'FYI0005') + return + + def test_nic_id(self): + """testing docID for NICs""" + nic: NIC = NIC(42) + self.assertEqual(nic.docID(), 'NIC42') + return + + def test_ien_id(self): + """testing docID for IENs""" + ien: IEN = IEN(42) + self.assertEqual(ien.docID(), 'IEN42') + return + + def test_rtr_id(self): + """testing docID for RTRs""" + rtr: RTR = RTR(42) + self.assertEqual(rtr.docID(), 'RTR42') + return + + def test_rfc_refs(self): + """testing get_references() for RFCs""" + rfc23: RFC = RFC( 23) + rfc1337: RFC = RFC(1337) + rfc12: RFC = RFC( 12) + rfc13: RFC = RFC( 13) + rfc52: RFC = RFC( 52) + rfc53: RFC = RFC( 53) + bcp1: BCP = BCP( 1) + std2: STD = STD( 2) + ien4223: IEN = IEN(4223) + nic42: NIC = NIC( 42) + rfc: RFC = RFC(42, + "Test", + Author("J. Doe"), + date(1970, 1, 1), + Status.INTERNET_STANDARD, + Status.INTERNET_STANDARD, + obsoletes=[rfc23], + obsoleted_by=[rfc1337], + updates=[rfc12, rfc13], + updated_by=[rfc52, rfc53], + is_also=[bcp1, std2], + see_also=[ien4223, nic42]) + reflist: list = rfc.get_references() + self.assertEqual(len(reflist), 10) + self.assertIn(("obsoletes", rfc23), reflist) + self.assertIn(("obsoleted by", rfc1337), reflist) + self.assertIn(("updates", rfc12), reflist) + self.assertIn(("updates", rfc13), reflist) + self.assertIn(("updated by", rfc52), reflist) + self.assertIn(("updated by", rfc53), reflist) + self.assertIn(("is also", bcp1), reflist) + self.assertIn(("is also", std2), reflist) + self.assertIn(("see also", ien4223), reflist) + self.assertIn(("see also", nic42), reflist) + return + + def test_not_issued_refs(self): + """testing get_references() for not issued RFCs""" + self.assertEqual(NotIssued(715).get_references(), []) + return + + def test_std_ref(self): + """testing get_references() for STDs""" + rfc42: RFC = RFC(42) + nic42: NIC = NIC(42) + std: STD = STD(42, + "test", + [rfc42, nic42]) + reflist: list = std.get_references() + self.assertEqual(len(reflist), 2) + self.assertIn(("is also", rfc42), reflist) + self.assertIn(("is also", nic42), reflist) + return + + def test_bcp_ref(self): + """testing get_references() for BCPs""" + rfc42: RFC = RFC(42) + nic42: NIC = NIC(42) + bcp: BCP = BCP(42, + "test", + [rfc42, nic42]) + reflist: list = bcp.get_references() + self.assertEqual(len(reflist), 2) + self.assertIn(("is also", rfc42), reflist) + self.assertIn(("is also", nic42), reflist) + return + + def test_fyi_ref(self): + """testing get_references() for FYIs""" + rfc42: RFC = RFC(42) + nic42: NIC = NIC(42) + fyi: FYI = FYI(42, + "test", + [rfc42, nic42]) + reflist: list = fyi.get_references() + self.assertEqual(len(reflist), 2) + self.assertIn(("is also", rfc42), reflist) + self.assertIn(("is also", nic42), reflist) + return + + def test_nic_refs(self): + """testing get_references() for NICs""" + self.assertEqual(NIC(42).get_references(), []) + return + + def test_ien_refs(self): + """testing get_references() for IENs""" + self.assertEqual(IEN(42).get_references(), []) + return + + def test_rtr_refs(self): + """testing get_references() for RTRs""" + self.assertEqual(RTR(42).get_references(), []) + return + + def test_rfc_update(self): + """testing RFC updates""" + ref: NIC = NIC(42) + rfc: RFC = RFC(42) + rfc.update(title="test", + authors=[Author("test")], + pub_date=date(1984, 1, 1), + current_status=Status.EXPERIMENTAL, + pub_status=Status.PROPOSED_STANDARD, + format=FileFormat.PDF, + page_count=42, + keywords=["foo"], + abstract=["bar"], + draft="draft", + notes="notes", + obsoletes=[ref], + obsoleted_by=[ref], + updates=[ref], + updated_by=[ref], + is_also=[ref], + see_also=[ref], + stream=Stream.IETF, + area="area", + wg_acronym="wg_acronym", + errata_url="errata_url", + doi="doi") + self.assertEqual(rfc.title, "test") + self.assertEqual(len(rfc.authors), 1) + self.assertEqual(rfc.authors[0].name, "test") + self.assertEqual(rfc.pub_date.year, 1984) + self.assertEqual(rfc.pub_date.month, 1) + self.assertEqual(rfc.pub_date.day, 1) + self.assertEqual(rfc.current_status, Status.EXPERIMENTAL) + self.assertEqual(rfc.pub_status, Status.PROPOSED_STANDARD) + self.assertEqual(rfc.format, FileFormat.PDF) + self.assertEqual(rfc.page_count, 42) + self.assertEqual(len(rfc.keywords), 1) + self.assertEqual(rfc.keywords[0], "foo") + self.assertEqual(len(rfc.abstract), 1) + self.assertEqual(rfc.abstract[0], "bar") + self.assertEqual(rfc.draft, "draft") + self.assertEqual(rfc.notes, "notes") + self.assertEqual(len(rfc.obsoletes), 1) + self.assertEqual(rfc.obsoletes[0], ref) + self.assertEqual(len(rfc.obsoleted_by), 1) + self.assertEqual(rfc.obsoleted_by[0], ref) + self.assertEqual(len(rfc.updates), 1) + self.assertEqual(rfc.updates[0], ref) + self.assertEqual(len(rfc.updated_by), 1) + self.assertEqual(rfc.updated_by[0], ref) + self.assertEqual(len(rfc.is_also), 1) + self.assertEqual(rfc.is_also[0], ref) + self.assertEqual(len(rfc.see_also), 1) + self.assertEqual(rfc.see_also[0], ref) + self.assertEqual(rfc.stream, Stream.IETF) + self.assertEqual(rfc.area, "area") + self.assertEqual(rfc.wg_acronym, "wg_acronym") + self.assertEqual(rfc.errata_url, "errata_url") + self.assertEqual(rfc.doi, "doi") + return + + def test_std_update(self): + """testing STD updates""" + ref: NIC = NIC(42) + std: STD = STD(42) + std.update(title="title", + is_also=[ref]) + self.assertEqual(std.title, "title") + self.assertEqual(len(std.is_also), 1) + self.assertEqual(std.is_also[0], ref) + return + + def test_bcp_update(self): + """testing BCP updates""" + ref: NIC = NIC(42) + bcp: BCP = BCP(42) + bcp.update(title="title", + is_also=[ref]) + self.assertEqual(bcp.title, "title") + self.assertEqual(len(bcp.is_also), 1) + self.assertEqual(bcp.is_also[0], ref) + return + + def test_fyi_update(self): + """testing FYI updates""" + ref: NIC = NIC(42) + fyi: FYI = FYI(42) + fyi.update(title="title", + is_also=[ref]) + self.assertEqual(fyi.title, "title") + self.assertEqual(len(fyi.is_also), 1) + self.assertEqual(fyi.is_also[0], ref) + return + + def test_not_issued_update(self): + """testing RFC not issued updates""" + before: NotIssued = NotIssued(42) + after: NotIssued = before.update(title="test") + self.assertEqual(before, after) + return + + def test_nic_update(self): + """testing NIC updates""" + before: NIC = NIC(42) + after: NIC = before.update(title="test") + self.assertEqual(before, after) + return + + def test_ien_update(self): + """testing IEN updates""" + before: IEN = IEN(42) + after: IEN = before.update(title="test") + self.assertEqual(before, after) + return + + def test_rtr_update(self): + """testing RTR updates""" + before: RTR = RTR(42) + after: RTR = before.update(title="test") + self.assertEqual(before, after) + return + + +class TestIndexParser(TestCase): + def test_bcp(self): + """testing index parser for BCP entries""" + xml: str = """ + + + BCP0001 + + + BCP0188 + + RFC7258 + + + + BCP0185 + + RFC7115 + RFC9319 + + + """ + parser: IndexParser = IndexParser(xml) + self.assertEqual(len(parser.index[DocType.BCP]), 3) + # w/o is-also + self.assertEqual(parser.index[DocType.BCP][1].docID(), 'BCP0001') + self.assertEqual(len(parser.index[DocType.BCP][1].is_also), 0) + # with single is-also + self.assertEqual(parser.index[DocType.BCP][188].docID(), 'BCP0188') + self.assertEqual(len(parser.index[DocType.BCP][188].is_also), 1) + self.assertEqual(parser.index[DocType.BCP][188].is_also[0].docID(), 'RFC7258') + # with multiple is-also + self.assertEqual(parser.index[DocType.BCP][185].docID(), 'BCP0185') + self.assertEqual(len(parser.index[DocType.BCP][185].is_also), 2) + self.assertEqual(parser.index[DocType.BCP][185].is_also[0].docID(), 'RFC7115') + self.assertEqual(parser.index[DocType.BCP][185].is_also[1].docID(), 'RFC9319') + return + + def test_fyi(self): + """testing index parser for FYI entries""" + xml: str = """ + + + FYI0042 + + + FYI0023 + + RFC1580 + + + + FYI0666 + + RFC1149 + RFC2549 + RFC6214 + + + """ + parser: IndexParser = IndexParser(xml) + self.assertEqual(len(parser.index[DocType.FYI]), 3) + # w/o is-also + self.assertEqual(parser.index[DocType.FYI][42].docID(), 'FYI0042') + self.assertEqual(len(parser.index[DocType.FYI][42].is_also), 0) + # with single is-also + self.assertEqual(parser.index[DocType.FYI][23].docID(), 'FYI0023') + self.assertEqual(len(parser.index[DocType.FYI][23].is_also), 1) + self.assertEqual(parser.index[DocType.FYI][23].is_also[0].docID(), 'RFC1580') + # with multiple is-also + self.assertEqual(parser.index[DocType.FYI][666].docID(), 'FYI0666') + self.assertEqual(len(parser.index[DocType.FYI][666].is_also), 3) + self.assertEqual(parser.index[DocType.FYI][666].is_also[0].docID(), 'RFC1149') + self.assertEqual(parser.index[DocType.FYI][666].is_also[1].docID(), 'RFC2549') + self.assertEqual(parser.index[DocType.FYI][666].is_also[2].docID(), 'RFC6214') + return + + def test_rfc(self): + """testing index parser for RFC entries""" + xml: str = """ + + + RFC0023 + Foo + + J. Doe + + + April + 2042 + + PROPOSED STANDARD + INFORMATIONAL + + + RFC0042 + Bar + + J. Doe + Editor + A Company that Makes Everything + ACME + + + 28 + February + 2042 + + + ASCII + + 42 + + foo + + +

This is a test

+
+ draft-test-test-test + this is a note + + RFC0023 + + + RFC0666 + + + RFC0023 + + + RFC0666 + + + BCP0666 + + + RFC0023 + + HISTORIC + UNKNOWN + IETF + foo + bar + http://example.org + 10.17487/RFC0042 +
+ + RFC0666 + FooBar + + J. Doe + Editor + A Company that Makes Everything + ACME + + + M. Mustermann + + + 28 + February + 2042 + + + TEXT + HTML + + 666 + + foo + bar + + +

This is a test

+

more testing

+
+ draft-test-test-test + this is a note + + RFC0023 + RFC0042 + + + RFC1337 + RFC6666 + + + RFC0023 + RFC0042 + + + RFC1337 + RFC6666 + + + BCP6666 + STD0666 + + + RFC0023 + RFC0042 + + INTERNET STANDARD + DRAFT STANDARD + INDEPENDENT + foo + bar + http://example.org + 10.17487/RFC0666 +
+
""" + parser: IndexParser = IndexParser(xml) + self.assertEqual(len(parser.index[DocType.RFC]), 5) + # minimal data + self.assertEqual(parser.index[DocType.RFC][23].docID(), 'RFC0023') + self.assertEqual(len(parser.index[DocType.RFC][23].is_also), 0) + self.assertEqual(parser.index[DocType.RFC][23].title, "Foo") + self.assertEqual(len(parser.index[DocType.RFC][23].authors), 1) + self.assertEqual(parser.index[DocType.RFC][23].authors[0].name, "J. Doe") + self.assertEqual(parser.index[DocType.RFC][23].authors[0].title, "") + self.assertEqual(parser.index[DocType.RFC][23].authors[0].organization, "") + self.assertEqual(parser.index[DocType.RFC][23].authors[0].org_abbrev, "") + self.assertEqual(parser.index[DocType.RFC][23].pub_date.year, 2042) + self.assertEqual(parser.index[DocType.RFC][23].pub_date.month, 4) + self.assertEqual(parser.index[DocType.RFC][23].pub_date.day, 1) + self.assertEqual(len(parser.index[DocType.RFC][23].format), 0) + self.assertEqual(parser.index[DocType.RFC][23].current_status, Status.PROPOSED_STANDARD) + self.assertEqual(parser.index[DocType.RFC][23].pub_status, Status.INFORMATIONAL) + self.assertIsNone(parser.index[DocType.RFC][23].page_count) + self.assertEqual(len(parser.index[DocType.RFC][23].keywords), 0) + self.assertEqual(len(parser.index[DocType.RFC][23].abstract), 0) + self.assertEqual(parser.index[DocType.RFC][23].draft, "") + self.assertEqual(parser.index[DocType.RFC][23].notes, "") + self.assertEqual(len(parser.index[DocType.RFC][23].obsoletes), 0) + self.assertEqual(len(parser.index[DocType.RFC][23].obsoleted_by), 0) + self.assertEqual(len(parser.index[DocType.RFC][23].updates), 0) + self.assertEqual(len(parser.index[DocType.RFC][23].updated_by), 0) + self.assertEqual(len(parser.index[DocType.RFC][23].is_also), 0) + self.assertEqual(len(parser.index[DocType.RFC][23].see_also), 0) + self.assertIsNone(parser.index[DocType.RFC][23].stream) + self.assertEqual(parser.index[DocType.RFC][23].area, "") + self.assertEqual(parser.index[DocType.RFC][23].wg_acronym, "") + self.assertEqual(parser.index[DocType.RFC][23].errata_url, "") + self.assertEqual(parser.index[DocType.RFC][23].doi, "") + # single entry for each data entry + self.assertEqual(parser.index[DocType.RFC][42].docID(), 'RFC0042') + self.assertEqual(parser.index[DocType.RFC][42].title, "Bar") + self.assertEqual(len(parser.index[DocType.RFC][42].authors), 1) + self.assertEqual(parser.index[DocType.RFC][42].authors[0].name, "J. Doe") + self.assertEqual(parser.index[DocType.RFC][42].authors[0].title, "Editor") + self.assertEqual(parser.index[DocType.RFC][42].authors[0].organization, "A Company that Makes Everything") + self.assertEqual(parser.index[DocType.RFC][42].authors[0].org_abbrev, "ACME") + self.assertEqual(parser.index[DocType.RFC][42].pub_date.year, 2042) + self.assertEqual(parser.index[DocType.RFC][42].pub_date.month, 2) + self.assertEqual(parser.index[DocType.RFC][42].pub_date.day, 28) + self.assertEqual(len(parser.index[DocType.RFC][42].format), 1) + self.assertEqual(parser.index[DocType.RFC][42].format[0], FileFormat.ASCII) + self.assertEqual(parser.index[DocType.RFC][42].current_status, Status.HISTORIC) + self.assertEqual(parser.index[DocType.RFC][42].pub_status, Status.UNKNOWN) + self.assertEqual(parser.index[DocType.RFC][42].page_count, 42) + self.assertEqual(len(parser.index[DocType.RFC][42].keywords), 1) + self.assertEqual(parser.index[DocType.RFC][42].keywords[0], "foo") + self.assertEqual(len(parser.index[DocType.RFC][42].abstract), 1) + self.assertEqual(parser.index[DocType.RFC][42].abstract[0], "This is a test") + self.assertEqual(parser.index[DocType.RFC][42].draft, "draft-test-test-test") + self.assertEqual(parser.index[DocType.RFC][42].notes, "this is a note") + self.assertEqual(len(parser.index[DocType.RFC][42].obsoletes), 1) + self.assertEqual(parser.index[DocType.RFC][42].obsoletes[0].docID(), 'RFC0023') + self.assertEqual(len(parser.index[DocType.RFC][42].obsoleted_by), 1) + self.assertEqual(parser.index[DocType.RFC][42].obsoleted_by[0].docID(), 'RFC0666') + self.assertEqual(len(parser.index[DocType.RFC][42].updates), 1) + self.assertEqual(parser.index[DocType.RFC][42].updates[0].docID(), 'RFC0023') + self.assertEqual(len(parser.index[DocType.RFC][42].updated_by), 1) + self.assertEqual(parser.index[DocType.RFC][42].updated_by[0].docID(), 'RFC0666') + self.assertEqual(len(parser.index[DocType.RFC][42].is_also), 1) + self.assertEqual(parser.index[DocType.RFC][42].is_also[0].docID(), 'BCP0666') + self.assertEqual(len(parser.index[DocType.RFC][42].see_also), 1) + self.assertEqual(parser.index[DocType.RFC][42].see_also[0].docID(), 'RFC0023') + self.assertEqual(parser.index[DocType.RFC][42].stream, Stream.IETF) + self.assertEqual(parser.index[DocType.RFC][42].area, "foo") + self.assertEqual(parser.index[DocType.RFC][42].wg_acronym, "bar") + self.assertEqual(parser.index[DocType.RFC][42].errata_url, "http://example.org") + self.assertEqual(parser.index[DocType.RFC][42].doi, "10.17487/RFC0042") + # multiple entries where allowed + self.assertEqual(parser.index[DocType.RFC][666].docID(), 'RFC0666') + self.assertEqual(parser.index[DocType.RFC][666].title, "FooBar") + self.assertEqual(len(parser.index[DocType.RFC][666].authors), 2) + self.assertEqual(parser.index[DocType.RFC][666].authors[0].name, "J. Doe") + self.assertEqual(parser.index[DocType.RFC][666].authors[0].title, "Editor") + self.assertEqual(parser.index[DocType.RFC][666].authors[0].organization, "A Company that Makes Everything") + self.assertEqual(parser.index[DocType.RFC][666].authors[0].org_abbrev, "ACME") + self.assertEqual(parser.index[DocType.RFC][666].authors[1].name, "M. Mustermann") + self.assertEqual(parser.index[DocType.RFC][666].authors[1].title, "") + self.assertEqual(parser.index[DocType.RFC][666].authors[1].organization, "") + self.assertEqual(parser.index[DocType.RFC][666].authors[1].org_abbrev, "") + self.assertEqual(parser.index[DocType.RFC][666].pub_date.year, 2042) + self.assertEqual(parser.index[DocType.RFC][666].pub_date.month, 2) + self.assertEqual(parser.index[DocType.RFC][666].pub_date.day, 28) + self.assertEqual(len(parser.index[DocType.RFC][666].format), 2) + self.assertEqual(parser.index[DocType.RFC][666].format[0], FileFormat.TEXT) + self.assertEqual(parser.index[DocType.RFC][666].format[1], FileFormat.HTML) + self.assertEqual(parser.index[DocType.RFC][666].current_status, Status.INTERNET_STANDARD) + self.assertEqual(parser.index[DocType.RFC][666].pub_status, Status.DRAFT_STANDARD) + self.assertEqual(parser.index[DocType.RFC][666].page_count, 666) + self.assertEqual(len(parser.index[DocType.RFC][666].keywords), 2) + self.assertEqual(parser.index[DocType.RFC][666].keywords[0], "foo") + self.assertEqual(parser.index[DocType.RFC][666].keywords[1], "bar") + self.assertEqual(len(parser.index[DocType.RFC][666].abstract), 2) + self.assertEqual(parser.index[DocType.RFC][666].abstract[0], "This is a test") + self.assertEqual(parser.index[DocType.RFC][666].abstract[1], "more testing") + self.assertEqual(parser.index[DocType.RFC][666].draft, "draft-test-test-test") + self.assertEqual(parser.index[DocType.RFC][666].notes, "this is a note") + self.assertEqual(len(parser.index[DocType.RFC][666].obsoletes), 2) + self.assertEqual(parser.index[DocType.RFC][666].obsoletes[0].docID(), 'RFC0023') + self.assertEqual(parser.index[DocType.RFC][666].obsoletes[1].docID(), 'RFC0042') + self.assertEqual(len(parser.index[DocType.RFC][666].obsoleted_by), 2) + self.assertEqual(parser.index[DocType.RFC][666].obsoleted_by[0].docID(), 'RFC1337') + self.assertEqual(parser.index[DocType.RFC][666].obsoleted_by[1].docID(), 'RFC6666') + self.assertEqual(len(parser.index[DocType.RFC][666].updates), 2) + self.assertEqual(parser.index[DocType.RFC][666].updates[0].docID(), 'RFC0023') + self.assertEqual(parser.index[DocType.RFC][666].updates[1].docID(), 'RFC0042') + self.assertEqual(len(parser.index[DocType.RFC][666].updated_by), 2) + self.assertEqual(parser.index[DocType.RFC][666].updated_by[0].docID(), 'RFC1337') + self.assertEqual(parser.index[DocType.RFC][666].updated_by[1].docID(), 'RFC6666') + self.assertEqual(len(parser.index[DocType.RFC][666].is_also), 2) + self.assertEqual(parser.index[DocType.RFC][666].is_also[0].docID(), 'BCP6666') + self.assertEqual(parser.index[DocType.RFC][666].is_also[1].docID(), 'STD0666') + self.assertEqual(len(parser.index[DocType.RFC][666].see_also), 2) + self.assertEqual(parser.index[DocType.RFC][666].see_also[0].docID(), 'RFC0023') + self.assertEqual(parser.index[DocType.RFC][666].see_also[1].docID(), 'RFC0042') + self.assertEqual(parser.index[DocType.RFC][666].stream, Stream.INDEPENDENT) + self.assertEqual(parser.index[DocType.RFC][666].area, "foo") + self.assertEqual(parser.index[DocType.RFC][666].wg_acronym, "bar") + self.assertEqual(parser.index[DocType.RFC][666].errata_url, "http://example.org") + self.assertEqual(parser.index[DocType.RFC][666].doi, "10.17487/RFC0666") + return + + def test_std(self): + """testing index parser for STD entries""" + xml: str = """ + + + STD0666 + test + + + STD0099 + HTTP/1.1 + + RFC9112 + + + + STD0078 + Simple Network Management Protocol (SNMP) Security + + RFC5343 + RFC5590 + RFC5591 + RFC6353 + + + """ + parser: IndexParser = IndexParser(xml) + self.assertEqual(len(parser.index[DocType.STD]), 3) + # w/o is-also + self.assertEqual(parser.index[DocType.STD][666].docID(), 'STD0666') + self.assertEqual(parser.index[DocType.STD][666].title, 'test') + self.assertEqual(len(parser.index[DocType.STD][666].is_also), 0) + # with single is-also + self.assertEqual(parser.index[DocType.STD][99].docID(), 'STD0099') + self.assertEqual(parser.index[DocType.STD][99].title, 'HTTP/1.1') + self.assertEqual(len(parser.index[DocType.STD][99].is_also), 1) + self.assertEqual(parser.index[DocType.STD][99].is_also[0].docID(), 'RFC9112') + # with multiple is-also + self.assertEqual(parser.index[DocType.STD][78].docID(), 'STD0078') + self.assertEqual(parser.index[DocType.STD][78].title, 'Simple Network Management Protocol (SNMP) Security') + self.assertEqual(len(parser.index[DocType.STD][78].is_also), 4) + self.assertEqual(parser.index[DocType.STD][78].is_also[0].docID(), 'RFC5343') + self.assertEqual(parser.index[DocType.STD][78].is_also[1].docID(), 'RFC5590') + self.assertEqual(parser.index[DocType.STD][78].is_also[2].docID(), 'RFC5591') + self.assertEqual(parser.index[DocType.STD][78].is_also[3].docID(), 'RFC6353') + return + + def test_not_issued(self): + """testing index parser for RFC not issued entries""" + xml: str = """ + + + RFC0042 + + """ + parser: IndexParser = IndexParser(xml) + self.assertEqual(len(parser.index[DocType.RFC]), 1) + self.assertEqual(parser.index[DocType.RFC][42].docID(), 'RFC0042') + return + + def test_get_index(self): + """testing the parser's get_index() function""" + xml: str = """ + + + RFC0042 + + """ + parser: IndexParser = IndexParser(xml) + index: dict[DocType: dict[int, Document]] = parser.get_index() + self.assertIn(DocType.RFC, index) + self.assertIn(DocType.STD, index) + self.assertIn(DocType.BCP, index) + self.assertIn(DocType.FYI, index) + self.assertIn(DocType.NIC, index) + self.assertIn(DocType.IEN, index) + self.assertIn(DocType.RTR, index) + self.assertEqual(index[DocType.RFC][42].docID(), 'RFC0042') + return diff --git a/tests/test_rfcartographer.py b/tests/test_rfcartographer.py new file mode 100644 index 0000000..bfa85e4 --- /dev/null +++ b/tests/test_rfcartographer.py @@ -0,0 +1,253 @@ +from unittest import TestCase +from networkx import MultiDiGraph +from rfcartography.rfcartographer import RFCartographer, RFCMap +from rfcartography.index_parser import DocType, Document, RFC, STD, BCP, FYI, NIC, IEN, RTR + + +class TestCartographer(TestCase): + def test_get_document(self): + """testing document retrieval""" + index: dict[DocType: dict[int, Document]] = {DocType.RFC: {42: RFC(42)}, + DocType.STD: {23: STD(23)}, + DocType.BCP: {42: BCP(42)}, + DocType.FYI: {23: FYI(23)}, + DocType.NIC: {42: NIC(42)}, + DocType.IEN: {23: IEN(23)}, + DocType.RTR: {42: RTR(42)}} + cartographer: RFCartographer = RFCartographer(index) + self.assertEqual(cartographer.get_document(DocType.RFC, 42).docID(), 'RFC0042') + self.assertEqual(cartographer.get_document(DocType.STD, 23).docID(), 'STD0023') + self.assertEqual(cartographer.get_document(DocType.BCP, 42).docID(), 'BCP0042') + self.assertEqual(cartographer.get_document(DocType.FYI, 23).docID(), 'FYI0023') + self.assertEqual(cartographer.get_document(DocType.NIC, 42).docID(), 'NIC42') + self.assertEqual(cartographer.get_document(DocType.IEN, 23).docID(), 'IEN23') + self.assertEqual(cartographer.get_document(DocType.RTR, 42).docID(), 'RTR42') + return + + def test_map_generation(self): + """testing generation of RFCMaps""" + nic42: NIC = NIC(42) + ien23: IEN = IEN(23) + rtr42: RTR = RTR(42) + rfc42: RFC = RFC(42, see_also=[nic42, ien23, rtr42]) + index: dict[DocType: dict[int, Document]] = {DocType.RFC: {42: rfc42}, + DocType.STD: {}, + DocType.BCP: {}, + DocType.FYI: {}, + DocType.NIC: {42: nic42}, + DocType.IEN: {23: ien23}, + DocType.RTR: {42: rtr42}} + cartographer: RFCartographer = RFCartographer(index) + rfc_map: RFCMap = cartographer.map_subnet(rfc42, "http://example.org/") + self.assertIn(rfc42, rfc_map.nodes[DocType.RFC]) + self.assertIn(nic42, rfc_map.nodes[DocType.NIC]) + self.assertIn(ien23, rfc_map.nodes[DocType.IEN]) + self.assertIn(rtr42, rfc_map.nodes[DocType.RTR]) + self.assertIn((rfc42, nic42), rfc_map.edges['see also']) + self.assertIn((rfc42, ien23), rfc_map.edges['see also']) + self.assertIn((rfc42, rtr42), rfc_map.edges['see also']) + return + + def test_map_generation_depth_limit(self): + """testing generation of RFCMaps with depth limit""" + rfc3: RFC = RFC(3) + rfc2: RFC = RFC(2, obsoleted_by=[rfc3]) + rfc1: RFC = RFC(1, obsoleted_by=[rfc2]) + index: dict[DocType: dict[int, Document]] = {DocType.RFC: {1: rfc1, + 2: rfc2, + 3: rfc3}, + DocType.STD: {}, + DocType.BCP: {}, + DocType.FYI: {}, + DocType.NIC: {}, + DocType.IEN: {}, + DocType.RTR: {}} + cartographer: RFCartographer = RFCartographer(index) + rfc_map: RFCMap = cartographer.map_subnet(rfc1,"http://example.org/", 1) + self.assertEqual(rfc_map.get_node_count(), 2) + return + + def test_map_generation_style_params(self): + """testing generation of RFCMaps with style parameters""" + rfc42: RFC = RFC(42) + node_colors: dict[DocType, str] = {DocType.RFC: '#aaaaaa', + DocType.STD: '#bbbbbb', + DocType.BCP: '#cccccc', + DocType.FYI: '#dddddd', + DocType.NIC: '#eeeeee', + DocType.IEN: '#ffffff', + DocType.RTR: '#000000'} + edge_style: dict[str, tuple[str, str]] = {'obsoletes': ('dashed', '#111111'), + 'obsoleted by': ('dashed', '#222222'), + 'updates': ('dashdot', '#333333'), + 'updated by': ('dashdot', '#444444'), + 'is also': ('solid', '#555555'), + 'see also': ('dotted', '#666666')} + index: dict[DocType: dict[int, Document]] = {DocType.RFC: {42: rfc42}, + DocType.STD: {}, + DocType.BCP: {}, + DocType.FYI: {}, + DocType.NIC: {}, + DocType.IEN: {}, + DocType.RTR: {}} + cartographer: RFCartographer = RFCartographer(index) + rfc_map: RFCMap = cartographer.map_subnet(rfc42,"http://example.org/", 0, + node_color=node_colors, + edge_style=edge_style) + self.assertEqual(rfc_map.get_node_colors(), node_colors) + self.assertEqual(rfc_map.get_edge_styles(), edge_style) + return + + +class TestMap(TestCase): + def test_node_color(self): + """testing node color update and retrieval""" + node_colors: dict[DocType, str] = {DocType.RFC: '#aaaaaa', + DocType.STD: '#bbbbbb', + DocType.BCP: '#cccccc', + DocType.FYI: '#dddddd', + DocType.NIC: '#eeeeee', + DocType.IEN: '#ffffff', + DocType.RTR: '#000000'} + rfc_map: RFCMap = RFCMap(MultiDiGraph(), + {DocType.RFC: [], + DocType.STD: [], + DocType.BCP: [], + DocType.FYI: [], + DocType.NIC: [], + DocType.IEN: [], + DocType.RTR: []}, + {'obsoletes': [], + 'obsoleted by': [], + 'updates': [], + 'updated by': [], + 'is also': [], + 'see also': []}, + 'https://example.org/', + node_color=node_colors) + self.assertEqual(rfc_map.get_node_colors(), node_colors) + rfc_map.set_node_color(DocType.RTR, '#123456') + node_colors[DocType.RTR] = '#123456' + self.assertEqual(rfc_map.get_node_colors(), node_colors) + return + + def test_edge_style(self): + """testing edge style update and retrieval""" + edge_style: dict[str, tuple[str, str]] = {'obsoletes': ('dashed', '#111111'), + 'obsoleted by': ('dashed', '#222222'), + 'updates': ('dashdot', '#333333'), + 'updated by': ('dashdot', '#444444'), + 'is also': ('solid', '#555555'), + 'see also': ('dotted', '#666666')} + rfc_map: RFCMap = RFCMap(MultiDiGraph(), + {DocType.RFC: [], + DocType.STD: [], + DocType.BCP: [], + DocType.FYI: [], + DocType.NIC: [], + DocType.IEN: [], + DocType.RTR: []}, + {'obsoletes': [], + 'obsoleted by': [], + 'updates': [], + 'updated by': [], + 'is also': [], + 'see also': []}, + 'https://example.org/', + edge_style=edge_style) + self.assertEqual(rfc_map.get_edge_styles(), edge_style) + rfc_map.set_edge_style('updates', '#ffffff') + edge_style['updates'] = '#ffffff' + self.assertEqual(rfc_map.get_edge_styles(), edge_style) + return + + def test_url_base(self): + """testing url base url update and retrieval""" + rfc_map: RFCMap = RFCMap(MultiDiGraph(), + {DocType.RFC: [], + DocType.STD: [], + DocType.BCP: [], + DocType.FYI: [], + DocType.NIC: [], + DocType.IEN: [], + DocType.RTR: []}, + {'obsoletes': [], + 'obsoleted by': [], + 'updates': [], + 'updated by': [], + 'is also': [], + 'see also': []}, + 'https://example.org/') + self.assertEqual(rfc_map.get_url_base(), 'https://example.org/') + rfc_map.set_url_base('https://rfc-editor.org/') + self.assertEqual(rfc_map.get_url_base(), 'https://rfc-editor.org/') + return + + def test_draw(self): + """testing generation of a svg""" + nic42: NIC = NIC(42) + ien23: IEN = IEN(23) + rtr42: RTR = RTR(42) + rfc42: RFC = RFC(42, see_also=[nic42, ien23, rtr42]) + nodes: dict[DocType, list[Document]] = {DocType.RFC: [rfc42], + DocType.STD: [], + DocType.BCP: [], + DocType.FYI: [], + DocType.NIC: [nic42], + DocType.IEN: [ien23], + DocType.RTR: [rtr42]} + edges: dict[str, tuple[Document, Document]] = {'obsoletes': [], + 'obsoleted by': [], + 'updates': [], + 'updated by': [], + 'is also': [], + 'see also': [(rfc42, nic42), + (rfc42, ien23), + (rfc42, rtr42)]} + graph: MultiDiGraph = MultiDiGraph() + graph.add_node(rfc42) + graph.add_node(nic42) + graph.add_node(ien23) + graph.add_node(rtr42) + graph.add_edge(rfc42, nic42, reftype='see also') + graph.add_edge(rfc42, ien23, reftype='see also') + graph.add_edge(rfc42, rtr42, reftype='see also') + rfc_map: RFCMap = RFCMap(graph, nodes, edges, 'https://example.org/') + svg: str = rfc_map.draw() + self.assertEqual(svg[:4], '\n') + return + + def test_counters(self): + """testing node and edge count retrieval""" + nic42: NIC = NIC(42) + ien23: IEN = IEN(23) + rtr42: RTR = RTR(42) + rfc42: RFC = RFC(42, see_also=[nic42, ien23, rtr42]) + nodes: dict[DocType, list[Document]] = {DocType.RFC: [rfc42], + DocType.STD: [], + DocType.BCP: [], + DocType.FYI: [], + DocType.NIC: [nic42], + DocType.IEN: [ien23], + DocType.RTR: [rtr42]} + edges: dict[str, tuple[Document, Document]] = {'obsoletes': [], + 'obsoleted by': [], + 'updates': [], + 'updated by': [], + 'is also': [], + 'see also': [(rfc42, nic42), + (rfc42, ien23), + (rfc42, rtr42)]} + graph: MultiDiGraph = MultiDiGraph() + graph.add_node(rfc42) + graph.add_node(nic42) + graph.add_node(ien23) + graph.add_node(rtr42) + graph.add_edge(rfc42, nic42, reftype='see also') + graph.add_edge(rfc42, ien23, reftype='see also') + graph.add_edge(rfc42, rtr42, reftype='see also') + rfc_map: RFCMap = RFCMap(graph, nodes, edges, 'https://example.org/') + self.assertEqual(rfc_map.get_node_count(), 4) + self.assertEqual(rfc_map.get_edge_count(), 3) + return diff --git a/tests/test_routing.py b/tests/test_routing.py new file mode 100644 index 0000000..8265672 --- /dev/null +++ b/tests/test_routing.py @@ -0,0 +1,34 @@ +from unittest import TestCase +from flask import Flask +from rfcartography import create_app + +class TestDetailsPages(TestCase): + def setUp(self): + """create an app object for testing purposes""" + self.app:Flask = create_app() + self.app.config.update({'TESTING': True}) + return + + def test_imprint(self): + """testing the imprint page""" + client: FlaskClient = self.app.test_client() + if 'IMPRINT' in self.app.config: + self.app.config.pop('IMPRINT') + response: TestResponse = client.get('/imprint') + self.assertEqual(response.status, '404 NOT FOUND') + self.app.config.update({'IMPRINT': [('Imprint', '123 test 123 test')]}) + response: TestResponse = client.get('/imprint') + self.assertEqual(response.status, '200 OK') + return + + def test_privacy(self): + """testing the privacy page""" + client: FlaskClient = self.app.test_client() + if 'PRIVACY' in self.app.config: + self.app.config.pop('PRIVACY') + response: TestResponse = client.get('/privacy') + self.assertEqual(response.status, '404 NOT FOUND') + self.app.config.update({'PRIVACY': [('Piracy Statement', 'Arrr')]}) + response: TestResponse = client.get('/privacy') + self.assertEqual(response.status, '200 OK') + return diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..64b477e --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,92 @@ +from unittest import TestCase +from flask import Flask +from werkzeug.datastructures import MultiDict +from rfcartography import create_app +from rfcartography.index_parser import RFC, NotIssued, STD, BCP, FYI, DocType +from rfcartography.rfcartographer import RFCartographer + + +class TestDetailsPages(TestCase): + def setUp(self): + """create an app object for testing purposes""" + self.app:Flask = create_app() + self.app.config.update({"TESTING": True}) + + rfc42: RFC = RFC(42) + rfc23: NotIssued = NotIssued(23) + std42: STD = STD(42) + bcp42: BCP = BCP(42) + fyi42: FYI = FYI(42) + index: dict[DocType: dict[int, Document]] = {DocType.RFC: {42: rfc42, + 23: rfc23}, + DocType.STD: {42: std42}, + DocType.BCP: {42: bcp42}, + DocType.FYI: {42: fyi42}, + DocType.NIC: {}, + DocType.IEN: {}, + DocType.RTR: {}} + self.app.cartographer = RFCartographer(index) + return + + def test_search_page(self): + """testing the start page""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/') + self.assertEqual(response.status, '200 OK') + + def test_no_search_result(self): + """testing searching for not existing RFCs""" + client: FlaskClient = self.app.test_client() + response: TestResponse = client.get('/map?type=rfc&num=666') + self.assertEqual(response.status, '404 NOT FOUND') + + def test_quicksearch_validation(self): + """testing the validation of user input in the quick search""" + client: FlaskClient = self.app.test_client() + # valid type and valid number shall be accepted + response = client.get('/map?type=rfc&num=42') + self.assertEqual(response.status, '200 OK') + # number with leading 0s shall be accepted + response = client.get('/map?type=rfc&num=0042') + self.assertEqual(response.status, '200 OK') + # number with letters shall be rejected + response = client.get('/map?type=rfc&num=42a') + self.assertEqual(response.status, '400 BAD REQUEST') + # invalid type shall be rejected + response = client.get('/map?type=test&num=42') + self.assertEqual(response.status, '400 BAD REQUEST') + # missing type shall be rejected + response = client.get('/map?num=42') + self.assertEqual(response.status, '400 BAD REQUEST') + # missing num shall be rejected + response = client.get('/map?type=test') + self.assertEqual(response.status, '400 BAD REQUEST') + return + + def test_advanced_search_validation(self): + """testing the validation of user input in the advanced search""" + client: FlaskClient = self.app.test_client() + # valid requests shall be accepted + response: TestResponse = client.get('/map?type=rfc&num=42&depth=23&nodes_enabled=rfc&nodes_enabled=std&nodes_enabled=bcp&nodes_enabled=fyi&nodes_enabled=nic&nodes_enabled=ien&nodes_enabled=rtr&rfc_color=%232072b1&std_color=%23c21a7e&bcp_color=%236d388d&fyi_color=%238bbd3e&nic_color=%23efe50b&ien_color=%23f28f20&rtr_color=%23e32326&obsoletes_style=dashed&obsoleted_by_style=dashed&updates_style=dashdot&updated_by_style=dashdot&is_also_style=solid&see_also_style=dotted&obsoletes_color=%23303c50&obsoleted_by_color=%23303c50&updates_color=%23607d8d&updated_by_color=%23607d8d&is_also_color=%23132e41&see_also_color=%23008e90') + self.assertEqual(response.status, '200 OK') + # invalid type values shall be rejected + response = client.get('/map?type=invalid&num=42&depth=23&nodes_enabled=rfc&nodes_enabled=std&nodes_enabled=bcp&nodes_enabled=fyi&nodes_enabled=nic&nodes_enabled=ien&nodes_enabled=rtr&rfc_color=%232072b1&std_color=%23c21a7e&bcp_color=%236d388d&fyi_color=%238bbd3e&nic_color=%23efe50b&ien_color=%23f28f20&rtr_color=%23e32326&obsoletes_style=dashed&obsoleted_by_style=dashed&updates_style=dashdot&updated_by_style=dashdot&is_also_style=solid&see_also_style=dotted&obsoletes_color=%23303c50&obsoleted_by_color=%23303c50&updates_color=%23607d8d&updated_by_color=%23607d8d&is_also_color=%23132e41&see_also_color=%23008e90') + self.assertEqual(response.status, '400 BAD REQUEST') + # invalid num values shall be rejected + response = client.get('/map?type=rfc&num=invalid&depth=23&nodes_enabled=rfc&nodes_enabled=std&nodes_enabled=bcp&nodes_enabled=fyi&nodes_enabled=nic&nodes_enabled=ien&nodes_enabled=rtr&rfc_color=%232072b1&std_color=%23c21a7e&bcp_color=%236d388d&fyi_color=%238bbd3e&nic_color=%23efe50b&ien_color=%23f28f20&rtr_color=%23e32326&obsoletes_style=dashed&obsoleted_by_style=dashed&updates_style=dashdot&updated_by_style=dashdot&is_also_style=solid&see_also_style=dotted&obsoletes_color=%23303c50&obsoleted_by_color=%23303c50&updates_color=%23607d8d&updated_by_color=%23607d8d&is_also_color=%23132e41&see_also_color=%23008e90') + self.assertEqual(response.status, '400 BAD REQUEST') + # num values with leading 0s shall be accepted + response = client.get('/map?type=rfc&num=0042&depth=23&nodes_enabled=rfc&nodes_enabled=std&nodes_enabled=bcp&nodes_enabled=fyi&nodes_enabled=nic&nodes_enabled=ien&nodes_enabled=rtr&rfc_color=%232072b1&std_color=%23c21a7e&bcp_color=%236d388d&fyi_color=%238bbd3e&nic_color=%23efe50b&ien_color=%23f28f20&rtr_color=%23e32326&obsoletes_style=dashed&obsoleted_by_style=dashed&updates_style=dashdot&updated_by_style=dashdot&is_also_style=solid&see_also_style=dotted&obsoletes_color=%23303c50&obsoleted_by_color=%23303c50&updates_color=%23607d8d&updated_by_color=%23607d8d&is_also_color=%23132e41&see_also_color=%23008e90') + self.assertEqual(response.status, '200 OK') + # invalid color values shall be rejected + response = client.get('/map?type=rfc&num=42&depth=23&nodes_enabled=rfc&nodes_enabled=std&nodes_enabled=bcp&nodes_enabled=fyi&nodes_enabled=nic&nodes_enabled=ien&nodes_enabled=rtr&rfc_color=%232072b1&std_color=%23c21a7e&bcp_color=%236d388d&fyi_color=invalid&nic_color=%23efe50b&ien_color=%23f28f20&rtr_color=%23e32326&obsoletes_style=dashed&obsoleted_by_style=dashed&updates_style=dashdot&updated_by_style=dashdot&is_also_style=solid&see_also_style=dotted&obsoletes_color=%23303c50&obsoleted_by_color=%23303c50&updates_color=%23607d8d&updated_by_color=%23607d8d&is_also_color=%23132e41&see_also_color=%23008e90') + # invalid color values shall be rejected + response = client.get('/map?type=rfc&num=42&depth=23&nodes_enabled=rfc&nodes_enabled=std&nodes_enabled=bcp&nodes_enabled=fyi&nodes_enabled=nic&nodes_enabled=ien&nodes_enabled=rtr&rfc_color=%232072b1&std_color=%23c21a7e&bcp_color=%236d388d&fyi_color=%238bbd3e&nic_color=%23efe50b&ien_color=%23f28f20&rtr_color=%23e32326&obsoletes_style=dashed&obsoleted_by_style=dashed&updates_style=dashdot&updated_by_style=dashdot&is_also_style=solid&see_also_style=dotted&obsoletes_color=%23fail00&obsoleted_by_color=%23303c50&updates_color=%23607d8d&updated_by_color=%23607d8d&is_also_color=%23132e41&see_also_color=%23008e90') + self.assertEqual(response.status, '400 BAD REQUEST') + # invalid line style values shall be rejected + response = client.get('/map?type=rfc&num=42&depth=23&nodes_enabled=rfc&nodes_enabled=std&nodes_enabled=bcp&nodes_enabled=fyi&nodes_enabled=nic&nodes_enabled=ien&nodes_enabled=rtr&rfc_color=%232072b1&std_color=%23c21a7e&bcp_color=%236d388d&fyi_color=%238bbd3e&nic_color=%23efe50b&ien_color=%23f28f20&rtr_color=%23e32326&obsoletes_style=dashed&obsoleted_by_style=dashed&updates_style=dashdot&updated_by_style=dashdot&is_also_style=solid&see_also_style=invalid&obsoletes_color=%23303c50&obsoleted_by_color=%23303c50&updates_color=%23607d8d&updated_by_color=%23607d8d&is_also_color=%23132e41&see_also_color=%23008e90') + self.assertEqual(response.status, '400 BAD REQUEST') + # invalid DocType shall be rejected + response = client.get('/map?type=rfc&num=42&depth=23&nodes_enabled=rfc&nodes_enabled=std&nodes_enabled=bcp&nodes_enabled=fyi&nodes_enabled=nic&nodes_enabled=invalid&nodes_enabled=rtr&rfc_color=%232072b1&std_color=%23c21a7e&bcp_color=%236d388d&fyi_color=%238bbd3e&nic_color=%23efe50b&ien_color=%23f28f20&rtr_color=%23e32326&obsoletes_style=dashed&obsoleted_by_style=dashed&updates_style=dashdot&updated_by_style=dashdot&is_also_style=solid&see_also_style=dotted&obsoletes_color=%23303c50&obsoleted_by_color=%23303c50&updates_color=%23607d8d&updated_by_color=%23607d8d&is_also_color=%23132e41&see_also_color=%23008e90') + self.assertEqual(response.status, '400 BAD REQUEST') + return