promnesia 1.2.20240810__py3-none-any.whl → 1.4.20250909__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.
- promnesia/__init__.py +18 -4
- promnesia/__main__.py +104 -78
- promnesia/cannon.py +108 -107
- promnesia/common.py +107 -88
- promnesia/compare.py +33 -30
- promnesia/compat.py +10 -10
- promnesia/config.py +37 -34
- promnesia/database/common.py +4 -3
- promnesia/database/dump.py +13 -13
- promnesia/database/load.py +7 -7
- promnesia/extract.py +19 -17
- promnesia/logging.py +27 -15
- promnesia/misc/install_server.py +32 -27
- promnesia/server.py +106 -79
- promnesia/sources/auto.py +104 -77
- promnesia/sources/auto_logseq.py +6 -5
- promnesia/sources/auto_obsidian.py +2 -2
- promnesia/sources/browser.py +20 -10
- promnesia/sources/browser_legacy.py +65 -50
- promnesia/sources/demo.py +7 -8
- promnesia/sources/fbmessenger.py +3 -3
- promnesia/sources/filetypes.py +22 -16
- promnesia/sources/github.py +9 -8
- promnesia/sources/guess.py +6 -2
- promnesia/sources/hackernews.py +7 -9
- promnesia/sources/hpi.py +5 -3
- promnesia/sources/html.py +11 -7
- promnesia/sources/hypothesis.py +3 -2
- promnesia/sources/instapaper.py +3 -2
- promnesia/sources/markdown.py +22 -12
- promnesia/sources/org.py +36 -17
- promnesia/sources/plaintext.py +41 -39
- promnesia/sources/pocket.py +5 -3
- promnesia/sources/reddit.py +24 -26
- promnesia/sources/roamresearch.py +5 -2
- promnesia/sources/rss.py +6 -8
- promnesia/sources/shellcmd.py +21 -11
- promnesia/sources/signal.py +27 -26
- promnesia/sources/smscalls.py +2 -3
- promnesia/sources/stackexchange.py +5 -4
- promnesia/sources/takeout.py +37 -34
- promnesia/sources/takeout_legacy.py +29 -19
- promnesia/sources/telegram.py +18 -12
- promnesia/sources/telegram_legacy.py +22 -11
- promnesia/sources/twitter.py +7 -6
- promnesia/sources/vcs.py +11 -6
- promnesia/sources/viber.py +11 -10
- promnesia/sources/website.py +8 -7
- promnesia/sources/zulip.py +3 -2
- promnesia/sqlite.py +13 -7
- promnesia/tests/common.py +10 -5
- promnesia/tests/server_helper.py +13 -10
- promnesia/tests/sources/test_auto.py +2 -3
- promnesia/tests/sources/test_filetypes.py +11 -8
- promnesia/tests/sources/test_hypothesis.py +10 -6
- promnesia/tests/sources/test_org.py +9 -5
- promnesia/tests/sources/test_plaintext.py +9 -8
- promnesia/tests/sources/test_shellcmd.py +13 -13
- promnesia/tests/sources/test_takeout.py +3 -5
- promnesia/tests/test_cannon.py +256 -239
- promnesia/tests/test_cli.py +12 -8
- promnesia/tests/test_compare.py +17 -13
- promnesia/tests/test_config.py +7 -8
- promnesia/tests/test_db_dump.py +15 -15
- promnesia/tests/test_extract.py +17 -10
- promnesia/tests/test_indexer.py +24 -18
- promnesia/tests/test_server.py +12 -13
- promnesia/tests/test_traverse.py +0 -2
- promnesia/tests/utils.py +3 -7
- promnesia-1.4.20250909.dist-info/METADATA +66 -0
- promnesia-1.4.20250909.dist-info/RECORD +80 -0
- {promnesia-1.2.20240810.dist-info → promnesia-1.4.20250909.dist-info}/WHEEL +1 -2
- promnesia/kjson.py +0 -121
- promnesia/sources/__init__.pyi +0 -0
- promnesia-1.2.20240810.dist-info/METADATA +0 -54
- promnesia-1.2.20240810.dist-info/RECORD +0 -83
- promnesia-1.2.20240810.dist-info/top_level.txt +0 -1
- {promnesia-1.2.20240810.dist-info → promnesia-1.4.20250909.dist-info}/entry_points.txt +0 -0
- {promnesia-1.2.20240810.dist-info → promnesia-1.4.20250909.dist-info/licenses}/LICENSE +0 -0
promnesia/compat.py
CHANGED
@@ -1,12 +1,12 @@
|
|
1
|
-
|
2
|
-
## keeping in case any sources depended on compat functions
|
3
|
-
from subprocess import PIPE, run, check_call, check_output, Popen
|
4
|
-
from typing import Protocol, Literal
|
5
|
-
##
|
1
|
+
from typing import TYPE_CHECKING
|
6
2
|
|
3
|
+
if not TYPE_CHECKING:
|
4
|
+
## we used to have compat fixes here for these for python3.7
|
5
|
+
## keeping in case any sources depended on compat functions
|
6
|
+
from subprocess import PIPE, Popen, check_call, check_output, run # noqa: F401
|
7
|
+
from typing import Literal, Protocol # noqa: F401
|
8
|
+
##
|
7
9
|
|
8
|
-
#
|
9
|
-
def removeprefix(text: str, prefix: str) -> str:
|
10
|
-
|
11
|
-
return text[len(prefix):]
|
12
|
-
return text
|
10
|
+
# todo deprecate properly
|
11
|
+
def removeprefix(text: str, prefix: str) -> str:
|
12
|
+
return text.removeprefix(prefix)
|
promnesia/config.py
CHANGED
@@ -1,55 +1,53 @@
|
|
1
|
-
from
|
2
|
-
|
3
|
-
from types import ModuleType
|
4
|
-
from typing import List, Optional, Union, NamedTuple, Iterable, Callable
|
1
|
+
from __future__ import annotations
|
2
|
+
|
5
3
|
import importlib
|
6
4
|
import importlib.util
|
5
|
+
import os
|
7
6
|
import warnings
|
7
|
+
from collections.abc import Callable, Iterable
|
8
|
+
from pathlib import Path
|
9
|
+
from types import ModuleType
|
10
|
+
from typing import NamedTuple
|
8
11
|
|
9
|
-
from .common import PathIsh,
|
10
|
-
from .common import Res, Source, DbVisit
|
11
|
-
|
12
|
+
from .common import DbVisit, PathIsh, Res, Source, default_cache_dir, default_output_dir
|
12
13
|
|
13
14
|
HookT = Callable[[Res[DbVisit]], Iterable[Res[DbVisit]]]
|
14
15
|
|
15
16
|
|
16
|
-
from typing import Any
|
17
|
-
|
18
|
-
|
19
17
|
ModuleName = str
|
20
18
|
|
21
19
|
# something that can be converted into a proper Source
|
22
|
-
ConfigSource =
|
20
|
+
ConfigSource = Source | ModuleName | ModuleType
|
23
21
|
|
24
22
|
|
25
23
|
class Config(NamedTuple):
|
26
24
|
# TODO remove default from sources once migrated
|
27
|
-
SOURCES:
|
25
|
+
SOURCES: list[ConfigSource] = [] # noqa: RUF012
|
28
26
|
|
29
27
|
# if not specified, uses user data dir
|
30
|
-
OUTPUT_DIR:
|
28
|
+
OUTPUT_DIR: PathIsh | None = None
|
31
29
|
|
32
|
-
CACHE_DIR:
|
33
|
-
FILTERS:
|
30
|
+
CACHE_DIR: PathIsh | None = ''
|
31
|
+
FILTERS: list[str] = [] # noqa: RUF012
|
34
32
|
|
35
|
-
HOOK:
|
33
|
+
HOOK: HookT | None = None
|
36
34
|
|
37
35
|
#
|
38
36
|
# NOTE: INDEXERS is deprecated, use SOURCES instead
|
39
|
-
INDEXERS:
|
40
|
-
#MIME_HANDLER: Optional[str] = None # TODO
|
37
|
+
INDEXERS: list[ConfigSource] = [] # noqa: RUF012
|
38
|
+
# MIME_HANDLER: Optional[str] = None # TODO
|
41
39
|
|
42
40
|
@property
|
43
41
|
def sources(self) -> Iterable[Res[Source]]:
|
44
|
-
idx = self.INDEXERS
|
45
|
-
|
46
42
|
if len(self.INDEXERS) > 0:
|
47
43
|
warnings.warn("'INDEXERS' is deprecated. Please use 'SOURCES'!", DeprecationWarning)
|
48
44
|
|
49
45
|
raw = self.SOURCES + self.INDEXERS
|
50
46
|
|
51
47
|
if len(raw) == 0:
|
52
|
-
raise RuntimeError(
|
48
|
+
raise RuntimeError(
|
49
|
+
"Please specify SOURCES in the config! See https://github.com/karlicoss/promnesia#setup for more information"
|
50
|
+
)
|
53
51
|
|
54
52
|
for r in raw:
|
55
53
|
if isinstance(r, ModuleName):
|
@@ -68,14 +66,14 @@ class Config(NamedTuple):
|
|
68
66
|
yield Source(r)
|
69
67
|
|
70
68
|
@property
|
71
|
-
def cache_dir(self) ->
|
69
|
+
def cache_dir(self) -> Path | None:
|
72
70
|
# TODO we used to use this for cachew, but it's best to rely on HPI modules etc to cofigure this
|
73
71
|
# keeping just in case for now
|
74
72
|
cd = self.CACHE_DIR
|
75
|
-
cpath:
|
73
|
+
cpath: Path | None
|
76
74
|
if cd is None:
|
77
|
-
cpath = None
|
78
|
-
elif cd == '':
|
75
|
+
cpath = None # means 'disabled' in cachew
|
76
|
+
elif cd == '': # meh.. but need to make it None friendly..
|
79
77
|
cpath = default_cache_dir()
|
80
78
|
else:
|
81
79
|
cpath = Path(cd)
|
@@ -96,15 +94,17 @@ class Config(NamedTuple):
|
|
96
94
|
return self.output_dir / 'promnesia.sqlite'
|
97
95
|
|
98
96
|
@property
|
99
|
-
def hook(self) ->
|
97
|
+
def hook(self) -> HookT | None:
|
100
98
|
return self.HOOK
|
101
99
|
|
102
|
-
|
100
|
+
|
101
|
+
instance: Config | None = None
|
103
102
|
|
104
103
|
|
105
104
|
def has() -> bool:
|
106
105
|
return instance is not None
|
107
106
|
|
107
|
+
|
108
108
|
def get() -> Config:
|
109
109
|
assert instance is not None, "Expected config to be set, but it's not"
|
110
110
|
return instance
|
@@ -126,9 +126,12 @@ def import_config(config_file: PathIsh) -> Config:
|
|
126
126
|
|
127
127
|
# todo just exec??
|
128
128
|
name = p.stem
|
129
|
-
spec = importlib.util.spec_from_file_location(name, p)
|
130
|
-
|
131
|
-
|
129
|
+
spec = importlib.util.spec_from_file_location(name, p)
|
130
|
+
assert spec is not None
|
131
|
+
mod = importlib.util.module_from_spec(spec)
|
132
|
+
assert mod is not None
|
133
|
+
loader = spec.loader
|
134
|
+
assert loader is not None
|
132
135
|
loader.exec_module(mod)
|
133
136
|
|
134
137
|
d = {}
|
@@ -139,7 +142,7 @@ def import_config(config_file: PathIsh) -> Config:
|
|
139
142
|
|
140
143
|
|
141
144
|
# TODO: ugh. this causes warnings to be repeated multiple times... need to reuse the pool or something..
|
142
|
-
def use_cores() ->
|
145
|
+
def use_cores() -> int | None:
|
143
146
|
'''
|
144
147
|
Somewhat experimental.
|
145
148
|
For now only used in sources.auto, perhaps later will be shared among the other indexers.
|
@@ -150,15 +153,15 @@ def use_cores() -> Optional[int]:
|
|
150
153
|
return None
|
151
154
|
try:
|
152
155
|
return int(cs)
|
153
|
-
except ValueError:
|
156
|
+
except ValueError: # any other value means 'use all
|
154
157
|
return 0
|
155
158
|
|
156
159
|
|
157
|
-
def extra_fd_args() ->
|
160
|
+
def extra_fd_args() -> list[str]:
|
158
161
|
'''
|
159
162
|
Not sure where it belongs yet... so via env variable for now
|
160
163
|
Can be used to pass --ignore-file parameter
|
161
164
|
'''
|
162
165
|
v = os.environ.get('PROMNESIA_FD_EXTRA_ARGS', '')
|
163
|
-
extra = v.split()
|
166
|
+
extra = v.split() # eh, hopefully splitting that way is ok...
|
164
167
|
return extra
|
promnesia/database/common.py
CHANGED
@@ -1,10 +1,11 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from collections.abc import Sequence
|
1
4
|
from datetime import datetime
|
2
|
-
from typing import Sequence, Tuple
|
3
5
|
|
4
6
|
from sqlalchemy import (
|
5
7
|
Column,
|
6
8
|
Integer,
|
7
|
-
Row,
|
8
9
|
String,
|
9
10
|
)
|
10
11
|
|
@@ -30,7 +31,7 @@ def get_columns() -> Sequence[Column]:
|
|
30
31
|
return res
|
31
32
|
|
32
33
|
|
33
|
-
def db_visit_to_row(v: DbVisit) ->
|
34
|
+
def db_visit_to_row(v: DbVisit) -> tuple:
|
34
35
|
# ugh, very hacky...
|
35
36
|
# we want to make sure the resulting tuple only consists of simple types
|
36
37
|
# so we can use dbengine directly
|
promnesia/database/dump.py
CHANGED
@@ -1,9 +1,10 @@
|
|
1
|
-
from
|
1
|
+
from __future__ import annotations
|
2
|
+
|
2
3
|
import sqlite3
|
3
|
-
from
|
4
|
+
from collections.abc import Iterable
|
5
|
+
from pathlib import Path
|
4
6
|
|
5
7
|
from more_itertools import chunked
|
6
|
-
|
7
8
|
from sqlalchemy import (
|
8
9
|
Engine,
|
9
10
|
MetaData,
|
@@ -16,6 +17,7 @@ from sqlalchemy import (
|
|
16
17
|
)
|
17
18
|
from sqlalchemy.dialects import sqlite as dialect_sqlite
|
18
19
|
|
20
|
+
from .. import config
|
19
21
|
from ..common import (
|
20
22
|
DbVisit,
|
21
23
|
Loc,
|
@@ -24,9 +26,7 @@ from ..common import (
|
|
24
26
|
get_logger,
|
25
27
|
now_tz,
|
26
28
|
)
|
27
|
-
from .common import
|
28
|
-
from .. import config
|
29
|
-
|
29
|
+
from .common import db_visit_to_row, get_columns
|
30
30
|
|
31
31
|
# NOTE: I guess the main performance benefit from this is not creating too many tmp lists and avoiding overhead
|
32
32
|
# since as far as sql is concerned it should all be in the same transaction. only a guess
|
@@ -50,7 +50,7 @@ def begin_immediate_transaction(conn):
|
|
50
50
|
conn.exec_driver_sql('BEGIN IMMEDIATE')
|
51
51
|
|
52
52
|
|
53
|
-
Stats =
|
53
|
+
Stats = dict[SourceName | None, int]
|
54
54
|
|
55
55
|
|
56
56
|
# returns critical warnings
|
@@ -58,8 +58,8 @@ def visits_to_sqlite(
|
|
58
58
|
vit: Iterable[Res[DbVisit]],
|
59
59
|
*,
|
60
60
|
overwrite_db: bool,
|
61
|
-
_db_path:
|
62
|
-
) ->
|
61
|
+
_db_path: Path | None = None, # only used in tests
|
62
|
+
) -> list[Exception]:
|
63
63
|
if _db_path is None:
|
64
64
|
db_path = config.get().db
|
65
65
|
else:
|
@@ -95,7 +95,7 @@ def visits_to_sqlite(
|
|
95
95
|
|
96
96
|
def query_total_stats(conn) -> Stats:
|
97
97
|
query = select(table.c.src, func.count(table.c.src)).select_from(table).group_by(table.c.src)
|
98
|
-
return
|
98
|
+
return dict(conn.execute(query).all())
|
99
99
|
|
100
100
|
def get_engine(*args, **kwargs) -> Engine:
|
101
101
|
# kwargs['echo'] = True # useful for debugging
|
@@ -122,7 +122,7 @@ def visits_to_sqlite(
|
|
122
122
|
# (note that this also requires WAL mode)
|
123
123
|
engine = get_engine(f'sqlite:///{db_path}', connect_args={'timeout': _CONNECTION_TIMEOUT_SECONDS})
|
124
124
|
|
125
|
-
cleared:
|
125
|
+
cleared: set[str] = set()
|
126
126
|
|
127
127
|
# by default, sqlalchemy does some sort of BEGIN (implicit) transaction, which doesn't provide proper isolation??
|
128
128
|
# see https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#serializable-isolation-savepoints-transactional-ddl
|
@@ -144,7 +144,7 @@ def visits_to_sqlite(
|
|
144
144
|
insert_stmt_raw = str(insert_stmt.compile(dialect=dialect_sqlite.dialect(paramstyle='qmark')))
|
145
145
|
|
146
146
|
for chunk in chunked(vit_ok(), n=_CHUNK_BY):
|
147
|
-
srcs =
|
147
|
+
srcs = {v.src or '' for v in chunk}
|
148
148
|
new = srcs.difference(cleared)
|
149
149
|
|
150
150
|
for src in new:
|
@@ -181,7 +181,7 @@ def visits_to_sqlite(
|
|
181
181
|
for k, v in stats_changes.items():
|
182
182
|
logger.info(f'database stats changes: {k} {v}')
|
183
183
|
|
184
|
-
res:
|
184
|
+
res: list[Exception] = []
|
185
185
|
if total_ok == 0:
|
186
186
|
res.append(RuntimeError('No visits were indexed, something is probably wrong!'))
|
187
187
|
return res
|
promnesia/database/load.py
CHANGED
@@ -1,19 +1,19 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
from pathlib import Path
|
2
|
-
from typing import Tuple, List
|
3
4
|
|
4
5
|
from sqlalchemy import (
|
5
|
-
create_engine,
|
6
|
-
exc,
|
7
6
|
Engine,
|
8
|
-
MetaData,
|
9
7
|
Index,
|
8
|
+
MetaData,
|
10
9
|
Table,
|
10
|
+
create_engine,
|
11
|
+
exc,
|
11
12
|
)
|
12
13
|
|
13
14
|
from .common import DbVisit, get_columns, row_to_db_visit
|
14
15
|
|
15
|
-
|
16
|
-
DbStuff = Tuple[Engine, Table]
|
16
|
+
DbStuff = tuple[Engine, Table]
|
17
17
|
|
18
18
|
|
19
19
|
def get_db_stuff(db_path: Path) -> DbStuff:
|
@@ -39,7 +39,7 @@ def get_db_stuff(db_path: Path) -> DbStuff:
|
|
39
39
|
return engine, table
|
40
40
|
|
41
41
|
|
42
|
-
def get_all_db_visits(db_path: Path) ->
|
42
|
+
def get_all_db_visits(db_path: Path) -> list[DbVisit]:
|
43
43
|
# NOTE: this is pretty inefficient if the DB is huge
|
44
44
|
# mostly intended for tests
|
45
45
|
engine, table = get_db_stuff(db_path)
|
promnesia/extract.py
CHANGED
@@ -1,20 +1,22 @@
|
|
1
|
-
from
|
1
|
+
from __future__ import annotations
|
2
|
+
|
2
3
|
import re
|
3
|
-
import
|
4
|
-
from
|
4
|
+
from collections.abc import Iterable, Sequence
|
5
|
+
from functools import lru_cache
|
5
6
|
|
6
7
|
from .cannon import CanonifyException
|
7
8
|
from .common import (
|
8
|
-
|
9
|
-
DbVisit, Visit,
|
10
|
-
Res,
|
11
|
-
SourceName, Source,
|
9
|
+
DbVisit,
|
12
10
|
Filter,
|
11
|
+
Res,
|
12
|
+
Results,
|
13
|
+
Source,
|
14
|
+
SourceName,
|
13
15
|
Url,
|
14
|
-
|
16
|
+
Visit,
|
17
|
+
logger,
|
15
18
|
)
|
16
19
|
|
17
|
-
|
18
20
|
DEFAULT_FILTERS = (
|
19
21
|
r'^chrome-\w+://',
|
20
22
|
r'chrome://newtab',
|
@@ -23,18 +25,17 @@ DEFAULT_FILTERS = (
|
|
23
25
|
r'^about:',
|
24
26
|
r'^blob:',
|
25
27
|
r'^view-source:',
|
26
|
-
|
27
28
|
r'^content:',
|
28
29
|
)
|
29
30
|
|
30
31
|
|
31
32
|
# TODO maybe move these to configs?
|
32
|
-
@lru_cache(1)
|
33
|
+
@lru_cache(1) # meh, not sure what would happen under tests?
|
33
34
|
def filters() -> Sequence[Filter]:
|
34
35
|
from . import config
|
35
36
|
|
36
37
|
flt = list(DEFAULT_FILTERS)
|
37
|
-
if config.has():
|
38
|
+
if config.has(): # meeeh...
|
38
39
|
cfg = config.get()
|
39
40
|
flt.extend(cfg.FILTERS)
|
40
41
|
return tuple(make_filter(f) for f in flt)
|
@@ -53,7 +54,7 @@ def extract_visits(source: Source, *, src: SourceName) -> Iterable[Res[DbVisit]]
|
|
53
54
|
yield e
|
54
55
|
return
|
55
56
|
|
56
|
-
handled:
|
57
|
+
handled: set[Visit] = set()
|
57
58
|
try:
|
58
59
|
for p in vit:
|
59
60
|
if isinstance(p, Exception):
|
@@ -65,7 +66,7 @@ def extract_visits(source: Source, *, src: SourceName) -> Iterable[Res[DbVisit]]
|
|
65
66
|
yield p
|
66
67
|
continue
|
67
68
|
|
68
|
-
if p in handled:
|
69
|
+
if p in handled: # no need to emit duplicates
|
69
70
|
continue
|
70
71
|
handled.add(p)
|
71
72
|
|
@@ -75,7 +76,6 @@ def extract_visits(source: Source, *, src: SourceName) -> Iterable[Res[DbVisit]]
|
|
75
76
|
logger.exception(e)
|
76
77
|
yield e
|
77
78
|
|
78
|
-
|
79
79
|
logger.info('extracting via %s: got %d visits', source.description, len(handled))
|
80
80
|
|
81
81
|
|
@@ -94,11 +94,13 @@ def filtered(url: Url) -> bool:
|
|
94
94
|
return any(f(url) for f in filters())
|
95
95
|
|
96
96
|
|
97
|
-
def make_filter(thing:
|
97
|
+
def make_filter(thing: str | Filter) -> Filter:
|
98
98
|
if isinstance(thing, str):
|
99
99
|
rc = re.compile(thing)
|
100
|
+
|
100
101
|
def filter_(u: str) -> bool:
|
101
102
|
return rc.search(u) is not None
|
103
|
+
|
102
104
|
return filter_
|
103
|
-
else:
|
105
|
+
else: # must be predicate
|
104
106
|
return thing
|
promnesia/logging.py
CHANGED
@@ -3,20 +3,25 @@
|
|
3
3
|
Default logger is a bit meh, see 'test'/run this file for a demo
|
4
4
|
'''
|
5
5
|
|
6
|
+
|
6
7
|
def test() -> None:
|
7
8
|
import logging
|
8
9
|
import sys
|
9
|
-
from
|
10
|
+
from collections.abc import Callable
|
10
11
|
|
11
12
|
M: Callable[[str], None] = lambda s: print(s, file=sys.stderr)
|
12
13
|
|
13
14
|
M(" Logging module's defaults are not great...'")
|
14
15
|
l = logging.getLogger('test_logger')
|
15
|
-
l.error(
|
16
|
+
l.error(
|
17
|
+
"For example, this should be logged as error. But it's not even formatted properly, doesn't have logger name or level"
|
18
|
+
)
|
16
19
|
|
17
20
|
M(" The reason is that you need to remember to call basicConfig() first")
|
18
21
|
logging.basicConfig()
|
19
|
-
l.error(
|
22
|
+
l.error(
|
23
|
+
"OK, this is better. But the default format kinda sucks, I prefer having timestamps and the file/line number"
|
24
|
+
)
|
20
25
|
|
21
26
|
M("")
|
22
27
|
M(" With LazyLogger you get a reasonable logging format, colours and other neat things")
|
@@ -29,12 +34,12 @@ def test() -> None:
|
|
29
34
|
|
30
35
|
|
31
36
|
import logging
|
32
|
-
from typing import Union, Optional, cast
|
33
37
|
import os
|
34
38
|
import warnings
|
39
|
+
from typing import cast
|
35
40
|
|
36
41
|
Level = int
|
37
|
-
LevelIsh =
|
42
|
+
LevelIsh = Level | str | None
|
38
43
|
|
39
44
|
|
40
45
|
def mklevel(level: LevelIsh) -> Level:
|
@@ -50,18 +55,22 @@ def mklevel(level: LevelIsh) -> Level:
|
|
50
55
|
|
51
56
|
|
52
57
|
FORMAT = '{start}[%(levelname)-7s %(asctime)s %(name)s %(filename)s:%(lineno)d]{end} %(message)s'
|
58
|
+
# fmt: off
|
53
59
|
FORMAT_COLOR = FORMAT.format(start='%(color)s', end='%(end_color)s')
|
54
|
-
FORMAT_NOCOLOR = FORMAT.format(start='', end='')
|
60
|
+
FORMAT_NOCOLOR = FORMAT.format(start='' , end='')
|
61
|
+
# fmt: on
|
55
62
|
DATEFMT = '%Y-%m-%d %H:%M:%S'
|
56
63
|
|
57
|
-
COLLAPSE_DEBUG_LOGS = os.environ.get('COLLAPSE_DEBUG_LOGS', False)
|
64
|
+
COLLAPSE_DEBUG_LOGS = os.environ.get('COLLAPSE_DEBUG_LOGS', False) # noqa: PLW1508
|
58
65
|
|
59
66
|
_init_done = 'lazylogger_init_done'
|
60
67
|
|
68
|
+
|
61
69
|
def setup_logger(logger: logging.Logger, level: LevelIsh) -> None:
|
62
70
|
lvl = mklevel(level)
|
63
71
|
try:
|
64
|
-
import logzero # type: ignore[import-not-found]
|
72
|
+
import logzero # type: ignore[import-not-found,import-untyped,unused-ignore]
|
73
|
+
|
65
74
|
formatter = logzero.LogFormatter(
|
66
75
|
fmt=FORMAT_COLOR,
|
67
76
|
datefmt=DATEFMT,
|
@@ -73,7 +82,7 @@ def setup_logger(logger: logging.Logger, level: LevelIsh) -> None:
|
|
73
82
|
use_logzero = False
|
74
83
|
|
75
84
|
logger.addFilter(AddExceptionTraceback())
|
76
|
-
if use_logzero and not COLLAPSE_DEBUG_LOGS:
|
85
|
+
if use_logzero and not COLLAPSE_DEBUG_LOGS: # all set, nothing to do
|
77
86
|
# 'simple' setup
|
78
87
|
logzero.setup_logger(logger.name, level=lvl, formatter=formatter) # type: ignore[possibly-undefined]
|
79
88
|
return
|
@@ -91,16 +100,17 @@ class LazyLogger(logging.Logger):
|
|
91
100
|
logger = logging.getLogger(name)
|
92
101
|
|
93
102
|
# this is called prior to all _log calls so makes sense to do it here?
|
94
|
-
def isEnabledFor_lazyinit(*args, logger=logger, orig=logger.isEnabledFor, **kwargs) -> bool:
|
103
|
+
def isEnabledFor_lazyinit(*args, logger: logging.Logger = logger, orig=logger.isEnabledFor, **kwargs) -> bool:
|
95
104
|
if not getattr(logger, _init_done, False): # init once, if necessary
|
96
105
|
setup_logger(logger, level=level)
|
97
106
|
setattr(logger, _init_done, True)
|
98
|
-
|
99
|
-
|
107
|
+
# restore the callback
|
108
|
+
logger.isEnabledFor = orig # type: ignore[method-assign] # ty: ignore[invalid-assignment]
|
109
|
+
return orig(*args, **kwargs) # ty: ignore[missing-argument]
|
100
110
|
|
101
111
|
# oh god.. otherwise might go into an inf loop
|
102
112
|
if not hasattr(logger, _init_done):
|
103
|
-
setattr(logger, _init_done, False)
|
113
|
+
setattr(logger, _init_done, False) # will setup on the first call
|
104
114
|
logger.isEnabledFor = isEnabledFor_lazyinit # type: ignore[method-assign]
|
105
115
|
return cast(LazyLogger, logger)
|
106
116
|
|
@@ -129,6 +139,7 @@ class CollapseDebugHandler(logging.StreamHandler):
|
|
129
139
|
Collapses subsequent debug log lines and redraws on the same line.
|
130
140
|
Hopefully this gives both a sense of progress and doesn't clutter the terminal as much?
|
131
141
|
'''
|
142
|
+
|
132
143
|
last = False
|
133
144
|
|
134
145
|
def emit(self, record: logging.LogRecord) -> None:
|
@@ -137,12 +148,13 @@ class CollapseDebugHandler(logging.StreamHandler):
|
|
137
148
|
cur = record.levelno == logging.DEBUG and '\n' not in msg
|
138
149
|
if cur:
|
139
150
|
if self.last:
|
140
|
-
self.stream.write('\033[K' + '\r')
|
151
|
+
self.stream.write('\033[K' + '\r') # clear line + return carriage
|
141
152
|
else:
|
142
153
|
if self.last:
|
143
|
-
self.stream.write('\n')
|
154
|
+
self.stream.write('\n') # clean up after the last debug line
|
144
155
|
self.last = cur
|
145
156
|
import os
|
157
|
+
|
146
158
|
columns, _ = os.get_terminal_size(0)
|
147
159
|
# ugh. the columns thing is meh. dunno I guess ultimately need curses for that
|
148
160
|
# TODO also would be cool to have a terminal post-processor? kinda like tail but aware of logging keywords (INFO/DEBUG/etc)
|
promnesia/misc/install_server.py
CHANGED
@@ -1,15 +1,12 @@
|
|
1
|
-
#!/usr/bin/env python3
|
2
1
|
from __future__ import annotations
|
3
2
|
|
4
3
|
import argparse
|
5
4
|
import os
|
5
|
+
import platform
|
6
6
|
import sys
|
7
7
|
import time
|
8
8
|
from pathlib import Path
|
9
|
-
import platform
|
10
|
-
import shutil
|
11
9
|
from subprocess import check_call, run
|
12
|
-
from typing import List
|
13
10
|
|
14
11
|
SYSTEM = platform.system()
|
15
12
|
UNSUPPORTED_SYSTEM = RuntimeError(f'Platform {SYSTEM} is not supported yet!')
|
@@ -54,51 +51,57 @@ LAUNCHD_TEMPLATE = '''
|
|
54
51
|
|
55
52
|
|
56
53
|
def systemd(*args: str | Path, method=check_call) -> None:
|
57
|
-
method([
|
58
|
-
'systemctl', '--no-pager', '--user', *args,
|
59
|
-
])
|
54
|
+
method(['systemctl', '--no-pager', '--user', *args])
|
60
55
|
|
61
56
|
|
62
|
-
def install_systemd(name: str, out: Path, launcher: str, largs:
|
57
|
+
def install_systemd(name: str, out: Path, launcher: str, largs: list[str]) -> None:
|
63
58
|
unit_name = name
|
64
59
|
|
65
60
|
import shlex
|
61
|
+
|
66
62
|
extra_args = ' '.join(shlex.quote(str(a)) for a in largs)
|
67
63
|
|
68
|
-
out.write_text(
|
69
|
-
|
70
|
-
|
71
|
-
|
64
|
+
out.write_text(
|
65
|
+
SYSTEMD_TEMPLATE.format(
|
66
|
+
launcher=launcher,
|
67
|
+
extra_args=extra_args,
|
68
|
+
)
|
69
|
+
)
|
72
70
|
|
73
71
|
try:
|
74
|
-
systemd('stop'
|
72
|
+
systemd('stop', unit_name, method=run) # ignore errors here if it wasn't running in the first place
|
75
73
|
systemd('daemon-reload')
|
76
74
|
systemd('enable', unit_name)
|
77
|
-
systemd('start'
|
75
|
+
systemd('start', unit_name)
|
78
76
|
systemd('status', unit_name)
|
79
77
|
except Exception as e:
|
80
|
-
print(
|
78
|
+
print(
|
79
|
+
f"Something has gone wrong... you might want to use 'journalctl --user -u {unit_name}' to investigate",
|
80
|
+
file=sys.stderr,
|
81
|
+
)
|
81
82
|
raise e
|
82
83
|
|
83
84
|
|
84
|
-
def install_launchd(name: str, out: Path, launcher: str, largs:
|
85
|
+
def install_launchd(name: str, out: Path, launcher: str, largs: list[str]) -> None:
|
85
86
|
service_name = name
|
86
87
|
arguments = '\n'.join(f'<string>{a}</string>' for a in [launcher, *largs])
|
87
|
-
out.write_text(
|
88
|
-
|
89
|
-
|
90
|
-
|
88
|
+
out.write_text(
|
89
|
+
LAUNCHD_TEMPLATE.format(
|
90
|
+
service_name=service_name,
|
91
|
+
arguments=arguments,
|
92
|
+
)
|
93
|
+
)
|
91
94
|
cmd = ['launchctl', 'load', '-w', str(out)]
|
92
95
|
print('Running: ' + ' '.join(cmd), file=sys.stderr)
|
93
96
|
check_call(cmd)
|
94
97
|
|
95
|
-
time.sleep(1)
|
98
|
+
time.sleep(1) # to give it some time? not sure if necessary
|
96
99
|
check_call(f'launchctl list | grep {name}', shell=True)
|
97
100
|
|
98
101
|
|
99
102
|
def install(args: argparse.Namespace) -> None:
|
100
103
|
name = args.name
|
101
|
-
# todo use
|
104
|
+
# todo use platformdirs for config dir detection
|
102
105
|
if SYSTEM == 'Linux':
|
103
106
|
# Check for existence of systemd
|
104
107
|
# https://www.freedesktop.org/software/systemd/man/sd_booted.html
|
@@ -108,7 +111,7 @@ def install(args: argparse.Namespace) -> None:
|
|
108
111
|
if Path(name).suffix != suf:
|
109
112
|
name = name + suf
|
110
113
|
out = Path(f'~/.config/systemd/user/{name}')
|
111
|
-
elif SYSTEM == 'Darwin':
|
114
|
+
elif SYSTEM == 'Darwin': # osx
|
112
115
|
out = Path(f'~/Library/LaunchAgents/{name}.plist')
|
113
116
|
else:
|
114
117
|
raise UNSUPPORTED_SYSTEM
|
@@ -116,22 +119,24 @@ def install(args: argparse.Namespace) -> None:
|
|
116
119
|
print(f"Writing launch script to {out}", file=sys.stderr)
|
117
120
|
|
118
121
|
# ugh. we want to know whether we're invoked 'properly' as an executable or ad-hoc via scripts/promnesia
|
122
|
+
extra_exe: list[str] = []
|
119
123
|
if os.environ.get('DIRTY_RUN') is not None:
|
120
124
|
launcher = str(root() / 'scripts/promnesia')
|
121
125
|
else:
|
122
|
-
|
123
|
-
|
126
|
+
launcher = sys.executable
|
127
|
+
extra_exe = ['-m', 'promnesia']
|
124
128
|
|
125
129
|
db = args.db
|
126
130
|
largs = [
|
131
|
+
*extra_exe,
|
127
132
|
'serve',
|
128
133
|
*([] if db is None else ['--db', str(db)]),
|
129
134
|
'--timezone', args.timezone,
|
130
135
|
'--host', args.host,
|
131
136
|
'--port', args.port,
|
132
|
-
]
|
137
|
+
] # fmt: skip
|
133
138
|
|
134
|
-
out.parent.mkdir(parents=True, exist_ok=True)
|
139
|
+
out.parent.mkdir(parents=True, exist_ok=True) # sometimes systemd dir doesn't exist
|
135
140
|
if SYSTEM == 'Linux':
|
136
141
|
install_systemd(name=name, out=out, launcher=launcher, largs=largs)
|
137
142
|
elif SYSTEM == 'Darwin':
|