├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── codecov.yml ├── docs ├── _static │ └── custom.css ├── conf.py └── index.md ├── pyproject.toml ├── src └── sphinx_timeline │ ├── __init__.py │ ├── dtime.py │ ├── main.py │ └── static │ ├── __init__.py │ ├── datetime.js │ └── default.css ├── tests ├── fixtures │ └── posttransform_html.txt ├── test_dtime.py └── test_simple.py └── tox.ini /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: 7 | - "v[0-9]+.[0-9]+.[0-9]+*" 8 | pull_request: 9 | 10 | jobs: 11 | 12 | tests: 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["3.8", "3.9", "3.10"] 18 | os: [ubuntu-latest] 19 | include: 20 | - os: windows-latest 21 | python-version: "3.8" 22 | 23 | runs-on: ${{ matrix.os }} 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v1 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install -e .[testing] 35 | - name: Run pytest 36 | run: pytest --cov=src --cov-report=xml --cov-report=term-missing 37 | - name: Upload coverage to Codecov 38 | uses: codecov/codecov-action@v3 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | 154 | .vscode/ 155 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.5.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | 11 | - repo: https://github.com/asottile/pyupgrade 12 | rev: v3.15.0 13 | hooks: 14 | - id: pyupgrade 15 | args: [--py37-plus] 16 | 17 | - repo: https://github.com/PyCQA/isort 18 | rev: 5.13.2 19 | hooks: 20 | - id: isort 21 | 22 | - repo: https://github.com/psf/black 23 | rev: 23.12.1 24 | hooks: 25 | - id: black 26 | 27 | - repo: https://github.com/PyCQA/flake8 28 | rev: 7.0.0 29 | hooks: 30 | - id: flake8 31 | additional_dependencies: 32 | - flake8-comprehensions 33 | - flake8-bugbear 34 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.8" 7 | 8 | python: 9 | install: 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - docs 14 | - furo 15 | 16 | sphinx: 17 | builder: html 18 | fail_on_warning: true 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true, 8 | "**/Thumbs.db": true, 9 | "**/__pycache__": true, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Chris Sewell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sphinx-timeline 2 | 3 | [![PyPI][pypi-badge]][pypi-link] 4 | 5 | A [sphinx](https://www.sphinx-doc.org) extension to create timelines. 6 | 7 | ## Usage 8 | 9 | Install `sphinx-timeline` with `pip install sphinx-timeline`, 10 | then add `sphinx_timeline` to your `conf.py` file's `extensions` variable: 11 | 12 | ```python 13 | extensions = ["sphinx_timeline"] 14 | ``` 15 | 16 | Now add a `timeline` directive to your document: 17 | 18 | ```restructuredtext 19 | .. timeline:: 20 | - start: 1980-02-03 21 | name: 1st event 22 | - start: 1990-02-03 23 | name: 2nd event 24 | - start: 2000-02-03 25 | name: 3rd event 26 | --- 27 | **{{dtrange}}** 28 | 29 | *{{e.name}}* 30 | 31 | Description ... 32 | ``` 33 | 34 | [pypi-badge]: https://img.shields.io/pypi/v/sphinx_timeline.svg 35 | [pypi-link]: https://pypi.org/project/sphinx_timeline 36 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 80% 6 | threshold: 0.5% 7 | patch: 8 | default: 9 | target: 75% 10 | threshold: 0.5% 11 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | /** highlight future events **/ 2 | ol.timeline-default>li.timeline>div.tl-item.dt-future { 3 | outline-color: green; 4 | } 5 | 6 | /** furo and pydata-sphinx-theme **/ 7 | body[data-theme="dark"] { 8 | --tl-dot-color: #9a9a9a; 9 | --tl-item-outline-color: #383838; 10 | --tl-item-tail-color: #747474; 11 | --tl-item-shadow: 0 4px 8px 0 rgba(100, 100, 100, 0.2), 0 6px 10px 0 rgba(100, 100, 100, 0.19); 12 | } 13 | 14 | @media (prefers-color-scheme: dark) { 15 | body:not([data-theme="light"]) { 16 | --tl-dot-color: #9a9a9a; 17 | --tl-item-outline-color: #383838; 18 | --tl-item-tail-color: #747474; 19 | --tl-item-shadow: 0 4px 8px 0 rgba(100, 100, 100, 0.2), 0 6px 10px 0 rgba(100, 100, 100, 0.19); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import os 3 | 4 | from sphinx_timeline import __version__ 5 | 6 | project = "Sphinx Timeline" 7 | author = "Chris Sewell" 8 | copyright = f"{datetime.now().year}, Chris Sewell" 9 | version = __version__ 10 | 11 | extensions = ["myst_parser", "sphinx_timeline"] 12 | 13 | myst_enable_extensions = ["deflist"] 14 | 15 | suppress_warnings = ["epub.unknown_project_files"] 16 | 17 | # get environment variables 18 | html_theme = os.environ.get("HTML_THEME", "furo") 19 | html_static_path = ["_static"] 20 | html_css_files = ["custom.css"] 21 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # sphinx-timeline 2 | 3 | A [sphinx](https://www.sphinx-doc.org) extension to create timelines. 4 | 5 | A scrolling horizontal timeline is created for HTML output, and other formats degrade gracefully to a simple ordered list. 6 | 7 | ```{timeline} 8 | :height: 350px 9 | 10 | - start: 1980-02-03 11 | name: 1st event 12 | - start: 1990-02-03 13 | name: 2nd event 14 | - start: 2000-02-03 15 | name: 3rd event 16 | - start: 2010-02-03 17 | name: 4th event 18 | - start: 2020-02-04 19 | name: 5th event 20 | - start: 2030-02-04 21 | name: 6th event 22 | --- 23 | **{{dtrange}}** 24 | 25 | *{{e.name}}* 26 | 27 | Description ... 28 | ``` 29 | 30 | ## Quick-start 31 | 32 | Install `sphinx-timeline` with `pip install sphinx-timeline`, 33 | then add `sphinx_timeline` to your `conf.py` file's `extensions` variable: 34 | 35 | ```python 36 | extensions = ["sphinx_timeline"] 37 | ``` 38 | 39 | Now add a `timeline` directive to your document: 40 | 41 | ```restructuredtext 42 | .. timeline:: 43 | 44 | - start: 1980-02-03 45 | name: 1st event 46 | - start: 1990-02-03 47 | name: 2nd event 48 | - start: 2000-02-03 49 | name: 3rd event 50 | --- 51 | **{{dtrange}}** 52 | 53 | *{{e.name}}* 54 | 55 | Description ... 56 | ``` 57 | 58 | ## Usage 59 | 60 | A timeline contains two critical pieces: 61 | 62 | 1. A list of events (in the form of a YAML list, JSON list, or CSV). 63 | 64 | - Each event must have at least `start` key, in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) date(time) format, which can also have a suffix [time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) in parenthesise, e.g. `2020-02-03 12:34:56 (Europe/Zurich)`. 65 | - Each event can have an optional `duration` key, to specify the delta from the `start`. 66 | This is in the format e.g. `30 minutes 4 hours 1 day 2 months 3 years`. 67 | All units are optional and can be in any order, e.g. `4 hours 30 minutes` is the same as `30 minutes 4 hours 0 day 0 months 0 years`. 68 | 69 | 2. A template for the event items content (in the form of a [Jinja2](https://jinja.palletsprojects.com) template) 70 | 71 | Each can be supplied *via* an external file, or directly in the directive content. 72 | If both are in the directive content, then they are split by a line with `---`. 73 | 74 | For example, to use external files: 75 | 76 | ```restructuredtext 77 | .. timeline:: 78 | :events: /path/to/events.yaml 79 | :template: /path/to/template.txt 80 | ``` 81 | 82 | If a path starts with `/`, then it is relative to the Sphinx source directory, otherwise it is relative to the current document. 83 | 84 | ## Jinja templates 85 | 86 | The template is a [Jinja2](https://jinja.palletsprojects.com) template, which is rendered for each event. 87 | The event dictionary is available as the `e` variable, and the following additional variables are available: 88 | 89 | **dtrange** 90 | : The event's `start` and `duration` formatted as a date range, e.g. `Wed 3rd Feb 2021, 10:00 PM - 11:01 PM (UTC)`. 91 | If the event has no `duration`, then only the `start` is used. 92 | This can also be parsed arguments to control the formatting 93 | 94 | ```restructuredtext 95 | .. timeline:: 96 | :style: none 97 | 98 | - start: 2022-02-03 22:00 99 | - start: 2021-02-03 22:00 100 | duration: 1 hour 30 minutes 101 | - start: 2020-02-03 102 | duration: 1 day 103 | --- 104 | - {{dtrange}} 105 | - {{dtrange(day_name=False)}} 106 | - {{dtrange(short_date=True)}} 107 | - {{dtrange(clock12=True)}} 108 | - {{dtrange(abbr=False)}} 109 | ``` 110 | 111 | ```{timeline} 112 | :style: none 113 | 114 | - start: 2022-02-03 22:00 115 | - start: 2021-02-03 22:00 (Europe/Zurich) 116 | duration: 1 hour 30 minutes 117 | - start: 2020-02-03 118 | duration: 1 day 119 | --- 120 | - {{dtrange}} 121 | - {{dtrange(day_name=False)}} 122 | - {{dtrange(short_date=True, short_delim="/")}} 123 | - {{dtrange(clock12=True)}} 124 | - {{dtrange(abbr=False)}} 125 | ``` 126 | 127 | **dt** 128 | : The same as `dtrange`, but `duration` is not included. 129 | 130 | ## Directive options 131 | 132 | events 133 | : Path to the timeline data file, otherwise the data is read from the content. 134 | If the path starts with `/`, then it is relative to the Sphinx source directory, otherwise it is relative to the current document. 135 | 136 | events-format 137 | : The format of the events. Can be `json`, `yaml`, or `csv`. Defaults to `yaml`. 138 | 139 | template 140 | : Path to the template file, otherwise the template is read from the content. 141 | If the path starts with `/`, then it is relative to the Sphinx source directory, otherwise it is relative to the current document. 142 | 143 | max-items 144 | : The maximum number of items to show. Defaults to all. 145 | 146 | reversed 147 | : Whether to reverse the order of the item sorting. 148 | 149 | style 150 | : The style of the timeline. Can be `default` or `none`. 151 | 152 | height 153 | : The height of the timeline (for default style). Defaults to `300px`. 154 | 155 | width-item 156 | : The width of each item (for default style). Defaults to `280px`. 157 | 158 | class 159 | : Classes to add to the timeline list. 160 | 161 | class-item 162 | : Classes to add to each item. 163 | 164 | ## Customise HTML output 165 | 166 | ### CSS Variables 167 | 168 | The following CSS variables can be used to customize the appearance of the timeline: 169 | 170 | ```css 171 | :root { 172 | --tl-height: 300px; 173 | --tl-item-outline-color: black; 174 | --tl-item-outline-width: 1px; 175 | --tl-item-border-radius: 10px; 176 | --tl-item-padding: 15px; 177 | --tl-item-width: 280px; 178 | --tl-item-gap-x: 8px; 179 | --tl-item-gap-y: 16px; 180 | --tl-item-tail-height: 10px; 181 | --tl-item-tail-color: #383838; 182 | --tl-item-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); 183 | --tl-line-color: #686868; 184 | --tl-line-width: 3px; 185 | --tl-dot-color: black; 186 | --tl-dot-diameter: 12px; 187 | } 188 | ``` 189 | 190 | For example, for [furo](https://github.com/pradyunsg/furo) and [pydata-sphinx-theme](https://github.com/pydata/pydata-sphinx-theme), which have dark/light themes, you can add the following to your `conf.py`: 191 | 192 | ```python 193 | html_static_path = ["_static"] 194 | html_css_files = ["custom.css"] 195 | ``` 196 | 197 | Then create the CSS `_static/custom.css`: 198 | 199 | ```css 200 | body[data-theme="dark"] { 201 | --tl-dot-color: #9a9a9a; 202 | --tl-item-outline-color: #383838; 203 | --tl-item-tail-color: #747474; 204 | --tl-item-shadow: 0 4px 8px 0 rgba(100, 100, 100, 0.2), 0 6px 10px 0 rgba(100, 100, 100, 0.19); 205 | } 206 | 207 | @media (prefers-color-scheme: dark) { 208 | body:not([data-theme="light"]) { 209 | --tl-dot-color: #9a9a9a; 210 | --tl-item-outline-color: #383838; 211 | --tl-item-tail-color: #747474; 212 | --tl-item-shadow: 0 4px 8px 0 rgba(100, 100, 100, 0.2), 0 6px 10px 0 rgba(100, 100, 100, 0.19); 213 | } 214 | } 215 | ``` 216 | 217 | ### Data attributes 218 | 219 | On the containing div for each event, the `data-dt` data attribute is added, which contains the event's `start` data/time in ISO format. 220 | JavaScript (if enabled) will also run, to add `dt-future` or `dt-past` class to each div, depending on whether the event is in the future or past. 221 | This means that you can use CSS selectors to style events based on their date/time. 222 | 223 | For example, to highlight future events, you can add the following to your `conf.py`: 224 | 225 | ```python 226 | html_static_path = ["_static"] 227 | html_css_files = ["custom.css"] 228 | ``` 229 | 230 | Then add to the CSS `_static/custom.css`: 231 | 232 | ```css 233 | ol.timeline-default>li.timeline>div.tl-item.dt-future { 234 | outline-color: green; 235 | } 236 | ``` 237 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "sphinx_timeline" 7 | dynamic = ["version", "description"] 8 | authors = [{name = "Chris Sewell", email = "chrisj_sewell@hotmail.com"}] 9 | readme = "README.md" 10 | license = {file = "LICENSE"} 11 | classifiers = [ 12 | "Development Status :: 4 - Beta", 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python :: 3", 15 | "Topic :: Software Development :: Libraries :: Python Modules", 16 | "Topic :: Text Processing :: Markup", 17 | "Framework :: Sphinx :: Extension", 18 | ] 19 | keywords = ["sphinx", "timeline"] 20 | requires-python = ">=3.8" 21 | dependencies = [ 22 | "sphinx", 23 | "jinja2", 24 | "pyyaml", 25 | "backports.zoneinfo;python_version<'3.9'", 26 | "tzdata", 27 | "python-dateutil" 28 | ] 29 | 30 | [project."optional-dependencies"] 31 | testing = [ 32 | "pytest", 33 | "pytest-cov", 34 | "pytest-regressions", 35 | "pytest-param-files", 36 | "sphinx-pytest>=0.0.4", 37 | "beautifulsoup4", 38 | ] 39 | docs = ["myst-parser"] 40 | furo = ["furo"] 41 | pst = ["pydata-sphinx-theme"] 42 | rtd = ["sphinx-rtd-theme"] 43 | 44 | [project.urls] 45 | Homepage = "https://github.com/chrisjsewell/sphinx-timeline" 46 | 47 | [tool.isort] 48 | profile = "black" 49 | force_sort_within_sections = true 50 | -------------------------------------------------------------------------------- /src/sphinx_timeline/__init__.py: -------------------------------------------------------------------------------- 1 | """A sphinx extension to create timelines.""" 2 | 3 | __version__ = "0.2.1" 4 | 5 | 6 | from .main import setup # noqa: F401 7 | -------------------------------------------------------------------------------- /src/sphinx_timeline/dtime.py: -------------------------------------------------------------------------------- 1 | """Utilities for parsing and formatting dates and times.""" 2 | from __future__ import annotations 3 | 4 | from datetime import date, datetime, time, timezone 5 | import re 6 | 7 | from dateutil.relativedelta import relativedelta 8 | 9 | try: 10 | import zoneinfo 11 | except ImportError: 12 | from backports import zoneinfo # noqa: F401 13 | 14 | 15 | RE_ISO_TZ = re.compile(r"(?P
.+)\((?P[^)]+)\)\s*$") 16 | 17 | 18 | def to_datetime(value: str | date | datetime, default_tz=timezone.utc) -> datetime: 19 | """Convert to a timezone-aware datetime object.""" 20 | final: datetime 21 | if isinstance(value, str): 22 | # find timezone name suffix in parentheses 23 | tz_match = RE_ISO_TZ.match(value) 24 | if tz_match: 25 | # parse datetime with timezone 26 | final = datetime.fromisoformat(tz_match.group("dt").strip()) 27 | try: 28 | tz = zoneinfo.ZoneInfo(tz_match.group("tz")) 29 | except Exception as exc: 30 | raise ValueError( 31 | f"Invalid timezone: {tz_match.group('tz')!r}" 32 | # f"\navailable: {zoneinfo.available_timezones()!r}" 33 | ) from exc 34 | final = final.replace(tzinfo=tz) 35 | else: 36 | # parse datetime without timezone 37 | final = datetime.fromisoformat(value.strip()) 38 | elif isinstance(value, date): 39 | final = datetime(value.year, value.month, value.day) 40 | elif not isinstance(value, datetime): 41 | raise TypeError(f"invalid type: {type(value)}") 42 | # if not timezone aware, assume UTC 43 | if final.tzinfo is None: 44 | final = final.replace(tzinfo=default_tz) 45 | return final 46 | 47 | 48 | def parse_duration(value: str) -> relativedelta: 49 | """Parse a duration string.""" 50 | if not isinstance(value, str): 51 | raise TypeError(f"not str: {type(value)}") 52 | delta = {} 53 | if year_match := re.search(r"(\d+)\s?y", value): 54 | delta["years"] = int(year_match.group(1)) 55 | if month_match := re.search(r"(\d+)\s?mon", value): 56 | delta["months"] = int(month_match.group(1)) 57 | if day_match := re.search(r"(\d+)\s?d", value): 58 | delta["days"] = int(day_match.group(1)) 59 | if hour_match := re.search(r"(\d+)\s?h", value): 60 | delta["hours"] = int(hour_match.group(1)) 61 | if minute_match := re.search(r"(\d+)\s?min", value): 62 | delta["minutes"] = int(minute_match.group(1)) 63 | if second_match := re.search(r"(\d+)\s?s", value): 64 | delta["seconds"] = int(second_match.group(1)) 65 | return relativedelta(**delta) 66 | 67 | 68 | def fmt_daterange( 69 | start: datetime, 70 | duration: relativedelta | None = None, 71 | *, 72 | day_name=True, 73 | short_date=False, 74 | short_delim="/", 75 | abbr=True, 76 | clock12=True, 77 | ) -> str: 78 | """Format a datetime span. 79 | 80 | :param start: start datetime 81 | :param duration: duration of span 82 | :param day_name: include day name (e.g. "Monday") 83 | :param short_date: use short date format (e.g. "01/01/2021") 84 | :param short_delim: delimiter for short date format 85 | :param abbr: use abbreviated day/month name (e.g. "Mon") 86 | :param clock12: use AM/PM time format 87 | """ 88 | day_name_code = ("%a " if abbr else "%A ") if day_name else "" 89 | day_code = f"%d{short_delim}" if short_date else "%D " 90 | month_code = f"%m{short_delim}" if short_date else ("%b " if abbr else "%B ") 91 | time_code = "%I:%M %p" if clock12 else "%H:%M" 92 | tz_code = " (%Z)" if start.tzinfo else "" 93 | 94 | start_fmt = f"{day_name_code}{day_code}{month_code}%Y, {time_code}{tz_code}" 95 | end_fmt = f"{day_name_code}{day_code}{month_code}%Y, {time_code}{tz_code}" 96 | 97 | if duration is None: 98 | end = start 99 | else: 100 | end = start + duration 101 | 102 | if start == end: 103 | if start.time() == time(0): 104 | # remove time from start format 105 | start_fmt = start_fmt.split(",")[0] 106 | return fmt_datetime(start, start_fmt) 107 | 108 | # remove timezone from start format 109 | start_fmt = start_fmt.replace(tz_code, "") 110 | 111 | if start.date() == end.date(): 112 | # remove date from end format 113 | end_fmt = end_fmt.split(",")[1].lstrip() 114 | return f"{fmt_datetime(start, start_fmt)} - {fmt_datetime(end, end_fmt)}" 115 | 116 | # assume if the start time is 00:00:00, it was set without a time 117 | if start.time() == time(0) and start.time() == end.time(): 118 | # remove time from start and end format 119 | start_fmt = start_fmt.split(",")[0] 120 | end_fmt = end_fmt.split(",")[0] 121 | 122 | if not short_date and start.year == end.year: 123 | start_fmt = start_fmt.replace(" %Y", "") 124 | if start.month == end.month: 125 | start_fmt = start_fmt.replace(f"{month_code.rstrip()}", "") 126 | start_fmt = start_fmt.replace(" ,", ",") 127 | 128 | return f"{fmt_datetime(start, start_fmt).rstrip()} - {fmt_datetime(end, end_fmt)}" 129 | 130 | 131 | def _ord_suffix(n: int): 132 | """Return ordinal suffix for a number.""" 133 | return ( 134 | "th" if 11 <= (n % 100) <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th") 135 | ) 136 | 137 | 138 | def fmt_datetime(dt: datetime, fmt: str) -> str: 139 | """Format a datetime. 140 | 141 | Extended strftime codes: 142 | - %D: day number with ordinal suffix (e.g. 1st, 2nd, 3rd, 4th, etc.) 143 | """ 144 | # TODO deal with locales (for day names, month names, etc.) 145 | fmt = fmt.replace("%D", str(dt.day) + _ord_suffix(dt.day)) 146 | return dt.strftime(fmt) 147 | 148 | 149 | def fmt_delta(delta: relativedelta | None) -> str: 150 | """Format a relativedelta object.""" 151 | if delta is None: 152 | return "" 153 | 154 | string = [] 155 | 156 | if delta.years: 157 | string.append(f"{delta.years} Year{_s(delta.years)}") 158 | if delta.months: 159 | string.append(f"{delta.months} Month{_s(delta.months)}") 160 | if delta.days: 161 | string.append(f"{delta.days} Day{_s(delta.days)}") 162 | if delta.hours: 163 | string.append(f"{delta.hours} Hour{_s(delta.hours)}") 164 | if delta.minutes: 165 | string.append(f"{delta.minutes} Minute{_s(delta.minutes)}") 166 | if delta.seconds: 167 | string.append(f"{delta.seconds} Second{_s(delta.seconds)}") 168 | return " ".join(string) 169 | 170 | 171 | def _s(n: int): 172 | """Return "s" if number is not 1.""" 173 | return "s" if n != 1 else "" 174 | 175 | 176 | class DtRangeStr: 177 | """A datetime range string format.""" 178 | 179 | def __init__(self, start: datetime, duration: relativedelta | None = None) -> None: 180 | self._start = start 181 | self._duration = duration 182 | self._default = fmt_daterange(start, duration) 183 | 184 | def __str__(self) -> str: 185 | return self._default 186 | 187 | def __call__(self, **kwargs) -> str: 188 | return fmt_daterange(self._start, self._duration, **kwargs) 189 | -------------------------------------------------------------------------------- /src/sphinx_timeline/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import csv 4 | import hashlib 5 | from importlib import resources 6 | from io import StringIO 7 | import json 8 | from pathlib import Path 9 | import re 10 | from typing import Any, Literal, TextIO 11 | 12 | from docutils import nodes 13 | from docutils.parsers.rst import directives 14 | from docutils.statemachine import StringList 15 | import jinja2 16 | from sphinx.application import Sphinx 17 | from sphinx.util.docutils import SphinxDirective 18 | import yaml 19 | 20 | from sphinx_timeline import dtime 21 | from sphinx_timeline import static as static_module 22 | 23 | 24 | def setup(app: Sphinx) -> None: 25 | """Setup the extension.""" 26 | from sphinx_timeline import __version__ 27 | 28 | app.connect("builder-inited", add_html_assets) 29 | app.connect("html-page-context", load_html_assets) 30 | app.add_directive("timeline", TimelineDirective) 31 | app.add_node( 32 | TimelineDiv, 33 | html=(visit_tl_div, depart_tl_div), 34 | latex=(visit_depart_null, visit_depart_null), 35 | text=(visit_depart_null, visit_depart_null), 36 | man=(visit_depart_null, visit_depart_null), 37 | texinfo=(visit_depart_null, visit_depart_null), 38 | ) 39 | 40 | return { 41 | "version": __version__, 42 | "parallel_read_safe": True, 43 | "parallel_write_safe": True, 44 | } 45 | 46 | 47 | def add_html_assets(app: Sphinx) -> None: 48 | """Add the HTML assets to the build directory.""" 49 | if (not app.builder) or app.builder.format != "html": 50 | return 51 | # setup up new static path in output dir 52 | static_path = (Path(app.outdir) / "_sphinx_timeline_static").absolute() 53 | static_path.mkdir(exist_ok=True) 54 | for path in static_path.glob("**/*"): 55 | path.unlink() 56 | app.config.html_static_path.append(str(static_path)) 57 | for resource in resources.contents(static_module): 58 | if not resource.endswith(".css") and not resource.endswith(".js"): 59 | continue 60 | # Read the content and hash it 61 | content = resources.read_text(static_module, resource) 62 | hash = hashlib.md5(content.encode("utf8")).hexdigest() 63 | # Write the file 64 | name, ext = resource.split(".", maxsplit=1) 65 | write_path = static_path / f"{name}.{hash}.{ext}" 66 | write_path.write_text(content, encoding="utf8") 67 | 68 | 69 | def load_html_assets(app: Sphinx, pagename: str, *args, **kwargs) -> None: 70 | """Ensure the HTML assets are loaded in the page, if necessary.""" 71 | if (not app.builder) or app.builder.format != "html": 72 | return 73 | if (not app.env) or not app.env.metadata.get(pagename, {}).get("timeline", False): 74 | return 75 | for resource in resources.contents(static_module): 76 | if not resource.endswith(".css") and not resource.endswith(".js"): 77 | continue 78 | # Read the content and hash it 79 | content = resources.read_text(static_module, resource) 80 | hash = hashlib.md5(content.encode("utf8")).hexdigest() 81 | # add the file to the context 82 | name, ext = resource.split(".", maxsplit=1) 83 | write_name = f"{name}.{hash}.{ext}" 84 | if ext == "css": 85 | app.add_css_file(write_name) 86 | if ext == "js": 87 | app.add_js_file(write_name) 88 | 89 | 90 | class TimelineDiv(nodes.General, nodes.Element): 91 | """A div for a timeline.""" 92 | 93 | def add_style(self, key, value): 94 | """Add a style to the div.""" 95 | self.attributes.setdefault("styles", {}) 96 | self["styles"][key] = value 97 | 98 | 99 | def visit_depart_null(self, node: nodes.Element) -> None: 100 | """visit/depart passthrough""" 101 | 102 | 103 | def visit_tl_div(self, node: nodes.Node): 104 | """visit tl_div""" 105 | attrs = {} 106 | if node.get("styles"): 107 | attrs["style"] = ";".join( 108 | (f"{key}: {val}" for key, val in node["styles"].items()) 109 | ) 110 | if node.get("dt"): 111 | attrs["data-dt"] = str(node["dt"]) 112 | self.body.append(self.starttag(node, "div", CLASS="docutils", **attrs)) 113 | 114 | 115 | def depart_tl_div(self, node: nodes.Node): 116 | """depart tl_div""" 117 | self.body.append("\n") 118 | 119 | 120 | RE_BREAKLINE = re.compile(r"^\s*-{3,}\s*$") 121 | 122 | 123 | class TimelineDirective(SphinxDirective): 124 | """A sphinx directive to create timelines.""" 125 | 126 | has_content = True 127 | required_arguments = 0 128 | optional_arguments = 0 129 | option_spec = { 130 | "events": directives.path, 131 | "template": directives.path, 132 | "events-format": lambda val: directives.choice(val, ["yaml", "json", "csv"]), 133 | "max-items": directives.nonnegative_int, 134 | "reversed": directives.flag, 135 | "height": directives.length_or_unitless, 136 | "width-item": directives.length_or_percentage_or_unitless, 137 | "style": lambda val: directives.choice(val, ["default", "none"]), 138 | "class": directives.class_option, 139 | "class-item": directives.class_option, 140 | } 141 | 142 | def run(self) -> list[nodes.Element]: 143 | """Run the directive.""" 144 | data: list[dict[str, Any]] 145 | template_lines: list[str] 146 | 147 | # get data 148 | if "events" in self.options: 149 | input_file = self.options["events"] 150 | # get file path relative to the document 151 | _, abs_path = self.env.relfn2path(input_file, self.env.docname) 152 | if not Path(abs_path).exists(): 153 | raise self.error(f"'data' path does not exist: {abs_path}") 154 | # read file 155 | with Path(abs_path).open("r", encoding="utf8") as handle: 156 | try: 157 | data = read_events( 158 | handle, self.options.get("events-format", "yaml") 159 | ) 160 | except Exception as exc: 161 | raise self.error(f"Error parsing data: {exc}") 162 | # add input file to dependencies 163 | self.env.note_dependency(input_file) 164 | 165 | template_lines = list(self.content) 166 | else: 167 | # split lines by first occurrence of line break 168 | data_lines = [] 169 | template_lines = [] 170 | in_template = False 171 | for line in self.content: 172 | if not in_template and RE_BREAKLINE.match(line): 173 | in_template = True 174 | elif in_template: 175 | template_lines.append(line) 176 | else: 177 | data_lines.append(line) 178 | 179 | try: 180 | data = read_events( 181 | StringIO("\n".join(data_lines)), 182 | self.options.get("events-format", "yaml"), 183 | ) 184 | except Exception as exc: 185 | raise self.error(f"Error parsing data: {exc}") 186 | 187 | if "template" in self.options: 188 | # get template from file 189 | input_file = self.options["template"] 190 | # get file path relative to the document 191 | _, abs_path = self.env.relfn2path(input_file) 192 | if not Path(abs_path).exists(): 193 | raise self.error(f"'template' path does not exist: {abs_path}") 194 | # read file 195 | with Path(abs_path).open() as handle: 196 | template_lines = handle.readlines() 197 | # add input file to dependencies 198 | self.env.note_dependency(input_file) 199 | 200 | # validate data 201 | if not isinstance(data, list): 202 | raise self.error("Data must be a list") 203 | if not data: 204 | raise self.error("Data must not be empty") 205 | 206 | for idx, item in enumerate(data): 207 | if not isinstance(item, dict): 208 | raise self.error(f"item {idx}: each data item must be a dictionary") 209 | 210 | if "start" not in item: 211 | raise self.error(f"item {idx}: each data item must contain 'start' key") 212 | try: 213 | item["start"] = dtime.to_datetime(item["start"]) 214 | except Exception as exc: 215 | raise self.error( 216 | f"item {idx}: error parsing 'start' value: {exc}" 217 | ) from exc 218 | 219 | if "duration" in item: 220 | try: 221 | item["duration"] = dtime.parse_duration(item["duration"]) 222 | except Exception as exc: 223 | raise self.error( 224 | f"item {idx}: error parsing 'duration' value: {exc}" 225 | ) from exc 226 | 227 | # validate template 228 | if not [line for line in template_lines if line.strip()]: 229 | raise self.error("Template cannot be empty") 230 | 231 | env = jinja2.Environment() 232 | # env.filters["fdtime"] = _format_datetime 233 | template = env.from_string("\n".join(template_lines)) 234 | 235 | container = TimelineDiv() 236 | if "height" in self.options: 237 | container.add_style("--tl-height", self.options["height"]) 238 | if "width-item" in self.options: 239 | container.add_style("--tl-item-width", self.options["width-item"]) 240 | 241 | list_node = nodes.enumerated_list( 242 | classes=[f"timeline-{self.options.get('style', 'default')}"] 243 | + self.options.get("class", []) 244 | ) 245 | self.set_source_info(list_node) 246 | container.append(list_node) 247 | 248 | for idx, item in enumerate( 249 | sorted( 250 | data, key=lambda x: x["start"], reverse=("reversed" not in self.options) 251 | ) 252 | ): 253 | if self.options.get("max-items") and idx >= self.options["max-items"]: 254 | break 255 | rendered = template.render( 256 | e=item, 257 | dt=dtime.DtRangeStr(item["start"]), 258 | duration=dtime.fmt_delta(item.get("duration")), 259 | dtrange=dtime.DtRangeStr(item["start"], item.get("duration")), 260 | ) 261 | item_node = nodes.list_item( 262 | classes=(["timeline"] + self.options.get("class-item", [])) 263 | ) 264 | self.set_source_info(item_node) 265 | list_node.append(item_node) 266 | item_container = TimelineDiv( 267 | classes=["tl-item"], dt=item["start"].isoformat() 268 | ) 269 | item_content = TimelineDiv(classes=["tl-item-content"]) 270 | item_container.append(item_content) 271 | # item_container.append(nodes.Text(rendered)) 272 | self.state.nested_parse( 273 | StringList(rendered.splitlines(), self.state.document.current_source), 274 | self.content_offset, 275 | item_content, 276 | ) 277 | item_node.append(item_container) 278 | 279 | self.env.metadata[self.env.docname]["timeline"] = True 280 | 281 | return [container] 282 | 283 | 284 | def read_events( 285 | stream: TextIO, fmt: Literal["yaml", "json", "csv"] 286 | ) -> list[dict[str, Any]]: 287 | """Read events from a stream.""" 288 | if fmt == "yaml": 289 | return yaml.safe_load(stream) 290 | if fmt == "json": 291 | return json.load(stream) 292 | if fmt == "csv": 293 | return list(csv.DictReader(stream)) 294 | 295 | raise ValueError(f"Unknown format: {fmt}") 296 | -------------------------------------------------------------------------------- /src/sphinx_timeline/static/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphinx-extensions2/sphinx-timeline/10f437b79462229ca14c9c5b73532807feed2c97/src/sphinx_timeline/static/__init__.py -------------------------------------------------------------------------------- /src/sphinx_timeline/static/datetime.js: -------------------------------------------------------------------------------- 1 | // On page load, 2 | // look for all divs with class "tl-item" and attribute "data-dt" 3 | // and a "dt-future" or "dt-past" class to each div, based on the current date. 4 | document.addEventListener("DOMContentLoaded", function () { 5 | const now = new Date(); 6 | const nodes = document.querySelectorAll("div.tl-item[data-dt]"); 7 | for (var i = 0, len = nodes.length; i < len; i++) { 8 | try { 9 | var dt = new Date(nodes[i].getAttribute("data-dt")); 10 | } catch (e) { 11 | console.warn(`Error parsing date: ${dt}`); 12 | continue; 13 | } 14 | if (dt > now) { 15 | nodes[i].classList.add("dt-future"); 16 | } else { 17 | nodes[i].classList.add("dt-past"); 18 | } 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /src/sphinx_timeline/static/default.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --tl-height: 300px; 3 | --tl-item-outline-color: black; 4 | --tl-item-outline-width: 1px; 5 | --tl-item-border-radius: 10px; 6 | --tl-item-padding: 15px; 7 | --tl-item-width: 280px; 8 | --tl-item-gap-x: 8px; 9 | --tl-item-gap-y: 16px; 10 | --tl-item-tail-height: 10px; 11 | --tl-item-tail-color: #383838; 12 | --tl-item-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); 13 | --tl-line-color: #686868; 14 | --tl-line-width: 3px; 15 | --tl-dot-color: black; 16 | --tl-dot-diameter: 12px; 17 | } 18 | 19 | ol.timeline-default { 20 | /** this stops the items from overlapping each other **/ 21 | white-space: nowrap; 22 | padding-top: calc(var(--tl-height) / 2); 23 | padding-bottom: calc(var(--tl-height) / 2); 24 | padding-left: 1.2rem; 25 | overflow-x: scroll; 26 | scroll-snap-type: x proximity; 27 | scroll-padding-left: 0.5em; 28 | } 29 | 30 | /** timeline line **/ 31 | ol.timeline-default>li.timeline { 32 | position: relative; 33 | display: inline-block; 34 | list-style-type: none; 35 | width: calc(var(--tl-item-gap-x) + var(--tl-item-width) / 2); 36 | max-width: 90vw; 37 | height: var(--tl-line-width); 38 | margin-left: 0; 39 | background: var(--tl-line-color); 40 | scroll-snap-align: start; 41 | } 42 | 43 | ol.timeline-default>li.timeline:last-child { 44 | background: linear-gradient(to right, 45 | var(--tl-line-color) 60%, 46 | rgba(1, 1, 1, 0)); 47 | } 48 | 49 | /** timeline dot **/ 50 | ol.timeline-default>li.timeline::after { 51 | content: ""; 52 | position: absolute; 53 | top: 50%; 54 | bottom: 0; 55 | width: var(--tl-dot-diameter); 56 | height: var(--tl-dot-diameter); 57 | transform: translateY(-50%) translateX(-0.5em); 58 | border-radius: 50%; 59 | background: var(--tl-dot-color); 60 | } 61 | 62 | /** timeline item (all) **/ 63 | ol.timeline-default>li.timeline>div.tl-item { 64 | position: absolute; 65 | width: var(--tl-item-width); 66 | padding: 0; 67 | border-radius: var(--tl-item-border-radius); 68 | white-space: normal; 69 | outline: var(--tl-item-outline-width) solid var(--tl-item-outline-color); 70 | box-shadow: var(--tl-item-shadow); 71 | } 72 | 73 | ol.timeline-default>li.timeline>div.tl-item>div.tl-item-content { 74 | padding: var(--tl-item-padding); 75 | max-height: calc((var(--tl-height) / 2) - var(--tl-item-gap-y) - 26px); 76 | overflow-y: scroll; 77 | } 78 | 79 | /** timeline item (top) **/ 80 | ol.timeline-default>li.timeline:nth-child(odd)>div.tl-item { 81 | top: calc(-1 * var(--tl-item-gap-y)); 82 | transform: translateY(-100%); 83 | } 84 | 85 | /** timeline item (bottom) **/ 86 | ol.timeline-default>li.timeline:nth-child(even)>div.tl-item { 87 | top: calc(100% + var(--tl-item-gap-y)); 88 | } 89 | 90 | /** timeline item tail (all) **/ 91 | ol.timeline-default>li.timeline>div.tl-item::before { 92 | content: ""; 93 | position: absolute; 94 | left: 6px; 95 | width: 0; 96 | height: 0; 97 | border-style: solid; 98 | } 99 | 100 | /** timeline item tail (top) **/ 101 | ol.timeline-default>li.timeline:nth-child(odd)>div.tl-item::before { 102 | top: 100%; 103 | border-width: var(--tl-item-tail-height) 8px 0 0; 104 | border-color: var(--tl-item-tail-color) transparent transparent transparent; 105 | } 106 | 107 | /** timeline item tail (bottom) **/ 108 | ol.timeline-default>li.timeline:nth-child(even)>div.tl-item::before { 109 | top: calc(-1 * var(--tl-item-tail-height)); 110 | border-width: var(--tl-item-tail-height) 0 0 8px; 111 | border-color: transparent transparent transparent var(--tl-item-tail-color); 112 | } 113 | -------------------------------------------------------------------------------- /tests/fixtures/posttransform_html.txt: -------------------------------------------------------------------------------- 1 | basic 2 | . 3 | .. timeline:: 4 | 5 | - start: 2021-02-03 6 | name: 2nd draft 7 | - start: 2022-02-03 02:00 (Europe/Zurich) 8 | name: 3rd draft 9 | - start: "2020-02-03 05:00" 10 | name: 1st draft 11 | --- 12 | {{dtrange}} - {{e.name}} 13 | . 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | Thu 3rd Feb 2022, 02:00 AM (CET) - 3rd draft 22 | 23 | 24 | 25 | 26 | Wed 3rd Feb 2021 - 2nd draft 27 | 28 | 29 | 30 | 31 | Mon 3rd Feb 2020, 05:00 AM (UTC) - 1st draft 32 | . 33 | 34 | json 35 | . 36 | .. timeline:: 37 | :events-format: json 38 | 39 | [{"start": "2021-02-03", "name": "2nd draft"}] 40 | --- 41 | {{dtrange}} - {{e.name}} 42 | . 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | Wed 3rd Feb 2021 - 2nd draft 51 | . 52 | 53 | csv 54 | . 55 | .. timeline:: 56 | :events-format: csv 57 | 58 | start,name 59 | 2021-02-03,2nd draft 60 | --- 61 | {{dtrange}} - {{e.name}} 62 | . 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | Wed 3rd Feb 2021 - 2nd draft 71 | . 72 | 73 | external-data-yaml 74 | . 75 | .. timeline:: 76 | :events: data.yaml 77 | 78 | {{dtrange}} - {{e.name}} 79 | . 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | Wed 3rd Feb 2021 - 1st draft 88 | . 89 | 90 | external-data-json 91 | . 92 | .. timeline:: 93 | :events: data.json 94 | :events-format: json 95 | 96 | {{dtrange}} - {{e.name}} 97 | . 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | Wed 3rd Feb 2021 - 1st draft 106 | . 107 | 108 | external-template 109 | . 110 | .. timeline:: 111 | :events: data.yaml 112 | :template: template.txt 113 | . 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | Wed 3rd Feb 2021 - 1st draft 122 | . 123 | 124 | duration 125 | . 126 | .. timeline:: 127 | - start: 2021-02-03 13:00:00 128 | duration: 1day 2hour 30min 129 | --- 130 | {{dtrange}}: 131 | {{duration}} 132 | . 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | Wed 3rd, 12:00 AM - Thu 4th Feb 2021, 02:30 AM (UTC): 141 | 1 Day 2 Hours 30 Minutes 142 | . 143 | -------------------------------------------------------------------------------- /tests/test_dtime.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sphinx_timeline import dtime 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "start,duration,kwargs,expected", 8 | [ 9 | ("2021-02-03 22:00", "", {}, "Wed 3rd Feb 2021, 10:00 PM (UTC)"), 10 | ("2021-02-03", "", {"day_name": False}, "3rd Feb 2021"), 11 | ("2021-02-03", "", {"abbr": False}, "Wednesday 3rd February 2021"), 12 | ("2021-02-03", "", {"short_date": True}, "Wed 03/02/2021"), 13 | ("2021-02-03", "", {"short_date": True, "short_delim": "-"}, "Wed 03-02-2021"), 14 | ("2021-02-03 22:00", "", {"clock12": False}, "Wed 3rd Feb 2021, 22:00 (UTC)"), 15 | ("2021-02-03", "1 day", {}, "Wed 3rd - Thu 4th Feb 2021"), 16 | ("2021-02-03", "1 month", {}, "Wed 3rd Feb - Wed 3rd Mar 2021"), 17 | ("2021-02-03", "1 year", {}, "Wed 3rd Feb 2021 - Thu 3rd Feb 2022"), 18 | ( 19 | "2021-02-03 22:00", 20 | "1 day", 21 | {}, 22 | "Wed 3rd, 10:00 PM - Thu 4th Feb 2021, 10:00 PM (UTC)", 23 | ), 24 | ( 25 | "2021-02-03 22:00", 26 | "1 year", 27 | {}, 28 | "Wed 3rd Feb 2021, 10:00 PM - Thu 3rd Feb 2022, 10:00 PM (UTC)", 29 | ), 30 | ( 31 | "2021-02-03 22:00", 32 | "1 hour 1min", 33 | {}, 34 | "Wed 3rd Feb 2021, 10:00 PM - 11:01 PM (UTC)", 35 | ), 36 | ], 37 | ) 38 | def test_fmt_daterange(start, duration, kwargs, expected): 39 | """Test formatting of date range.""" 40 | dt = dtime.to_datetime(start) 41 | rng = dtime.parse_duration(duration) 42 | assert dtime.fmt_daterange(dt, rng, **kwargs) == expected 43 | -------------------------------------------------------------------------------- /tests/test_simple.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | # from bs4 import BeautifulSoup 5 | import pytest 6 | from sphinx_pytest.plugin import CreateDoctree 7 | import yaml 8 | 9 | FIXTURE_PATH = Path(__file__).parent.joinpath("fixtures") 10 | 11 | 12 | @pytest.mark.param_file(FIXTURE_PATH / "posttransform_html.txt") 13 | def test_posttransform_html(file_params, sphinx_doctree: CreateDoctree): 14 | """Test AST output after post-transforms, when using the HTML builder.""" 15 | sphinx_doctree.set_conf({"extensions": ["sphinx_timeline"]}) 16 | sphinx_doctree.buildername = "html" 17 | example_data = [{"start": "2021-02-03", "name": "1st draft"}] 18 | sphinx_doctree.srcdir.joinpath("data.yaml").write_text(yaml.dump(example_data)) 19 | sphinx_doctree.srcdir.joinpath("data.json").write_text(json.dumps(example_data)) 20 | sphinx_doctree.srcdir.joinpath("template.txt").write_text( 21 | "{{dtrange}} - {{e.name}}" 22 | ) 23 | result = sphinx_doctree(file_params.content) 24 | assert not result.warnings 25 | file_params.assert_expected(result.get_resolved_pformat(), rstrip_lines=True) 26 | 27 | 28 | # @pytest.mark.param_file(FIXTURE_PATH / "build_html.txt") 29 | # def test_doctree(file_params, sphinx_doctree: CreateDoctree): 30 | # """Test HTML build output.""" 31 | # sphinx_doctree.set_conf({"extensions": ["sphinx_timeline"]}) 32 | # sphinx_doctree.buildername = "html" 33 | # result = sphinx_doctree(file_params.content) 34 | # assert not result.warnings 35 | # html = BeautifulSoup( 36 | # Path(result.builder.outdir).joinpath("index.html").read_text(), "html.parser" 37 | # ) 38 | # fig_html = str(html.select_one("figure.sphinx-subfigure")) 39 | # file_params.assert_expected(fig_html, rstrip_lines=True) 40 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # configuration to run via tox 2 | 3 | [tox] 4 | envlist = py38 5 | 6 | [testenv] 7 | usedevelop = true 8 | 9 | [testenv:py{38,39,310,311}] 10 | description = Run pytest 11 | extras = testing 12 | commands = pytest {posargs} 13 | 14 | [testenv:docs-{rtd,furo,pst}] 15 | description = Build the documentation 16 | extras = 17 | docs 18 | furo: furo 19 | pst: pst 20 | rtd: rtd 21 | setenv = 22 | BUILDER = {env:BUILDER:html} 23 | furo: HTML_THEME = furo 24 | pst: HTML_THEME = pydata_sphinx_theme 25 | rtd: HTML_THEME = sphinx_rtd_theme 26 | whitelist_externals = 27 | echo 28 | rm 29 | commands_pre = rm -rf docs/_build/{env:BUILDER} 30 | commands = sphinx-build -nW --keep-going -b {env:BUILDER} {posargs} docs/ docs/_build/{env:BUILDER} 31 | commands_post = echo "open file://{toxinidir}/docs/_build/html/index.html" 32 | 33 | [flake8] 34 | max-line-length = 100 35 | --------------------------------------------------------------------------------