forest-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
forest/sync_state.py ADDED
@@ -0,0 +1,182 @@
1
+ """Sync state models and persistence for tracking push/pull operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from forest.fsutil import atomic_write
14
+
15
+
16
+ class SyncEntry(BaseModel):
17
+ """A single sync operation record for a data unit."""
18
+
19
+ remote: str
20
+ stage: str
21
+ unit_id: str
22
+ schema_: str | None = Field(default=None, alias="schema")
23
+ local_path: str | None = None
24
+ remote_path: str | None = None
25
+ direction: Literal["push", "pull"]
26
+ timestamp: str
27
+ file_count: int
28
+ total_bytes: int
29
+ checksum: str
30
+ schema_version: str | None = None
31
+ forest_version: str | None = None
32
+ file_snapshot: dict[str, int] | None = None
33
+
34
+ model_config = {"populate_by_name": True}
35
+
36
+
37
+ class SyncState(BaseModel):
38
+ """Top-level sync state tracking all push/pull operations."""
39
+
40
+ version: int = 1
41
+ project: str = ""
42
+ entries: list[SyncEntry] = Field(default_factory=list)
43
+
44
+ model_config = {"populate_by_name": True}
45
+
46
+
47
+ def load_state(path: str | Path) -> SyncState:
48
+ """Load sync state from a JSON file.
49
+
50
+ Returns an empty SyncState if the file does not exist.
51
+ """
52
+ path = Path(path)
53
+ if not path.exists():
54
+ return SyncState()
55
+
56
+ data = json.loads(path.read_text())
57
+ if data == []:
58
+ return SyncState()
59
+ return SyncState.model_validate(data)
60
+
61
+
62
+ def save_state(state: SyncState, path: str | Path) -> None:
63
+ """Write sync state to a JSON file with pretty-printed formatting.
64
+
65
+ Creates parent directories if they do not exist.
66
+ """
67
+ path = Path(path)
68
+ atomic_write(path, json.dumps(state.model_dump(by_alias=True), indent=2) + "\n")
69
+
70
+
71
+ def record_push(state: SyncState, entry_data: dict[str, Any]) -> SyncState:
72
+ """Record a push operation, returning a new SyncState.
73
+
74
+ If an entry with the same (remote, stage, unit_id) already exists,
75
+ it is replaced. The original state is not mutated.
76
+ """
77
+ return _record(state, entry_data, direction="push")
78
+
79
+
80
+ def record_pull(state: SyncState, entry_data: dict[str, Any]) -> SyncState:
81
+ """Record a pull operation, returning a new SyncState.
82
+
83
+ If an entry with the same (remote, stage, unit_id) already exists,
84
+ it is replaced. The original state is not mutated.
85
+ """
86
+ return _record(state, entry_data, direction="pull")
87
+
88
+
89
+ def _record(state: SyncState, entry_data: dict[str, Any], *, direction: str) -> SyncState:
90
+ """Internal helper to record a push or pull entry."""
91
+ remote = entry_data["remote"]
92
+ stage = entry_data["stage"]
93
+ unit_id = entry_data["unit_id"]
94
+
95
+ entry_data = {
96
+ **entry_data,
97
+ "direction": direction,
98
+ "timestamp": datetime.now(tz=timezone.utc).isoformat(),
99
+ }
100
+ new_entry = SyncEntry(**entry_data)
101
+
102
+ kept = [
103
+ e
104
+ for e in state.entries
105
+ if not (e.remote == remote and e.stage == stage and e.unit_id == unit_id and e.direction == direction)
106
+ ]
107
+ kept.append(new_entry)
108
+
109
+ return SyncState(version=state.version, project=state.project, entries=kept)
110
+
111
+
112
+ def get_status(
113
+ state: SyncState,
114
+ stage: str,
115
+ unit_id: str,
116
+ *,
117
+ unit_path: str | Path | None = None,
118
+ remote: str | None = None,
119
+ ) -> str:
120
+ """Determine the sync status of a data unit.
121
+
122
+ When *unit_path* is not provided, it is derived from the matching
123
+ ``SyncEntry.local_path`` (if one exists).
124
+
125
+ Returns one of:
126
+ - ``"synced"`` — local checksum matches stored entry
127
+ - ``"stale"`` — local exists and entry exists but checksums differ
128
+ - ``"local-only"`` — local dir exists but no sync entry
129
+ - ``"remote-only"`` — sync entry exists but no local dir
130
+ """
131
+ # Find matching entry
132
+ entry = _find_entry(state, stage, unit_id, remote=remote)
133
+
134
+ # Derive unit_path from entry if not explicitly provided
135
+ if unit_path is None and entry is not None and entry.local_path is not None:
136
+ unit_path = Path(entry.local_path)
137
+ elif unit_path is not None:
138
+ unit_path = Path(unit_path)
139
+
140
+ if entry is None:
141
+ return "local-only"
142
+
143
+ if unit_path is None or not unit_path.is_dir():
144
+ return "remote-only"
145
+
146
+ # Both exist — compare checksums
147
+ local_files = sorted(f for f in unit_path.rglob("*") if f.is_file())
148
+ current_checksum = compute_checksum(unit_path, local_files)
149
+
150
+ if current_checksum == entry.checksum:
151
+ return "synced"
152
+
153
+ return "stale"
154
+
155
+
156
+ def _find_entry(
157
+ state: SyncState,
158
+ stage: str,
159
+ unit_id: str,
160
+ *,
161
+ remote: str | None = None,
162
+ ) -> SyncEntry | None:
163
+ """Find the most recent matching sync entry."""
164
+ for entry in reversed(state.entries):
165
+ if entry.stage == stage and entry.unit_id == unit_id and (remote is None or entry.remote == remote):
166
+ return entry
167
+ return None
168
+
169
+
170
+ def compute_checksum(unit_path: str | Path, file_list: list[Path]) -> str:
171
+ """Compute a deterministic SHA-256 checksum from (relative_path, file_size) tuples.
172
+
173
+ The checksum is independent of file modification times and content —
174
+ only the relative path and file size in bytes are used. The list is
175
+ sorted before hashing to ensure determinism regardless of input order.
176
+ """
177
+ unit_path = Path(unit_path)
178
+ tuples = sorted((str(f.relative_to(unit_path)), f.stat().st_size) for f in file_list)
179
+ h = hashlib.sha256()
180
+ for rel_path, size in tuples:
181
+ h.update(f"{rel_path}:{size}\n".encode())
182
+ return h.hexdigest()
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: forest-cli
3
+ Version: 0.1.0
4
+ Summary: git-like data management for arbitrary data trees: workspaces, checkouts, remotes, and sync
5
+ Author-email: Troy Sincomb <troysincomb@gmail.com>
6
+ License: MIT
7
+ Keywords: data-management,sync,cli
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: click>=8.0
15
+ Requires-Dist: polars>=1.0
16
+ Requires-Dist: pydantic>=2.0
17
+ Requires-Dist: pyyaml>=6.0
18
+ Provides-Extra: observability
19
+ Requires-Dist: sentry-sdk>=2.0; extra == "observability"
20
+ Provides-Extra: dev
21
+ Requires-Dist: deptry>=0.23; extra == "dev"
22
+ Requires-Dist: mypy>=1.14; extra == "dev"
23
+ Requires-Dist: pre-commit>=4.0; extra == "dev"
24
+ Requires-Dist: pylint>=3.3; extra == "dev"
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: pytest-cov>=6.0; extra == "dev"
27
+ Requires-Dist: pytest-randomly>=3.15; extra == "dev"
28
+ Requires-Dist: pytest-rerunfailures>=15.0; extra == "dev"
29
+ Requires-Dist: pytest-xdist>=3.6; extra == "dev"
30
+ Requires-Dist: ruff>=0.4; extra == "dev"
31
+ Requires-Dist: types-PyYAML; extra == "dev"
32
+ Requires-Dist: vulture>=2.14; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # 🌲 forest <a href="#-dogfood-this-repo-runs-forest"><img align="right" width="42%" src="docs/assets/hero.svg" alt="Animated isometric forest: data trees on a workspace platform, packets syncing up a git branch to a remote cloud"></a>
36
+
37
+ **git-like data management for arbitrary data trees.**
38
+
39
+ [![CI](https://github.com/tmsincomb/forest/actions/workflows/ci.yml/badge.svg)](https://github.com/tmsincomb/forest/actions/workflows/ci.yml)
40
+ [![Docs](https://github.com/tmsincomb/forest/actions/workflows/docs.yml/badge.svg)](https://github.com/tmsincomb/forest/actions/workflows/docs.yml)
41
+ [![Release](https://github.com/tmsincomb/forest/actions/workflows/release.yml/badge.svg)](https://github.com/tmsincomb/forest/actions/workflows/release.yml)
42
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/)
43
+ [![License: MIT](https://img.shields.io/badge/license-MIT-2ea44f)](LICENSE)
44
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
45
+ [![mypy: strict](https://img.shields.io/badge/mypy-strict-blue)](https://mypy-lang.org/)
46
+
47
+ Forest is the data-side parallel to git's version control. Git tracks code in
48
+ `.git/`; forest tracks large data — local layout plus remote sync — in
49
+ `.forest/`. It borrows git's mental model and verbs (`checkout`, `status`,
50
+ `push`, `pull`, `remote`, a HEAD-style pointer) so your git intuition carries
51
+ over, but the two domains never overlap and neither requires the other.
52
+
53
+ Forest is **domain-agnostic and self-contained**: it manages *any* data trees,
54
+ knows nothing about what the data means, and depends on no other project. It
55
+ moves files and tracks their sync state; it does not validate or interpret their
56
+ contents.
57
+
58
+ ## Model
59
+
60
+ Fixed-depth, no arbitrary nesting:
61
+
62
+ ```
63
+ workspace → checkout → stage → unit → files
64
+ ```
65
+
66
+ - **Workspace** — a per-repo `.forest/` control area (registry + active pointer).
67
+ - **Checkout** — a named data view/focus registered in the workspace (like a git
68
+ branch you stay rooted in). Switching is an O(1) pointer rewrite; data never
69
+ moves.
70
+ - **Stage** — a named data category inside a checkout, with a remote layout.
71
+ - **Unit** — one addressable item within a stage (a subdirectory, a directory,
72
+ or a file, per the stage's `sync_by`).
73
+
74
+ ## Install
75
+
76
+ Requires [`rclone`](https://rclone.org/) on `$PATH` for transfers:
77
+
78
+ ```bash
79
+ pip install -e ./forest
80
+ ```
81
+
82
+ ## Quick start
83
+
84
+ No config files are hand-written. Onboarding is a few commands:
85
+
86
+ ```bash
87
+ forest init # create the nameless .forest/ workspace container
88
+ forest checkout demo # create if absent, register + activate 'demo'
89
+ forest remote add origin s3://my-bucket/prefix # allowed before any local data exists
90
+ forest add raw ./data/raw # register stage 'raw' and bind it to a local path
91
+ forest push # sync every bound stage to the active remote
92
+ ```
93
+
94
+ - `forest init` creates **only** `.forest/config.yaml` (`version: 1`,
95
+ `checkouts: {}`) and a managed `.gitignore`. No root config, no checkout, no
96
+ active pointer.
97
+ - `forest checkout <name>` switches to the checkout, creating, registering, and
98
+ activating it first (with `.forest/checkouts/<name>/forest.yaml`) when the
99
+ name is not registered. `forest checkout create <name>` is the explicit form.
100
+ - Remotes can be added before any local binding — useful when your data is
101
+ remote-only at first.
102
+ - `forest add STAGE PATH` registers a new stage and binds it to a local path in
103
+ one step (use `forest bind` to rebind an existing stage).
104
+
105
+ ## Metadata layout
106
+
107
+ Everything forest owns lives under `.forest/`; your data does not.
108
+
109
+ ```
110
+ .forest/
111
+ config.yaml # workspace registry: version, checkouts{}
112
+ HEAD # active checkout name (gitignored)
113
+ checkouts/
114
+ demo/
115
+ forest.yaml # shared: stages, remotes, manifest
116
+ local.yaml # user-local: active_remote, stage_paths (gitignored)
117
+ sync_state.json # user-local push/pull state (gitignored)
118
+ ```
119
+
120
+ Shared metadata (`config.yaml`, each `forest.yaml`) is committed so a fresh
121
+ clone bootstraps with `bind` + `remote use` + `pull`. User-local files
122
+ (`HEAD`, `local.yaml`, `sync_state.json`) are gitignored.
123
+
124
+ ## Commands
125
+
126
+ | Command | Purpose |
127
+ |---|---|
128
+ | `forest init` | Create the workspace container, or report setup status if it exists. |
129
+ | `forest checkout create/adopt/list/current/remove <name>` | Manage checkouts; bare `forest checkout <name>` switches, creating first if needed. `remove --yes` skips the prompt for scripts. |
130
+ | `forest add STAGE PATH [--sync-by MODE]` | Register a new stage and bind it to a local path; `--sync-by` picks unit discovery (`subdirectory`/`directory`/`file`). |
131
+ | `forest bind [STAGE PATH]` / `forest unbind STAGE` | Manage local stage↔path bindings. |
132
+ | `forest remote add/remove/list/use/show` | Manage remotes; `use` selects the active remote (optional while only one remote exists). |
133
+ | `forest push / pull / status / diff / ls` | Sync and inspect against the active remote. Bare `push`/`pull`/`status`/`diff` cover every bound stage (unbound stages warn and skip); `--all` requires all stages bound. |
134
+ | `forest flow` | Emit a Mermaid data-flow diagram of the active checkout. |
135
+ | `forest migrate` | Migrate a legacy `biostore` layout in place (see below). |
136
+
137
+ Run any command with `-C <path>` to operate on another repo without `cd`.
138
+
139
+ Forest syncs **all** files in a data unit, skipping OS junk (`.DS_Store`,
140
+ AppleDouble `._*`, `*.tmp`). It applies no content-based include/exclude rules.
141
+
142
+ ## Config reference
143
+
144
+ Checkout `forest.yaml` (shared, committed):
145
+
146
+ ```yaml
147
+ project: demo
148
+ remotes:
149
+ origin:
150
+ url: s3://my-bucket/prefix
151
+ region: us-east-2 # optional; also endpoint, profile, key_file, known_hosts
152
+ stages:
153
+ raw:
154
+ remote_path: demo/raw # optional; defaults to <checkout>/<stage>
155
+ sync_by: subdirectory # subdirectory | directory | file
156
+ ```
157
+
158
+ Checkout `local.yaml` (per-machine, gitignored):
159
+
160
+ ```yaml
161
+ active_remote: origin
162
+ stage_paths:
163
+ raw: ../data/raw # relative resolves from the workspace root
164
+ ```
165
+
166
+ ## Environment variables
167
+
168
+ All optional, all off by default — forest is silent and sends nothing anywhere
169
+ unless configured. Copy `.env.example` for a commented template; operational
170
+ guides live in `docs/runbooks/`.
171
+
172
+ | Variable | Default | Effect |
173
+ |---|---|---|
174
+ | `FOREST_LOG_FILE` | unset | Append structured logs (JSON lines) to this file. |
175
+ | `FOREST_LOG_FORMAT` | `json` | `json` or `text`; set without `FOREST_LOG_FILE` to log to stderr. |
176
+ | `FOREST_LOG_LEVEL` | `INFO` | Standard logging level name. |
177
+ | `FOREST_METRICS_FILE` | unset | Append metric samples as JSON lines for external collectors. |
178
+ | `FOREST_ANALYTICS_FILE` | unset | Opt-in local usage analytics (JSON lines); nothing leaves the machine. |
179
+ | `FOREST_SENTRY_DSN` | unset | Sentry error tracking; needs `pip install "forest-cli[observability]"`. |
180
+ | `FOREST_ALERT_WEBHOOK` | unset | POST failure alerts to this HTTPS endpoint (Slack/Mattermost compatible). |
181
+ | `FOREST_TRANSFER_RETRIES` | `2` | Extra attempts for transient rclone failures; `0` disables. |
182
+ | `FOREST_RETRY_BASE_DELAY` | `0.5` | Initial retry backoff in seconds; doubles per attempt. |
183
+ | `FOREST_BREAKER_THRESHOLD` | `5` | Consecutive transfer failures before the circuit opens; `0` disables. |
184
+ | `FOREST_BREAKER_RESET_SECONDS` | `60` | Cool-down before an open circuit allows a probe operation. |
185
+ | `FOREST_FLAGS` | unset | Comma-separated feature flags; `raw-logs` disables log secret-scrubbing. |
186
+
187
+ ## Dogfood: this repo runs forest
188
+
189
+ This repository manages its own `examples/` tree with forest — a live
190
+ demonstration that `.forest/` and `.git/` coexist without overlapping. It was
191
+ set up with exactly the quick-start commands:
192
+
193
+ ```bash
194
+ forest init
195
+ forest checkout demo
196
+ forest remote add origin s3://forest-test-542222635421-us-east-2-an --region us-east-2
197
+ forest add examples ./examples
198
+ forest push
199
+ ```
200
+
201
+ Inspect the result:
202
+
203
+ ```bash
204
+ git ls-files .forest # what a clone gets: config.yaml + checkouts/demo/forest.yaml
205
+ cat .gitignore # forest-managed: HEAD, local.yaml, sync_state.json stay local
206
+ forest status # sync state of the examples stage
207
+ ```
208
+
209
+ A fresh clone bootstraps the local half with `forest bind examples ./examples`
210
+ followed by `forest pull` (the single configured remote is used automatically).
211
+ Pulling needs AWS credentials for the bucket; the layout is the demonstration.
212
+
213
+ ## Migrating from biostore
214
+
215
+ Existing biostore repos use `.biostore/` and `biostore.yaml`. Migrate in place:
216
+
217
+ ```bash
218
+ forest migrate
219
+ ```
220
+
221
+ This renames `.biostore/` → `.forest/`, each `biostore.yaml` → `forest.yaml`,
222
+ rewrites the managed `.gitignore` patterns, and verifies the registry parses. It
223
+ refuses to run if a `.forest/` already exists.
224
+
225
+ ## Notes
226
+
227
+ - **Single active machine (v1).** `HEAD`/`local.yaml`/`sync_state.json` are
228
+ git-invisible but may be synced by a file-syncing tool; forest assumes one
229
+ active machine and uses atomic writes plus a per-checkout `flock` for
230
+ intra-machine write races.
231
+ - **Real filenames.** Forest stores data under real paths, not a
232
+ content-addressed blob store.
233
+ - See `docs/adr/` for the design decisions behind the workspace/checkout model.
@@ -0,0 +1,22 @@
1
+ forest/__init__.py,sha256=GXkEqeYHPZirXscjOYs5XrAERnyduI-aAuu_gvKbRmA,540
2
+ forest/analytics.py,sha256=RA1GzGAyaETLH1aVxVuILHP56EokzrhVYE8DnA_WKqM,961
3
+ forest/checkout.py,sha256=xwTit-EdSfZxe27LPaEVqGTBE-DJSD3xiChlWOYER-k,38657
4
+ forest/cli.py,sha256=ohDAiYd0OCwh9Y9GdFj1B2Q7XaxcH4EjrFX_jqtUV5c,103533
5
+ forest/config.py,sha256=Dy-pDOGN8F0QnJhH558zB-Xk_PnOzfY8ZyDuDOeSpS8,9271
6
+ forest/flags.py,sha256=4UCA8aSqQT5my9mzqltlWw2kPZ45Ch0PzUii2jw4GWg,641
7
+ forest/flow.py,sha256=ewb1jqw_kI4gyzBpWogSuIdsdhBfufS7N_7dPVlWWS8,5349
8
+ forest/fsutil.py,sha256=BQGOqx5Sa_wBNoOymIcG3bqEhfLsOF5U1mWZ_YXKOGw,1210
9
+ forest/logger.py,sha256=nIYMV2DWjPRYsl7DTvlb1ggQL3Y0EwegZ-d1wsmYeUo,7042
10
+ forest/manifest.py,sha256=hklrMvYU3WolJNTmq42YOvm_uMmhMyS0HBASstdubhg,3260
11
+ forest/metrics.py,sha256=dBz9LNolPi2d4xeSayJMQT7uHd2e8SgMG29rikCJY3w,2494
12
+ forest/monitoring.py,sha256=jaNfdIPfjOV6t3anVIHswf4zc0YLnuOpQl28lHFYcS0,4862
13
+ forest/paths.py,sha256=oc0aYMz9HX4kgaKnRXe2CTk0Hq9BWrrs7UOeA1sYgYE,6818
14
+ forest/rclone.py,sha256=cVsU0NvLTDGH5Rz9M-5dcOCuybbsDU4tXE-a86KL6nA,21359
15
+ forest/resilience.py,sha256=oEHiPOQjCP_voskkE8WZz5zQ0CfN2v8nmthoAYptXL4,4321
16
+ forest/sync_state.py,sha256=h8EmIN3uE3pQOj-20Tlhg6NFq2b5AmHOeHVNfIkvQwI,5542
17
+ forest_cli-0.1.0.dist-info/licenses/LICENSE,sha256=PRvBVRcqmEyg2k5TekU4LBP_BQTdVALzv5F12ofRXbE,1069
18
+ forest_cli-0.1.0.dist-info/METADATA,sha256=QQubDaJYFRltotTWORcGgby76UGZIPMkuQPpGKPxZio,10695
19
+ forest_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
20
+ forest_cli-0.1.0.dist-info/entry_points.txt,sha256=ogQn8m-0npXNFnEu1RW14gBIy6Hzttrhp2zcOLcttsM,43
21
+ forest_cli-0.1.0.dist-info/top_level.txt,sha256=Qh1kq9Xt0-pDXxBKCtInwGCWt8k7IEBrKEtwJIQjlKg,7
22
+ forest_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ forest = forest.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Troy Sincomb
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 @@
1
+ forest