nthp_api 0.1.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. nthp_api-0.1.9/LICENCE +20 -0
  2. nthp_api-0.1.9/PKG-INFO +82 -0
  3. nthp_api-0.1.9/README.md +49 -0
  4. nthp_api-0.1.9/nthp_api/__init__.py +0 -0
  5. nthp_api-0.1.9/nthp_api/cli/__init__.py +88 -0
  6. nthp_api-0.1.9/nthp_api/cli/logs.py +20 -0
  7. nthp_api-0.1.9/nthp_api/nthp_build/__init__.py +0 -0
  8. nthp_api-0.1.9/nthp_api/nthp_build/assets.py +229 -0
  9. nthp_api-0.1.9/nthp_api/nthp_build/config.py +22 -0
  10. nthp_api-0.1.9/nthp_api/nthp_build/content.py +39 -0
  11. nthp_api-0.1.9/nthp_api/nthp_build/database.py +142 -0
  12. nthp_api-0.1.9/nthp_api/nthp_build/documents.py +55 -0
  13. nthp_api-0.1.9/nthp_api/nthp_build/dumper.py +385 -0
  14. nthp_api-0.1.9/nthp_api/nthp_build/fields.py +15 -0
  15. nthp_api-0.1.9/nthp_api/nthp_build/history.py +17 -0
  16. nthp_api-0.1.9/nthp_api/nthp_build/loader.py +280 -0
  17. nthp_api-0.1.9/nthp_api/nthp_build/models.py +200 -0
  18. nthp_api-0.1.9/nthp_api/nthp_build/parallel.py +57 -0
  19. nthp_api-0.1.9/nthp_api/nthp_build/people.py +227 -0
  20. nthp_api-0.1.9/nthp_api/nthp_build/playwrights.py +137 -0
  21. nthp_api-0.1.9/nthp_api/nthp_build/roles.py +213 -0
  22. nthp_api-0.1.9/nthp_api/nthp_build/schema.py +440 -0
  23. nthp_api-0.1.9/nthp_api/nthp_build/search.py +17 -0
  24. nthp_api-0.1.9/nthp_api/nthp_build/shows.py +182 -0
  25. nthp_api-0.1.9/nthp_api/nthp_build/smugmug.py +40 -0
  26. nthp_api-0.1.9/nthp_api/nthp_build/spec.py +298 -0
  27. nthp_api-0.1.9/nthp_api/nthp_build/trivia.py +65 -0
  28. nthp_api-0.1.9/nthp_api/nthp_build/venues.py +69 -0
  29. nthp_api-0.1.9/nthp_api/nthp_build/version.py +8 -0
  30. nthp_api-0.1.9/nthp_api/nthp_build/years.py +40 -0
  31. nthp_api-0.1.9/nthp_api/smugmugger/__init__.py +12 -0
  32. nthp_api-0.1.9/nthp_api/smugmugger/album.py +20 -0
  33. nthp_api-0.1.9/nthp_api/smugmugger/client.py +118 -0
  34. nthp_api-0.1.9/nthp_api/smugmugger/config.py +16 -0
  35. nthp_api-0.1.9/nthp_api/smugmugger/database.py +33 -0
  36. nthp_api-0.1.9/nthp_api/smugmugger/schema.py +58 -0
  37. nthp_api-0.1.9/nthp_api/smugmugger/smugmug.py +67 -0
  38. nthp_api-0.1.9/pyproject.toml +92 -0
nthp_api-0.1.9/LICENCE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+ Copyright (c) 2021 The Nottingham New Theatre
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ this software and associated documentation files (the "Software"), to deal in
6
+ the Software without restriction, including without limitation the rights to
7
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
8
+ of the Software, and to permit persons to whom the Software is furnished to do
9
+ so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: nthp_api
3
+ Version: 0.1.9
4
+ Summary: This is a CLI tool for building the Nottingham New Theatre's History Project content database.
5
+ License: MIT
6
+ License-File: LICENCE
7
+ Author: Will Pimblett
8
+ Author-email: will@wjdp.uk
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Dist: Markdown (>=3.4.3,<4.0.0)
18
+ Requires-Dist: PyYAML (>=6.0,<7.0)
19
+ Requires-Dist: click (>=8.1.3,<9.0.0)
20
+ Requires-Dist: coloredlogs (>=15.0.1,<16.0.0)
21
+ Requires-Dist: httpx (>=0.24.1,<0.25.0)
22
+ Requires-Dist: peewee (>=3.16.2,<4.0.0)
23
+ Requires-Dist: pydantic (>=2.1.1,<3.0.0)
24
+ Requires-Dist: pydantic-collections (>=0.5.0,<0.6.0)
25
+ Requires-Dist: pydantic-settings (>=2.0.2,<3.0.0)
26
+ Requires-Dist: pyhumps (>=3.8.0,<4.0.0)
27
+ Requires-Dist: python-frontmatter (>=1.0.0,<2.0.0)
28
+ Requires-Dist: python-slugify (>=8.0.1,<9.0.0)
29
+ Project-URL: Homepage, https://github.com/newtheatre/nthp-api
30
+ Project-URL: Repository, https://github.com/newtheatre/nthp-api
31
+ Description-Content-Type: text/markdown
32
+
33
+ # History Project API Generator
34
+
35
+ This project generates a JSON API from the [history-project](https://github.com/newtheatre/history-project) content repository. It does that in two steps:
36
+
37
+ - **load**: Generate a sqlite database from the source files.
38
+ - **dump**: Use that database to generate a JSON API.
39
+
40
+ ## Endpoints
41
+
42
+ - The API is currently hosted at <https://nthp-api.wjdp.uk/v1>.
43
+ - The specification is available at <https://nthp-api.wjdp.uk/v1/master/openapi.json>.
44
+ - To render the spec into human-readable docs use a tool such as:
45
+ - ReDoc <https://redocly.github.io/redoc/?url=https://nthp-api.wjdp.uk/v1/master/openapi.json>.
46
+ - Stoplight <https://elements-demo.stoplight.io/?spec=https://nthp-api.wjdp.uk/v1/master/openapi.json>
47
+
48
+ # Usage
49
+
50
+ ## From source
51
+
52
+ - Ensure you have [Poetry](https://python-poetry.org/) installed.
53
+ - Run `poetry install`.
54
+ - Clone the history project repository into the `content/` directory. You can do a shallow clone for this: `GIT_LFS_SKIP_SMUDGE=1 git clone --depth 1 --branch master git@github.com:newtheatre/history-project content`.
55
+ - Run `./nthp load` to generate the sqlite database from the content files.
56
+ - Run `./nthp dump` to generate the API from the database into the `dist/` directory.
57
+
58
+ ## From PyPi
59
+
60
+ - With pip installed run `pip install --user nthp-api`. If you have your local bin folder on your path you should now be able to run `nthp`.
61
+ - Within the history-project repo run `nthp load` to generate the sqlite database from the content files.
62
+ - Run `nthp dump` to generate the API from the database into the `dist/` directory.
63
+
64
+ Alternatively you can run `nthp build` to run both steps in one go.
65
+
66
+ ## Contributing
67
+
68
+ ### pre-commit hooks
69
+
70
+ pre-commit hooks are used to lint the source code using [Black](https://black.readthedocs.io/en/stable/) and [ruff](https://ruff.rs)
71
+
72
+ - Ensure you have [pre-commit](https://pre-commit.com/) installed.
73
+ - Run `pre-commit install` to install pre-commit hooks.
74
+
75
+ ### Tests
76
+
77
+ Run `pytest` or use the included PyCharm run configuration.
78
+
79
+ ## Release
80
+
81
+ See the `bin/release.sh` script for the release process. This assumes that your local machine has the correct credentials to publish to PyPi.
82
+
@@ -0,0 +1,49 @@
1
+ # History Project API Generator
2
+
3
+ This project generates a JSON API from the [history-project](https://github.com/newtheatre/history-project) content repository. It does that in two steps:
4
+
5
+ - **load**: Generate a sqlite database from the source files.
6
+ - **dump**: Use that database to generate a JSON API.
7
+
8
+ ## Endpoints
9
+
10
+ - The API is currently hosted at <https://nthp-api.wjdp.uk/v1>.
11
+ - The specification is available at <https://nthp-api.wjdp.uk/v1/master/openapi.json>.
12
+ - To render the spec into human-readable docs use a tool such as:
13
+ - ReDoc <https://redocly.github.io/redoc/?url=https://nthp-api.wjdp.uk/v1/master/openapi.json>.
14
+ - Stoplight <https://elements-demo.stoplight.io/?spec=https://nthp-api.wjdp.uk/v1/master/openapi.json>
15
+
16
+ # Usage
17
+
18
+ ## From source
19
+
20
+ - Ensure you have [Poetry](https://python-poetry.org/) installed.
21
+ - Run `poetry install`.
22
+ - Clone the history project repository into the `content/` directory. You can do a shallow clone for this: `GIT_LFS_SKIP_SMUDGE=1 git clone --depth 1 --branch master git@github.com:newtheatre/history-project content`.
23
+ - Run `./nthp load` to generate the sqlite database from the content files.
24
+ - Run `./nthp dump` to generate the API from the database into the `dist/` directory.
25
+
26
+ ## From PyPi
27
+
28
+ - With pip installed run `pip install --user nthp-api`. If you have your local bin folder on your path you should now be able to run `nthp`.
29
+ - Within the history-project repo run `nthp load` to generate the sqlite database from the content files.
30
+ - Run `nthp dump` to generate the API from the database into the `dist/` directory.
31
+
32
+ Alternatively you can run `nthp build` to run both steps in one go.
33
+
34
+ ## Contributing
35
+
36
+ ### pre-commit hooks
37
+
38
+ pre-commit hooks are used to lint the source code using [Black](https://black.readthedocs.io/en/stable/) and [ruff](https://ruff.rs)
39
+
40
+ - Ensure you have [pre-commit](https://pre-commit.com/) installed.
41
+ - Run `pre-commit install` to install pre-commit hooks.
42
+
43
+ ### Tests
44
+
45
+ Run `pytest` or use the included PyCharm run configuration.
46
+
47
+ ## Release
48
+
49
+ See the `bin/release.sh` script for the release process. This assumes that your local machine has the correct credentials to publish to PyPi.
File without changes
@@ -0,0 +1,88 @@
1
+ import logging
2
+ from os import environ
3
+
4
+ import click
5
+
6
+ from nthp_api.cli import logs
7
+ from nthp_api.nthp_build.version import get_version
8
+
9
+ logs.init()
10
+
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ @click.group()
16
+ def cli():
17
+ pass
18
+
19
+
20
+ @cli.command()
21
+ def version():
22
+ print(f"nthp-api {get_version()}") # noqa T201
23
+
24
+
25
+ @click.argument("path", type=click.Path(exists=True))
26
+ @cli.command()
27
+ def load(path):
28
+ environ["CONTENT_ROOT"] = str(path)
29
+
30
+ from nthp_api.nthp_build import database, loader
31
+
32
+ database.init_db(create=True)
33
+ loader.run_loaders()
34
+
35
+
36
+ @cli.command()
37
+ def stats():
38
+ environ["CONTENT_ROOT"] = "does-not-matter"
39
+
40
+ from nthp_api.nthp_build import database
41
+
42
+ database.init_db()
43
+ database.show_stats()
44
+
45
+
46
+ @cli.command()
47
+ def smug():
48
+ environ["CONTENT_ROOT"] = "does-not-matter"
49
+
50
+ import nthp_api.smugmugger.database
51
+ from nthp_api.nthp_build import database, smugmug
52
+
53
+ database.init_db()
54
+ nthp_api.smugmugger.database.init_db()
55
+ smugmug.run()
56
+
57
+
58
+ @cli.command()
59
+ def dump():
60
+ environ["CONTENT_ROOT"] = "does-not-matter"
61
+
62
+ from nthp_api.nthp_build import database, dumper
63
+
64
+ database.init_db()
65
+ dumper.delete_output_dir()
66
+ dumper.dump_all()
67
+
68
+
69
+ @click.argument("path", type=click.Path(exists=True))
70
+ @cli.command()
71
+ def build(path):
72
+ # Set settings using environment variables as workers and threads will recreate
73
+ # the settings object and not pick up the values if set here.
74
+ environ["DB_URI"] = ":memory:"
75
+ environ["CONTENT_ROOT"] = str(path)
76
+
77
+ log.info(f"Building from {path} using in-memory database")
78
+
79
+ import nthp_api.smugmugger.database
80
+ from nthp_api.nthp_build import database, dumper, loader, smugmug
81
+
82
+ database.init_db(create=True)
83
+ loader.run_loaders()
84
+ database.show_stats()
85
+ nthp_api.smugmugger.database.init_db()
86
+ smugmug.run()
87
+ dumper.delete_output_dir()
88
+ dumper.dump_all()
@@ -0,0 +1,20 @@
1
+ import logging
2
+ from os import environ
3
+
4
+ import coloredlogs
5
+
6
+ LOG_FORMAT = "%(asctime)s %(levelname)s %(message)s"
7
+ DATE_FORMAT = "%H:%M:%S"
8
+
9
+
10
+ def init():
11
+ nthp_build_logger = logging.getLogger("nthp_api.nthp_build")
12
+ smugmugger_logger = logging.getLogger("nthp_api.smugmugger")
13
+
14
+ log_level = environ.get("LOG_LEVEL", "INFO")
15
+ coloredlogs.install(
16
+ level=log_level, logger=nthp_build_logger, fmt=LOG_FORMAT, datefmt=DATE_FORMAT
17
+ )
18
+ coloredlogs.install(
19
+ level=log_level, logger=smugmugger_logger, fmt=LOG_FORMAT, datefmt=DATE_FORMAT
20
+ )
File without changes
@@ -0,0 +1,229 @@
1
+ import json
2
+ import logging
3
+ import mimetypes
4
+ from collections.abc import Generator, Iterable
5
+ from enum import Enum
6
+
7
+ from nthp_api.nthp_build import database, models, schema
8
+ from nthp_api.nthp_build.documents import DocumentPath
9
+ from nthp_api.smugmugger import SmugMugImage
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ class AssetTarget(database.DbCompatEnumMixin, Enum):
15
+ SHOW = "show"
16
+ PERSON = "person"
17
+ VENUE = "venue"
18
+ ALBUM = "album"
19
+
20
+
21
+ class AssetSource(database.DbCompatEnumMixin, Enum):
22
+ SMUGMUG = "smugmug"
23
+ FILE = "file"
24
+
25
+
26
+ class AssetType(database.DbCompatEnumMixin, Enum):
27
+ ALBUM = "album" # A collection of other assets
28
+ IMAGE = "image" # A single image, a photo or a scanned document
29
+ VIDEO = "video" # A video
30
+ OTHER = "other" # A video
31
+
32
+
33
+ class AssetCategory(database.DbCompatEnumMixin, Enum):
34
+ POSTER = "poster"
35
+ FLYER = "flyer"
36
+ PROGRAMME = "programme"
37
+ HEADSHOT = "headshot"
38
+
39
+
40
+ def get_mime_type(source: AssetSource, type: AssetType, id: str) -> str | None:
41
+ """
42
+ Decide or determine the mime-type of an asset.
43
+ :param source: Where does this asset come from?
44
+ :param type: Type of asset
45
+ :param id:
46
+ :return: A mime type, or None if either we can't determine one or one does not apply
47
+ """
48
+ if source is AssetSource.SMUGMUG:
49
+ if type is AssetType.ALBUM:
50
+ return None
51
+ if type is AssetType.IMAGE:
52
+ return "image/jpeg"
53
+ if type is AssetType.VIDEO:
54
+ return "video/mp4" # TODO: check if correct
55
+ raise ValueError(f"Unhandled asset type {type}")
56
+
57
+ if source is AssetSource.FILE:
58
+ guess = mimetypes.guess_type(id)[0]
59
+ if guess is None:
60
+ raise ValueError(f"Could not guess mime type for {id}")
61
+ return guess
62
+
63
+ raise ValueError(f"Unhandled asset source {source}")
64
+
65
+
66
+ def save_asset( # noqa: PLR0913
67
+ target_id: str,
68
+ target_type: AssetTarget,
69
+ source: AssetSource,
70
+ type: AssetType,
71
+ id: str,
72
+ category: AssetCategory | str | None = None,
73
+ title: str | None = None,
74
+ page: int | None = None,
75
+ ) -> database.Asset:
76
+ return database.Asset.create(
77
+ target_id=target_id,
78
+ target_type=target_type,
79
+ asset_source=source,
80
+ asset_type=type,
81
+ asset_mime_type=get_mime_type(source, type, id),
82
+ asset_id=id,
83
+ asset_category=category,
84
+ title=title,
85
+ page=page,
86
+ )
87
+
88
+
89
+ def assets_from_show_model(
90
+ show: models.Show,
91
+ ) -> Generator[schema.Asset, None, None]:
92
+ """Generate show assets (output type) from a show model (input type)"""
93
+ for asset in show.assets:
94
+ if asset.image:
95
+ source = AssetSource.SMUGMUG
96
+ type = AssetType.IMAGE
97
+ elif asset.video:
98
+ source = AssetSource.SMUGMUG
99
+ type = AssetType.VIDEO
100
+ elif asset.filename:
101
+ source = AssetSource.FILE
102
+ # We assume files are something else, but could be image or video.
103
+ # In reality they tend to be PDF / audio / other docs
104
+ type = AssetType.OTHER
105
+ else:
106
+ raise ValueError(f"Unhandled mode for asset {asset}")
107
+
108
+ asset_id = asset.image or asset.video or asset.filename
109
+ if not asset_id:
110
+ raise ValueError(f"No ID for asset {asset}")
111
+
112
+ yield schema.Asset(
113
+ type=str(type),
114
+ source=str(source),
115
+ mime_type=get_mime_type(source, type, asset_id),
116
+ id=asset_id,
117
+ category=asset.type,
118
+ # we use type for image/video/other, source uses it for category
119
+ title=asset.title,
120
+ page=asset.page,
121
+ )
122
+
123
+ if show.prod_shots:
124
+ yield schema.Asset(
125
+ type=str(AssetType.ALBUM),
126
+ source=str(AssetSource.SMUGMUG),
127
+ mime_type=get_mime_type(
128
+ AssetSource.SMUGMUG, AssetType.ALBUM, show.prod_shots
129
+ ),
130
+ id=show.prod_shots,
131
+ )
132
+
133
+
134
+ def save_show_assets(
135
+ path: DocumentPath, show_assets: Iterable[schema.Asset]
136
+ ) -> list[database.Asset]:
137
+ """Write assets to the database"""
138
+ return [
139
+ save_asset(
140
+ target_id=path.id,
141
+ target_type=AssetTarget.SHOW,
142
+ source=AssetSource(asset.source),
143
+ type=AssetType(asset.type),
144
+ id=asset.id,
145
+ category=asset.category,
146
+ title=asset.title,
147
+ page=asset.page,
148
+ )
149
+ for asset in show_assets
150
+ ]
151
+
152
+
153
+ def save_person_assets(
154
+ path: DocumentPath, person: models.Person
155
+ ) -> list[database.Asset]:
156
+ assets = []
157
+ if person.headshot:
158
+ assets.append(
159
+ save_asset(
160
+ target_id=path.id,
161
+ target_type=AssetTarget.PERSON,
162
+ source=AssetSource.SMUGMUG,
163
+ type=AssetType.IMAGE,
164
+ id=person.headshot,
165
+ category=AssetCategory.HEADSHOT,
166
+ title=person.title,
167
+ )
168
+ )
169
+ return assets
170
+
171
+
172
+ def filter_assets_by_type(assets, type):
173
+ return list(filter(lambda asset: asset.type.lower() == type.value, assets))
174
+
175
+
176
+ def pick_show_primary_image(assets: list[models.Asset]) -> str | None:
177
+ """
178
+ Pick an image to use as the primary, to be used in list views &c
179
+ TODO: Currently we return a SmugMug ID rather than a full Asset object.
180
+ """
181
+ image_assets = list(filter(lambda asset: asset.image is not None, assets))
182
+ if override_assets := list(filter(lambda asset: asset.display_image, image_assets)):
183
+ return override_assets[0].image
184
+ if posters := filter_assets_by_type(image_assets, AssetCategory.POSTER):
185
+ return posters[0].image
186
+ if flyers := filter_assets_by_type(image_assets, AssetCategory.FLYER):
187
+ return flyers[0].image
188
+ if programmes := filter_assets_by_type(image_assets, AssetCategory.PROGRAMME):
189
+ return programmes[0].image
190
+ # No suitable image found, oh well we tried
191
+ return None
192
+
193
+
194
+ def smugmug_asset_to_asset(smugmug_asset: SmugMugImage) -> schema.Asset:
195
+ """Convert a SmugMug asset to a schema asset"""
196
+ asset_type = AssetType.VIDEO if smugmug_asset.IsVideo else AssetType.IMAGE
197
+ return schema.Asset(
198
+ id=smugmug_asset.ImageKey,
199
+ source=AssetSource.SMUGMUG.value,
200
+ title=smugmug_asset.Title or None,
201
+ type=asset_type.value,
202
+ mime_type=get_mime_type(
203
+ AssetSource.SMUGMUG, asset_type, smugmug_asset.ImageKey
204
+ ),
205
+ )
206
+
207
+
208
+ def get_asset_collection_from_album(
209
+ album: database.Asset,
210
+ ) -> schema.AssetCollection | None:
211
+ """
212
+ Get AssetCollection from an asset album.
213
+ Currently, we only support SmugMug albums.
214
+ If the SmugMug data isn't present or fails to parse, we return None.
215
+ """
216
+ if album.asset_source != AssetSource.SMUGMUG.value:
217
+ raise ValueError(f"Album source '{album.asset_source}' unsupported")
218
+ if not album.asset_smugmug_data:
219
+ log.warning(f"No smugmug data for album {album.asset_id}")
220
+ return None
221
+ try:
222
+ smugmug_album_images = json.loads(album.asset_smugmug_data)
223
+ smugmug_assets = [SmugMugImage(**asset) for asset in smugmug_album_images]
224
+ except json.JSONDecodeError:
225
+ log.warning(f"Could not decode smugmug data for album {album.asset_id}")
226
+ return None
227
+ return schema.AssetCollection(
228
+ [smugmug_asset_to_asset(asset) for asset in smugmug_assets]
229
+ )
@@ -0,0 +1,22 @@
1
+ import datetime
2
+ from pathlib import Path
3
+
4
+ from pydantic_settings import BaseSettings
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ db_uri: str = "nthp.db"
9
+ branch: str = "master"
10
+ content_root: Path
11
+
12
+ year_start: int = 1940
13
+ year_end: int = datetime.datetime.now().year
14
+
15
+ # How many years to wait until guessing someone has left, if it's not been this
16
+ # long we can assume they may still be a student.
17
+ graduation_recency_limit: int = 2
18
+ # What month (1-12) do people tend to graduate in?
19
+ graduation_month: int = 6
20
+
21
+
22
+ settings = Settings()
@@ -0,0 +1,39 @@
1
+ from io import StringIO
2
+
3
+ import markdown
4
+ from markdown import Markdown
5
+
6
+
7
+ def unmark_element(element, stream=None):
8
+ if stream is None:
9
+ stream = StringIO()
10
+ if element.text:
11
+ stream.write(element.text)
12
+ for sub in element:
13
+ unmark_element(sub, stream)
14
+ if element.tail:
15
+ stream.write(element.tail)
16
+ return stream.getvalue()
17
+
18
+
19
+ # Make a markdown parser that outputs plaintext
20
+ # Stole from https://stackoverflow.com/a/54923798/1345360
21
+ Markdown.output_formats["plain"] = unmark_element # type: ignore
22
+ _markdown_unmarker = Markdown(output_format="plain") # type: ignore
23
+ _markdown_unmarker.stripTopLevelTags = False # type: ignore
24
+
25
+
26
+ def markdown_to_html(markdown_text: str | None) -> str | None:
27
+ if not markdown_text:
28
+ return None
29
+ if not markdown_text.strip():
30
+ return None
31
+ return markdown.markdown(markdown_text)
32
+
33
+
34
+ def markdown_to_plaintext(markdown_text: str | None) -> str | None:
35
+ if not markdown_text:
36
+ return None
37
+ if not markdown_text.strip():
38
+ return None
39
+ return _markdown_unmarker.convert(markdown_text)