notes-watcher 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Britt Crawford
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.4
2
+ Name: notes-watcher
3
+ Version: 0.1.0
4
+ Summary: A daemon that detects @ mentions in Obsidian notes, dispatches instructions to AI agents, and writes results back inline.
5
+ Author: Britt Crawford
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/britt/obsidian-notes-watcher
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: watchdog>=3.0
18
+ Requires-Dist: pyyaml>=6.0
19
+ Requires-Dist: click>=8.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0; extra == "dev"
22
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # Note Watcher
26
+
27
+ A tool that detects `@` mentions in Obsidian markdown notes stored in Git and dispatches instructions to configured agents — like [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — that can read, modify, and reorganize your notes directly.
28
+
29
+ Write `@agent_name do something` in any note, and Note Watcher dispatches the instruction to the named agent. The agent can edit files, create new notes, restructure content, or make any other changes to your vault. The original instruction is then replaced with a completion marker (an HTML comment, invisible in rendered markdown) so it is never reprocessed:
30
+
31
+ ```markdown
32
+ <!-- @done agent_name: do something
33
+ Agent response summary goes here.
34
+ /@done -->
35
+ ```
36
+
37
+ The real work happens in the commit: the agent's changes to your vault are committed back to Git. The completion comment is just a record that the instruction was processed.
38
+
39
+ ## Modes of Operation
40
+
41
+ | Mode | Use case |
42
+ |------|----------|
43
+ | **Daemon** | Real-time file watching on macOS via a LaunchAgent |
44
+ | **GitHub Action** | One-shot batch processing on every push that changes `.md` files |
45
+
46
+ ## Requirements
47
+
48
+ - Python 3.10+
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ pip install notes-watcher
54
+ ```
55
+
56
+ For development:
57
+
58
+ ```bash
59
+ pip install -e ".[dev]"
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ Copy the example config and edit it:
65
+
66
+ ```bash
67
+ mkdir -p ~/.config/note-watcher
68
+ cp config.example.yml ~/.config/note-watcher/config.yml
69
+ ```
70
+
71
+ The default config location is `~/.config/note-watcher/config.yml`. You can override it with `--config`:
72
+
73
+ ```bash
74
+ note-watcher watch --config /path/to/config.yml
75
+ ```
76
+
77
+ ### Config reference
78
+
79
+ ```yaml
80
+ # Path to your Obsidian vault
81
+ vault: ~/Obsidian/MyVault
82
+
83
+ # Seconds to wait before processing after a file change
84
+ debounce_seconds: 1.0
85
+
86
+ # File patterns to ignore (glob syntax)
87
+ ignore_patterns:
88
+ - "*.excalidraw.md"
89
+ - ".trash/**"
90
+
91
+ # Agent definitions
92
+ agents:
93
+ summarizer:
94
+ type: echo # Returns instruction text unchanged
95
+ uppercase:
96
+ type: uppercase # Returns instruction text in uppercase
97
+ word_count:
98
+ type: command
99
+ command: "wc -w" # Runs a shell command, passes instruction via stdin
100
+ ```
101
+
102
+ ### Agent types
103
+
104
+ | Type | Behavior |
105
+ |------|----------|
106
+ | `echo` | Returns the instruction text unchanged |
107
+ | `uppercase` | Returns the instruction text in uppercase |
108
+ | `command` | Runs a shell command with instruction text on stdin, returns stdout |
109
+
110
+ ### Example: Using Claude Code as an agent
111
+
112
+ Configure a `command` agent that dispatches instructions to [Claude Code](https://docs.anthropic.com/en/docs/claude-code):
113
+
114
+ ```yaml
115
+ agents:
116
+ claude:
117
+ type: command
118
+ command: "claude -p" # Dispatches instruction to Claude Code CLI
119
+ ```
120
+
121
+ Claude Code runs with full access to your vault, so it can edit notes, create new files, and reorganize content — not just respond in a comment. Write `@claude` instructions in your notes:
122
+
123
+ ```markdown
124
+ @claude Summarize the key points of this meeting and add action items to my Tasks note
125
+ ```
126
+
127
+ ## Daemon Mode
128
+
129
+ Daemon mode continuously watches your Obsidian vault for changes and processes `@` mentions in real time.
130
+
131
+ ### Running manually
132
+
133
+ ```bash
134
+ # Watch the vault specified in your config
135
+ note-watcher watch
136
+
137
+ # Override the vault path
138
+ note-watcher watch --vault ~/Obsidian/MyVault
139
+
140
+ # Enable verbose logging
141
+ note-watcher -v watch --vault ~/Obsidian/MyVault
142
+ ```
143
+
144
+ Stop the daemon with `Ctrl+C` (`SIGINT`) or `SIGTERM`.
145
+
146
+ ### Installing as a macOS LaunchAgent
147
+
148
+ The included install script sets up Note Watcher as a LaunchAgent that starts on login and restarts on crash:
149
+
150
+ ```bash
151
+ ./scripts/install.sh
152
+ ```
153
+
154
+ The script is idempotent and safe to run multiple times. It will:
155
+
156
+ 1. Detect the `note-watcher` executable on your system
157
+ 2. Generate a LaunchAgent plist from the included template
158
+ 3. Install it to `~/Library/LaunchAgents/`
159
+ 4. Start the daemon
160
+
161
+ Logs are written to `~/Library/Logs/note-watcher/`.
162
+
163
+ ### Uninstalling the LaunchAgent
164
+
165
+ ```bash
166
+ ./scripts/uninstall.sh
167
+ ```
168
+
169
+ To also remove the log directory:
170
+
171
+ ```bash
172
+ ./scripts/uninstall.sh --clean
173
+ ```
174
+
175
+ ## GitHub Action Mode
176
+
177
+ GitHub Action mode processes all pending `@` instructions across the entire vault in a single batch run. This is useful for vaults stored in a Git repository.
178
+
179
+ ### CLI usage
180
+
181
+ ```bash
182
+ note-watcher process --all --vault /path/to/vault
183
+ ```
184
+
185
+ ### Setting up the GitHub Actions workflow
186
+
187
+ See [`examples/github-action/`](examples/github-action/) for a complete, ready-to-copy example that uses [Claude Code](https://docs.anthropic.com/en/docs/claude-code) as the AI agent.
188
+
189
+ To set it up:
190
+
191
+ 1. Copy `examples/github-action/.github/` into your notes repository
192
+ 2. Add a `config.yml` to your notes repo (see `examples/github-action/config.example.yml`)
193
+ 3. Add your `ANTHROPIC_API_KEY` as a repository secret
194
+ 4. Under **Settings > Actions > General**, set "Workflow permissions" to "Read and write permissions"
195
+
196
+ The workflow triggers on any push that modifies `.md` files, processes all unprocessed `@` instructions, and commits the agent's changes back to your repository. It uses `[skip ci]` to prevent infinite loops.
197
+
198
+ See the [Claude Code GitHub Actions documentation](https://docs.anthropic.com/en/docs/claude-code/github-actions) for more on setting up Claude Code in CI.
199
+
200
+ ## Running Tests
201
+
202
+ ```bash
203
+ pytest
204
+ ```
205
+
206
+ With coverage:
207
+
208
+ ```bash
209
+ pytest --cov=note_watcher
210
+ ```
211
+
212
+ ## License
213
+
214
+ [MIT](LICENSE)
@@ -0,0 +1,190 @@
1
+ # Note Watcher
2
+
3
+ A tool that detects `@` mentions in Obsidian markdown notes stored in Git and dispatches instructions to configured agents — like [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — that can read, modify, and reorganize your notes directly.
4
+
5
+ Write `@agent_name do something` in any note, and Note Watcher dispatches the instruction to the named agent. The agent can edit files, create new notes, restructure content, or make any other changes to your vault. The original instruction is then replaced with a completion marker (an HTML comment, invisible in rendered markdown) so it is never reprocessed:
6
+
7
+ ```markdown
8
+ <!-- @done agent_name: do something
9
+ Agent response summary goes here.
10
+ /@done -->
11
+ ```
12
+
13
+ The real work happens in the commit: the agent's changes to your vault are committed back to Git. The completion comment is just a record that the instruction was processed.
14
+
15
+ ## Modes of Operation
16
+
17
+ | Mode | Use case |
18
+ |------|----------|
19
+ | **Daemon** | Real-time file watching on macOS via a LaunchAgent |
20
+ | **GitHub Action** | One-shot batch processing on every push that changes `.md` files |
21
+
22
+ ## Requirements
23
+
24
+ - Python 3.10+
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install notes-watcher
30
+ ```
31
+
32
+ For development:
33
+
34
+ ```bash
35
+ pip install -e ".[dev]"
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ Copy the example config and edit it:
41
+
42
+ ```bash
43
+ mkdir -p ~/.config/note-watcher
44
+ cp config.example.yml ~/.config/note-watcher/config.yml
45
+ ```
46
+
47
+ The default config location is `~/.config/note-watcher/config.yml`. You can override it with `--config`:
48
+
49
+ ```bash
50
+ note-watcher watch --config /path/to/config.yml
51
+ ```
52
+
53
+ ### Config reference
54
+
55
+ ```yaml
56
+ # Path to your Obsidian vault
57
+ vault: ~/Obsidian/MyVault
58
+
59
+ # Seconds to wait before processing after a file change
60
+ debounce_seconds: 1.0
61
+
62
+ # File patterns to ignore (glob syntax)
63
+ ignore_patterns:
64
+ - "*.excalidraw.md"
65
+ - ".trash/**"
66
+
67
+ # Agent definitions
68
+ agents:
69
+ summarizer:
70
+ type: echo # Returns instruction text unchanged
71
+ uppercase:
72
+ type: uppercase # Returns instruction text in uppercase
73
+ word_count:
74
+ type: command
75
+ command: "wc -w" # Runs a shell command, passes instruction via stdin
76
+ ```
77
+
78
+ ### Agent types
79
+
80
+ | Type | Behavior |
81
+ |------|----------|
82
+ | `echo` | Returns the instruction text unchanged |
83
+ | `uppercase` | Returns the instruction text in uppercase |
84
+ | `command` | Runs a shell command with instruction text on stdin, returns stdout |
85
+
86
+ ### Example: Using Claude Code as an agent
87
+
88
+ Configure a `command` agent that dispatches instructions to [Claude Code](https://docs.anthropic.com/en/docs/claude-code):
89
+
90
+ ```yaml
91
+ agents:
92
+ claude:
93
+ type: command
94
+ command: "claude -p" # Dispatches instruction to Claude Code CLI
95
+ ```
96
+
97
+ Claude Code runs with full access to your vault, so it can edit notes, create new files, and reorganize content — not just respond in a comment. Write `@claude` instructions in your notes:
98
+
99
+ ```markdown
100
+ @claude Summarize the key points of this meeting and add action items to my Tasks note
101
+ ```
102
+
103
+ ## Daemon Mode
104
+
105
+ Daemon mode continuously watches your Obsidian vault for changes and processes `@` mentions in real time.
106
+
107
+ ### Running manually
108
+
109
+ ```bash
110
+ # Watch the vault specified in your config
111
+ note-watcher watch
112
+
113
+ # Override the vault path
114
+ note-watcher watch --vault ~/Obsidian/MyVault
115
+
116
+ # Enable verbose logging
117
+ note-watcher -v watch --vault ~/Obsidian/MyVault
118
+ ```
119
+
120
+ Stop the daemon with `Ctrl+C` (`SIGINT`) or `SIGTERM`.
121
+
122
+ ### Installing as a macOS LaunchAgent
123
+
124
+ The included install script sets up Note Watcher as a LaunchAgent that starts on login and restarts on crash:
125
+
126
+ ```bash
127
+ ./scripts/install.sh
128
+ ```
129
+
130
+ The script is idempotent and safe to run multiple times. It will:
131
+
132
+ 1. Detect the `note-watcher` executable on your system
133
+ 2. Generate a LaunchAgent plist from the included template
134
+ 3. Install it to `~/Library/LaunchAgents/`
135
+ 4. Start the daemon
136
+
137
+ Logs are written to `~/Library/Logs/note-watcher/`.
138
+
139
+ ### Uninstalling the LaunchAgent
140
+
141
+ ```bash
142
+ ./scripts/uninstall.sh
143
+ ```
144
+
145
+ To also remove the log directory:
146
+
147
+ ```bash
148
+ ./scripts/uninstall.sh --clean
149
+ ```
150
+
151
+ ## GitHub Action Mode
152
+
153
+ GitHub Action mode processes all pending `@` instructions across the entire vault in a single batch run. This is useful for vaults stored in a Git repository.
154
+
155
+ ### CLI usage
156
+
157
+ ```bash
158
+ note-watcher process --all --vault /path/to/vault
159
+ ```
160
+
161
+ ### Setting up the GitHub Actions workflow
162
+
163
+ See [`examples/github-action/`](examples/github-action/) for a complete, ready-to-copy example that uses [Claude Code](https://docs.anthropic.com/en/docs/claude-code) as the AI agent.
164
+
165
+ To set it up:
166
+
167
+ 1. Copy `examples/github-action/.github/` into your notes repository
168
+ 2. Add a `config.yml` to your notes repo (see `examples/github-action/config.example.yml`)
169
+ 3. Add your `ANTHROPIC_API_KEY` as a repository secret
170
+ 4. Under **Settings > Actions > General**, set "Workflow permissions" to "Read and write permissions"
171
+
172
+ The workflow triggers on any push that modifies `.md` files, processes all unprocessed `@` instructions, and commits the agent's changes back to your repository. It uses `[skip ci]` to prevent infinite loops.
173
+
174
+ See the [Claude Code GitHub Actions documentation](https://docs.anthropic.com/en/docs/claude-code/github-actions) for more on setting up Claude Code in CI.
175
+
176
+ ## Running Tests
177
+
178
+ ```bash
179
+ pytest
180
+ ```
181
+
182
+ With coverage:
183
+
184
+ ```bash
185
+ pytest --cov=note_watcher
186
+ ```
187
+
188
+ ## License
189
+
190
+ [MIT](LICENSE)
@@ -0,0 +1,3 @@
1
+ """Note Watcher - Detect @ mentions in Obsidian notes and dispatch to AI agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,116 @@
1
+ """Click CLI for Note Watcher.
2
+
3
+ Provides two commands:
4
+ - `watch`: Starts the file watcher daemon
5
+ - `process`: Batch processes all pending instructions
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ from note_watcher.config import load_config
17
+ from note_watcher.dispatcher import AgentDispatcher
18
+ from note_watcher.watcher import process_file_reparse, start_watcher
19
+
20
+
21
+ def setup_logging(verbose: bool = False) -> None:
22
+ """Configure logging to stdout/stderr."""
23
+ level = logging.DEBUG if verbose else logging.INFO
24
+ logging.basicConfig(
25
+ level=level,
26
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
27
+ stream=sys.stderr,
28
+ )
29
+
30
+
31
+ @click.group()
32
+ @click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
33
+ def main(verbose: bool) -> None:
34
+ """Note Watcher - Detect @ mentions in Obsidian notes and dispatch to AI agents."""
35
+ setup_logging(verbose)
36
+
37
+
38
+ @main.command()
39
+ @click.option(
40
+ "--vault",
41
+ type=click.Path(exists=True, file_okay=False, resolve_path=True),
42
+ help="Path to the Obsidian vault directory.",
43
+ )
44
+ @click.option(
45
+ "--config",
46
+ "config_path",
47
+ type=click.Path(exists=True, dir_okay=False, resolve_path=True),
48
+ help="Path to the configuration file.",
49
+ )
50
+ def watch(vault: str | None, config_path: str | None) -> None:
51
+ """Start the file watcher daemon.
52
+
53
+ Monitors the vault directory for changes to .md files and processes
54
+ @ mention instructions as they appear.
55
+ """
56
+ config = load_config(config_path)
57
+
58
+ if vault:
59
+ config.vault = Path(vault)
60
+
61
+ if not config.vault.is_dir():
62
+ click.echo(f"Error: Vault directory does not exist: {config.vault}", err=True)
63
+ sys.exit(1)
64
+
65
+ click.echo(f"Watching vault: {config.vault}")
66
+ start_watcher(config)
67
+
68
+
69
+ @main.command()
70
+ @click.option(
71
+ "--all",
72
+ "process_all",
73
+ is_flag=True,
74
+ required=True,
75
+ help="Process all pending instructions.",
76
+ )
77
+ @click.option(
78
+ "--vault",
79
+ type=click.Path(exists=True, file_okay=False, resolve_path=True),
80
+ help="Path to the Obsidian vault directory.",
81
+ )
82
+ @click.option(
83
+ "--config",
84
+ "config_path",
85
+ type=click.Path(exists=True, dir_okay=False, resolve_path=True),
86
+ help="Path to the configuration file.",
87
+ )
88
+ def process(process_all: bool, vault: str | None, config_path: str | None) -> None:
89
+ """Batch process all pending @ mention instructions.
90
+
91
+ Scans all .md files in the vault for unprocessed instructions,
92
+ dispatches them to configured agents, and writes results inline.
93
+ """
94
+ config = load_config(config_path)
95
+
96
+ if vault:
97
+ config.vault = Path(vault)
98
+
99
+ if not config.vault.is_dir():
100
+ click.echo(f"Error: Vault directory does not exist: {config.vault}", err=True)
101
+ sys.exit(1)
102
+
103
+ dispatcher = AgentDispatcher(config)
104
+ vault_path = config.vault
105
+
106
+ # Find all .md files
107
+ md_files = sorted(vault_path.rglob("*.md"))
108
+ total_processed = 0
109
+
110
+ for md_file in md_files:
111
+ count = process_file_reparse(str(md_file), dispatcher)
112
+ if count > 0:
113
+ click.echo(f"Processed {count} instruction(s) in {md_file}")
114
+ total_processed += count
115
+
116
+ click.echo(f"Done. Processed {total_processed} instruction(s) total.")
@@ -0,0 +1,110 @@
1
+ """Configuration loading and management for Note Watcher.
2
+
3
+ Loads YAML configuration from a file, applies sensible defaults,
4
+ and validates required fields.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+
17
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "note-watcher" / "config.yml"
18
+
19
+ DEFAULT_DEBOUNCE_SECONDS = 1.0
20
+
21
+ DEFAULT_IGNORE_PATTERNS: list[str] = [
22
+ "*.excalidraw.md",
23
+ ".trash/**",
24
+ ]
25
+
26
+
27
+ @dataclass
28
+ class AgentConfig:
29
+ """Configuration for a single agent."""
30
+
31
+ name: str
32
+ type: str
33
+ command: str | None = None
34
+ callable: str | None = None
35
+
36
+ @classmethod
37
+ def from_dict(cls, name: str, data: dict[str, Any]) -> AgentConfig:
38
+ return cls(
39
+ name=name,
40
+ type=data.get("type", "echo"),
41
+ command=data.get("command"),
42
+ callable=data.get("callable"),
43
+ )
44
+
45
+
46
+ @dataclass
47
+ class Config:
48
+ """Application configuration."""
49
+
50
+ vault: Path
51
+ debounce_seconds: float = DEFAULT_DEBOUNCE_SECONDS
52
+ ignore_patterns: list[str] = field(default_factory=lambda: list(DEFAULT_IGNORE_PATTERNS))
53
+ agents: dict[str, AgentConfig] = field(default_factory=dict)
54
+
55
+ @classmethod
56
+ def from_dict(cls, data: dict[str, Any]) -> Config:
57
+ """Create a Config from a parsed YAML dictionary."""
58
+ vault_str = data.get("vault", ".")
59
+ # Expand ~ and environment variables in the vault path
60
+ vault = Path(os.path.expanduser(os.path.expandvars(vault_str)))
61
+
62
+ debounce = data.get("debounce_seconds", DEFAULT_DEBOUNCE_SECONDS)
63
+
64
+ ignore = data.get("ignore_patterns", list(DEFAULT_IGNORE_PATTERNS))
65
+
66
+ agents: dict[str, AgentConfig] = {}
67
+ for name, agent_data in data.get("agents", {}).items():
68
+ if isinstance(agent_data, dict):
69
+ agents[name] = AgentConfig.from_dict(name, agent_data)
70
+ else:
71
+ # Simple string value treated as the type
72
+ agents[name] = AgentConfig(name=name, type=str(agent_data))
73
+
74
+ return cls(
75
+ vault=vault,
76
+ debounce_seconds=float(debounce),
77
+ ignore_patterns=ignore,
78
+ agents=agents,
79
+ )
80
+
81
+ @classmethod
82
+ def defaults(cls, vault: str | Path = ".") -> Config:
83
+ """Create a Config with default values."""
84
+ return cls(vault=Path(vault))
85
+
86
+
87
+ def load_config(config_path: str | Path | None = None) -> Config:
88
+ """Load configuration from a YAML file.
89
+
90
+ Args:
91
+ config_path: Path to the config file. If None, uses the default path.
92
+
93
+ Returns:
94
+ A Config instance. If the config file doesn't exist, returns defaults.
95
+ """
96
+ if config_path is None:
97
+ path = DEFAULT_CONFIG_PATH
98
+ else:
99
+ path = Path(config_path)
100
+
101
+ if not path.exists():
102
+ return Config.defaults()
103
+
104
+ with open(path) as f:
105
+ data = yaml.safe_load(f)
106
+
107
+ if data is None:
108
+ return Config.defaults()
109
+
110
+ return Config.from_dict(data)