Repository: frictionlessdata/frictionless-py

Issue:
Implement package/resource/schema.to_markdown
# Overview

> https://framework.frictionlessdata.io/docs/guides/describing-data

In Frictionless Framework a user can instantiate a Schema, Resource, and Package objects. All of them are Metadata objects which is a Python dictionary with some additional functionality. 

For example, all these classes have `metadata.to_yaml` that converts the metadata that a package/resource/schema has to a YAML string or file.

Users are interested in having `package/resource/schema.to_markdown` function that will convert metadata to a MD string or file. It might be useful for composing readmes on github etc. See https://github.com/frictionlessdata/project/discussions/665

# Plan

- [ ] Review an [existing external lib](https://github.com/geoffreyaldebert/table-schema-to-markdown) for reference (not sure we can re-use this code)
- [ ] Decide on the way of composing markdown text: using some markdown composer, Jinja template or native Python strings (the latter might be messy) ping @roll to sync
- [ ] Decide on what should be a Markdown output. For example, this one for a Table Schema - https://github.com/geoffreyaldebert/table-schema-to-markdown/blob/master/tests/files/expected_repertoire.md. And this one - https://datahub.io/core/co2-ppm - is a package presentation (of course we can't have interactive parts) ping @roll to sync
- [ ] Prototype all the functions
- [ ] Implement all the functions adding docstrings
- [ ] Write functional tests


Discussion:
ezwelty: @roll  For credit, I guess you could link directly to my specific comments from the function docstrings, and throw in my name/handle if that feels appropriate ('Ethan Welty @ezwelty')?

- https://github.com/frictionlessdata/frictionless-py/issues/837#issuecomment-1108604310 for `json_to_markdown` (and `render_markdown`, depending on the version used)
- https://github.com/frictionlessdata/frictionless-py/issues/837#issuecomment-906246975 for the others

Thanks @shashigharti for implementing this!

Something I notice while skimming the code: In your `render_markdown`, you are pointing `FileSystemLoader` to the built-in template directory, not the parent directory of the file designated by the `path` argument. Therefore, as written, `render_markdown` will not work with an arbitrary template, but only for a template in the template directory. Was this limitation intentional? If so, it may be worth including in the `render_markdown` docstring.

https://github.com/frictionlessdata/frictionless-py/blob/e726b7a8e77902d7570eb5ec00d5eeee9bd7845d/frictionless/helpers.py#L625

```py
def render_markdown(path: str, data: dict) -> str:
    """Render any JSON-like object as Markdown, using jinja2 template"""

    template_dir = os.path.join(os.path.dirname(__file__), "metadata/templates/markdown")
    environ = jinja2.Environment(
        loader=jinja2.FileSystemLoader(template_dir), lstrip_blocks=True, trim_blocks=True
    )
    environ.filters["filter_dict"] = filter_dict
    environ.filters["dict_to_markdown"] = json_to_markdown
    environ.filters["tabulate"] = dicts_to_markdown_table
    template = environ.get_template(path)
    return template.render(**data)
```

Original:

```py
class RelativeEnvironment(jinja2.Environment):
    """Override join_path() to enable relative template paths."""

    def join_path(self, template, parent):
        return os.path.normpath(os.path.join(os.path.dirname(parent), template))

def render_template(path: str, data: dict) -> str:
    dir = os.path.dirname(os.path.abspath(path))
    file = os.path.split(path)[1]
    environment = RelativeEnvironment(
        loader=jinja2.FileSystemLoader(dir), lstrip_blocks=True, trim_blocks=True
    )
    environment.filters["filter_dict"] = filter_dict
    environment.filters["dict_to_markdown"] = json_to_markdown
    environment.filters["tabulate"] = dicts_to_markdown_table
    template = environment.get_template(file)
    return template.render(**data)
```
shashigharti: @ezwelty thanks again! . I have added the credit information as you have suggested. For path, it was intentional and I was thinking of simple solution without custom template to get started. For now parameter 'path' to_markdown function is the path to save the markdown file. We can add the custom template feature in coming days. @roll  what do you suggest?
shashigharti: @roll  I have made the changes as per your feedback. It is ready for review. Thank you!

Patch:
diff --git a/frictionless/assets/templates/field-table.md b/frictionless/assets/templates/field-table.md
new file mode 100644
index 00000000..05a9880c
--- /dev/null
+++ b/frictionless/assets/templates/field-table.md
@@ -0,0 +1,18 @@
+### `{{ field.name }}`{% if field.title %} {{ field.title }}{% endif %}
+{{ '\n' }}
+{% if field.description %}
+  - `description` {{ field.description | indent(4, False) }}
+{% endif %}
+{% if field.type %}
+  - `type` {{ field.type }}
+{% endif %}
+{% if field.constraints %}
+  - `constraints`:
+  {% for key, value in field.constraints.items() if value %}
+    {% if key == 'pattern' %}
+    - `{{ key }}` `{{ value }}`
+    {% else %}
+    - `{{ key }}` {{ value }}
+    {% endif %}
+  {% endfor %}
+{% endif %}
\ No newline at end of file
diff --git a/frictionless/assets/templates/field.md b/frictionless/assets/templates/field.md
new file mode 100644
index 00000000..3231e5bd
--- /dev/null
+++ b/frictionless/assets/templates/field.md
@@ -0,0 +1,18 @@
+### `{{ field.name }}`{% if field.title %} {{ field.title }}{% endif %}
+
+{% if field.description %}
+  - `description` {{ field.description | indent(4, False) }}
+{% endif %}
+{% if field.type %}
+  - `type` {{ field.type }}
+{% endif %}
+{% if field.constraints %}
+  - `constraints`:
+  {% for key, value in field.constraints.items() if value %}
+    {% if key == 'pattern' %}
+    - `{{ key }}` `{{ value }}`
+    {% else %}
+    - `{{ key }}` {{ value }}
+    {% endif %}
+  {% endfor %}
+{% endif %}
\ No newline at end of file
diff --git a/frictionless/assets/templates/package-table.md b/frictionless/assets/templates/package-table.md
new file mode 100644
index 00000000..c6020560
--- /dev/null
+++ b/frictionless/assets/templates/package-table.md
@@ -0,0 +1,5 @@
+# `{{ package.name }}`{% if package.title %} {{ package.title }}{% endif %}
+{{ package | filter_dict(exclude=['name', 'title', 'resources', 'contributor']) | dict_to_markdown() }}
+{% for resource in package.resources %}
+  {% include 'resource-table.md' %}
+{% endfor %}
\ No newline at end of file
diff --git a/frictionless/assets/templates/package.md b/frictionless/assets/templates/package.md
new file mode 100644
index 00000000..fd85a7f8
--- /dev/null
+++ b/frictionless/assets/templates/package.md
@@ -0,0 +1,5 @@
+# `{{ package.name }}`{% if package.title %} {{ package.title }}{% endif %}
+{{ package | filter_dict(exclude=['name', 'title', 'resources', 'contributor']) | dict_to_markdown() }}
+{% for resource in package.resources %}
+  {% include 'resource.md' %}
+{% endfor %}
\ No newline at end of file
diff --git a/frictionless/assets/templates/resource-table.md b/frictionless/assets/templates/resource-table.md
new file mode 100644
index 00000000..02fc1e41
--- /dev/null
+++ b/frictionless/assets/templates/resource-table.md
@@ -0,0 +1,14 @@
+## `{{ resource.name }}`{% if resource.title %} {{ resource.title }}{% endif %}
+
+{% if resource.description %}
+  - `description` {{ resource.description | indent(4, False) }}
+{% endif %}
+{% if resource.path %}
+  - `path` {{ resource.path }}
+{% endif %}
+{% if resource.schema %}
+  - `schema`
+  {{ resource.schema | filter_dict(exclude=['fields']) | dict_to_markdown(level=2) }}
+  {{ resource.schema.fields | tabulate() }}
+{% endif %}
+
diff --git a/frictionless/assets/templates/resource.md b/frictionless/assets/templates/resource.md
new file mode 100644
index 00000000..5b502951
--- /dev/null
+++ b/frictionless/assets/templates/resource.md
@@ -0,0 +1,15 @@
+## `{{resource.name}}`{% if resource.title %} {{resource.title}}{% endif %}
+
+{% if resource.description %}
+  - `description` {{ resource.description | indent(4, False) }}
+{% endif %}
+{% if resource.path %}
+  - `path` {{ resource.path }}
+{% endif %}
+{% if resource.schema %}
+  - `schema`
+  {{ resource.schema | filter_dict(exclude=['fields']) | dict_to_markdown(level=2) }}
+  {% for field in resource.schema.fields %}
+    {% include 'field.md' %}
+  {% endfor %}
+{% endif %}
diff --git a/frictionless/assets/templates/schema-table.md b/frictionless/assets/templates/schema-table.md
new file mode 100644
index 00000000..26c895d3
--- /dev/null
+++ b/frictionless/assets/templates/schema-table.md
@@ -0,0 +1,3 @@
+## `schema`
+
+{{ schema.fields | tabulate() }}
\ No newline at end of file
diff --git a/frictionless/assets/templates/schema.md b/frictionless/assets/templates/schema.md
new file mode 100644
index 00000000..63a812f2
--- /dev/null
+++ b/frictionless/assets/templates/schema.md
@@ -0,0 +1,5 @@
+## `schema`
+
+{% for field in schema.fields %}
+  {% include 'field.md' %}
+{% endfor %}
\ No newline at end of file
diff --git a/frictionless/helpers.py b/frictionless/helpers.py
index b9c41303..d6bffbc2 100644
--- a/frictionless/helpers.py
+++ b/frictionless/helpers.py
@@ -4,6 +4,7 @@ import os
 import csv
 import json
 import glob
+import jinja2
 import marko
 import atexit
 import shutil
@@ -13,6 +14,7 @@ import datetime
 import platform
 import textwrap
 import stringcase
+from typing import List, Union
 from inspect import signature
 from html.parser import HTMLParser
 from importlib import import_module
@@ -532,3 +534,106 @@ class cached_property:
                         )
                         raise TypeError(msg) from None
         return val
+
+
+# Markdown
+
+
+def render_markdown(path: str, data: dict) -> str:
+    """Render any JSON-like object as Markdown, using jinja2 template"""
+
+    template_dir = os.path.join(os.path.dirname(__file__), "assets/templates")
+    environ = jinja2.Environment(
+        loader=jinja2.FileSystemLoader(template_dir), lstrip_blocks=True, trim_blocks=True
+    )
+    environ.filters["filter_dict"] = filter_dict
+    environ.filters["dict_to_markdown"] = json_to_markdown
+    environ.filters["tabulate"] = dicts_to_markdown_table
+    template = environ.get_template(path)
+    return template.render(**data)
+
+
+def filter_dict(
+    x: dict, include: list = None, exclude: list = None, order: list = None
+) -> dict:
+    """Filter and order dictionary by key names,
+    credit: Ethan Welty @ezwelty https://github.com/frictionlessdata/frictionless-py/issues/837#issuecomment-906246975"""
+
+    if include:
+        x = {key: x[key] for key in x if key in include}
+    if exclude:
+        x = {key: x[key] for key in x if key not in exclude}
+    if order:
+        index = [
+            (order.index(key) if key in order else len(order), i)
+            for i, key in enumerate(x)
+        ]
+        sorted_keys = [key for _, key in sorted(zip(index, x.keys()))]
+        x = {key: x[key] for key in sorted_keys}
+    return x
+
+
+def json_to_markdown(
+    x: Union[dict, list, int, float, str, bool],
+    level: int = 0,
+    tab: int = 2,
+    flatten_scalar_lists: bool = True,
+) -> str:
+    """Render any JSON-like object as Markdown, using nested bulleted lists,
+    credit: Ethan Welty @ezwelty https://github.com/frictionlessdata/frictionless-py/issues/837#issuecomment-1108604310"""
+
+    def _scalar_list(x) -> bool:
+        return isinstance(x, list) and all(not isinstance(xi, (dict, list)) for xi in x)
+
+    def _iter(x: Union[dict, list, int, float, str, bool], level: int = 0) -> str:
+        if isinstance(x, (dict, list)):
+            if isinstance(x, dict):
+                labels = [f"- `{key}`" for key in x]
+            elif isinstance(x, list):
+                labels = [f"- [{i + 1}]" for i in range(len(x))]
+            values = x if isinstance(x, list) else list(x.values())
+            if isinstance(x, list) and flatten_scalar_lists:
+                scalar = [not isinstance(value, (dict, list)) for value in values]
+                if all(scalar):
+                    values = [f"{values}"]
+            lines = []
+            for label, value in zip(labels, values):
+                if isinstance(value, (dict, list)) and (
+                    not flatten_scalar_lists or not _scalar_list(value)
+                ):
+                    lines.append(f"{label}\n{_iter(value, level=level + 1)}")
+                else:
+                    if isinstance(value, str):
+                        # Indent to align following lines with '- '
+                        value = jinja2.filters.do_indent(value, width=2, first=False)
+                    lines.append(f"{label} {value}")
+            txt = "\n".join(lines)
+        else:
+            txt = str(x)
+        if level > 0:
+            txt = jinja2.filters.do_indent(txt, width=tab, first=True, blank=False)
+        return txt
+
+    return jinja2.filters.do_indent(
+        _iter(x, level=0), width=tab * level, first=True, blank=False
+    )
+
+
+def dicts_to_markdown_table(dicts: List[dict], **kwargs) -> str:
+    """Tabulate dictionaries and render as a Markdown table,
+    credit: Ethan Welty @ezwelty https://github.com/frictionlessdata/frictionless-py/issues/837#issuecomment-906246975
+    """
+
+    if kwargs:
+        dicts = [filter_dict(x, **kwargs) for x in dicts]
+    try:
+        pandas = import_module("pandas")
+        # dependency for pandas
+        import_module("tabulate")
+        df = pandas.DataFrame(dicts)
+    except ImportError:
+        module = import_module("frictionless.exception")
+        errors = import_module("frictionless.errors")
+        error = errors.GeneralError(note="Please install modules pandas and tabulate")
+        raise module.FrictionlessException(error)
+    return df.where(df.notnull(), None).to_markdown(index=False)
diff --git a/frictionless/metadata.py b/frictionless/metadata.py
index 419d13b0..c9cec845 100644
--- a/frictionless/metadata.py
+++ b/frictionless/metadata.py
@@ -9,7 +9,7 @@ from operator import setitem
 from functools import partial
 from importlib import import_module
 from .exception import FrictionlessException
-from .helpers import cached_property
+from .helpers import cached_property, render_markdown
 from . import helpers
 import pprint as pp
 
@@ -155,6 +155,27 @@ class Metadata(helpers.ControlledDict):
                 raise FrictionlessException(self.__Error(note=str(exc))) from exc
         return text
 
+    def to_markdown(self, path: str = None, table: bool = False) -> str:
+        """Convert metadata as a markdown
+
+        Parameters:
+            path (str): target path
+            table (bool): if true converts markdown to tabular format
+
+        Raises:
+            FrictionlessException: on any error
+        """
+
+        filename = self.__class__.__name__.lower()
+        template = f"{filename}-table.md" if table is True else f"{filename}.md"
+        md_output = render_markdown(f"{template}", {filename: self}).strip()
+        if path:
+            try:
+                helpers.write_file(path, md_output)
+            except Exception as exc:
+                raise FrictionlessException(self.__Error(note=str(exc))) from exc
+        return md_output
+
     # Metadata
 
     @property
diff --git a/setup.py b/setup.py
index 1ea6db89..1b3b539e 100644
--- a/setup.py
+++ b/setup.py
@@ -23,7 +23,6 @@ TESTS_REQUIRE = [
     "mypy",
     "moto",
     "black",
-    "jinja2",
     "pylama",
     "pytest",
     "ipython",
@@ -71,7 +70,9 @@ INSTALL_REQUIRES = [
     "validators>=0.18",
     "python-slugify>=1.2",
     "python-dateutil>=2.8",
-    "tableschema-to-template>=0.0.12"
+    "tableschema-to-template>=0.0.12",
+    "tabulate>=0.8.9",
+    "jinja2>=3.0.3"
 ]
 README = read("README.md")
 VERSION = read(PACKAGE, "assets", "VERSION")


Test patch:
diff --git a/tests/fixtures/output-markdown/package-table.md b/tests/fixtures/output-markdown/package-table.md
new file mode 100644
index 00000000..82842bc0
--- /dev/null
+++ b/tests/fixtures/output-markdown/package-table.md
@@ -0,0 +1,21 @@
+# `package`
+## `main`
+  - `schema`
+      - `primaryKey` ['id']
+  | name           | description                 | type    | constraints                   |
+|:---------------|:----------------------------|:--------|:------------------------------|
+| id             | Any positive integer        | integer | {'minimum': 1}                |
+| integer_minmax | An integer between 1 and 10 | integer | {'minimum': 1, 'maximum': 10} |
+| boolean        | Any boolean                 | boolean |                               |
+## `secondary`
+  - `schema`
+      - `foreignKeys`
+      - [1]
+        - `fields` ['main_id']
+        - `reference`
+          - `resource` main
+          - `fields` ['id']
+  | name    | description                      | type    | constraints      |
+|:--------|:---------------------------------|:--------|:-----------------|
+| main_id | Any value in main.id             | integer |                  |
+| string  | Any string of up to 3 characters | string  | {'maxLength': 3} |
\ No newline at end of file
diff --git a/tests/fixtures/output-markdown/package.md b/tests/fixtures/output-markdown/package.md
new file mode 100644
index 00000000..fff830ce
--- /dev/null
+++ b/tests/fixtures/output-markdown/package.md
@@ -0,0 +1,34 @@
+# `package`
+## `main`
+  - `schema`
+      - `primaryKey` ['id']
+### `id`
+  - `description` Any positive integer
+  - `type` integer
+  - `constraints`:
+    - `minimum` 1
+### `integer_minmax`
+  - `description` An integer between 1 and 10
+  - `type` integer
+  - `constraints`:
+    - `minimum` 1
+    - `maximum` 10
+### `boolean`
+  - `description` Any boolean
+  - `type` boolean
+## `secondary`
+  - `schema`
+      - `foreignKeys`
+      - [1]
+        - `fields` ['main_id']
+        - `reference`
+          - `resource` main
+          - `fields` ['id']
+### `main_id`
+  - `description` Any value in main.id
+  - `type` integer
+### `string`
+  - `description` Any string of up to 3 characters
+  - `type` string
+  - `constraints`:
+    - `maxLength` 3
\ No newline at end of file
diff --git a/tests/fixtures/output-markdown/resource-table.md b/tests/fixtures/output-markdown/resource-table.md
new file mode 100644
index 00000000..8ba055cb
--- /dev/null
+++ b/tests/fixtures/output-markdown/resource-table.md
@@ -0,0 +1,7 @@
+## `main`
+  - `schema`
+      - `primaryKey` ['id']
+  | name           | description                 | type    | constraints                   |
+|:---------------|:----------------------------|:--------|:------------------------------|
+| id             | Any positive integer        | integer | {'minimum': 1}                |
+| integer_minmax | An integer between 1 and 10 | integer | {'minimum': 1, 'maximum': 10} |
\ No newline at end of file
diff --git a/tests/fixtures/output-markdown/resource.md b/tests/fixtures/output-markdown/resource.md
new file mode 100644
index 00000000..172d1921
--- /dev/null
+++ b/tests/fixtures/output-markdown/resource.md
@@ -0,0 +1,14 @@
+## `main`
+  - `schema`
+      - `primaryKey` ['id']
+### `id`
+  - `description` Any positive integer
+  - `type` integer
+  - `constraints`:
+    - `minimum` 1
+### `integer_minmax`
+  - `description` An integer between 1 and 10
+  - `type` integer
+  - `constraints`:
+    - `minimum` 1
+    - `maximum` 10
\ No newline at end of file
diff --git a/tests/fixtures/output-markdown/schema-table.md b/tests/fixtures/output-markdown/schema-table.md
new file mode 100644
index 00000000..7ce0e9ab
--- /dev/null
+++ b/tests/fixtures/output-markdown/schema-table.md
@@ -0,0 +1,6 @@
+## `schema`
+
+| name   | description          | type    | constraints    | title   |
+|:-------|:---------------------|:--------|:---------------|:--------|
+| id     | Any positive integer | integer | {'minimum': 1} |         |
+| age    | Any number >= 1      | number  | {'minimum': 1} | Age     |
\ No newline at end of file
diff --git a/tests/fixtures/output-markdown/schema.md b/tests/fixtures/output-markdown/schema.md
new file mode 100644
index 00000000..32838288
--- /dev/null
+++ b/tests/fixtures/output-markdown/schema.md
@@ -0,0 +1,12 @@
+## `schema`
+
+### `id`
+  - `description` Any positive integer
+  - `type` integer
+  - `constraints`:
+    - `minimum` 1
+### `age` Age
+  - `description` Any number >= 1
+  - `type` number
+  - `constraints`:
+    - `minimum` 1
\ No newline at end of file
diff --git a/tests/test_package.py b/tests/test_package.py
index d55a2b68..5cca0ea5 100644
--- a/tests/test_package.py
+++ b/tests/test_package.py
@@ -1107,3 +1107,195 @@ def test_package_pprint_1029():
     expected = """{'resources': [{'data': [['id', 'name'], ['1', 'english'], ['2', '中国人']],
                 'name': 'name'}]}"""
     assert repr(package) == expected
+
+
+def test_package_to_markdown_837():
+    descriptor = {
+        "name": "package",
+        "resources": [
+            {
+                "name": "main",
+                "schema": {
+                    "fields": [
+                        {
+                            "name": "id",
+                            "description": "Any positive integer",
+                            "type": "integer",
+                            "constraints": {"minimum": 1},
+                        },
+                        {
+                            "name": "integer_minmax",
+                            "description": "An integer between 1 and 10",
+                            "type": "integer",
+                            "constraints": {"minimum": 1, "maximum": 10},
+                        },
+                        {
+                            "name": "boolean",
+                            "description": "Any boolean",
+                            "type": "boolean",
+                        },
+                    ],
+                    "primaryKey": ["id"],
+                },
+            },
+            {
+                "name": "secondary",
+                "schema": {
+                    "fields": [
+                        {
+                            "name": "main_id",
+                            "description": "Any value in main.id",
+                            "type": "integer",
+                        },
+                        {
+                            "name": "string",
+                            "description": "Any string of up to 3 characters",
+                            "type": "string",
+                            "constraints": {"maxLength": 3},
+                        },
+                    ],
+                    "foreignKeys": [
+                        {
+                            "fields": ["main_id"],
+                            "reference": {"resource": "main", "fields": ["id"]},
+                        }
+                    ],
+                },
+            },
+        ],
+    }
+    package = Package(descriptor)
+    md_file_path = Path(Path(__file__).parent, "fixtures/output-markdown/package.md")
+    with open(md_file_path, encoding="utf-8") as file:
+        expected = file.read()
+    assert package.to_markdown().strip() == expected
+
+
+def test_package_to_markdown_table_837():
+    descriptor = {
+        "name": "package",
+        "resources": [
+            {
+                "name": "main",
+                "schema": {
+                    "fields": [
+                        {
+                            "name": "id",
+                            "description": "Any positive integer",
+                            "type": "integer",
+                            "constraints": {"minimum": 1},
+                        },
+                        {
+                            "name": "integer_minmax",
+                            "description": "An integer between 1 and 10",
+                            "type": "integer",
+                            "constraints": {"minimum": 1, "maximum": 10},
+                        },
+                        {
+                            "name": "boolean",
+                            "description": "Any boolean",
+                            "type": "boolean",
+                        },
+                    ],
+                    "primaryKey": ["id"],
+                },
+            },
+            {
+                "name": "secondary",
+                "schema": {
+                    "fields": [
+                        {
+                            "name": "main_id",
+                            "description": "Any value in main.id",
+                            "type": "integer",
+                        },
+                        {
+                            "name": "string",
+                            "description": "Any string of up to 3 characters",
+                            "type": "string",
+                            "constraints": {"maxLength": 3},
+                        },
+                    ],
+                    "foreignKeys": [
+                        {
+                            "fields": ["main_id"],
+                            "reference": {"resource": "main", "fields": ["id"]},
+                        }
+                    ],
+                },
+            },
+        ],
+    }
+    package = Package(descriptor)
+    md_file_path = Path(
+        Path(__file__).parent, "fixtures/output-markdown/package-table.md"
+    )
+    with open(md_file_path, encoding="utf-8") as file:
+        expected = file.read()
+    assert package.to_markdown(table=True).strip() == expected
+
+
+def test_package_to_markdown_file_837(tmpdir):
+    descriptor = descriptor = descriptor = {
+        "name": "package",
+        "resources": [
+            {
+                "name": "main",
+                "schema": {
+                    "fields": [
+                        {
+                            "name": "id",
+                            "description": "Any positive integer",
+                            "type": "integer",
+                            "constraints": {"minimum": 1},
+                        },
+                        {
+                            "name": "integer_minmax",
+                            "description": "An integer between 1 and 10",
+                            "type": "integer",
+                            "constraints": {"minimum": 1, "maximum": 10},
+                        },
+                        {
+                            "name": "boolean",
+                            "description": "Any boolean",
+                            "type": "boolean",
+                        },
+                    ],
+                    "primaryKey": ["id"],
+                },
+            },
+            {
+                "name": "secondary",
+                "schema": {
+                    "fields": [
+                        {
+                            "name": "main_id",
+                            "description": "Any value in main.id",
+                            "type": "integer",
+                        },
+                        {
+                            "name": "string",
+                            "description": "Any string of up to 3 characters",
+                            "type": "string",
+                            "constraints": {"maxLength": 3},
+                        },
+                    ],
+                    "foreignKeys": [
+                        {
+                            "fields": ["main_id"],
+                            "reference": {"resource": "main", "fields": ["id"]},
+                        }
+                    ],
+                },
+            },
+        ],
+    }
+    md_file_path = Path(Path(__file__).parent, "fixtures/output-markdown/package.md")
+    with open(md_file_path, encoding="utf-8") as file:
+        expected = file.read()
+    target = str(tmpdir.join("package.md"))
+    package = Package(descriptor)
+    package.to_markdown(path=target).strip()
+    with open(target, encoding="utf-8") as file:
+        output = file.read()
+    assert expected == output
diff --git a/tests/test_resource.py b/tests/test_resource.py
index ba9a6ff1..c8fa9044 100644
--- a/tests/test_resource.py
+++ b/tests/test_resource.py
@@ -3,6 +3,7 @@ import sys
 import json
 import yaml
 import pytest
+from pathlib import Path
 from frictionless import Package, Resource, Schema, Field, Layout, Detector, helpers
 from frictionless import FrictionlessException, describe_resource
 from frictionless.plugins.remote import RemoteControl
@@ -2759,3 +2760,93 @@ def test_resource_pprint_1029():
  'path': 'data/table.csv',
  'title': 'My Resource'}"""
     assert repr(resource) == expected
+
+
+def test_resource_to_markdown_path_schema_837():
+    descriptor = {
+        "name": "main",
+        "schema": {
+            "fields": [
+                {
+                    "name": "id",
+                    "description": "Any positive integer",
+                    "type": "integer",
+                    "constraints": {"minimum": 1},
+                },
+                {
+                    "name": "integer_minmax",
+                    "description": "An integer between 1 and 10",
+                    "type": "integer",
+                    "constraints": {"minimum": 1, "maximum": 10},
+                },
+            ],
+            "primaryKey": ["id"],
+        },
+    }
+    resource = Resource(descriptor)
+    md_file_path = Path(Path(__file__).parent, "fixtures/output-markdown/resource.md")
+    with open(md_file_path, encoding="utf-8") as file:
+        expected = file.read()
+    assert resource.to_markdown().strip() == expected
+
+
+def test_resource_to_markdown_path_schema_table_837():
+    descriptor = {
+        "name": "main",
+        "schema": {
+            "fields": [
+                {
+                    "name": "id",
+                    "description": "Any positive integer",
+                    "type": "integer",
+                    "constraints": {"minimum": 1},
+                },
+                {
+                    "name": "integer_minmax",
+                    "description": "An integer between 1 and 10",
+                    "type": "integer",
+                    "constraints": {"minimum": 1, "maximum": 10},
+                },
+            ],
+            "primaryKey": ["id"],
+        },
+    }
+    resource = Resource(descriptor)
+    md_file_path = Path(
+        Path(__file__).parent, "fixtures/output-markdown/resource-table.md"
+    )
+    with open(md_file_path, encoding="utf-8") as file:
+        expected = file.read()
+    assert resource.to_markdown(table=True).strip() == expected
+
+
+def test_resource_to_markdown_file_837(tmpdir):
+    descriptor = descriptor = {
+        "name": "main",
+        "schema": {
+            "fields": [
+                {
+                    "name": "id",
+                    "description": "Any positive integer",
+                    "type": "integer",
+                    "constraints": {"minimum": 1},
+                },
+                {
+                    "name": "integer_minmax",
+                    "description": "An integer between 1 and 10",
+                    "type": "integer",
+                    "constraints": {"minimum": 1, "maximum": 10},
+                },
+            ],
+            "primaryKey": ["id"],
+        },
+    }
+    md_file_path = Path(Path(__file__).parent, "fixtures/output-markdown/resource.md")
+    with open(md_file_path, encoding="utf-8") as file:
+        expected = file.read()
+    target = str(tmpdir.join("resource.md"))
+    resource = Resource(descriptor)
+    resource.to_markdown(path=target).strip()
+    with open(target, encoding="utf-8") as file:
+        output = file.read()
+    assert expected == output
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 89e26cd6..8a68318a 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -534,3 +534,82 @@ def test_schema_pprint_1029():
             {'format': 'default', 'name': 'test_2', 'type': 'string'},
             {'format': 'default', 'name': 'test_3', 'type': 'string'}]}"""
     assert repr(schema) == expected
+
+
+def test_schema_to_markdown_837(tmpdir):
+    descriptor = {
+        "fields": [
+            {
+                "name": "id",
+                "description": "Any positive integer",
+                "type": "integer",
+                "constraints": {"minimum": 1},
+            },
+            {
+                "name": "age",
+                "title": "Age",
+                "description": "Any number >= 1",
+                "type": "number",
+                "constraints": {"minimum": 1},
+            },
+        ]
+    }
+    schema = Schema(descriptor)
+    md_file_path = Path(Path(__file__).parent, "fixtures/output-markdown/schema.md")
+    with open(md_file_path, encoding="utf-8") as file:
+        expected = file.read()
+    assert schema.to_markdown().strip() == expected
+
+
+def test_schema_to_markdown_table_837():
+    descriptor = {
+        "fields": [
+            {
+                "name": "id",
+                "description": "Any positive integer",
+                "type": "integer",
+                "constraints": {"minimum": 1},
+            },
+            {
+                "name": "age",
+                "title": "Age",
+                "description": "Any number >= 1",
+                "type": "number",
+                "constraints": {"minimum": 1},
+            },
+        ]
+    }
+    schema = Schema(descriptor)
+    md_file_path = Path(Path(__file__).parent, "fixtures/output-markdown/schema-table.md")
+    with open(md_file_path, encoding="utf-8") as file:
+        expected = file.read()
+    assert schema.to_markdown(table=True).strip() == expected
+
+
+def test_schema_to_markdown_file_837(tmpdir):
+    descriptor = {
+        "fields": [
+            {
+                "name": "id",
+                "description": "Any positive integer",
+                "type": "integer",
+                "constraints": {"minimum": 1},
+            },
+            {
+                "name": "age",
+                "title": "Age",
+                "description": "Any number >= 1",
+                "type": "number",
+                "constraints": {"minimum": 1},
+            },
+        ]
+    }
+    md_file_path = Path(Path(__file__).parent, "fixtures/output-markdown/schema.md")
+    with open(md_file_path, encoding="utf-8") as file:
+        expected = file.read()
+    target = str(tmpdir.join("schema.md"))
+    schema = Schema(descriptor)
+    schema.to_markdown(path=target).strip()
+    with open(target, encoding="utf-8") as file:
+        output = file.read()
+    assert expected == output