nthp_api 0.1.9__py3-none-any.whl

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 (39) hide show
  1. nthp_api/__init__.py +0 -0
  2. nthp_api/cli/__init__.py +88 -0
  3. nthp_api/cli/logs.py +20 -0
  4. nthp_api/nthp_build/__init__.py +0 -0
  5. nthp_api/nthp_build/assets.py +229 -0
  6. nthp_api/nthp_build/config.py +22 -0
  7. nthp_api/nthp_build/content.py +39 -0
  8. nthp_api/nthp_build/database.py +142 -0
  9. nthp_api/nthp_build/documents.py +55 -0
  10. nthp_api/nthp_build/dumper.py +385 -0
  11. nthp_api/nthp_build/fields.py +15 -0
  12. nthp_api/nthp_build/history.py +17 -0
  13. nthp_api/nthp_build/loader.py +280 -0
  14. nthp_api/nthp_build/models.py +200 -0
  15. nthp_api/nthp_build/parallel.py +57 -0
  16. nthp_api/nthp_build/people.py +227 -0
  17. nthp_api/nthp_build/playwrights.py +137 -0
  18. nthp_api/nthp_build/roles.py +213 -0
  19. nthp_api/nthp_build/schema.py +440 -0
  20. nthp_api/nthp_build/search.py +17 -0
  21. nthp_api/nthp_build/shows.py +182 -0
  22. nthp_api/nthp_build/smugmug.py +40 -0
  23. nthp_api/nthp_build/spec.py +298 -0
  24. nthp_api/nthp_build/trivia.py +65 -0
  25. nthp_api/nthp_build/venues.py +69 -0
  26. nthp_api/nthp_build/version.py +8 -0
  27. nthp_api/nthp_build/years.py +40 -0
  28. nthp_api/smugmugger/__init__.py +12 -0
  29. nthp_api/smugmugger/album.py +20 -0
  30. nthp_api/smugmugger/client.py +118 -0
  31. nthp_api/smugmugger/config.py +16 -0
  32. nthp_api/smugmugger/database.py +33 -0
  33. nthp_api/smugmugger/schema.py +58 -0
  34. nthp_api/smugmugger/smugmug.py +67 -0
  35. nthp_api-0.1.9.dist-info/METADATA +82 -0
  36. nthp_api-0.1.9.dist-info/RECORD +39 -0
  37. nthp_api-0.1.9.dist-info/WHEEL +4 -0
  38. nthp_api-0.1.9.dist-info/entry_points.txt +4 -0
  39. nthp_api-0.1.9.dist-info/licenses/LICENCE +20 -0
nthp_api/__init__.py ADDED
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()
nthp_api/cli/logs.py ADDED
@@ -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)
@@ -0,0 +1,142 @@
1
+ import logging
2
+
3
+ import peewee
4
+
5
+ from nthp_api.nthp_build.config import settings
6
+
7
+ log = logging.getLogger(__name__)
8
+ db = peewee.SqliteDatabase(settings.db_uri)
9
+
10
+
11
+ class NthpDbModel(peewee.Model):
12
+ class Meta:
13
+ database = db
14
+
15
+
16
+ class DbCompatEnumMixin:
17
+ def __str__(self):
18
+ return self.value
19
+
20
+
21
+ class TargetType:
22
+ SHOW = "show"
23
+
24
+
25
+ class PersonRoleType:
26
+ CAST = "CAST"
27
+ CREW = "CREW"
28
+ COMMITTEE = "COMMITTEE"
29
+
30
+
31
+ class PersonRole(NthpDbModel):
32
+ target_id = peewee.CharField(index=True)
33
+ target_type = peewee.CharField(index=True)
34
+ # Uses YYYY, not YY_YY, 2000 means 2000-2001
35
+ target_year = peewee.IntegerField(index=True)
36
+
37
+ person_id = peewee.CharField(index=True, null=True)
38
+ person_name = peewee.CharField(null=True)
39
+ role = peewee.CharField(null=True, index=True)
40
+ is_person = peewee.BooleanField(index=True)
41
+ data = peewee.TextField()
42
+
43
+
44
+ class Show(NthpDbModel):
45
+ id = peewee.CharField(primary_key=True)
46
+ source_path = peewee.CharField()
47
+ year = peewee.IntegerField()
48
+ year_id = peewee.CharField(index=True)
49
+ title = peewee.CharField()
50
+ venue_id = peewee.CharField(index=True, null=True)
51
+ season_sort = peewee.IntegerField(null=True, index=True)
52
+ date_start = peewee.DateField(null=True, index=True)
53
+ date_end = peewee.DateField(null=True)
54
+ primary_image = peewee.CharField(null=True)
55
+ assets = peewee.TextField()
56
+ data = peewee.TextField()
57
+ content = peewee.TextField(null=True)
58
+ plaintext = peewee.TextField(null=True)
59
+
60
+
61
+ class PlaywrightShow(NthpDbModel):
62
+ play_id = peewee.CharField(index=True)
63
+ play_name = peewee.CharField()
64
+ playwright_id = peewee.CharField(index=True)
65
+ playwright_name = peewee.CharField()
66
+ show_id = peewee.CharField(index=True)
67
+ person_id = peewee.CharField(null=True, index=True)
68
+
69
+
70
+ class Venue(NthpDbModel):
71
+ id = peewee.CharField(primary_key=True)
72
+ name = peewee.CharField()
73
+ data = peewee.TextField()
74
+ content = peewee.TextField(null=True)
75
+ plaintext = peewee.TextField(null=True)
76
+
77
+
78
+ class Person(NthpDbModel):
79
+ id = peewee.CharField(primary_key=True)
80
+ title = peewee.CharField()
81
+ graduated = peewee.IntegerField(index=True, null=True)
82
+ headshot = peewee.CharField(null=True)
83
+ data = peewee.TextField()
84
+ content = peewee.TextField(null=True)
85
+ plaintext = peewee.TextField(null=True)
86
+
87
+
88
+ class Trivia(NthpDbModel):
89
+ target_id = peewee.CharField(index=True)
90
+ target_type = peewee.CharField(index=True)
91
+ target_name = peewee.CharField()
92
+ target_image_id = peewee.CharField(null=True)
93
+ # Uses YYYY, not YY_YY, 2000 means 2000-2001
94
+ target_year = peewee.IntegerField(index=True, null=True)
95
+
96
+ person_id = peewee.CharField(index=True, null=True)
97
+ person_name = peewee.CharField(null=True)
98
+
99
+ quote = peewee.TextField()
100
+ submitted = peewee.DateField(null=True, index=True)
101
+
102
+ data = peewee.TextField()
103
+
104
+
105
+ class HistoryRecord(NthpDbModel):
106
+ year = peewee.CharField()
107
+ academic_year = peewee.CharField(null=True, index=True)
108
+ title = peewee.CharField()
109
+ description = peewee.TextField()
110
+
111
+
112
+ class Asset(NthpDbModel):
113
+ target_id = peewee.CharField(index=True)
114
+ target_type = peewee.CharField(index=True)
115
+
116
+ asset_source = peewee.CharField(index=True)
117
+ asset_type = peewee.CharField(index=True)
118
+ asset_mime_type = peewee.CharField(null=True)
119
+ asset_id = peewee.CharField(index=True)
120
+
121
+ asset_category = peewee.CharField(null=True)
122
+ asset_title = peewee.CharField(null=True)
123
+ asset_page = peewee.IntegerField(null=True)
124
+
125
+ asset_smugmug_data = peewee.CharField(null=True)
126
+
127
+
128
+ MODELS = [Show, PlaywrightShow, Venue, PersonRole, Person, Trivia, HistoryRecord, Asset]
129
+
130
+
131
+ def init_db(create: bool = False):
132
+ log.info(f"Initializing database: {db.database}")
133
+
134
+ db.connect()
135
+ if create:
136
+ db.drop_tables(MODELS)
137
+ db.create_tables(MODELS)
138
+
139
+
140
+ def show_stats():
141
+ for model in MODELS:
142
+ log.info(f"{model.__name__} has {model.select().count()} records")
@@ -0,0 +1,55 @@
1
+ import logging
2
+ from collections.abc import Iterable
3
+ from pathlib import Path
4
+ from typing import Any, NamedTuple
5
+
6
+ import frontmatter
7
+ import yaml
8
+
9
+ from nthp_api.nthp_build.config import settings
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ class DocumentPath(NamedTuple):
15
+ path: Path
16
+ id: str
17
+ content_path: Path
18
+ filename: str
19
+ basename: str
20
+
21
+
22
+ def find_documents(content_directory: Path | str) -> Iterable[DocumentPath]:
23
+ def map_path(path: Path) -> DocumentPath | None:
24
+ if path.name.startswith("_"):
25
+ return None
26
+ return DocumentPath(
27
+ path=path,
28
+ id=str(
29
+ path.relative_to(settings.content_root / content_directory).parent
30
+ / path.stem
31
+ ).lstrip("_"),
32
+ content_path=path.relative_to(settings.content_root),
33
+ filename=path.name,
34
+ basename=path.stem,
35
+ )
36
+
37
+ return [
38
+ doc_path
39
+ for doc_path in map(
40
+ map_path, (settings.content_root / Path(content_directory)).rglob("*.md")
41
+ )
42
+ if doc_path is not None
43
+ ]
44
+
45
+
46
+ def load_document(path: Path) -> frontmatter.Post:
47
+ return frontmatter.load(path)
48
+
49
+
50
+ def load_yaml(path: Path | str) -> Any:
51
+ with (settings.content_root / Path(path)).open() as stream:
52
+ try:
53
+ return yaml.safe_load(stream)
54
+ except yaml.YAMLError:
55
+ log.exception("Error loading YAML file %s", path)