oldnews 0.4.0__py3-none-any.whl → 0.6.0__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.
oldnews/__main__.py CHANGED
@@ -10,6 +10,7 @@ from operator import attrgetter
10
10
  # Local imports.
11
11
  from . import __doc__, __version__
12
12
  from .data import initialise_database, reset_data
13
+ from .data.locations import config_dir, data_dir
13
14
  from .oldnews import OldNews
14
15
 
15
16
 
@@ -49,6 +50,13 @@ def get_args() -> Namespace:
49
50
  dest="command", help="Available commands", required=False
50
51
  )
51
52
 
53
+ # Add the 'directories' command.
54
+ sub_parser.add_parser(
55
+ "directories",
56
+ aliases=["dirs", "d"],
57
+ help="Show the directories created and used by OldNews",
58
+ )
59
+
52
60
  # Add the 'license' command.
53
61
  sub_parser.add_parser(
54
62
  "license",
@@ -135,6 +143,9 @@ def reset_news(args: Namespace) -> None:
135
143
  def main() -> None:
136
144
  """Main entry function."""
137
145
  match (args := get_args()).command:
146
+ case "d" | "dirs" | "directories":
147
+ print(config_dir())
148
+ print(data_dir())
138
149
  case "reset":
139
150
  reset_news(args)
140
151
  case "license" | "licence":
@@ -3,12 +3,15 @@
3
3
  ##############################################################################
4
4
  # Local imports.
5
5
  from .main import (
6
+ AddSubscription,
6
7
  Copy,
7
8
  CopyArticleToClipboard,
8
9
  CopyFeedToClipboard,
9
10
  CopyHomePageToClipboard,
10
11
  Escape,
12
+ Information,
11
13
  MarkAllRead,
14
+ MoveSubscription,
12
15
  Next,
13
16
  NextUnread,
14
17
  OpenArticle,
@@ -16,25 +19,32 @@ from .main import (
16
19
  Previous,
17
20
  PreviousUnread,
18
21
  RefreshFromTheOldReader,
22
+ Remove,
23
+ Rename,
19
24
  ToggleShowAll,
20
25
  )
21
26
 
22
27
  ##############################################################################
23
28
  # Exports.
24
29
  __all__ = [
30
+ "AddSubscription",
25
31
  "Copy",
26
32
  "CopyArticleToClipboard",
27
33
  "CopyFeedToClipboard",
28
34
  "CopyHomePageToClipboard",
29
35
  "Escape",
36
+ "Information",
30
37
  "MarkAllRead",
31
38
  "Next",
32
39
  "NextUnread",
40
+ "MoveSubscription",
33
41
  "OpenArticle",
34
42
  "OpenHomePage",
35
43
  "Previous",
36
44
  "PreviousUnread",
37
45
  "RefreshFromTheOldReader",
46
+ "Rename",
47
+ "Remove",
38
48
  "ToggleShowAll",
39
49
  ]
40
50
 
oldnews/commands/main.py CHANGED
@@ -105,4 +105,39 @@ class Copy(Command):
105
105
  BINDING_KEY = "ctrl+c"
106
106
 
107
107
 
108
+ ##############################################################################
109
+ class AddSubscription(Command):
110
+ """Add a subscription feed"""
111
+
112
+ BINDING_KEY = "plus"
113
+
114
+
115
+ ##############################################################################
116
+ class Rename(Command):
117
+ """Rename the current folder or subscription"""
118
+
119
+ BINDING_KEY = "f6"
120
+
121
+
122
+ ##############################################################################
123
+ class Remove(Command):
124
+ """Remove the current folder or subscription"""
125
+
126
+ BINDING_KEY = "delete"
127
+
128
+
129
+ ##############################################################################
130
+ class MoveSubscription(Command):
131
+ """Move the current subscription to folder"""
132
+
133
+ BINDING_KEY = "f7"
134
+
135
+
136
+ ##############################################################################
137
+ class Information(Command):
138
+ """Show low-level information about the selected item"""
139
+
140
+ BINDING_KEY = "f8"
141
+
142
+
108
143
  ### main.py ends here
oldnews/data/__init__.py CHANGED
@@ -10,6 +10,7 @@ from .config import (
10
10
  update_configuration,
11
11
  )
12
12
  from .db import initialise_database
13
+ from .dump import data_dump
13
14
  from .last_grab import last_grabbed_data_at, remember_we_last_grabbed_at
14
15
  from .local_articles import (
15
16
  clean_old_read_articles,
@@ -17,11 +18,16 @@ from .local_articles import (
17
18
  get_unread_article_ids,
18
19
  locally_mark_article_ids_read,
19
20
  locally_mark_read,
21
+ move_subscription_articles,
22
+ remove_folder_from_articles,
23
+ remove_subscription_articles,
24
+ rename_folder_for_articles,
20
25
  save_local_articles,
21
26
  )
22
27
  from .local_folders import get_local_folders, save_local_folders
23
28
  from .local_subscriptions import get_local_subscriptions, save_local_subscriptions
24
29
  from .local_unread import LocalUnread, get_local_unread, total_unread
30
+ from .log import Log
25
31
  from .navigation_state import get_navigation_state, save_navigation_state
26
32
  from .reset import reset_data
27
33
 
@@ -29,7 +35,9 @@ from .reset import reset_data
29
35
  # Exports.
30
36
  __all__ = [
31
37
  "Configuration",
38
+ "Log",
32
39
  "clean_old_read_articles",
40
+ "data_dump",
33
41
  "get_auth_token",
34
42
  "get_local_articles",
35
43
  "get_local_folders",
@@ -43,7 +51,11 @@ __all__ = [
43
51
  "locally_mark_read",
44
52
  "locally_mark_article_ids_read",
45
53
  "LocalUnread",
54
+ "move_subscription_articles",
46
55
  "remember_we_last_grabbed_at",
56
+ "remove_folder_from_articles",
57
+ "remove_subscription_articles",
58
+ "rename_folder_for_articles",
47
59
  "reset_data",
48
60
  "save_configuration",
49
61
  "save_local_articles",
oldnews/data/db.py CHANGED
@@ -3,14 +3,11 @@
3
3
  ##############################################################################
4
4
  # Python imports.
5
5
  from pathlib import Path
6
- from typing import Any
7
6
 
8
7
  ##############################################################################
9
8
  # TypeDAL imports.
10
- from typedal import TypeDAL, TypedField, TypedTable
9
+ from typedal import TypeDAL
11
10
  from typedal.config import TypeDALConfig
12
- from typedal.helpers import get_field
13
- from typedal.types import Field
14
11
 
15
12
  ##############################################################################
16
13
  # Local imports.
@@ -20,6 +17,7 @@ from .local_folders import LocalFolder
20
17
  from .local_subscriptions import LocalSubscription, LocalSubscriptionCategory
21
18
  from .locations import data_dir
22
19
  from .navigation_state import NavigationState
20
+ from .tools import safely_index
23
21
 
24
22
 
25
23
  ##############################################################################
@@ -32,31 +30,6 @@ def db_file() -> Path:
32
30
  return data_dir() / "oldnews.db"
33
31
 
34
32
 
35
- ##############################################################################
36
- def _safely_index(
37
- table: type[TypedTable], name: str, field: str | Field | TypedField[Any]
38
- ) -> None:
39
- """Create an index on a type, but handle errors.
40
-
41
- Args:
42
- table: The table to create the index against.
43
- name: The name of the index.
44
- field: The field to index.
45
-
46
- Notes:
47
- From what I can gather TypeDAL *should* only create the index if it
48
- doesn't exist. Instead it throws an error if it exists. So here I
49
- swallow the `RuntimeError`. Hopefully there is a better way and I've
50
- just missed it.
51
- """
52
- try:
53
- table.create_index(
54
- name, get_field(field) if isinstance(field, TypedField) else field
55
- )
56
- except RuntimeError:
57
- pass
58
-
59
-
60
33
  ##############################################################################
61
34
  def initialise_database() -> TypeDAL:
62
35
  """Create the database.
@@ -73,20 +46,20 @@ def initialise_database() -> TypeDAL:
73
46
  dal = TypeDAL(f"sqlite://{db_file()}", folder=data_dir(), config=TypeDALConfig())
74
47
 
75
48
  dal.define(LocalArticle)
76
- _safely_index(LocalArticle, "idx_local_article_article_id", LocalArticle.article_id)
77
- _safely_index(
49
+ safely_index(LocalArticle, "idx_local_article_article_id", LocalArticle.article_id)
50
+ safely_index(
78
51
  LocalArticle,
79
52
  "idx_local_article_origin_stream_id",
80
53
  LocalArticle.origin_stream_id,
81
54
  )
82
55
 
83
56
  dal.define(LocalArticleCategory)
84
- _safely_index(
57
+ safely_index(
85
58
  LocalArticleCategory,
86
59
  "idx_local_article_category_article",
87
60
  LocalArticleCategory.article,
88
61
  )
89
- _safely_index(
62
+ safely_index(
90
63
  LocalArticleCategory,
91
64
  "idx_local_article_category_category",
92
65
  LocalArticleCategory.category,
@@ -97,19 +70,19 @@ def initialise_database() -> TypeDAL:
97
70
  dal.define(LocalFolder)
98
71
 
99
72
  dal.define(LocalSubscription)
100
- _safely_index(
73
+ safely_index(
101
74
  LocalSubscription,
102
75
  "idx_local_subscription_subscription_id",
103
76
  LocalSubscription.subscription_id,
104
77
  )
105
78
 
106
79
  dal.define(LocalSubscriptionCategory)
107
- _safely_index(
80
+ safely_index(
108
81
  LocalSubscriptionCategory,
109
82
  "idx_local_subscription_category_subscription",
110
83
  LocalSubscriptionCategory.subscription,
111
84
  )
112
- _safely_index(
85
+ safely_index(
113
86
  LocalSubscriptionCategory,
114
87
  "idx_local_subscription_category_category_id",
115
88
  LocalSubscriptionCategory.category_id,
oldnews/data/dump.py ADDED
@@ -0,0 +1,75 @@
1
+ """Provides tools to dump data into an easy-to-browse format."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ from functools import singledispatch
6
+ from typing import Any
7
+
8
+ ##############################################################################
9
+ # OldAS imports.
10
+ from oldas import Article, Folder, Subscription
11
+
12
+ ##############################################################################
13
+ type DataDump = tuple[tuple[str, str], ...]
14
+ """Type of a data dump."""
15
+
16
+
17
+ ##############################################################################
18
+ @singledispatch
19
+ def data_dump(data: Any) -> DataDump:
20
+ """Dump the given data into an easy-to-browse format.
21
+
22
+ Args:
23
+ data: The data to dump.
24
+
25
+ Returns:
26
+ A `DataDump` of the data.
27
+ """
28
+ return (("Data", str(data)),)
29
+
30
+
31
+ ##############################################################################
32
+ @data_dump.register
33
+ def _(data: Folder) -> DataDump:
34
+ return (("ID", data.id), ("Sort ID", data.sort_id))
35
+
36
+
37
+ ##############################################################################
38
+ @data_dump.register
39
+ def _(data: Subscription) -> DataDump:
40
+ return (
41
+ ("ID", data.id),
42
+ ("Title", data.title),
43
+ ("Sort ID", data.sort_id),
44
+ ("First Item Time", f"{data.first_item_time}"),
45
+ ("URL", data.url),
46
+ ("HTML URL", data.html_url),
47
+ *(
48
+ (
49
+ f"Category[{n}]",
50
+ f"{category.id}, {category.label}",
51
+ )
52
+ for n, category in enumerate(data.categories)
53
+ ),
54
+ )
55
+
56
+
57
+ ##############################################################################
58
+ @data_dump.register
59
+ def _(data: Article) -> DataDump:
60
+ # TODO: The article has pretty rich data, so in here I'm not showing
61
+ # it all, just enough to be useful. In the future perhaps make it a
62
+ # lot richer.
63
+ return (
64
+ ("ID", data.id),
65
+ ("Title", data.title),
66
+ ("Published", f"{data.published}"),
67
+ ("Updated", f"{data.updated}"),
68
+ *(
69
+ (f"Category[{n}]", f"{category}")
70
+ for n, category in enumerate(data.categories)
71
+ ),
72
+ )
73
+
74
+
75
+ ### dump.py ends here
oldnews/data/last_grab.py CHANGED
@@ -8,6 +8,10 @@ from datetime import datetime, timezone
8
8
  # TypeDAL imports.
9
9
  from typedal import TypedTable
10
10
 
11
+ ##############################################################################
12
+ # Local imports.
13
+ from .tools import commit
14
+
11
15
 
12
16
  ##############################################################################
13
17
  class LastGrabbed(TypedTable):
@@ -39,10 +43,9 @@ def remember_we_last_grabbed_at(grab_time: datetime | None = None) -> None:
39
43
  Note:
40
44
  If `grab_time` isn't supplied then it is recorded as now.
41
45
  """
42
- assert LastGrabbed._db is not None
43
46
  LastGrabbed.truncate()
44
47
  LastGrabbed.insert(at_time=grab_time or datetime.now(timezone.utc))
45
- LastGrabbed._db.commit()
48
+ commit(LastGrabbed)
46
49
 
47
50
 
48
51
  ### last_grab.py ends here
@@ -8,13 +8,18 @@ from typing import Iterable, Iterator, cast
8
8
 
9
9
  ##############################################################################
10
10
  # OldAS imports.
11
- from oldas import Article, Articles, Folder, State, Subscription
11
+ from oldas import Article, Articles, Folder, Folders, State, Subscription
12
12
  from oldas.articles import Alternate, Alternates, Direction, Origin, Summary
13
13
 
14
14
  ##############################################################################
15
15
  # TypeDAL imports.
16
16
  from typedal import TypedField, TypedTable, relationship
17
17
 
18
+ ##############################################################################
19
+ # Local imports.
20
+ from .log import Log
21
+ from .tools import commit
22
+
18
23
 
19
24
  ##############################################################################
20
25
  class LocalArticle(TypedTable):
@@ -62,9 +67,8 @@ class LocalArticle(TypedTable):
62
67
  category: The category to add.
63
68
  """
64
69
  if not str(category) in self.categories:
65
- assert LocalArticleCategory._db is not None
66
70
  LocalArticleCategory.insert(article=self.id, category=str(category))
67
- LocalArticleCategory._db.commit()
71
+ commit(LocalArticleCategory)
68
72
 
69
73
  def remove_category(self, category: str | State) -> None:
70
74
  """Remove a given category from the local article.
@@ -73,12 +77,11 @@ class LocalArticle(TypedTable):
73
77
  category: The category to add.
74
78
  """
75
79
  if str(category) in self.categories:
76
- assert LocalArticleCategory._db is not None
77
80
  LocalArticleCategory.where(
78
81
  (LocalArticleCategory.article == self.id)
79
82
  & (LocalArticleCategory.category == str(category))
80
83
  ).delete()
81
- LocalArticleCategory._db.commit()
84
+ commit(LocalArticleCategory)
82
85
 
83
86
 
84
87
  ##############################################################################
@@ -113,7 +116,6 @@ def save_local_articles(articles: Articles) -> Articles:
113
116
  Returns:
114
117
  The articles.
115
118
  """
116
- assert LocalArticle._db is not None
117
119
  for article in articles:
118
120
  local_article = LocalArticle.update_or_insert(
119
121
  LocalArticle.article_id == article.id,
@@ -146,7 +148,7 @@ def save_local_articles(articles: Articles) -> Articles:
146
148
  for alternate in article.alternate
147
149
  ]
148
150
  )
149
- LocalArticle._db.commit()
151
+ commit(LocalArticle)
150
152
  return articles
151
153
 
152
154
 
@@ -336,16 +338,93 @@ def get_unread_article_ids() -> list[str]:
336
338
  ##############################################################################
337
339
  def clean_old_read_articles(cutoff: timedelta) -> int:
338
340
  """Clean up articles that are older than the given cutoff time."""
339
- assert LocalArticle._db is not None
340
341
  read = get_local_read_article_ids()
341
342
  retire_time = datetime.now() - cutoff
343
+ Log().debug(f"Cleaning up read articles published before {retire_time}")
342
344
  cleaned = len(
343
345
  LocalArticle.where(
344
346
  (LocalArticle.published < retire_time) & LocalArticle.id.belongs(read)
345
347
  ).delete()
346
348
  )
347
- LocalArticle._db.commit()
349
+ Log().debug(f"Cleaned: {cleaned}")
350
+ commit(LocalArticle)
348
351
  return cleaned
349
352
 
350
353
 
354
+ ##############################################################################
355
+ def rename_folder_for_articles(rename_from: str | Folder, rename_to: str) -> None:
356
+ """Rename a folder for all articles that are in that folder.
357
+
358
+ Args:
359
+ rename_from: The folder name to rename from.
360
+ rename_to: The folder name to rename to.
361
+ """
362
+ rename_from = Folders.full_id(rename_from)
363
+ rename_to = Folders.full_id(rename_to)
364
+ Log().debug(f"Renaming folder for local articles from {rename_from} to {rename_to}")
365
+ LocalArticleCategory.where(LocalArticleCategory.category == rename_from).update(
366
+ category=rename_to
367
+ )
368
+ commit(LocalArticleCategory)
369
+
370
+
371
+ ##############################################################################
372
+ def remove_folder_from_articles(folder: str | Folder) -> None:
373
+ """Remove a folder from being associated with all articles.
374
+
375
+ Args:
376
+ folder: The folder to remove from all articles.
377
+ """
378
+ folder = Folders.full_id(folder)
379
+ Log().debug(f"Removing folder {folder} from all local articles")
380
+ LocalArticleCategory.where(LocalArticleCategory.category == folder).delete()
381
+ commit(LocalArticleCategory)
382
+
383
+
384
+ ##############################################################################
385
+ def move_subscription_articles(
386
+ subscription: Subscription,
387
+ from_folder: str | Folder | None,
388
+ to_folder: str | Folder | None,
389
+ ) -> None:
390
+ """Move the articles of a subscription from one folder to another.
391
+
392
+ Args:
393
+ subscription: The subscription whose articles we should move.
394
+ from_folder: The folder to move from.
395
+ to_folder: The folder to move to.
396
+ """
397
+ from_folder = (
398
+ Folders.full_id(from_folder) if from_folder is not None else from_folder
399
+ )
400
+ to_folder = Folders.full_id(to_folder) if to_folder is not None else to_folder
401
+ Log().debug(
402
+ f"Moving all articles of {subscription.title} ({subscription.id}) from folder {from_folder} to {to_folder}"
403
+ )
404
+ for article in LocalArticle.where(origin_stream_id=subscription.id).join().select():
405
+ if from_folder:
406
+ LocalArticleCategory.where(
407
+ (LocalArticleCategory.article == article.id)
408
+ & (LocalArticleCategory.category == from_folder)
409
+ ).delete()
410
+ commit(LocalArticleCategory)
411
+ if to_folder:
412
+ LocalArticleCategory.insert(article=article.id, category=to_folder)
413
+ commit(LocalArticleCategory)
414
+
415
+
416
+ ##############################################################################
417
+ def remove_subscription_articles(subscription: str | Subscription) -> None:
418
+ """Remove all the articles associated with the given subscription.
419
+
420
+ Args:
421
+ subscription: The subscription to remove the articles for.
422
+ """
423
+ if isinstance(subscription, Subscription):
424
+ subscription = subscription.id
425
+ Log().debug(f"Removing all local articles for subscription {subscription}")
426
+ LocalArticle.where(origin_stream_id=subscription).delete()
427
+ commit(LocalArticle)
428
+
429
+
351
430
  ### local_articles.py ends here
@@ -8,6 +8,10 @@ from oldas import Folder, Folders
8
8
  # TypeDAL imports.
9
9
  from typedal import TypedTable
10
10
 
11
+ ##############################################################################
12
+ # Local imports.
13
+ from .tools import commit
14
+
11
15
 
12
16
  ##############################################################################
13
17
  class LocalFolder(TypedTable):
@@ -42,12 +46,11 @@ def save_local_folders(folders: Folders) -> Folders:
42
46
  Returns:
43
47
  The folders.
44
48
  """
45
- assert LocalFolder._db is not None
46
49
  LocalFolder.truncate()
47
50
  LocalFolder.bulk_insert(
48
51
  [{"folder_id": folder.id, "sort_id": folder.sort_id} for folder in folders]
49
52
  )
50
- LocalFolder._db.commit()
53
+ commit(LocalFolder)
51
54
  return folders
52
55
 
53
56
 
@@ -13,6 +13,10 @@ from oldas.subscriptions import Categories, Category
13
13
  # TypeDAL imports.
14
14
  from typedal import TypedTable
15
15
 
16
+ ##############################################################################
17
+ # Local imports.
18
+ from .tools import commit
19
+
16
20
 
17
21
  ##############################################################################
18
22
  class LocalSubscription(TypedTable):
@@ -83,29 +87,33 @@ def save_local_subscriptions(subscriptions: Subscriptions) -> Subscriptions:
83
87
  Returns:
84
88
  The subscriptions.
85
89
  """
86
- assert LocalSubscription._db is not None
87
- for subscription in subscriptions:
88
- LocalSubscriptionCategory.where(subscription=subscription.id).delete()
89
- LocalSubscriptionCategory.bulk_insert(
90
- [
91
- {
92
- "subscription": subscription.id,
93
- "category_id": category.id,
94
- "label": category.label,
95
- }
96
- for category in subscription.categories
97
- ]
98
- )
99
- LocalSubscription.update_or_insert(
100
- LocalSubscription.subscription_id == subscription.id,
101
- subscription_id=subscription.id,
102
- title=subscription.title,
103
- sort_id=subscription.sort_id,
104
- first_item_time=subscription.first_item_time,
105
- url=subscription.url,
106
- html_url=subscription.html_url,
107
- )
108
- LocalSubscription._db.commit()
90
+ LocalSubscription.truncate()
91
+ LocalSubscriptionCategory.truncate()
92
+ LocalSubscription.bulk_insert(
93
+ [
94
+ {
95
+ "subscription_id": subscription.id,
96
+ "title": subscription.title,
97
+ "sort_id": subscription.sort_id,
98
+ "first_item_time": subscription.first_item_time,
99
+ "url": subscription.url,
100
+ "html_url": subscription.html_url,
101
+ }
102
+ for subscription in subscriptions
103
+ ]
104
+ )
105
+ LocalSubscriptionCategory.bulk_insert(
106
+ [
107
+ {
108
+ "subscription": subscription.id,
109
+ "category_id": category.id,
110
+ "label": category.label,
111
+ }
112
+ for subscription in subscriptions
113
+ for category in subscription.categories
114
+ ]
115
+ )
116
+ commit(LocalSubscription)
109
117
  return subscriptions
110
118
 
111
119
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ##############################################################################
4
4
  # OldAS imports.
5
- from oldas import Folders, Subscriptions, id_is_a_folder
5
+ from oldas import Folders, Subscriptions, id_is_a_feed
6
6
 
7
7
  ##############################################################################
8
8
  # Local imports.
@@ -41,7 +41,7 @@ def total_unread(unread: LocalUnread) -> int:
41
41
  Returns:
42
42
  The total unread.
43
43
  """
44
- return sum(count for category, count in unread.items() if id_is_a_folder(category))
44
+ return sum(count for category, count in unread.items() if id_is_a_feed(category))
45
45
 
46
46
 
47
47
  ### local_unread.py ends here
oldnews/data/log.py ADDED
@@ -0,0 +1,38 @@
1
+ """Provides the application's logger."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ from functools import cache
6
+ from logging import DEBUG, INFO, Formatter, Logger, getLogger
7
+ from logging.handlers import RotatingFileHandler
8
+ from os import getenv
9
+
10
+ ##############################################################################
11
+ # Local imports.
12
+ from .locations import data_dir
13
+
14
+
15
+ ##############################################################################
16
+ def _build_logger() -> Logger:
17
+ """Build a logger for the application.
18
+
19
+ Returns:
20
+ A configured `Logger` object.
21
+ """
22
+ logger = getLogger("oldnews")
23
+ logger.setLevel(DEBUG if getenv("OLDNEWS_DEBUG") else INFO)
24
+ file_handler = RotatingFileHandler(
25
+ data_dir() / "oldnews.log", maxBytes=1024 * 1024, backupCount=5
26
+ )
27
+ file_handler.setFormatter(
28
+ Formatter("%(asctime)s - %(levelname)s - %(module)s:%(lineno)d - %(message)s")
29
+ )
30
+ logger.addHandler(file_handler)
31
+ return logger
32
+
33
+
34
+ ##############################################################################
35
+ Log = cache(_build_logger)
36
+ """The application-wide logging object."""
37
+
38
+ ### log.py ends here