notekey 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: notekey
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: CLI utilities for Obsidian vault notes: initialize note bases, search markdown files, and read note content.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
+ Requires-Dist: black>=26.3.1
7
8
  Requires-Dist: python-frontmatter>=1.1.0
8
9
  Requires-Dist: readtime>=3.0.0
9
10
 
@@ -38,7 +39,9 @@ notekey search [path] [--tags TAGS] [--filename NAME] [--content TEXT]
38
39
  notekey read FILENAME
39
40
  ```
40
41
 
41
- If `path` is omitted, `notekey` uses the `OBSIDIAN_VAULT` environment variable. If that variable is not set, it uses the current directory.
42
+ For `init`, omitting `path` uses the current directory.
43
+
44
+ For `search` and `read`, omitting `path` uses the `OBSIDIAN_VAULT` environment variable. If that variable is not set, it uses the current directory.
42
45
 
43
46
  ```bash
44
47
  export OBSIDIAN_VAULT="/path/to/your/vault"
@@ -46,6 +49,13 @@ export OBSIDIAN_VAULT="/path/to/your/vault"
46
49
 
47
50
  ### Initialize a note folder
48
51
 
52
+ ```bash
53
+ cd /path/to/vault/Projects/MyProject
54
+ notekey init
55
+ ```
56
+
57
+ Or pass the target folder explicitly:
58
+
49
59
  ```bash
50
60
  notekey init /path/to/vault/Projects/MyProject
51
61
  ```
@@ -29,7 +29,9 @@ notekey search [path] [--tags TAGS] [--filename NAME] [--content TEXT]
29
29
  notekey read FILENAME
30
30
  ```
31
31
 
32
- If `path` is omitted, `notekey` uses the `OBSIDIAN_VAULT` environment variable. If that variable is not set, it uses the current directory.
32
+ For `init`, omitting `path` uses the current directory.
33
+
34
+ For `search` and `read`, omitting `path` uses the `OBSIDIAN_VAULT` environment variable. If that variable is not set, it uses the current directory.
33
35
 
34
36
  ```bash
35
37
  export OBSIDIAN_VAULT="/path/to/your/vault"
@@ -37,6 +39,13 @@ export OBSIDIAN_VAULT="/path/to/your/vault"
37
39
 
38
40
  ### Initialize a note folder
39
41
 
42
+ ```bash
43
+ cd /path/to/vault/Projects/MyProject
44
+ notekey init
45
+ ```
46
+
47
+ Or pass the target folder explicitly:
48
+
40
49
  ```bash
41
50
  notekey init /path/to/vault/Projects/MyProject
42
51
  ```
@@ -45,12 +45,16 @@ def _build_filters(name: str, tags: str | None = None) -> str:
45
45
  return ", ".join(f'"{tag}"' for tag in filters)
46
46
 
47
47
 
48
- def _create_base(target: Path, name: str, tags: str | None = None, force: bool = False) -> Path:
48
+ def _create_base(
49
+ target: Path, name: str, tags: str | None = None, force: bool = False
50
+ ) -> Path:
49
51
  vault_root = _find_vault_root(target)
50
52
  folder = target.relative_to(vault_root).as_posix()
51
53
  filters = _build_filters(name, tags)
52
54
  base_path = target / f"{name}.base"
53
- content = BASE_FILTER_TEMPLATE.format(folder=folder, name=name, filters=filters).lstrip()
55
+ content = BASE_FILTER_TEMPLATE.format(
56
+ folder=folder, name=name, filters=filters
57
+ ).lstrip()
54
58
 
55
59
  if base_path.exists() and not force:
56
60
  raise FileExistsError(f"Base file already exists: {base_path}")
@@ -124,9 +128,7 @@ def _search_files(
124
128
  filename_exact, filename_val = (
125
129
  _parse_filter(filename) if filename else (False, None)
126
130
  )
127
- content_exact, content_val = (
128
- _parse_filter(content) if content else (False, None)
129
- )
131
+ content_exact, content_val = _parse_filter(content) if content else (False, None)
130
132
  parsed_tags: list[tuple[bool, str]] = (
131
133
  [_parse_filter(t) for t in tags] if tags else []
132
134
  )
@@ -199,11 +201,7 @@ def _display_search_results(results: list[Markdown], vault_root: Path) -> None:
199
201
 
200
202
  for md in results:
201
203
  rel = _relative_to_vault(md, vault_root)
202
- tags_str = (
203
- ", ".join(md.tags[:5])
204
- + ("..." if len(md.tags) > 5 else "")
205
- or "—"
206
- )
204
+ tags_str = ", ".join(md.tags[:5]) + ("..." if len(md.tags) > 5 else "") or "—"
207
205
  print(
208
206
  f" {rel:<{name_width}} "
209
207
  f"tags: [{tags_str}] "
@@ -236,7 +234,7 @@ def build_parser() -> argparse.ArgumentParser:
236
234
  "path",
237
235
  nargs="?",
238
236
  default=None,
239
- help="Target folder (defaults to $OBSIDIAN_VAULT or current directory)",
237
+ help="Target folder (defaults to current directory)",
240
238
  )
241
239
  init_parser.add_argument(
242
240
  "-t",
@@ -250,7 +248,9 @@ def build_parser() -> argparse.ArgumentParser:
250
248
  )
251
249
 
252
250
  # -- search -------------------------------------------------------------
253
- search_parser = subparsers.add_parser("search", help="Search markdown files in the vault")
251
+ search_parser = subparsers.add_parser(
252
+ "search", help="Search markdown files in the vault"
253
+ )
254
254
  search_parser.add_argument(
255
255
  "path",
256
256
  nargs="?",
@@ -274,7 +274,9 @@ def build_parser() -> argparse.ArgumentParser:
274
274
  )
275
275
 
276
276
  # -- read ---------------------------------------------------------------
277
- read_parser = subparsers.add_parser("read", help="Read a single markdown file by name or path")
277
+ read_parser = subparsers.add_parser(
278
+ "read", help="Read a single markdown file by name or path"
279
+ )
278
280
  read_parser.add_argument(
279
281
  "filename",
280
282
  help="Filename or path to match — first match wins (prefix = for exact)",
@@ -288,7 +290,7 @@ def main() -> None:
288
290
  args = parser.parse_args()
289
291
 
290
292
  if args.command == "init":
291
- target = _get_folder(_resolve_path(args.path))
293
+ target = _get_folder(args.path)
292
294
  current_location = str(target)
293
295
  folder_name = target.name
294
296
  base_path = _create_base(target, folder_name, tags=args.tags, force=args.force)
@@ -5,7 +5,12 @@ from typing import Any
5
5
  import frontmatter
6
6
  import readtime
7
7
 
8
- from notekey.utils import extract_inline_tags, extract_markdown_links, extract_wiki_links, normalize_size
8
+ from notekey.utils import (
9
+ extract_inline_tags,
10
+ extract_markdown_links,
11
+ extract_wiki_links,
12
+ normalize_size,
13
+ )
9
14
 
10
15
 
11
16
  class Markdown:
@@ -37,7 +42,8 @@ class Markdown:
37
42
  self.name = self._path.stem
38
43
  self._size_bytes: float = float(stat.st_size)
39
44
  self.size = self._size_bytes
40
- self.created_at = datetime.datetime.fromtimestamp(stat.st_birthtime)
45
+ created_ts = getattr(stat, "st_birthtime", stat.st_ctime)
46
+ self.created_at = datetime.datetime.fromtimestamp(created_ts)
41
47
  self.updated_at = datetime.datetime.fromtimestamp(stat.st_mtime)
42
48
 
43
49
  # Full raw content (frontmatter + body) — needed for link extraction.
@@ -83,6 +89,4 @@ class Markdown:
83
89
  return normalize_size(self._size_bytes)
84
90
 
85
91
  def __repr__(self) -> str:
86
- return (
87
- f"<Markdown filename='{self.name}' size='{self._normalize_size()}'>"
88
- )
92
+ return f"<Markdown filename='{self.name}' size='{self._normalize_size()}'>"
@@ -1,4 +1,4 @@
1
- BASE_FILTER_TEMPLATE="""
1
+ BASE_FILTER_TEMPLATE = """
2
2
  views:
3
3
  - type: table
4
4
  name: Notes
@@ -22,7 +22,7 @@ views:
22
22
  direction: ASC
23
23
  """
24
24
 
25
- MOC_TEMPLATE="""
25
+ MOC_TEMPLATE = """
26
26
  ### Notes
27
27
  ![[{name}.base]]
28
28
  """
@@ -20,9 +20,7 @@ def extract_inline_tags(text: str) -> set[str]:
20
20
  # Match #identifier (with optional /path). The tag body cannot
21
21
  # contain a trailing period so that ``#tag.`` correctly yields ``tag``.
22
22
  pattern = re.compile(
23
- r"(?:^|(?<=\s))"
24
- r"#([a-zA-Z_][a-zA-Z0-9_/-]*)"
25
- r"(?=\s|$|[.,;:!?)])"
23
+ r"(?:^|(?<=\s))" r"#([a-zA-Z_][a-zA-Z0-9_/-]*)" r"(?=\s|$|[.,;:!?)])"
26
24
  )
27
25
 
28
26
  for match in pattern.finditer(body):
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: notekey
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: CLI utilities for Obsidian vault notes: initialize note bases, search markdown files, and read note content.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
+ Requires-Dist: black>=26.3.1
7
8
  Requires-Dist: python-frontmatter>=1.1.0
8
9
  Requires-Dist: readtime>=3.0.0
9
10
 
@@ -38,7 +39,9 @@ notekey search [path] [--tags TAGS] [--filename NAME] [--content TEXT]
38
39
  notekey read FILENAME
39
40
  ```
40
41
 
41
- If `path` is omitted, `notekey` uses the `OBSIDIAN_VAULT` environment variable. If that variable is not set, it uses the current directory.
42
+ For `init`, omitting `path` uses the current directory.
43
+
44
+ For `search` and `read`, omitting `path` uses the `OBSIDIAN_VAULT` environment variable. If that variable is not set, it uses the current directory.
42
45
 
43
46
  ```bash
44
47
  export OBSIDIAN_VAULT="/path/to/your/vault"
@@ -46,6 +49,13 @@ export OBSIDIAN_VAULT="/path/to/your/vault"
46
49
 
47
50
  ### Initialize a note folder
48
51
 
52
+ ```bash
53
+ cd /path/to/vault/Projects/MyProject
54
+ notekey init
55
+ ```
56
+
57
+ Or pass the target folder explicitly:
58
+
49
59
  ```bash
50
60
  notekey init /path/to/vault/Projects/MyProject
51
61
  ```
@@ -1,2 +1,3 @@
1
+ black>=26.3.1
1
2
  python-frontmatter>=1.1.0
2
3
  readtime>=3.0.0
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "notekey"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "CLI utilities for Obsidian vault notes: initialize note bases, search markdown files, and read note content."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  dependencies = [
12
+ "black>=26.3.1",
12
13
  "python-frontmatter>=1.1.0",
13
14
  "readtime>=3.0.0",
14
15
  ]
@@ -25,7 +25,6 @@ from notekey.main import (
25
25
  main,
26
26
  )
27
27
 
28
-
29
28
  # ---------------------------------------------------------------------------
30
29
  # Fixtures: a mini vault with several .md files
31
30
  # ---------------------------------------------------------------------------
@@ -39,8 +38,7 @@ def vault(tmp_path: Path) -> Path:
39
38
  (root / ".obsidian").mkdir()
40
39
 
41
40
  # File A: has tags "python" and "web", mentions "flask"
42
- (root / "flask-app.md").write_text(
43
- """\
41
+ (root / "flask-app.md").write_text("""\
44
42
  ---
45
43
  title: Flask App
46
44
  tags: [python, web]
@@ -48,12 +46,10 @@ tags: [python, web]
48
46
  # Flask App
49
47
 
50
48
  Building a web app with #flask.
51
- """
52
- )
49
+ """)
53
50
 
54
51
  # File B: has tags "python" and "data", mentions "pandas"
55
- (root / "pandas-guide.md").write_text(
56
- """\
52
+ (root / "pandas-guide.md").write_text("""\
57
53
  ---
58
54
  title: Pandas Guide
59
55
  tags: [python, data]
@@ -61,12 +57,10 @@ tags: [python, data]
61
57
  # Pandas Guide
62
58
 
63
59
  Working with #pandas and dataframes.
64
- """
65
- )
60
+ """)
66
61
 
67
62
  # File C: has tags "javascript" and "web", mentions "react"
68
- (root / "react-setup.md").write_text(
69
- """\
63
+ (root / "react-setup.md").write_text("""\
70
64
  ---
71
65
  title: React Setup
72
66
  tags: [javascript, web]
@@ -74,30 +68,25 @@ tags: [javascript, web]
74
68
  # React Setup
75
69
 
76
70
  Getting started with #react.
77
- """
78
- )
71
+ """)
79
72
 
80
73
  # File D: no frontmatter tags, but has inline #tag, no frontmatter
81
- (root / "scratchpad.md").write_text(
82
- """\
74
+ (root / "scratchpad.md").write_text("""\
83
75
  # Scratchpad
84
76
 
85
77
  Random thoughts and #ideas.
86
- """
87
- )
78
+ """)
88
79
 
89
80
  # File E: in subdirectory
90
81
  sub = root / "deep"
91
82
  sub.mkdir()
92
- (sub / "hidden-note.md").write_text(
93
- """\
83
+ (sub / "hidden-note.md").write_text("""\
94
84
  ---
95
85
  title: Hidden
96
86
  tags: [python, devops]
97
87
  ---
98
88
  Hidden note about #docker and #kubernetes.
99
- """
100
- )
89
+ """)
101
90
 
102
91
  return root
103
92
 
@@ -115,7 +104,9 @@ class TestPathAndVaultHelpers:
115
104
  monkeypatch.setenv("OBSIDIAN_VAULT", "/env/vault")
116
105
  assert _resolve_path() == "/env/vault"
117
106
 
118
- def test_resolve_path_default_current_dir(self, monkeypatch: pytest.MonkeyPatch) -> None:
107
+ def test_resolve_path_default_current_dir(
108
+ self, monkeypatch: pytest.MonkeyPatch
109
+ ) -> None:
119
110
  monkeypatch.delenv("OBSIDIAN_VAULT", raising=False)
120
111
  assert _resolve_path() == "."
121
112
 
@@ -206,7 +197,9 @@ class TestFilenameAndRelativePathHelpers:
206
197
  "deep/hidden-note",
207
198
  )
208
199
 
209
- def test_filename_candidates_outside_vault(self, tmp_path: Path, vault: Path) -> None:
200
+ def test_filename_candidates_outside_vault(
201
+ self, tmp_path: Path, vault: Path
202
+ ) -> None:
210
203
  note = tmp_path / "outside.md"
211
204
  note.write_text("outside")
212
205
  assert _filename_candidates(note, vault) == ("outside", "outside.md", "outside")
@@ -252,7 +245,13 @@ class TestSearchFiles:
252
245
  results = _search_files(vault)
253
246
  # 5 .md files across the vault (4 in root + 1 in deep/)
254
247
  names = {md.name for md in results}
255
- assert names == {"flask-app", "pandas-guide", "react-setup", "scratchpad", "hidden-note"}
248
+ assert names == {
249
+ "flask-app",
250
+ "pandas-guide",
251
+ "react-setup",
252
+ "scratchpad",
253
+ "hidden-note",
254
+ }
256
255
  assert len(results) == 5
257
256
 
258
257
  def test_filename_filter(self, vault: Path) -> None:
@@ -489,9 +488,7 @@ class TestDisplayFile:
489
488
  assert "Building a web app with #flask." in captured.out
490
489
  assert "---" in captured.out
491
490
 
492
- def test_no_extra_output(
493
- self, vault: Path, capsys: pytest.CaptureFixture
494
- ) -> None:
491
+ def test_no_extra_output(self, vault: Path, capsys: pytest.CaptureFixture) -> None:
495
492
  from notekey.markdown import Markdown
496
493
 
497
494
  raw = (vault / "flask-app.md").read_text()
@@ -509,7 +506,10 @@ class TestDisplayFile:
509
506
 
510
507
  class TestMainCommandBranches:
511
508
  def test_main_init(
512
- self, vault: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
509
+ self,
510
+ vault: Path,
511
+ monkeypatch: pytest.MonkeyPatch,
512
+ capsys: pytest.CaptureFixture,
513
513
  ) -> None:
514
514
  target = vault / "new-project"
515
515
  target.mkdir()
@@ -522,8 +522,33 @@ class TestMainCommandBranches:
522
522
  assert (target / "new-project.base").exists()
523
523
  assert (target / "new-project.md").exists()
524
524
 
525
+ def test_main_init_without_path_uses_current_directory(
526
+ self,
527
+ vault: Path,
528
+ monkeypatch: pytest.MonkeyPatch,
529
+ capsys: pytest.CaptureFixture,
530
+ ) -> None:
531
+ target = vault / "01-PROJECTS" / "OpenClaw"
532
+ target.mkdir(parents=True)
533
+ monkeypatch.setenv("OBSIDIAN_VAULT", str(vault))
534
+ monkeypatch.chdir(target)
535
+ monkeypatch.setattr(sys, "argv", ["notekey", "init", "--force"])
536
+
537
+ main()
538
+
539
+ captured = capsys.readouterr()
540
+ assert f"Created base: {target / 'OpenClaw.base'}" in captured.out
541
+ assert f"Created markdown: {target / 'OpenClaw.md'}" in captured.out
542
+ assert (target / "OpenClaw.base").exists()
543
+ assert (target / "OpenClaw.md").exists()
544
+ assert not (vault / "vault.base").exists()
545
+ assert not (vault / "vault.md").exists()
546
+
525
547
  def test_main_search_uses_obsidian_vault_default(
526
- self, vault: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
548
+ self,
549
+ vault: Path,
550
+ monkeypatch: pytest.MonkeyPatch,
551
+ capsys: pytest.CaptureFixture,
527
552
  ) -> None:
528
553
  monkeypatch.setenv("OBSIDIAN_VAULT", str(vault))
529
554
  monkeypatch.setattr(sys, "argv", ["notekey", "search", "--tags", "python"])
@@ -535,7 +560,10 @@ class TestMainCommandBranches:
535
560
  assert "flask-app.md" in captured.out
536
561
 
537
562
  def test_main_read_no_results(
538
- self, vault: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
563
+ self,
564
+ vault: Path,
565
+ monkeypatch: pytest.MonkeyPatch,
566
+ capsys: pytest.CaptureFixture,
539
567
  ) -> None:
540
568
  monkeypatch.setenv("OBSIDIAN_VAULT", str(vault))
541
569
  monkeypatch.setattr(sys, "argv", ["notekey", "read", "missing-note"])
@@ -546,7 +574,10 @@ class TestMainCommandBranches:
546
574
  assert "No file found matching: missing-note" in captured.out
547
575
 
548
576
  def test_main_read_single_result(
549
- self, vault: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
577
+ self,
578
+ vault: Path,
579
+ monkeypatch: pytest.MonkeyPatch,
580
+ capsys: pytest.CaptureFixture,
550
581
  ) -> None:
551
582
  monkeypatch.setenv("OBSIDIAN_VAULT", str(vault))
552
583
  monkeypatch.setattr(sys, "argv", ["notekey", "read", "=flask-app"])
@@ -557,7 +588,10 @@ class TestMainCommandBranches:
557
588
  assert captured.out == (vault / "flask-app.md").read_text()
558
589
 
559
590
  def test_main_read_multiple_results(
560
- self, vault: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
591
+ self,
592
+ vault: Path,
593
+ monkeypatch: pytest.MonkeyPatch,
594
+ capsys: pytest.CaptureFixture,
561
595
  ) -> None:
562
596
  monkeypatch.setenv("OBSIDIAN_VAULT", str(vault))
563
597
  monkeypatch.setattr(sys, "argv", ["notekey", "read", "a"])
@@ -568,12 +602,17 @@ class TestMainCommandBranches:
568
602
  assert "Multiple matches" in captured.out
569
603
 
570
604
  def test_script_entrypoint(
571
- self, vault: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
605
+ self,
606
+ vault: Path,
607
+ monkeypatch: pytest.MonkeyPatch,
608
+ capsys: pytest.CaptureFixture,
572
609
  ) -> None:
573
610
  monkeypatch.setenv("OBSIDIAN_VAULT", str(vault))
574
611
  monkeypatch.setattr(sys, "argv", ["notekey", "read", "=flask-app"])
575
612
 
576
- runpy.run_path(str(Path(__file__).parents[1] / "notekey" / "main.py"), run_name="__main__")
613
+ runpy.run_path(
614
+ str(Path(__file__).parents[1] / "notekey" / "main.py"), run_name="__main__"
615
+ )
577
616
 
578
617
  captured = capsys.readouterr()
579
618
  assert captured.out == (vault / "flask-app.md").read_text()
@@ -600,7 +639,16 @@ class TestBuildParser:
600
639
  def test_search_accepts_all_flags(self) -> None:
601
640
  parser = build_parser()
602
641
  args = parser.parse_args(
603
- ["search", "/some/path", "--tags", "a,b", "--filename", "test", "--content", "hello"]
642
+ [
643
+ "search",
644
+ "/some/path",
645
+ "--tags",
646
+ "a,b",
647
+ "--filename",
648
+ "test",
649
+ "--content",
650
+ "hello",
651
+ ]
604
652
  )
605
653
  assert args.path == "/some/path"
606
654
  assert args.tags == "a,b"
@@ -6,7 +6,12 @@ from pathlib import Path
6
6
  import pytest
7
7
 
8
8
  from notekey.markdown import Markdown
9
- from notekey.utils import extract_inline_tags, extract_markdown_links, extract_wiki_links, normalize_size
9
+ from notekey.utils import (
10
+ extract_inline_tags,
11
+ extract_markdown_links,
12
+ extract_wiki_links,
13
+ normalize_size,
14
+ )
10
15
 
11
16
  # ---------------------------------------------------------------------------
12
17
  # Fixtures
@@ -236,9 +241,7 @@ class TestMarkdownInit:
236
241
 
237
242
 
238
243
  class TestMarkdownTags:
239
- def test_tags_combined_from_frontmatter_and_inline(
240
- self, sample_md: Path
241
- ) -> None:
244
+ def test_tags_combined_from_frontmatter_and_inline(self, sample_md: Path) -> None:
242
245
  md = Markdown(sample_md)
243
246
  # frontmatter: project, documentation, obsidian
244
247
  # inline: python, testing, project/active, topic/obsidian/plugins
@@ -309,9 +312,7 @@ class TestMarkdownMetadata:
309
312
  assert md.metadata["created"] == datetime.date(2024, 1, 15)
310
313
  assert md.metadata["status"] == "active"
311
314
 
312
- def test_no_frontmatter_metadata(
313
- self, sample_md_no_frontmatter: Path
314
- ) -> None:
315
+ def test_no_frontmatter_metadata(self, sample_md_no_frontmatter: Path) -> None:
315
316
  md = Markdown(sample_md_no_frontmatter)
316
317
  assert md.metadata == {}
317
318
 
File without changes
File without changes