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.
Files changed (79) hide show
  1. promnesia/__init__.py +18 -4
  2. promnesia/__main__.py +104 -78
  3. promnesia/cannon.py +108 -107
  4. promnesia/common.py +107 -88
  5. promnesia/compare.py +33 -30
  6. promnesia/compat.py +10 -10
  7. promnesia/config.py +37 -34
  8. promnesia/database/common.py +4 -3
  9. promnesia/database/dump.py +13 -13
  10. promnesia/database/load.py +7 -7
  11. promnesia/extract.py +19 -17
  12. promnesia/logging.py +27 -15
  13. promnesia/misc/install_server.py +32 -27
  14. promnesia/server.py +106 -79
  15. promnesia/sources/auto.py +104 -77
  16. promnesia/sources/auto_logseq.py +6 -5
  17. promnesia/sources/auto_obsidian.py +2 -2
  18. promnesia/sources/browser.py +20 -10
  19. promnesia/sources/browser_legacy.py +65 -50
  20. promnesia/sources/demo.py +7 -8
  21. promnesia/sources/fbmessenger.py +3 -3
  22. promnesia/sources/filetypes.py +22 -16
  23. promnesia/sources/github.py +9 -8
  24. promnesia/sources/guess.py +6 -2
  25. promnesia/sources/hackernews.py +7 -9
  26. promnesia/sources/hpi.py +5 -3
  27. promnesia/sources/html.py +11 -7
  28. promnesia/sources/hypothesis.py +3 -2
  29. promnesia/sources/instapaper.py +3 -2
  30. promnesia/sources/markdown.py +22 -12
  31. promnesia/sources/org.py +36 -17
  32. promnesia/sources/plaintext.py +41 -39
  33. promnesia/sources/pocket.py +5 -3
  34. promnesia/sources/reddit.py +24 -26
  35. promnesia/sources/roamresearch.py +5 -2
  36. promnesia/sources/rss.py +6 -8
  37. promnesia/sources/shellcmd.py +21 -11
  38. promnesia/sources/signal.py +27 -26
  39. promnesia/sources/smscalls.py +2 -3
  40. promnesia/sources/stackexchange.py +5 -4
  41. promnesia/sources/takeout.py +37 -34
  42. promnesia/sources/takeout_legacy.py +29 -19
  43. promnesia/sources/telegram.py +18 -12
  44. promnesia/sources/telegram_legacy.py +22 -11
  45. promnesia/sources/twitter.py +7 -6
  46. promnesia/sources/vcs.py +11 -6
  47. promnesia/sources/viber.py +11 -10
  48. promnesia/sources/website.py +8 -7
  49. promnesia/sources/zulip.py +3 -2
  50. promnesia/sqlite.py +13 -7
  51. promnesia/tests/common.py +10 -5
  52. promnesia/tests/server_helper.py +13 -10
  53. promnesia/tests/sources/test_auto.py +2 -3
  54. promnesia/tests/sources/test_filetypes.py +11 -8
  55. promnesia/tests/sources/test_hypothesis.py +10 -6
  56. promnesia/tests/sources/test_org.py +9 -5
  57. promnesia/tests/sources/test_plaintext.py +9 -8
  58. promnesia/tests/sources/test_shellcmd.py +13 -13
  59. promnesia/tests/sources/test_takeout.py +3 -5
  60. promnesia/tests/test_cannon.py +256 -239
  61. promnesia/tests/test_cli.py +12 -8
  62. promnesia/tests/test_compare.py +17 -13
  63. promnesia/tests/test_config.py +7 -8
  64. promnesia/tests/test_db_dump.py +15 -15
  65. promnesia/tests/test_extract.py +17 -10
  66. promnesia/tests/test_indexer.py +24 -18
  67. promnesia/tests/test_server.py +12 -13
  68. promnesia/tests/test_traverse.py +0 -2
  69. promnesia/tests/utils.py +3 -7
  70. promnesia-1.4.20250909.dist-info/METADATA +66 -0
  71. promnesia-1.4.20250909.dist-info/RECORD +80 -0
  72. {promnesia-1.2.20240810.dist-info → promnesia-1.4.20250909.dist-info}/WHEEL +1 -2
  73. promnesia/kjson.py +0 -121
  74. promnesia/sources/__init__.pyi +0 -0
  75. promnesia-1.2.20240810.dist-info/METADATA +0 -54
  76. promnesia-1.2.20240810.dist-info/RECORD +0 -83
  77. promnesia-1.2.20240810.dist-info/top_level.txt +0 -1
  78. {promnesia-1.2.20240810.dist-info → promnesia-1.4.20250909.dist-info}/entry_points.txt +0 -0
  79. {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
- ## we used to have compat fixes here for these for python3.7
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
- # can remove after python3.9
9
- def removeprefix(text: str, prefix: str) -> str:
10
- if text.startswith(prefix):
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 pathlib import Path
2
- import os
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, default_output_dir, default_cache_dir
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 = Union[Source, ModuleName, ModuleType]
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: List[ConfigSource] = []
25
+ SOURCES: list[ConfigSource] = [] # noqa: RUF012
28
26
 
29
27
  # if not specified, uses user data dir
30
- OUTPUT_DIR: Optional[PathIsh] = None
28
+ OUTPUT_DIR: PathIsh | None = None
31
29
 
32
- CACHE_DIR: Optional[PathIsh] = ''
33
- FILTERS: List[str] = []
30
+ CACHE_DIR: PathIsh | None = ''
31
+ FILTERS: list[str] = [] # noqa: RUF012
34
32
 
35
- HOOK: Optional[HookT] = None
33
+ HOOK: HookT | None = None
36
34
 
37
35
  #
38
36
  # NOTE: INDEXERS is deprecated, use SOURCES instead
39
- INDEXERS: List[ConfigSource] = []
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("Please specify SOURCES in the config! See https://github.com/karlicoss/promnesia#setup for more information")
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) -> Optional[Path]:
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: Optional[Path]
73
+ cpath: Path | None
76
74
  if cd is None:
77
- cpath = None # means 'disabled' in cachew
78
- elif cd == '': # meh.. but need to make it None friendly..
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) -> Optional[HookT]:
97
+ def hook(self) -> HookT | None:
100
98
  return self.HOOK
101
99
 
102
- instance: Optional[Config] = None
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); assert spec is not None
130
- mod = importlib.util.module_from_spec(spec); assert mod is not None
131
- loader = spec.loader; assert loader is not None
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() -> Optional[int]:
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: # any other value means 'use all
156
+ except ValueError: # any other value means 'use all
154
157
  return 0
155
158
 
156
159
 
157
- def extra_fd_args() -> List[str]:
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() # eh, hopefully splitting that way is ok...
166
+ extra = v.split() # eh, hopefully splitting that way is ok...
164
167
  return extra
@@ -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) -> Tuple:
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
@@ -1,9 +1,10 @@
1
- from pathlib import Path
1
+ from __future__ import annotations
2
+
2
3
  import sqlite3
3
- from typing import Dict, Iterable, List, Optional, Set
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 get_columns, db_visit_to_row
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 = Dict[Optional[SourceName], int]
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: Optional[Path] = None, # only used in tests
62
- ) -> List[Exception]:
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 {src: cnt for (src, cnt) in conn.execute(query).all()}
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: Set[str] = set()
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 = set(v.src or '' for v in chunk)
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: List[Exception] = []
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
@@ -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) -> List[DbVisit]:
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 functools import lru_cache
1
+ from __future__ import annotations
2
+
2
3
  import re
3
- import traceback
4
- from typing import Set, Iterable, Sequence, Union
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
- logger,
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
- Results, Extractor,
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) #meh, not sure what would happen under tests?
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(): # meeeh...
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: Set[Visit] = set()
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: # no need to emit duplicates
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: Union[str, Filter]) -> Filter:
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: # must be predicate
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 typing import Callable
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("For example, this should be logged as error. But it's not even formatted properly, doesn't have logger name or level")
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("OK, this is better. But the default format kinda sucks, I prefer having timestamps and the file/line number")
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 = Optional[Union[Level, str]]
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: # all set, nothing to do
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
- logger.isEnabledFor = orig # restore the callback
99
- return orig(*args, **kwargs)
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) # will setup on the first call
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') # clear line + return carriage
151
+ self.stream.write('\033[K' + '\r') # clear line + return carriage
141
152
  else:
142
153
  if self.last:
143
- self.stream.write('\n') # clean up after the last debug line
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)
@@ -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: List[str]) -> None:
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(SYSTEMD_TEMPLATE.format(
69
- launcher=launcher,
70
- extra_args=extra_args,
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' , unit_name, method=run) # ignore errors here if it wasn't running in the first place
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' , unit_name)
75
+ systemd('start', unit_name)
78
76
  systemd('status', unit_name)
79
77
  except Exception as e:
80
- print(f"Something has gone wrong... you might want to use 'journalctl --user -u {unit_name}' to investigate", file=sys.stderr)
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: List[str]) -> None:
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(LAUNCHD_TEMPLATE.format(
88
- service_name=service_name,
89
- arguments=arguments,
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) # to give it some time? not sure if necessary
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 appdirs for config dir detection
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': # osx
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
- exe = shutil.which('promnesia'); assert exe is not None
123
- launcher = exe # older systemd wants absolute paths..
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) # sometimes systemd dir doesn't exist
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':