diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index d6638e7f0863..76c7380cd4ef 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -17,6 +17,7 @@ exports_files( "source/tools/export/output-root/Export.daml", "source/tools/export/output-root/args.json", "source/tools/export/output-root/daml.yaml", + "sphinx_ext/self_service_error_codes_extension.py", ], ) @@ -198,6 +199,7 @@ genrule( genrule( name = "docs-no-pdf", srcs = glob([ + "sphinx_ext/**", "configs/html/**", "configs/static/pygments_daml_lexer.py", "configs/static/typescript.py", @@ -205,6 +207,7 @@ genrule( ":sources", ":theme", ":hoogle_db.tar.gz", + "//docs:generate-error-codes-json", "//language-support/java:javadoc", "//language-support/ts/daml-react:docs", "//language-support/ts/daml-ledger:docs", @@ -215,6 +218,7 @@ genrule( "@daml-cheat-sheet//:site", ":scripts/check-closing-quotes.sh", ":scripts/check-closing-quotes.sh.allow", + "//docs:error_codes_export.json", ], outs = ["html-only.tar.gz"], cmd = (""" @@ -265,9 +269,16 @@ genrule( # Unfortunately, an update is not so easy because Sphinx 2.3.1 breaks # the PDF documentation due to issues with the FreeSerif font in the # fontspec package. So, for now we ignore `FutureWarning`. - WARNINGS=$$(../$(location @sphinx_nix//:bin/sphinx-build) -c docs/configs/html docs/source html 2>&1 | \\ - grep -Pi "(?&1) || SPHINX_BUILD_EXIT_CODE=$$? + if [ "$$SPHINX_BUILD_EXIT_CODE" -ne 0 ]; then + >&2 echo "## SPHINX-BUILD OUTPUT:" + >&2 echo "$$SPHINX_BUILD_OUTPUT" + >&2 echo "## SPHINX-BUILD OUTPUT END" + exit 1 + fi + # NOTE: appending ' || true' to force exit code of 0 from grep because grep normally exits with 1 if no lines are selected: + WARNINGS=$$(echo "$$SPHINX_BUILD_OUTPUT" | grep -Pi "(? $BUILD_DIR/gen/versions.json pipenv install -pipenv run sphinx-autobuild -c $BUILD_DIR/configs/html $BUILD_DIR/source $BUILD_DIR/gen +pipenv run sphinx-autobuild -D error_codes_json_export=../../bazel-bin/docs/error_codes_export.json -c $BUILD_DIR/configs/html $BUILD_DIR/source $BUILD_DIR/gen diff --git a/docs/source/error-codes/self-service/index.rst b/docs/source/error-codes/self-service/index.rst new file mode 100644 index 000000000000..4807b78ad8f7 --- /dev/null +++ b/docs/source/error-codes/self-service/index.rst @@ -0,0 +1,10 @@ +.. Copyright (c) 2021 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +.. SPDX-License-Identifier: Apache-2.0 + +Self-Service Error Codes (Experimental) +####################################### + +.. toctree:: + :hidden: + +.. list-all-error-codes:: diff --git a/docs/source/index.rst b/docs/source/index.rst index fca852cb5c5b..c416512745c8 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -52,6 +52,7 @@ Daml Documentation triggers/index tools/trigger-service/index tools/auth-middleware/index + error-codes/self-service/index .. toctree:: :titlesonly: diff --git a/docs/sphinx_ext/self_service_error_codes_extension.py b/docs/sphinx_ext/self_service_error_codes_extension.py new file mode 100644 index 000000000000..41fa5ac726d8 --- /dev/null +++ b/docs/sphinx_ext/self_service_error_codes_extension.py @@ -0,0 +1,199 @@ +# Copyright (c) 2021 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from collections import defaultdict +from docutils import nodes +from docutils.parsers.rst import Directive +from sphinx.writers.html import HTMLTranslator +from typing import Dict, Any + +import json + +error_codes_data = {} + +CONFIG_OPT = 'error_codes_json_export' + +def load_data(app, config): + global error_codes_data + + if CONFIG_OPT in config: + file_name = config[CONFIG_OPT] + try: + with open(file_name) as f: + tmp = json.load(f) + error_codes_data = {error["code"]: error for error in tmp["errorCodes"]} + except EnvironmentError: + print(f"Failed to open file: '{file_name}'") + raise + + +def setup(app): + app.add_config_value(CONFIG_OPT, '', 'env') + app.connect('config-inited', load_data) + app.add_node(error_code_node) + app.add_directive('list-all-error-codes', ListAllErrorCodesDirective) + # Callback functions for populating error code section after the doctree is resolved + app.connect('doctree-resolved', process_error_code_nodes) + # Overwriting standard Sphinx translator to allow linking of return types + app.set_translator('html', PatchedHTMLTranslator) + + +class error_code_node(nodes.General, nodes.Element): + def __init__(self, codes, expl="", res=""): + nodes.Element.__init__(self) + print("found error node codes: %s" % codes) + self.codes = codes + self.expl = expl + self.res = res + + +class ListAllErrorCodesDirective(Directive): + has_contents = True + required_arguments = 0 + final_argument_whitespace = True + def run(self): + return [error_code_node([], None, None)] + + +def text_node(n, txt): + # Doubling the parameter, as TextElements want the raw text and the text + return n(txt, txt) + + +def process_error_code_nodes(app, doctree, fromDocName): + def build_indented_bold_and_non_bold_node(bold_text: str, non_bold_text: str): + bold = text_node(n=nodes.strong, txt=bold_text) + non_bold = text_node(n=nodes.inline, txt=non_bold_text) + both = nodes.definition('', bold) + both += non_bold + return both + + def item_to_node(item: Dict[str, Any]) -> nodes.definition_list_item: + node = nodes.definition_list_item() + term_node = text_node(nodes.term, "%s" % (item["code"])) + definition_node = nodes.definition('', text_node(nodes.paragraph, '')) + if item["explanation"]: + definition_node += build_indented_bold_and_non_bold_node( + bold_text="Explanation: ", + non_bold_text=item['explanation']) + definition_node += build_indented_bold_and_non_bold_node( + bold_text="Category: ", + non_bold_text=item['category']) + if item["conveyance"]: + definition_node += build_indented_bold_and_non_bold_node( + bold_text="Conveyance: ", + non_bold_text=item['conveyance']) + if item["resolution"]: + definition_node += build_indented_bold_and_non_bold_node( + bold_text="Resolution: ", + non_bold_text=item['resolution']) + permalink_node = build_permalink( + app=app, + fromDocName=fromDocName, + term=item["code"], + # NOTE: This is the path to the docs file in Sphinx's source dir + docname='error-codes/self-service/index', + node_to_permalink_to=term_node) + node += [permalink_node, definition_node] + + return node + + # A node of this tree is a dict that can contain + # 1. further nodes and/or + # 2. 'leaves' in the form of a list of error (code) data + # Thus, the resulting tree is very similar to a trie + def build_hierarchical_tree_of_error_data(data) -> defaultdict: + create_node = lambda: defaultdict(create_node) + root = defaultdict(create_node) + for error_data in data: + current = root + for group in error_data["hierarchicalGrouping"]: + current = current[group] + if 'error-codes' in current: + current['error-codes'].append(error_data) + else: + current['error-codes'] = [error_data] + return root + + # DFS to traverse the error code data tree from `build_hierarchical_tree_of_error_data` + # While traversing the tree, the presentation of the error codes on the documentation is built + def dfs(tree, node, prefix: str) -> None: + if 'error-codes' in tree: + dlist = nodes.definition_list() + for code in tree['error-codes']: + dlist += item_to_node(item=code) + node += dlist + i = 1 + for subtopic, subtree in tree.items(): + if subtopic == 'error-codes': + continue + subprefix = f"{prefix}{i}." + i += 1 + subtree_node = text_node(n=nodes.rubric, txt = subprefix + " " + subtopic) + dfs(tree=subtree, node=subtree_node, prefix=subprefix) + node += subtree_node + + for node in doctree.traverse(error_code_node): + # Valid error codes given to the .. error-codes:: directive as argument + # given_error_codes = [error_codes_data[code] for code in node.codes if code in error_codes_data] + # Code for manually overwriting the explanation or resolution of an error code + + section = nodes.section() + root = nodes.rubric(rawsource = "", text = "") + section += root + tree = build_hierarchical_tree_of_error_data(data=error_codes_data.values()) + dfs(tree=tree, node=root, prefix="") + node.replace_self(new=[section]) + + +# Build a permalink/anchor to a specific command/metric +def build_permalink(app, fromDocName, term, docname, node_to_permalink_to): + reference_node = nodes.reference('', '') + reference_node['refuri'] = app.builder.get_relative_uri(fromDocName, docname) + '#' + term + + reference_node += node_to_permalink_to + + target_node = nodes.target('', '', ids=[term]) + node_to_permalink_to += target_node + return reference_node + + +class PatchedHTMLTranslator(HTMLTranslator): + # We overwrite this method as otherwise an assertion fails whenever we create a reference whose parent is + # not a TextElement. Concretely, this enables using method `build_return_type_node` for creating links from + # return types to the appropriate scaladocs + # Similar to solution from https://stackoverflow.com/a/61669375 + + def visit_reference(self, node): + # type: (nodes.Node) -> None + atts = {'class': 'reference'} + if node.get('internal') or 'refuri' not in node: + atts['class'] += ' internal' + else: + atts['class'] += ' external' + if 'refuri' in node: + atts['href'] = node['refuri'] or '#' + if self.settings.cloak_email_addresses and \ + atts['href'].startswith('mailto:'): + atts['href'] = self.cloak_mailto(atts['href']) + self.in_mailto = 1 + else: + assert 'refid' in node, \ + 'References must have "refuri" or "refid" attribute.' + atts['href'] = '#' + node['refid'] + if not isinstance(node.parent, nodes.TextElement): + # --------------------- + # Commenting out this assertion is the only change compared to Sphinx version 3.4.3 + # assert len(node) == 1 and isinstance(node[0], nodes.image) + # --------------------- + atts['class'] += ' image-reference' + if 'reftitle' in node: + atts['title'] = node['reftitle'] + if 'target' in node: + atts['target'] = node['target'] + self.body.append(self.starttag(node, 'a', '', **atts)) + + if node.get('secnumber'): + self.body.append(('%s' + self.secnumber_suffix) % + '.'.join(map(str, node['secnumber']))) + diff --git a/ledger/daml-on-sql/BUILD.bazel b/ledger/daml-on-sql/BUILD.bazel index 97900a111687..3a483d1ae7f3 100644 --- a/ledger/daml-on-sql/BUILD.bazel +++ b/ledger/daml-on-sql/BUILD.bazel @@ -126,11 +126,14 @@ genrule( srcs = [ "README.rst", "//docs:theme", + "//docs:sphinx_ext/self_service_error_codes_extension.py", "//docs:configs/html/conf.py", "//docs:configs/static/pygments_daml_lexer.py", "//docs:configs/static/typescript.py", "//docs:scripts/check-closing-quotes.sh", "//docs:scripts/check-closing-quotes.sh.allow", + "//docs:generate-error-codes-json", + "//docs:error_codes_export.json", ], outs = ["html.tar.gz"], cmd = """ @@ -139,6 +142,8 @@ genrule( mkdir -p build/docs/configs/html cp $(location //docs:configs/html/conf.py) build/docs/configs/html/conf.py + mkdir -p build/docs/sphinx_ext + cp $(location //docs:sphinx_ext/self_service_error_codes_extension.py) build/docs/sphinx_ext/self_service_error_codes_extension.py mkdir -p build/docs/configs/static cp $(location //docs:configs/static/pygments_daml_lexer.py) build/docs/configs/static/pygments_daml_lexer.py cp $(location //docs:configs/static/typescript.py) build/docs/configs/static/typescript.py @@ -169,14 +174,20 @@ genrule( # Unfortunately, an update is not so easy because Sphinx 2.3.1 breaks # the PDF documentation due to issues with the FreeSerif font in the # fontspec package. So, for now we ignore `FutureWarning`. - WARNINGS=$$(../$(location @sphinx_nix//:bin/sphinx-build) -c docs/configs/html docs/source html 2>&1 | \\ - grep -Pi "(?&1) || SPHINX_BUILD_EXIT_CODE=$$? + if [ "$$SPHINX_BUILD_EXIT_CODE" -ne 0 ]; then + >&2 echo "## SPHINX-BUILD OUTPUT:" + >&2 echo "$$SPHINX_BUILD_OUTPUT" + >&2 echo "## SPHINX-BUILD OUTPUT END" + exit 1 + fi + # NOTE: appending ' || true' to force exit code of 0 from grep because grep normally exits with 1 if no lines are selected: + WARNINGS=$$(echo "$$SPHINX_BUILD_OUTPUT" | grep -Pi "(? segment.trim.nonEmpty) + .getOrElse( + throw new IllegalStateException( + s"Could not parse full class name: '${fullClassName}' for the error class name" + ) + ) + parent.extend(name) + } } diff --git a/ledger/error/src/main/scala/com/daml/error/generator/ErrorCodeDocumentationGenerator.scala b/ledger/error/src/main/scala/com/daml/error/generator/ErrorCodeDocumentationGenerator.scala index 064756533d0e..4aaf07a9b01f 100644 --- a/ledger/error/src/main/scala/com/daml/error/generator/ErrorCodeDocumentationGenerator.scala +++ b/ledger/error/src/main/scala/com/daml/error/generator/ErrorCodeDocumentationGenerator.scala @@ -50,7 +50,7 @@ case class ErrorCodeDocumentationGenerator(prefix: String = "com.daml") { val (expl, res) = getErrorNameAndAnnotations(error) DocItem( className = error.getClass.getName, - category = error.category.getClass.getSimpleName, + category = error.category.getClass.getSimpleName.replace("$", ""), hierarchicalGrouping = error.parent.docNames.filter(_ != ""), conveyance = error.errorConveyanceDocString.getOrElse(""), code = error.id, diff --git a/ledger/error/src/main/scala/com/daml/error/generator/Main.scala b/ledger/error/src/main/scala/com/daml/error/generator/Main.scala new file mode 100644 index 000000000000..ee3f0bb87fb7 --- /dev/null +++ b/ledger/error/src/main/scala/com/daml/error/generator/Main.scala @@ -0,0 +1,49 @@ +// Copyright (c) 2021 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.daml.error.generator + +import io.circe.Encoder +import io.circe.syntax._ + +import java.nio.file.{Files, Paths, StandardOpenOption} + +/** Outputs information about self-service error codes needed for generating documentation to a json file. + */ +object Main { + + case class Output(errorCodes: Seq[DocItem]) + + implicit val errorCodeEncode: Encoder[DocItem] = + Encoder.forProduct7( + "className", + "category", + "hierarchicalGrouping", + "conveyance", + "code", + "explanation", + "resolution", + )(i => + ( + i.className, + i.category, + i.hierarchicalGrouping, + i.conveyance, + i.code, + i.explanation.explanation, + i.resolution.resolution, + ) + ) + + implicit val outputEncode: Encoder[Output] = + Encoder.forProduct1("errorCodes")(i => (i.errorCodes)) + + def main(args: Array[String]): Unit = { + val errorCodes = ErrorCodeDocumentationGenerator().getDocItems + val outputFile = Paths.get(args(0)) + val output = Output(errorCodes) + val outputText: String = output.asJson.spaces2 + val outputBytes = outputText.getBytes + val _ = Files.write(outputFile, outputBytes, StandardOpenOption.CREATE_NEW) + } +} diff --git a/ledger/error/src/test/suite/scala/com/daml/error/ErrorGroupSpec.scala b/ledger/error/src/test/suite/scala/com/daml/error/ErrorGroupSpec.scala new file mode 100644 index 000000000000..b51c1db4c1d3 --- /dev/null +++ b/ledger/error/src/test/suite/scala/com/daml/error/ErrorGroupSpec.scala @@ -0,0 +1,31 @@ +// Copyright (c) 2021 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.daml.error + +import org.scalatest.BeforeAndAfter +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class ErrorGroupSpec extends AnyFlatSpec with Matchers with BeforeAndAfter { + + object ErrorGroupBar extends ErrorGroup()(ErrorClass.root()) + + object ErrorGroupsFoo { + private implicit val errorClass: ErrorClass = ErrorClass.root() + + object ErrorGroupFoo1 extends ErrorGroup() { + object ErrorGroupFoo2 extends ErrorGroup() { + object ErrorGroupFoo3 extends ErrorGroup() + } + } + } + + it should "resolve correct error group names" in { + ErrorGroupsFoo.ErrorGroupFoo1.ErrorGroupFoo2.ErrorGroupFoo3.errorClass shouldBe ErrorClass( + List("ErrorGroupFoo1", "ErrorGroupFoo2", "ErrorGroupFoo3") + ) + ErrorGroupBar.errorClass shouldBe ErrorClass(List("ErrorGroupBar")) + } + +} diff --git a/ledger/error/src/test/suite/scala/com/daml/error/generator/ErrorCodeDocumentationGeneratorSpec.scala b/ledger/error/src/test/suite/scala/com/daml/error/generator/ErrorCodeDocumentationGeneratorSpec.scala index 7ece843a8b3e..adb3a85be7c2 100644 --- a/ledger/error/src/test/suite/scala/com/daml/error/generator/ErrorCodeDocumentationGeneratorSpec.scala +++ b/ledger/error/src/test/suite/scala/com/daml/error/generator/ErrorCodeDocumentationGeneratorSpec.scala @@ -3,7 +3,6 @@ package com.daml.error.generator -import com.daml.error.ErrorCategory.{SystemInternalAssumptionViolated, TransientServerFailure} import com.daml.error.utils.testpackage.SeriousError import com.daml.error.utils.testpackage.subpackage.NotSoSeriousError import com.daml.error.{Explanation, Resolution} @@ -22,7 +21,7 @@ class ErrorCodeDocumentationGeneratorSpec extends AnyFlatSpec with Matchers { val expectedDocItems = Seq( DocItem( className = SeriousError.getClass.getTypeName, - category = SystemInternalAssumptionViolated.getClass.getSimpleName, + category = "SystemInternalAssumptionViolated", hierarchicalGrouping = Nil, conveyance = "This error is logged with log-level ERROR on the server side.\nThis error is exposed on the API with grpc-status INTERNAL without any details due to security reasons", @@ -32,7 +31,7 @@ class ErrorCodeDocumentationGeneratorSpec extends AnyFlatSpec with Matchers { ), DocItem( className = NotSoSeriousError.getClass.getTypeName, - category = TransientServerFailure.getClass.getSimpleName, + category = "TransientServerFailure", hierarchicalGrouping = "Some error class" :: Nil, conveyance = "This error is logged with log-level INFO on the server side.\nThis error is exposed on the API with grpc-status UNAVAILABLE including a detailed error message",