├── .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 |
--------------------------------------------------------------------------------