8 Commits

Author SHA1 Message Date
Nathaniel Landau
c1a40ed8a4 bump(release): v0.8.0 → v0.9.0 2023-03-20 18:20:10 -04:00
Nathaniel Landau
6f14076e33 fix: find more instances of inline metadata 2023-03-20 18:15:05 -04:00
Nathaniel Landau
ca42823a2f fix: ensure frontmatter values are unique within a key 2023-03-20 13:59:58 -04:00
Nathaniel Landau
36adfece51 fix: improve validation of bulk imports 2023-03-20 12:56:22 -04:00
Nathaniel Landau
d636fb2672 feat: bulk update metadata from a CSV file 2023-03-20 00:19:12 -04:00
Nathaniel Landau
593dbc3b55 build: add script to bump dependencies 2023-03-18 19:17:23 -04:00
Nathaniel Landau
009801a691 style: pass additional linters 2023-03-17 14:30:50 -04:00
Nathaniel Landau
2493db5f23 fix: improve logging to screen 2023-03-13 07:56:49 -04:00
27 changed files with 2071 additions and 734 deletions

View File

@@ -61,7 +61,7 @@ repos:
entry: yamllint --strict --config-file .yamllint.yml
- repo: "https://github.com/charliermarsh/ruff-pre-commit"
rev: "v0.0.254"
rev: "v0.0.257"
hooks:
- id: ruff
args: ["--extend-ignore", "I001,D301,D401"]

View File

@@ -1,3 +1,16 @@
## v0.9.0 (2023-03-20)
### Feat
- bulk update metadata from a CSV file
### Fix
- find more instances of inline metadata
- ensure frontmatter values are unique within a key
- improve validation of bulk imports
- improve logging to screen
## v0.8.0 (2023-03-12)
### Feat

View File

@@ -43,13 +43,18 @@ Once installed, run `obsidian-metadata` in your terminal to enter an interactive
- Backup: Create a backup of the vault.
- Delete Backup: Delete a backup of the vault.
**Export Metadata**
- Export all metadata to a CSV organized by metadata type
- Export all metadata to a CSV organized by note path
- Export all metadata to a JSON file organized by metadata type
**Inspect Metadata**
- **View all metadata in the vault**
- View all **frontmatter**
- View all **inline metadata**
- View all **inline tags**
- **Export all metadata to CSV or JSON file**
**Filter Notes in Scope**: Limit the scope of notes to be processed with one or more filters.
@@ -59,6 +64,8 @@ Once installed, run `obsidian-metadata` in your terminal to enter an interactive
- **List and clear filters**: List all current filters and clear one or all
- **List notes in scope**: List notes that will be processed.
**Bulk Edit Metadata** from a CSV file (See the _making bulk edits_ section below)
**Add Metadata**: Add new metadata to your vault.
When adding a new key to inline metadata, the `insert location` value in the config file will specify where in the note it will be inserted.
@@ -132,6 +139,36 @@ Below is an example with two vaults.
To bypass the configuration file and specify a vault to use at runtime use the `--vault-path` option.
### Making bulk edits
Bulk edits are supported by importing a CSV file containing the following columns. Column headers must be lowercase.
1. `path` - Path to note relative to the vault root folder
2. `type` - Type of metadata. One of `frontmatter`, `inline_metadata`, or `tag`
3. `key` - The key to add (leave blank for a tag)
4. `value` - the value to add to the key
An example valid CSV file is
```csv
path,type,key,value
folder 1/note1.md,frontmatter,fruits,apple
folder 1/note1.md,frontmatter,fruits,banana
folder 1/note1.md,inline_metadata,cars,toyota
folder 1/note1.md,inline_metadata,cars,honda
folder 1/note1.md,tag,,tag1
folder 1/note1.md,tag,,tag2
```
How bulk imports work:
- Only notes which match the path in the CSV file are updated
- Effected notes will have ALL of their metadata changed to reflect the values in the CSV file
- Existing metadata in an effected note will be rewritten. This may result in it's location and/or formatting within the note being changed
- inline tags ignore any value added to the `key` column
You can export all your notes with their associated metadata in this format from the "Export Metadata" section of the script to be used as a template for your bulk changes.
# Contributing
## Setup: Once per project
@@ -163,3 +200,7 @@ There are two ways to contribute to this project.
- Run `poetry add {package}` from within the development environment to install a run time dependency and add it to `pyproject.toml` and `poetry.lock`.
- Run `poetry remove {package}` from within the development environment to uninstall a run time dependency and remove it from `pyproject.toml` and `poetry.lock`.
- Run `poetry update` from within the development environment to upgrade all dependencies to the latest versions allowed by `pyproject.toml`.
```
```

250
poetry.lock generated
View File

@@ -4,7 +4,7 @@
name = "argcomplete"
version = "2.0.6"
description = "Bash tab completion for argparse"
category = "dev"
category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -100,7 +100,7 @@ files = [
name = "charset-normalizer"
version = "2.1.1"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "dev"
category = "main"
optional = false
python-versions = ">=3.6.0"
files = [
@@ -142,7 +142,7 @@ files = [
name = "commitizen"
version = "2.42.1"
description = "Python commitizen client tool"
category = "dev"
category = "main"
optional = false
python-versions = ">=3.6.2,<4.0.0"
files = [
@@ -165,63 +165,63 @@ typing-extensions = ">=4.0.1,<5.0.0"
[[package]]
name = "coverage"
version = "7.2.1"
version = "7.2.2"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "coverage-7.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:49567ec91fc5e0b15356da07a2feabb421d62f52a9fff4b1ec40e9e19772f5f8"},
{file = "coverage-7.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2ef6cae70168815ed91388948b5f4fcc69681480a0061114db737f957719f03"},
{file = "coverage-7.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3004765bca3acd9e015794e5c2f0c9a05587f5e698127ff95e9cfba0d3f29339"},
{file = "coverage-7.2.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cca7c0b7f5881dfe0291ef09ba7bb1582cb92ab0aeffd8afb00c700bf692415a"},
{file = "coverage-7.2.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2167d116309f564af56f9aa5e75ef710ef871c5f9b313a83050035097b56820"},
{file = "coverage-7.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cb5f152fb14857cbe7f3e8c9a5d98979c4c66319a33cad6e617f0067c9accdc4"},
{file = "coverage-7.2.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:87dc37f16fb5e3a28429e094145bf7c1753e32bb50f662722e378c5851f7fdc6"},
{file = "coverage-7.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e191a63a05851f8bce77bc875e75457f9b01d42843f8bd7feed2fc26bbe60833"},
{file = "coverage-7.2.1-cp310-cp310-win32.whl", hash = "sha256:e3ea04b23b114572b98a88c85379e9e9ae031272ba1fb9b532aa934c621626d4"},
{file = "coverage-7.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0cf557827be7eca1c38a2480484d706693e7bb1929e129785fe59ec155a59de6"},
{file = "coverage-7.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:570c21a29493b350f591a4b04c158ce1601e8d18bdcd21db136fbb135d75efa6"},
{file = "coverage-7.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9e872b082b32065ac2834149dc0adc2a2e6d8203080501e1e3c3c77851b466f9"},
{file = "coverage-7.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fac6343bae03b176e9b58104a9810df3cdccd5cfed19f99adfa807ffbf43cf9b"},
{file = "coverage-7.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abacd0a738e71b20e224861bc87e819ef46fedba2fb01bc1af83dfd122e9c319"},
{file = "coverage-7.2.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9256d4c60c4bbfec92721b51579c50f9e5062c21c12bec56b55292464873508"},
{file = "coverage-7.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:80559eaf6c15ce3da10edb7977a1548b393db36cbc6cf417633eca05d84dd1ed"},
{file = "coverage-7.2.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0bd7e628f6c3ec4e7d2d24ec0e50aae4e5ae95ea644e849d92ae4805650b4c4e"},
{file = "coverage-7.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09643fb0df8e29f7417adc3f40aaf379d071ee8f0350ab290517c7004f05360b"},
{file = "coverage-7.2.1-cp311-cp311-win32.whl", hash = "sha256:1b7fb13850ecb29b62a447ac3516c777b0e7a09ecb0f4bb6718a8654c87dfc80"},
{file = "coverage-7.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:617a94ada56bbfe547aa8d1b1a2b8299e2ec1ba14aac1d4b26a9f7d6158e1273"},
{file = "coverage-7.2.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8649371570551d2fd7dee22cfbf0b61f1747cdfb2b7587bb551e4beaaa44cb97"},
{file = "coverage-7.2.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d2b9b5e70a21474c105a133ba227c61bc95f2ac3b66861143ce39a5ea4b3f84"},
{file = "coverage-7.2.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae82c988954722fa07ec5045c57b6d55bc1a0890defb57cf4a712ced65b26ddd"},
{file = "coverage-7.2.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:861cc85dfbf55a7a768443d90a07e0ac5207704a9f97a8eb753292a7fcbdfcfc"},
{file = "coverage-7.2.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0339dc3237c0d31c3b574f19c57985fcbe494280153bbcad33f2cdf469f4ac3e"},
{file = "coverage-7.2.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:5928b85416a388dd557ddc006425b0c37e8468bd1c3dc118c1a3de42f59e2a54"},
{file = "coverage-7.2.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8d3843ca645f62c426c3d272902b9de90558e9886f15ddf5efe757b12dd376f5"},
{file = "coverage-7.2.1-cp37-cp37m-win32.whl", hash = "sha256:6a034480e9ebd4e83d1aa0453fd78986414b5d237aea89a8fdc35d330aa13bae"},
{file = "coverage-7.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6fce673f79a0e017a4dc35e18dc7bb90bf6d307c67a11ad5e61ca8d42b87cbff"},
{file = "coverage-7.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7f099da6958ddfa2ed84bddea7515cb248583292e16bb9231d151cd528eab657"},
{file = "coverage-7.2.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:97a3189e019d27e914ecf5c5247ea9f13261d22c3bb0cfcfd2a9b179bb36f8b1"},
{file = "coverage-7.2.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a81dbcf6c6c877986083d00b834ac1e84b375220207a059ad45d12f6e518a4e3"},
{file = "coverage-7.2.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2c3dde4c0b9be4b02067185136b7ee4681978228ad5ec1278fa74f5ca3e99"},
{file = "coverage-7.2.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a209d512d157379cc9ab697cbdbb4cfd18daa3e7eebaa84c3d20b6af0037384"},
{file = "coverage-7.2.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f3d07edb912a978915576a776756069dede66d012baa503022d3a0adba1b6afa"},
{file = "coverage-7.2.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8dca3c1706670297851bca1acff9618455122246bdae623be31eca744ade05ec"},
{file = "coverage-7.2.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b1991a6d64231a3e5bbe3099fb0dd7c9aeaa4275ad0e0aeff4cb9ef885c62ba2"},
{file = "coverage-7.2.1-cp38-cp38-win32.whl", hash = "sha256:22c308bc508372576ffa3d2dbc4824bb70d28eeb4fcd79d4d1aed663a06630d0"},
{file = "coverage-7.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:b0c0d46de5dd97f6c2d1b560bf0fcf0215658097b604f1840365296302a9d1fb"},
{file = "coverage-7.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4dd34a935de268a133e4741827ae951283a28c0125ddcdbcbba41c4b98f2dfef"},
{file = "coverage-7.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0f8318ed0f3c376cfad8d3520f496946977abde080439d6689d7799791457454"},
{file = "coverage-7.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:834c2172edff5a08d78e2f53cf5e7164aacabeb66b369f76e7bb367ca4e2d993"},
{file = "coverage-7.2.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4d70c853f0546855f027890b77854508bdb4d6a81242a9d804482e667fff6e6"},
{file = "coverage-7.2.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a6450da4c7afc4534305b2b7d8650131e130610cea448ff240b6ab73d7eab63"},
{file = "coverage-7.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:99f4dd81b2bb8fc67c3da68b1f5ee1650aca06faa585cbc6818dbf67893c6d58"},
{file = "coverage-7.2.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bdd3f2f285ddcf2e75174248b2406189261a79e7fedee2ceeadc76219b6faa0e"},
{file = "coverage-7.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f29351393eb05e6326f044a7b45ed8e38cb4dcc38570d12791f271399dc41431"},
{file = "coverage-7.2.1-cp39-cp39-win32.whl", hash = "sha256:e2b50ebc2b6121edf352336d503357321b9d8738bb7a72d06fc56153fd3f4cd8"},
{file = "coverage-7.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:bd5a12239c0006252244f94863f1c518ac256160cd316ea5c47fb1a11b25889a"},
{file = "coverage-7.2.1-pp37.pp38.pp39-none-any.whl", hash = "sha256:436313d129db7cf5b4ac355dd2bd3f7c7e5294af077b090b85de75f8458b8616"},
{file = "coverage-7.2.1.tar.gz", hash = "sha256:c77f2a9093ccf329dd523a9b2b3c854c20d2a3d968b6def3b820272ca6732242"},
{file = "coverage-7.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c90e73bdecb7b0d1cea65a08cb41e9d672ac6d7995603d6465ed4914b98b9ad7"},
{file = "coverage-7.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e2926b8abedf750c2ecf5035c07515770944acf02e1c46ab08f6348d24c5f94d"},
{file = "coverage-7.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57b77b9099f172804e695a40ebaa374f79e4fb8b92f3e167f66facbf92e8e7f5"},
{file = "coverage-7.2.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:efe1c0adad110bf0ad7fb59f833880e489a61e39d699d37249bdf42f80590169"},
{file = "coverage-7.2.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2199988e0bc8325d941b209f4fd1c6fa007024b1442c5576f1a32ca2e48941e6"},
{file = "coverage-7.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:81f63e0fb74effd5be736cfe07d710307cc0a3ccb8f4741f7f053c057615a137"},
{file = "coverage-7.2.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:186e0fc9cf497365036d51d4d2ab76113fb74f729bd25da0975daab2e107fd90"},
{file = "coverage-7.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:420f94a35e3e00a2b43ad5740f935358e24478354ce41c99407cddd283be00d2"},
{file = "coverage-7.2.2-cp310-cp310-win32.whl", hash = "sha256:38004671848b5745bb05d4d621526fca30cee164db42a1f185615f39dc997292"},
{file = "coverage-7.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:0ce383d5f56d0729d2dd40e53fe3afeb8f2237244b0975e1427bfb2cf0d32bab"},
{file = "coverage-7.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3eb55b7b26389dd4f8ae911ba9bc8c027411163839dea4c8b8be54c4ee9ae10b"},
{file = "coverage-7.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d2b96123a453a2d7f3995ddb9f28d01fd112319a7a4d5ca99796a7ff43f02af5"},
{file = "coverage-7.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:299bc75cb2a41e6741b5e470b8c9fb78d931edbd0cd009c58e5c84de57c06731"},
{file = "coverage-7.2.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e1df45c23d4230e3d56d04414f9057eba501f78db60d4eeecfcb940501b08fd"},
{file = "coverage-7.2.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:006ed5582e9cbc8115d2e22d6d2144a0725db542f654d9d4fda86793832f873d"},
{file = "coverage-7.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d683d230b5774816e7d784d7ed8444f2a40e7a450e5720d58af593cb0b94a212"},
{file = "coverage-7.2.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8efb48fa743d1c1a65ee8787b5b552681610f06c40a40b7ef94a5b517d885c54"},
{file = "coverage-7.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4c752d5264053a7cf2fe81c9e14f8a4fb261370a7bb344c2a011836a96fb3f57"},
{file = "coverage-7.2.2-cp311-cp311-win32.whl", hash = "sha256:55272f33da9a5d7cccd3774aeca7a01e500a614eaea2a77091e9be000ecd401d"},
{file = "coverage-7.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:92ebc1619650409da324d001b3a36f14f63644c7f0a588e331f3b0f67491f512"},
{file = "coverage-7.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5afdad4cc4cc199fdf3e18088812edcf8f4c5a3c8e6cb69127513ad4cb7471a9"},
{file = "coverage-7.2.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0484d9dd1e6f481b24070c87561c8d7151bdd8b044c93ac99faafd01f695c78e"},
{file = "coverage-7.2.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d530191aa9c66ab4f190be8ac8cc7cfd8f4f3217da379606f3dd4e3d83feba69"},
{file = "coverage-7.2.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac0f522c3b6109c4b764ffec71bf04ebc0523e926ca7cbe6c5ac88f84faced0"},
{file = "coverage-7.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ba279aae162b20444881fc3ed4e4f934c1cf8620f3dab3b531480cf602c76b7f"},
{file = "coverage-7.2.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:53d0fd4c17175aded9c633e319360d41a1f3c6e352ba94edcb0fa5167e2bad67"},
{file = "coverage-7.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c99cb7c26a3039a8a4ee3ca1efdde471e61b4837108847fb7d5be7789ed8fd9"},
{file = "coverage-7.2.2-cp37-cp37m-win32.whl", hash = "sha256:5cc0783844c84af2522e3a99b9b761a979a3ef10fb87fc4048d1ee174e18a7d8"},
{file = "coverage-7.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:817295f06eacdc8623dc4df7d8b49cea65925030d4e1e2a7c7218380c0072c25"},
{file = "coverage-7.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6146910231ece63facfc5984234ad1b06a36cecc9fd0c028e59ac7c9b18c38c6"},
{file = "coverage-7.2.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:387fb46cb8e53ba7304d80aadca5dca84a2fbf6fe3faf6951d8cf2d46485d1e5"},
{file = "coverage-7.2.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:046936ab032a2810dcaafd39cc4ef6dd295df1a7cbead08fe996d4765fca9fe4"},
{file = "coverage-7.2.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e627dee428a176ffb13697a2c4318d3f60b2ccdde3acdc9b3f304206ec130ccd"},
{file = "coverage-7.2.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fa54fb483decc45f94011898727802309a109d89446a3c76387d016057d2c84"},
{file = "coverage-7.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3668291b50b69a0c1ef9f462c7df2c235da3c4073f49543b01e7eb1dee7dd540"},
{file = "coverage-7.2.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7c20b731211261dc9739bbe080c579a1835b0c2d9b274e5fcd903c3a7821cf88"},
{file = "coverage-7.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5764e1f7471cb8f64b8cda0554f3d4c4085ae4b417bfeab236799863703e5de2"},
{file = "coverage-7.2.2-cp38-cp38-win32.whl", hash = "sha256:4f01911c010122f49a3e9bdc730eccc66f9b72bd410a3a9d3cb8448bb50d65d3"},
{file = "coverage-7.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:c448b5c9e3df5448a362208b8d4b9ed85305528313fca1b479f14f9fe0d873b8"},
{file = "coverage-7.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bfe7085783cda55e53510482fa7b5efc761fad1abe4d653b32710eb548ebdd2d"},
{file = "coverage-7.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9d22e94e6dc86de981b1b684b342bec5e331401599ce652900ec59db52940005"},
{file = "coverage-7.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:507e4720791977934bba016101579b8c500fb21c5fa3cd4cf256477331ddd988"},
{file = "coverage-7.2.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc4803779f0e4b06a2361f666e76f5c2e3715e8e379889d02251ec911befd149"},
{file = "coverage-7.2.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db8c2c5ace167fd25ab5dd732714c51d4633f58bac21fb0ff63b0349f62755a8"},
{file = "coverage-7.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4f68ee32d7c4164f1e2c8797535a6d0a3733355f5861e0f667e37df2d4b07140"},
{file = "coverage-7.2.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d52f0a114b6a58305b11a5cdecd42b2e7f1ec77eb20e2b33969d702feafdd016"},
{file = "coverage-7.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:797aad79e7b6182cb49c08cc5d2f7aa7b2128133b0926060d0a8889ac43843be"},
{file = "coverage-7.2.2-cp39-cp39-win32.whl", hash = "sha256:db45eec1dfccdadb179b0f9ca616872c6f700d23945ecc8f21bb105d74b1c5fc"},
{file = "coverage-7.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:8dbe2647bf58d2c5a6c5bcc685f23b5f371909a5624e9f5cd51436d6a9f6c6ef"},
{file = "coverage-7.2.2-pp37.pp38.pp39-none-any.whl", hash = "sha256:872d6ce1f5be73f05bea4df498c140b9e7ee5418bfa2cc8204e7f9b817caa968"},
{file = "coverage-7.2.2.tar.gz", hash = "sha256:36dd42da34fe94ed98c39887b86db9d06777b1c8f860520e21126a75507024f2"},
]
[package.extras]
@@ -231,7 +231,7 @@ toml = ["tomli"]
name = "decli"
version = "0.5.2"
description = "Minimal, easy-to-use, declarative cli tool"
category = "dev"
category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -253,14 +253,14 @@ files = [
[[package]]
name = "exceptiongroup"
version = "1.1.0"
version = "1.1.1"
description = "Backport of PEP 654 (exception groups)"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"},
{file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"},
{file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"},
{file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"},
]
[package.extras]
@@ -283,30 +283,30 @@ testing = ["pre-commit"]
[[package]]
name = "filelock"
version = "3.9.0"
version = "3.10.0"
description = "A platform independent file lock."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "filelock-3.9.0-py3-none-any.whl", hash = "sha256:f58d535af89bb9ad5cd4df046f741f8553a418c01a7856bf0d173bbc9f6bd16d"},
{file = "filelock-3.9.0.tar.gz", hash = "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de"},
{file = "filelock-3.10.0-py3-none-any.whl", hash = "sha256:e90b34656470756edf8b19656785c5fea73afa1953f3e1b0d645cef11cab3182"},
{file = "filelock-3.10.0.tar.gz", hash = "sha256:3199fd0d3faea8b911be52b663dfccceb84c95949dd13179aa21436d1a79c4ce"},
]
[package.extras]
docs = ["furo (>=2022.12.7)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
testing = ["covdefaults (>=2.2.2)", "coverage (>=7.0.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"]
docs = ["furo (>=2022.12.7)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.2.1)", "pytest (>=7.2.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"]
[[package]]
name = "identify"
version = "2.5.19"
version = "2.5.21"
description = "File identification library for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "identify-2.5.19-py2.py3-none-any.whl", hash = "sha256:3ee3533e7f6f5023157fbebbd5687bb4b698ce6f305259e0d24b2d7d9efb72bc"},
{file = "identify-2.5.19.tar.gz", hash = "sha256:4102ecd051f6884449e7359e55b38ba6cd7aafb6ef27b8e2b38495a5723ea106"},
{file = "identify-2.5.21-py2.py3-none-any.whl", hash = "sha256:69edcaffa8e91ae0f77d397af60f148b6b45a8044b2cc6d99cafa5b04793ff00"},
{file = "identify-2.5.21.tar.gz", hash = "sha256:7671a05ef9cfaf8ff63b15d45a91a1147a03aaccb2976d4e9bd047cbbc508471"},
]
[package.extras]
@@ -354,7 +354,7 @@ tests = ["pytest", "pytest-cov", "pytest-mock"]
name = "jinja2"
version = "3.1.2"
description = "A very fast and expressive template engine."
category = "dev"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -416,7 +416,7 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
name = "markupsafe"
version = "2.1.2"
description = "Safely add untrusted strings to HTML/XML markup."
category = "dev"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -562,7 +562,7 @@ setuptools = "*"
name = "packaging"
version = "23.0"
description = "Core utilities for Python packages"
category = "dev"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -584,14 +584,14 @@ files = [
[[package]]
name = "pathspec"
version = "0.11.0"
version = "0.11.1"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"},
{file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"},
{file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"},
{file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"},
]
[[package]]
@@ -679,14 +679,14 @@ files = [
[[package]]
name = "pre-commit"
version = "3.1.1"
version = "3.2.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
category = "dev"
optional = false
python-versions = ">=3.8"
files = [
{file = "pre_commit-3.1.1-py2.py3-none-any.whl", hash = "sha256:b80254e60668e1dd1f5c03a1c9e0413941d61f568a57d745add265945f65bfe8"},
{file = "pre_commit-3.1.1.tar.gz", hash = "sha256:d63e6537f9252d99f65755ae5b79c989b462d511ebbc481b561db6a297e1e865"},
{file = "pre_commit-3.2.0-py2.py3-none-any.whl", hash = "sha256:f712d3688102e13c8e66b7d7dbd8934a6dda157e58635d89f7d6fecdca39ce8a"},
{file = "pre_commit-3.2.0.tar.gz", hash = "sha256:818f0d998059934d0f81bb3667e3ccdc32da6ed7ccaac33e43dc231561ddaaa9"},
]
[package.dependencies]
@@ -738,21 +738,6 @@ files = [
[package.extras]
plugins = ["importlib-metadata"]
[[package]]
name = "pysnooper"
version = "1.1.1"
description = "A poor man's debugger for Python."
category = "dev"
optional = false
python-versions = "*"
files = [
{file = "PySnooper-1.1.1-py2.py3-none-any.whl", hash = "sha256:378f13d731a3e04d3d0350e5f295bdd0f1b49fc8a8b8bf2067fe1e5290bd20be"},
{file = "PySnooper-1.1.1.tar.gz", hash = "sha256:d17dc91cca1593c10230dce45e46b1d3ff0f8910f0c38e941edf6ba1260b3820"},
]
[package.extras]
tests = ["pytest"]
[[package]]
name = "pytest"
version = "7.2.2"
@@ -831,14 +816,14 @@ test = ["pytest-adaptavist (>=5.1.1)"]
[[package]]
name = "pytest-xdist"
version = "3.2.0"
version = "3.2.1"
description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "pytest-xdist-3.2.0.tar.gz", hash = "sha256:fa10f95a2564cd91652f2d132725183c3b590d9fdcdec09d3677386ecf4c1ce9"},
{file = "pytest_xdist-3.2.0-py3-none-any.whl", hash = "sha256:336098e3bbd8193276867cc87db8b22903c3927665dff9d1ac8684c02f597b68"},
{file = "pytest-xdist-3.2.1.tar.gz", hash = "sha256:1849bd98d8b242b948e472db7478e090bf3361912a8fed87992ed94085f54727"},
{file = "pytest_xdist-3.2.1-py3-none-any.whl", hash = "sha256:37290d161638a20b672401deef1cba812d110ac27e35d213f091d15b8beb40c9"},
]
[package.dependencies]
@@ -854,7 +839,7 @@ testing = ["filelock"]
name = "pyyaml"
version = "6.0"
description = "YAML parser and emitter for Python"
category = "dev"
category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -1102,29 +1087,29 @@ files = [
[[package]]
name = "ruff"
version = "0.0.254"
version = "0.0.257"
description = "An extremely fast Python linter, written in Rust."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.0.254-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:dd58c500d039fb381af8d861ef456c3e94fd6855c3d267d6c6718c9a9fe07be0"},
{file = "ruff-0.0.254-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:688379050ae05394a6f9f9c8471587fd5dcf22149bd4304a4ede233cc4ef89a1"},
{file = "ruff-0.0.254-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1429be6d8bd3db0bf5becac3a38bd56f8421447790c50599cd90fd53417ec4"},
{file = "ruff-0.0.254-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:059a380c08e849b6f312479b18cc63bba2808cff749ad71555f61dd930e3c9a2"},
{file = "ruff-0.0.254-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3f15d5d033fd3dcb85d982d6828ddab94134686fac2c02c13a8822aa03e1321"},
{file = "ruff-0.0.254-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8deba44fd563361c488dedec90dc330763ee0c01ba54e17df54ef5820079e7e0"},
{file = "ruff-0.0.254-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef20bf798ffe634090ad3dc2e8aa6a055f08c448810a2f800ab716cc18b80107"},
{file = "ruff-0.0.254-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0deb1d7226ea9da9b18881736d2d96accfa7f328c67b7410478cc064ad1fa6aa"},
{file = "ruff-0.0.254-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27d39d697fdd7df1f2a32c1063756ee269ad8d5345c471ee3ca450636d56e8c6"},
{file = "ruff-0.0.254-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2fc21d060a3197ac463596a97d9b5db2d429395938b270ded61dd60f0e57eb21"},
{file = "ruff-0.0.254-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f70dc93bc9db15cccf2ed2a831938919e3e630993eeea6aba5c84bc274237885"},
{file = "ruff-0.0.254-py3-none-musllinux_1_2_i686.whl", hash = "sha256:09c764bc2bd80c974f7ce1f73a46092c286085355a5711126af351b9ae4bea0c"},
{file = "ruff-0.0.254-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d4385cdd30153b7aa1d8f75dfd1ae30d49c918ead7de07e69b7eadf0d5538a1f"},
{file = "ruff-0.0.254-py3-none-win32.whl", hash = "sha256:c38291bda4c7b40b659e8952167f386e86ec29053ad2f733968ff1d78b4c7e15"},
{file = "ruff-0.0.254-py3-none-win_amd64.whl", hash = "sha256:e15742df0f9a3615fbdc1ee9a243467e97e75bf88f86d363eee1ed42cedab1ec"},
{file = "ruff-0.0.254-py3-none-win_arm64.whl", hash = "sha256:b435afc4d65591399eaf4b2af86e441a71563a2091c386cadf33eaa11064dc09"},
{file = "ruff-0.0.254.tar.gz", hash = "sha256:0eb66c9520151d3bd950ea43b3a088618a8e4e10a5014a72687881e6f3606312"},
{file = "ruff-0.0.257-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:7280640690c1d0046b20e0eb924319a89d8e22925d7d232180ce31196e7478f8"},
{file = "ruff-0.0.257-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:4582b73da61ab410ffda35b2987a6eacb33f18263e1c91810f0b9779ec4f41a9"},
{file = "ruff-0.0.257-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5acae9878f1136893e266348acdb9d30dfae23c296d3012043816432a5abdd51"},
{file = "ruff-0.0.257-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9f0912d045eee15e8e02e335c16d7a7f9fb6821aa5eb1628eeb5bbfa3d88908"},
{file = "ruff-0.0.257-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a9542c34ee5298b31be6c6ba304f14b672dcf104846ee65adb2466d3e325870"},
{file = "ruff-0.0.257-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3464f1ad4cea6c4b9325da13ae306bd22bf15d226e18d19c52db191b1f4355ac"},
{file = "ruff-0.0.257-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a54bfd559e558ee0df2a2f3756423fe6a9de7307bc290d807c3cdf351cb4c24"},
{file = "ruff-0.0.257-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3438fd38446e1a0915316f4085405c9feca20fe00a4b614995ab7034dbfaa7ff"},
{file = "ruff-0.0.257-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:358cc2b547bd6451dcf2427b22a9c29a2d9c34e66576c693a6381c5f2ed3011d"},
{file = "ruff-0.0.257-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:783390f1e94a168c79d7004426dae3e4ae2999cc85f7d00fdd86c62262b71854"},
{file = "ruff-0.0.257-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aaa3b5b6929c63a854b6bcea7a229453b455ab26337100b2905fae4523ca5667"},
{file = "ruff-0.0.257-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ecd7a84db4816df2dcd0f11c5365a9a2cf4fa70a19b3ac161b7b0bfa592959d"},
{file = "ruff-0.0.257-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3db8d77d5651a2c0d307102d717627a025d4488d406f54c2764b21cfbe11d822"},
{file = "ruff-0.0.257-py3-none-win32.whl", hash = "sha256:d2c8755fa4f6c5e5ec032ad341ca3beeecd16786e12c3f26e6b0cc40418ae998"},
{file = "ruff-0.0.257-py3-none-win_amd64.whl", hash = "sha256:3cec07d6fecb1ebbc45ea8eeb1047b929caa2f7dfb8dd4b0e1869ff789326da5"},
{file = "ruff-0.0.257-py3-none-win_arm64.whl", hash = "sha256:352f1bdb9b433b3b389aee512ffb0b82226ae1e25b3d92e4eaf0e7be6b1b6f6a"},
{file = "ruff-0.0.257.tar.gz", hash = "sha256:fedfd06a37ddc17449203c3e38fc83fb68de7f20b5daa0ee4e60d3599b38bab0"},
]
[[package]]
@@ -1144,6 +1129,18 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-g
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
[[package]]
name = "sh"
version = "2.0.3"
description = "Python subprocess replacement"
category = "dev"
optional = false
python-versions = ">=3.8.1,<4.0"
files = [
{file = "sh-2.0.3-py3-none-any.whl", hash = "sha256:351f8968a2ed99755665fef62f038d60b5245999d73c2f1b1705f48b22e2a853"},
{file = "sh-2.0.3.tar.gz", hash = "sha256:800efeda403b63879b0a5625f65a0021fd1ea61ed181954da0346372a7b2a341"},
]
[[package]]
name = "shellingham"
version = "1.5.0.post1"
@@ -1175,7 +1172,7 @@ widechars = ["wcwidth"]
name = "termcolor"
version = "2.2.0"
description = "ANSI color formatting for output in terminal"
category = "dev"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -1224,19 +1221,22 @@ files = [
[[package]]
name = "typeguard"
version = "2.13.3"
version = "3.0.1"
description = "Run-time type checker for Python"
category = "dev"
optional = false
python-versions = ">=3.5.3"
python-versions = ">=3.7.4"
files = [
{file = "typeguard-2.13.3-py3-none-any.whl", hash = "sha256:5e3e3be01e887e7eafae5af63d1f36c849aaa94e3a0112097312aabfa16284f1"},
{file = "typeguard-2.13.3.tar.gz", hash = "sha256:00edaa8da3a133674796cf5ea87d9f4b4c367d77476e185e80251cc13dfbb8c4"},
{file = "typeguard-3.0.1-py3-none-any.whl", hash = "sha256:15628045c830abf68533247afd2cb04683b5ce6f4e30d5401a5ef6f5182280de"},
{file = "typeguard-3.0.1.tar.gz", hash = "sha256:beb0e67c5dc76eea4a6d00a6606d444d899589908362960769d0c4a1d32bca70"},
]
[package.dependencies]
typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.11\""}
[package.extras]
doc = ["sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["mypy", "pytest", "typing-extensions"]
doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["mypy (>=0.991)", "pytest (>=7)"]
[[package]]
name = "typer"
@@ -1275,7 +1275,7 @@ files = [
name = "typing-extensions"
version = "4.5.0"
description = "Backported and Experimental Type Hints for Python 3.7+"
category = "dev"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -1285,14 +1285,14 @@ files = [
[[package]]
name = "virtualenv"
version = "20.20.0"
version = "20.21.0"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "virtualenv-20.20.0-py3-none-any.whl", hash = "sha256:3c22fa5a7c7aa106ced59934d2c20a2ecb7f49b4130b8bf444178a16b880fa45"},
{file = "virtualenv-20.20.0.tar.gz", hash = "sha256:a8a4b8ca1e28f864b7514a253f98c1d62b64e31e77325ba279248c65fb4fcef4"},
{file = "virtualenv-20.21.0-py3-none-any.whl", hash = "sha256:31712f8f2a17bd06234fa97fdf19609e789dd4e3e4bf108c3da71d710651adbc"},
{file = "virtualenv-20.21.0.tar.gz", hash = "sha256:f50e3e60f990a0757c9b68333c9fdaa72d7188caa417f96af9e52407831a3b68"},
]
[package.dependencies]
@@ -1349,4 +1349,4 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "b77f1653a71eca187c8d47f8b27d2677191fe37d932fc9ae8e696c462d2ea999"
content-hash = "e9e2ff35a5ae15991d1d123dffa9f15fdf5afaf00624c26577412555d0464eaf"

View File

@@ -11,7 +11,7 @@
name = "obsidian-metadata"
readme = "README.md"
repository = "https://github.com/natelandau/obsidian-metadata"
version = "0.8.0"
version = "0.9.0"
[tool.poetry.scripts] # https://python-poetry.org/docs/pyproject/#scripts
obsidian-metadata = "obsidian_metadata.cli:app"
@@ -26,82 +26,40 @@
shellingham = "^1.5.0.post1"
tomlkit = "^0.11.6"
typer = "^0.7.0"
commitizen = "^2.42.1"
[tool.poetry.group.test.dependencies]
pytest = "^7.2.2"
pytest-clarity = "^1.0.1"
pytest-mock = "^3.10.0"
pytest-pretty-terminal = "^1.1.0"
pytest-xdist = "^3.2.0"
pytest-xdist = "^3.2.1"
[tool.poetry.group.dev.dependencies]
black = "^23.1.0"
commitizen = "^2.42.1"
coverage = "^7.2.1"
coverage = "^7.2.2"
interrogate = "^1.5.0"
mypy = "^1.1.1"
pdoc = "^13.0.0"
poethepoet = "^0.18.1"
pre-commit = "^3.1.1"
pysnooper = "^1.1.1"
ruff = "^0.0.254"
typeguard = "^2.13.3"
pre-commit = "^3.2.0"
ruff = "0.0.257"
typeguard = "^3.0.1"
types-python-dateutil = "^2.8.19.10"
vulture = "^2.7"
sh = "2.0.3"
[tool.ruff] # https://github.com/charliermarsh/ruff
fix = true
ignore = [
"B006",
"B008",
"D107",
"D203",
"D204",
"D213",
"D215",
"D404",
"D406",
"D407",
"D408",
"D409",
"D413",
"E501",
"N805",
"PGH001",
"PGH003",
"UP007",
]
ignore-init-module-imports = true
[tool.black]
line-length = 100
per-file-ignores = { "cli.py" = ["PLR0912", "PLR0913"], "tests/*.py" = ["PLR0913", "PLR2004"] }
select = [
"A",
"B",
"BLE",
"C4",
"C90",
"D",
"E",
"ERA",
"F",
"I",
"N",
"PGH",
"PLC",
"PLE",
"PLR",
"PLW",
"RET",
"RUF",
"SIM",
"TID",
"UP",
"W",
"YTT",
]
src = ["src", "tests"]
target-version = "py310"
unfixable = ["ERA001", "F401", "F841", "UP007"]
[tool.commitizen]
bump_message = "bump(release): v$current_version → v$new_version"
changelog_incremental = true
tag_format = "v$version"
update_changelog_on_bump = true
version = "0.9.0"
version_files = ["pyproject.toml:version", "src/obsidian_metadata/__version__.py:__version__"]
[tool.coverage.report] # https://coverage.readthedocs.io/en/latest/config.html#report
exclude_lines = [
@@ -134,17 +92,6 @@
[tool.coverage.xml]
output = "reports/coverage.xml"
[tool.black]
line-length = 100
[tool.commitizen]
bump_message = "bump(release): v$current_version → v$new_version"
changelog_incremental = true
tag_format = "v$version"
update_changelog_on_bump = true
version = "0.8.0"
version_files = ["pyproject.toml:version", "src/obsidian_metadata/__version__.py:__version__"]
[tool.interrogate]
exclude = ["build", "docs", "tests"]
fail-under = 90
@@ -178,6 +125,108 @@
testpaths = ["src", "tests"]
xfail_strict = true
[tool.ruff] # https://github.com/charliermarsh/ruff
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".hg",
".mypy_cache",
".nox",
".pants.d",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"venv",
]
# Avoiding flagging (and removing) `V101` from any `# noqa`
# directives, despite Ruff's lack of support for `vulture`.
external = ["V101"]
fix = true
ignore = [
"B006",
"B008",
"D107",
"D203",
"D204",
"D213",
"D215",
"D404",
"D406",
"D407",
"D408",
"D409",
"D413",
"E501",
"N805",
"PGH001",
"PGH003",
"UP007",
]
ignore-init-module-imports = true
line-length = 100
per-file-ignores = { "cli.py" = [
"PLR0912",
"PLR0913",
], "tests/*.py" = [
"PLR0913",
"PLR2004",
"S101",
] }
select = [
"A", # flake8-builtins
"ARG", # flake8-unused-arguments
"B", # flake8-bugbear
"BLE", # flake8-blind-exception
"C40", # flake8-comprehensions
"C90", # McCabe
"D", # pydocstyle
"E", # pycodestyle Errors
"ERA", # flake8-eradicate
"EXE", # flake8-executable
"F", # pyflakes
"I", # iSort
"N", # Pep8-naming
"PGH", # pygrep-hooks
"PLC", # pylint Convention
"PLE", # pylint Error
"PLR", # pylint Refactor
"PLW", # pylint Warning
"PT", # flake8-pytest-style
"PTH", # flake8-use-pathlib
"Q", # flake8-quotes
"RET", # flake8-return
"RUF", # Ruff-specific rules
"S", # flake8-bandit
"SIM", # flake8-simplify
"TID", # flake8-tidy-imports
"UP", # pyupgrade
"W", # pycodestyle Warnings
"YTT", # flake8-2020
]
src = ["src", "tests"]
target-version = "py310"
unfixable = ["ERA001", "F401", "F841", "UP007"]
[tool.ruff.mccabe]
# Unlike Flake8, default to a complexity level of 10.
max-complexity = 10
[tool.ruff.pydocstyle]
convention = "google"
[tool.ruff.pylint]
max-args = 6
[tool.vulture] # https://pypi.org/project/vulture/
# exclude = ["file*.py", "dir/"]
# ignore_decorators = ["@app.route", "@require_*"]
@@ -203,7 +252,7 @@
help = "Lint this package"
[[tool.poe.tasks.lint.sequence]]
shell = "ruff --extend-ignore=I001,D301,D401 src/"
shell = "ruff src/ --no-fix"
[[tool.poe.tasks.lint.sequence]]
shell = "black --check src/ tests/"

150
scripts/update_dependencies.py Executable file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python
"""Script to update the pyproject.toml file with the latest versions of the dependencies."""
from pathlib import Path
from textwrap import wrap
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover
import tomli as tomllib # type: ignore [no-redef]
import sh
from rich.console import Console
console = Console()
def dryrun(msg: str) -> None:
"""Print a message if the dry run flag is set.
Args:
msg: Message to print
"""
console.print(f"[cyan]DRYRUN | {msg}[/cyan]")
def success(msg: str) -> None:
"""Print a success message without using logging.
Args:
msg: Message to print
"""
console.print(f"[green]SUCCESS | {msg}[/green]")
def warning(msg: str) -> None:
"""Print a warning message without using logging.
Args:
msg: Message to print
"""
console.print(f"[yellow]WARNING | {msg}[/yellow]")
def error(msg: str) -> None:
"""Print an error message without using logging.
Args:
msg: Message to print
"""
console.print(f"[red]ERROR | {msg}[/red]")
def notice(msg: str) -> None:
"""Print a notice message without using logging.
Args:
msg: Message to print
"""
console.print(f"[bold]NOTICE | {msg}[/bold]")
def info(msg: str) -> None:
"""Print a notice message without using logging.
Args:
msg: Message to print
"""
console.print(f"INFO | {msg}")
def usage(msg: str, width: int = 80) -> None:
"""Print a usage message without using logging.
Args:
msg: Message to print
width (optional): Width of the message
"""
for _n, line in enumerate(wrap(msg, width=width)):
if _n == 0:
console.print(f"[dim]USAGE | {line}")
else:
console.print(f"[dim] | {line}")
def debug(msg: str) -> None:
"""Print a debug message without using logging.
Args:
msg: Message to print
"""
console.print(f"[blue]DEBUG | {msg}[/blue]")
def dim(msg: str) -> None:
"""Print a message in dimmed color.
Args:
msg: Message to print
"""
console.print(f"[dim]{msg}[/dim]")
# Load the pyproject.toml file
pyproject = Path(__file__).parents[1] / "pyproject.toml"
if not pyproject.exists():
console.print("pyproject.toml file not found")
raise SystemExit(1)
with pyproject.open("rb") as f:
try:
data = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
raise SystemExit(1) from e
# Get the latest versions of all dependencies
info("Getting latest versions of dependencies...")
packages: dict = {}
for line in sh.poetry("--no-ansi", "show", "--outdated").splitlines():
package, current, latest = line.split()[:3]
packages[package] = {"current_version": current, "new_version": latest}
if not packages:
success("All dependencies are up to date")
raise SystemExit(0)
dependencies = data["tool"]["poetry"]["dependencies"]
groups = data["tool"]["poetry"]["group"]
for p in dependencies:
if p in packages:
notice(
f"Updating {p} from {packages[p]['current_version']} to {packages[p]['new_version']}"
)
sh.poetry("add", f"{p}@latest", _fg=True)
for group in groups:
for p in groups[group]["dependencies"]:
if p in packages:
notice(
f"Updating {p} from {packages[p]['current_version']} to {packages[p]['new_version']}"
)
sh.poetry("add", f"{p}@{packages[p]['new_version']}", "--group", group, _fg=True)
sh.poetry("update", _fg=True)
success("All dependencies are up to date")
raise SystemExit(0)

View File

@@ -1,2 +1,2 @@
"""obsidian-metadata version."""
__version__ = "0.8.0"
__version__ = "0.9.0"

View File

@@ -91,7 +91,7 @@ class Config:
def _load_config(self) -> dict[str, Any]:
"""Load the configuration file."""
try:
with open(self.config_path, encoding="utf-8") as fp:
with self.config_path.open(encoding="utf-8") as fp:
return tomlkit.load(fp)
except tomlkit.exceptions.TOMLKitError as e:
alerts.error(f"Could not parse '{self.config_path}'")

View File

@@ -6,10 +6,12 @@ from obsidian_metadata._utils.utilities import (
clean_dictionary,
clear_screen,
dict_contains,
dict_keys_to_lower,
dict_values_to_lists_strings,
docstring_parameter,
merge_dictionaries,
remove_markdown_sections,
validate_csv_bulk_imports,
version_callback,
)
@@ -18,11 +20,12 @@ __all__ = [
"clean_dictionary",
"clear_screen",
"dict_contains",
"dict_keys_to_lower",
"dict_values_to_lists_strings",
"docstring_parameter",
"LoggerManager",
"merge_dictionaries",
"remove_markdown_sections",
"vault_validation",
"validate_csv_bulk_imports",
"version_callback",
]

View File

@@ -87,13 +87,16 @@ def info(msg: str) -> None:
console.print(f"INFO | {msg}")
def usage(msg: str, width: int = 80) -> None:
def usage(msg: str, width: int = None) -> None:
"""Print a usage message without using logging.
Args:
msg: Message to print
width (optional): Width of the message
"""
if width is None:
width = console.width - 15
for _n, line in enumerate(wrap(msg, width=width)):
if _n == 0:
console.print(f"[dim]USAGE | {line}")
@@ -126,9 +129,12 @@ def _log_formatter(record: dict) -> str:
or record["level"].name == "SUCCESS"
or record["level"].name == "WARNING"
):
return "<level>{level: <8}</level> | <level>{message}</level>\n{exception}"
return "<level><normal>{level: <8} | {message}</normal></level>\n{exception}"
return "<level>{level: <8}</level> | <level>{message}</level> <fg #c5c5c5>({name}:{function}:{line})</fg #c5c5c5>\n{exception}"
if record["level"].name == "TRACE" or record["level"].name == "DEBUG":
return "<level><normal>{level: <8} | {message}</normal></level> <fg #c5c5c5>({name}:{function}:{line})</fg #c5c5c5>\n{exception}"
return "<level>{level: <8} | {message}</level> <fg #c5c5c5>({name}:{function}:{line})</fg #c5c5c5>\n{exception}"
@rich.repr.auto

View File

@@ -1,11 +1,15 @@
"""Utility functions."""
import csv
import re
from os import name, system
from pathlib import Path
from typing import Any
import typer
from obsidian_metadata.__version__ import __version__
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata._utils.console import console
@@ -63,6 +67,18 @@ def dict_contains(
return key in dictionary and value in dictionary[key]
def dict_keys_to_lower(dictionary: dict) -> dict:
"""Convert all keys in a dictionary to lowercase.
Args:
dictionary (dict): Dictionary to convert
Returns:
dict: Dictionary with all keys converted to lowercase
"""
return {key.lower(): value for key, value in dictionary.items()}
def dict_values_to_lists_strings(
dictionary: dict,
strip_null_values: bool = False,
@@ -86,7 +102,7 @@ def dict_values_to_lists_strings(
new_dict[key] = sorted([str(item) for item in value if item is not None])
elif isinstance(value, dict):
new_dict[key] = dict_values_to_lists_strings(value) # type: ignore[assignment]
elif value is None or value == "None" or value == "":
elif value is None or value == "None" or not value:
new_dict[key] = []
else:
new_dict[key] = [str(value)]
@@ -179,7 +195,56 @@ def remove_markdown_sections(
if strip_frontmatter:
text = re.sub(r"^\s*---.*?---", "", text, flags=re.DOTALL)
return text # noqa: RET504
return text
def validate_csv_bulk_imports(csv_path: Path, note_paths: list) -> dict[str, list[dict[str, str]]]:
"""Validate the bulk import CSV file.
Args:
csv_path (dict): Dictionary to validate
note_paths (list): List of paths to all notes in vault
Returns:
dict: Validated dictionary
"""
csv_dict: dict[str, Any] = {}
with csv_path.expanduser().open("r") as csv_file:
csv_reader = csv.DictReader(csv_file, delimiter=",")
row_num = 0
for row in csv_reader:
if row_num == 0:
if "path" not in row:
raise typer.BadParameter("Missing 'path' column in CSV file")
if "type" not in row:
raise typer.BadParameter("Missing 'type' column in CSV file")
if "key" not in row:
raise typer.BadParameter("Missing 'key' column in CSV file")
if "value" not in row:
raise typer.BadParameter("Missing 'value' column in CSV file")
row_num += 1
if row["path"] not in csv_dict:
csv_dict[row["path"]] = []
csv_dict[row["path"]].append(
{"type": row["type"], "key": row["key"], "value": row["value"]}
)
if row_num == 0 or row_num == 1:
raise typer.BadParameter("Empty CSV file")
paths_to_remove = [x for x in csv_dict if x not in note_paths]
for _path in paths_to_remove:
alerts.warning(f"'{_path}' does not exist in vault. Skipping...")
del csv_dict[_path]
if len(csv_dict) == 0:
log.error("No paths in the CSV file matched paths in the vault")
raise typer.Exit(1)
return csv_dict
def version_callback(value: bool) -> None:

View File

@@ -79,7 +79,7 @@ def main(
help="""Set verbosity level (0=WARN, 1=INFO, 2=DEBUG, 3=TRACE)""",
count=True,
),
version: Optional[bool] = typer.Option(
version: Optional[bool] = typer.Option( # noqa: ARG001
None, "--version", help="Print version and exit", callback=version_callback, is_eager=True
),
) -> None:
@@ -91,74 +91,7 @@ def main(
[bold underline]Configuration:[/]
Configuration is specified in a configuration file. On First run, this file will be created at [tan]~/.{0}.env[/]. Any options specified on the command line will override the configuration file.
[bold underline]Usage:[/]
[tan]Obsidian-metadata[/] provides a menu of sub-commands.
[bold underline]Vault Actions[/]
Create or delete a backup of your vault.
• Backup: Create a backup of the vault.
• Delete Backup: Delete a backup of the vault.
[bold underline]Inspect Metadata[/]
Inspect the metadata in your vault.
• View all metadata in the vault
• View all frontmatter
• View all inline metadata
• View all inline tags
• Export all metadata to CSV or JSON file
[bold underline]Filter Notes in Scope[/]
Limit the scope of notes to be processed with one or more filters.
• Path filter (regex): Limit scope based on the path or filename
• Metadata filter: Limit scope based on a key or key/value pair
• Tag filter: Limit scope based on an in-text tag
• List and clear filters: List all current filters and clear one or all
• List notes in scope: List notes that will be processed.
[bold underline]Add Metadata[/]
Add new metadata to your vault.
• Add new metadata to the frontmatter
• Add new inline metadata - Set `insert_location` in the config to
control where the new metadata is inserted. (Default: Bottom)
• Add new inline tag - Set `insert_location` in the config to
control where the new tag is inserted. (Default: Bottom)
[bold underline]Rename Metadata[/]
Rename either a key and all associated values, a specific value within a key. or an in-text tag.
• Rename a key
• Rename a value
• rename an inline tag
[bold underline]Delete Metadata[/]
Delete either a key and all associated values, or a specific value.
• Delete a key and associated values
• Delete a value from a key
• Delete an inline tag
[bold underline]Move Inline Metadata[/]
Move inline metadata to a specified location with a note
• Move to Top - Move all inline metadata beneath the frontmatter
• Move to After Title* - Move all inline metadata beneath the first markdown header
• Move to Bottom - Move all inline metadata to the bottom of the note
[bold underline]Transpose Metadata[/]
Move metadata from inline to frontmatter or the reverse. When transposing to inline metadata,
the `insert location` value in the config file will specify where in the
note it will be inserted.
• Transpose all metadata - Moves all frontmatter to inline
metadata, or the reverse
• Transpose key - Transposes a specific key and all it's values
• Transpose value - Transpose a specific key:value pair
[bold underline]Review Changes[/]
Prior to committing changes, review all changes that will be made.
• View a diff of the changes that will be made
[bold underline]Commit Changes[/]
Write the changes to disk. This step is not undoable.
• Commit changes to the vault
Full usage information is available at https://github.com/natelandau/obsidian-metadata
"""
# Instantiate logger

View File

@@ -10,7 +10,7 @@ from rich import box
from rich.table import Table
from obsidian_metadata._config import VaultConfig
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils import alerts, validate_csv_bulk_imports
from obsidian_metadata._utils.console import console
from obsidian_metadata.models import InsertLocation, Vault, VaultFilter
from obsidian_metadata.models.enums import MetadataType
@@ -53,8 +53,12 @@ class Application:
match self.questions.ask_application_main():
case "vault_actions":
self.application_vault()
case "export_metadata":
self.application_export_metadata()
case "inspect_metadata":
self.application_inspect_metadata()
case "import_from_csv":
self.application_import_csv()
case "filter_notes":
self.application_filter()
case "add_metadata":
@@ -73,7 +77,6 @@ class Application:
break
console.print("Done!")
return
def application_add_metadata(self) -> None:
"""Add metadata."""
@@ -125,6 +128,7 @@ class Application:
alerts.usage("Delete either a key and all associated values, or a specific value.")
choices = [
questionary.Separator(),
{"name": "Delete inline tag", "value": "delete_inline_tag"},
{"name": "Delete key", "value": "delete_key"},
{"name": "Delete value", "value": "delete_value"},
@@ -148,6 +152,7 @@ class Application:
alerts.usage("Select the type of metadata to rename.")
choices = [
questionary.Separator(),
{"name": "Rename inline tag", "value": "rename_inline_tag"},
{"name": "Rename key", "value": "rename_key"},
{"name": "Rename value", "value": "rename_value"},
@@ -171,6 +176,7 @@ class Application:
alerts.usage("Limit the scope of notes to be processed with one or more filters.")
choices = [
questionary.Separator(),
{"name": "Apply new regex path filter", "value": "apply_path_filter"},
{"name": "Apply new metadata filter", "value": "apply_metadata_filter"},
{"name": "Apply new in-text tag filter", "value": "apply_tag_filter"},
@@ -183,7 +189,7 @@ class Application:
match self.questions.ask_selection(choices=choices, question="Select an action"):
case "apply_path_filter":
path = self.questions.ask_filter_path()
if path is None or path == "": # pragma: no cover
if path is None or not path: # pragma: no cover
return
self.filters.append(VaultFilter(path_filter=path))
@@ -200,7 +206,7 @@ class Application:
)
if value is None: # pragma: no cover
return
if value == "":
if not value:
self.filters.append(VaultFilter(key_filter=key))
else:
self.filters.append(VaultFilter(key_filter=key, value_filter=value))
@@ -208,7 +214,7 @@ class Application:
case "apply_tag_filter":
tag = self.questions.ask_existing_inline_tag()
if tag is None or tag == "":
if tag is None or not tag:
return
self.filters.append(VaultFilter(tag_filter=tag))
@@ -277,6 +283,76 @@ class Application:
case _:
return
def application_import_csv(self) -> None:
"""Import CSV for bulk changes to metadata."""
alerts.usage(
"Import CSV to make build changes to metadata. The CSV must have the following columns: path, type, key, value. Where type is one of 'frontmatter', 'inline_metadata', or 'tag'. Note: this will not create new notes."
)
path = self.questions.ask_path(question="Enter path to a CSV file", valid_file=True)
if path is None:
return
csv_path = Path(path).expanduser()
if "csv" not in csv_path.suffix.lower():
alerts.error("File must be a CSV file")
return
note_paths = [
str(n.note_path.relative_to(self.vault.vault_path)) for n in self.vault.all_notes
]
dict_from_csv = validate_csv_bulk_imports(csv_path, note_paths)
num_changed = self.vault.update_from_dict(dict_from_csv)
if num_changed == 0:
alerts.warning("No notes were changed")
return
alerts.success(f"Rewrote metadata for {num_changed} notes.")
def application_export_metadata(self) -> None:
"""Export metadata to various formats."""
alerts.usage(
"Export the metadata in your vault. Note, uncommitted changes will be reflected in these files. The notes csv export can be used as template for importing bulk changes"
)
choices = [
questionary.Separator(),
{"name": "Metadata by type to CSV", "value": "export_csv"},
{"name": "Metadata by type to JSON", "value": "export_json"},
{
"name": "Metadata by note to CSV [Bulk import template]",
"value": "export_notes_csv",
},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
while True:
match self.questions.ask_selection(choices=choices, question="Export format"):
case "export_csv":
path = self.questions.ask_path(question="Enter a path for the CSV file")
if path is None:
return
self.vault.export_metadata(path=path, export_format="csv")
alerts.success(f"CSV written to {path}")
case "export_json":
path = self.questions.ask_path(question="Enter a path for the JSON file")
if path is None:
return
self.vault.export_metadata(path=path, export_format="json")
alerts.success(f"JSON written to {path}")
case "export_notes_csv":
path = self.questions.ask_path(question="Enter a path for the CSV file")
if path is None:
return
self.vault.export_notes_to_csv(path=path)
alerts.success(f"CSV written to {path}")
return
case _:
return
def application_inspect_metadata(self) -> None:
"""View metadata."""
alerts.usage(
@@ -284,19 +360,17 @@ class Application:
)
choices = [
questionary.Separator(),
{"name": "View all frontmatter", "value": "all_frontmatter"},
{"name": "View all inline metadata", "value": "all_inline"},
{"name": "View all inline tags", "value": "all_tags"},
{"name": "View all keys", "value": "all_keys"},
{"name": "View all metadata", "value": "all_metadata"},
questionary.Separator(),
{"name": "Write all metadata to CSV", "value": "export_csv"},
{"name": "Write all metadata to JSON file", "value": "export_json"},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
while True:
match self.questions.ask_selection(choices=choices, question="Select a vault action"):
match self.questions.ask_selection(choices=choices, question="Select an action"):
case "all_metadata":
console.print("")
self.vault.metadata.print_metadata(area=MetadataType.ALL)
@@ -317,18 +391,6 @@ class Application:
console.print("")
self.vault.metadata.print_metadata(area=MetadataType.TAGS)
console.print("")
case "export_csv":
path = self.questions.ask_path(question="Enter a path for the CSV file")
if path is None:
return
self.vault.export_metadata(path=path, export_format="csv")
alerts.success(f"Metadata written to {path}")
case "export_json":
path = self.questions.ask_path(question="Enter a path for the JSON file")
if path is None:
return
self.vault.export_metadata(path=path, export_format="json")
alerts.success(f"Metadata written to {path}")
case _:
return
@@ -343,6 +405,7 @@ class Application:
alerts.usage(" 2. Move the location of inline metadata within a note.")
choices = [
questionary.Separator(),
{"name": "Move inline metadata to top of note", "value": "move_to_top"},
{
"name": "Move inline metadata beneath the first header",
@@ -375,6 +438,7 @@ class Application:
alerts.usage("Create or delete a backup of your vault.")
choices = [
questionary.Separator(),
{"name": "Backup vault", "value": "backup_vault"},
{"name": "Delete vault backup", "value": "delete_backup"},
questionary.Separator(),
@@ -565,6 +629,7 @@ class Application:
alerts.info(f"Found {len(changed_notes)} changed notes in the vault")
choices: list[dict[str, Any] | questionary.Separator] = []
choices.append(questionary.Separator())
for n, note in enumerate(changed_notes, start=1):
_selection = {
"name": f"{n}: {note.note_path.relative_to(self.vault.vault_path)}",

View File

@@ -210,7 +210,7 @@ class VaultMetadata:
class Frontmatter:
"""Representation of frontmatter metadata."""
def __init__(self, file_content: str):
def __init__(self, file_content: str) -> None:
self.dict: dict[str, list[str]] = self._grab_note_frontmatter(file_content)
self.dict_original: dict[str, list[str]] = copy.deepcopy(self.dict)
@@ -245,6 +245,9 @@ class Frontmatter:
except Exception as e: # noqa: BLE001
raise AttributeError(e) from e
if frontmatter is None or frontmatter == [None]:
return {}
for k in frontmatter:
if frontmatter[k] is None:
frontmatter[k] = []
@@ -278,6 +281,7 @@ class Frontmatter:
if key in self.dict and value not in self.dict[key]:
if isinstance(value, list):
self.dict[key].extend(value)
self.dict[key] = list(sorted(set(self.dict[key])))
return True
self.dict[key].append(value)
@@ -308,7 +312,7 @@ class Frontmatter:
Returns:
bool: True if a value was deleted
"""
new_dict = dict(self.dict)
new_dict = copy.deepcopy(self.dict)
if value_to_delete is None:
for _k in list(new_dict):
@@ -326,6 +330,10 @@ class Frontmatter:
return False
def delete_all(self) -> None:
"""Delete all Frontmatter from the note."""
self.dict = {}
def has_changes(self) -> bool:
"""Check if the frontmatter has changes.
@@ -389,8 +397,8 @@ class Frontmatter:
class InlineMetadata:
"""Representation of inline metadata in the form of `key:: value`."""
def __init__(self, file_content: str):
self.dict: dict[str, list[str]] = self.grab_inline_metadata(file_content)
def __init__(self, file_content: str) -> None:
self.dict: dict[str, list[str]] = self._grab_inline_metadata(file_content)
self.dict_original: dict[str, list[str]] = copy.deepcopy(self.dict)
def __repr__(self) -> str: # pragma: no cover
@@ -401,6 +409,30 @@ class InlineMetadata:
"""
return f"InlineMetadata(inline_metadata={self.dict})"
def _grab_inline_metadata(self, file_content: str) -> dict[str, list[str]]:
"""Grab inline metadata from a note.
Returns:
dict[str, str]: Inline metadata from the note.
"""
content = remove_markdown_sections(
file_content,
strip_codeblocks=True,
strip_inlinecode=True,
strip_frontmatter=True,
)
all_results = PATTERNS.find_inline_metadata.findall(content)
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
inline_metadata: dict[str, list[str]] = {}
for k, v in stripped_null_values:
if k in inline_metadata:
inline_metadata[k].append(str(v))
else:
inline_metadata[k] = [str(v)]
return clean_dictionary(inline_metadata)
def add(self, key: str, value: str | list[str] = None) -> bool: # noqa: PLR0911
"""Add a key and value to the inline metadata.
@@ -428,6 +460,7 @@ class InlineMetadata:
if key in self.dict and value not in self.dict[key]:
if isinstance(value, list):
self.dict[key].extend(value)
self.dict[key] = list(sorted(set(self.dict[key])))
return True
self.dict[key].append(value)
@@ -476,30 +509,6 @@ class InlineMetadata:
return False
def grab_inline_metadata(self, file_content: str) -> dict[str, list[str]]:
"""Grab inline metadata from a note.
Returns:
dict[str, str]: Inline metadata from the note.
"""
content = remove_markdown_sections(
file_content,
strip_codeblocks=True,
strip_inlinecode=True,
strip_frontmatter=True,
)
all_results = PATTERNS.find_inline_metadata.findall(content)
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
inline_metadata: dict[str, list[str]] = {}
for k, v in stripped_null_values:
if k in inline_metadata:
inline_metadata[k].append(str(v))
else:
inline_metadata[k] = [str(v)]
return clean_dictionary(inline_metadata)
def has_changes(self) -> bool:
"""Check if the metadata has changes.
@@ -535,7 +544,7 @@ class InlineMetadata:
class InlineTags:
"""Representation of inline tags."""
def __init__(self, file_content: str):
def __init__(self, file_content: str) -> None:
self.metadata_key = INLINE_TAG_KEY
self.list: list[str] = self._grab_inline_tags(file_content)
self.list_original: list[str] = self.list.copy()

View File

@@ -41,7 +41,7 @@ class Note:
inline_metadata (dict): Dictionary of inline metadata in the note.
"""
def __init__(self, note_path: Path, dry_run: bool = False):
def __init__(self, note_path: Path, dry_run: bool = False) -> None:
log.trace(f"Creating Note object for {note_path}")
self.note_path: Path = Path(note_path)
self.dry_run: bool = dry_run
@@ -146,7 +146,7 @@ class Note:
return
try:
with open(p, "w") as f:
with p.open(mode="w") as f:
log.trace(f"Writing note {p} to disk")
f.write(self.file_content)
except FileNotFoundError as e:
@@ -190,6 +190,17 @@ class Note:
return False
def delete_all_metadata(self) -> None:
"""Delete all metadata from the note. Removes all frontmatter and inline metadata and tags from the body of the note and from the associated metadata objects."""
for key in self.inline_metadata.dict:
self.delete_metadata(key=key, area=MetadataType.INLINE)
for tag in self.inline_tags.list:
self.delete_inline_tag(tag=tag)
self.frontmatter.delete_all()
self.write_frontmatter()
def delete_inline_tag(self, tag: str) -> bool:
"""Delete an inline tag from the `inline_tags` attribute AND removes the tag from the text of the note if it exists.
@@ -354,7 +365,7 @@ class Note:
self.file_content = re.sub(pattern, replacement, self.file_content, re.MULTILINE)
def transpose_metadata( # noqa: C901, PLR0912, PLR0911, PLR0913
def transpose_metadata( # noqa: C901, PLR0912, PLR0911
self,
begin: MetadataType,
end: MetadataType,
@@ -579,7 +590,7 @@ class Note:
except AttributeError:
top = ""
if top == "":
if not top:
self.file_content = f"{new_string}\n{self.file_content}"
return True
@@ -593,7 +604,7 @@ class Note:
except AttributeError:
top = ""
if top == "":
if not top:
self.file_content = f"{new_string}\n{self.file_content}"
return True

View File

@@ -25,7 +25,7 @@ class Patterns:
([-_\w\d\/\*\u263a-\U0001f999]+?)::[ ]? # Find key
(.*?)\] # Find value until closing bracket
| # Else look for key values at start of line
(?:^|[^ \w\d]+| \[) # Any non-word or non-digit character
(?:^|[^ \w\d]+|^ *>?[-\d\|]?\.? ) # Any non-word or non-digit character
([-_\w\d\/\*\u263a-\U0001f9995]+?)::(?!\n)(?:[ ](?!\n))? # Capture the key if not a new line
(.*?)$ # Capture the value
""",

View File

@@ -200,6 +200,23 @@ class Questions:
return True
def _validate_path_is_file(self, text: str) -> bool | str:
"""Validate a path is a file.
Args:
text (str): The path to validate.
Returns:
bool | str: True if the path is valid, otherwise a string with the error message.
"""
path_to_validate: Path = Path(text).expanduser().resolve()
if not path_to_validate.exists():
return f"Path does not exist: {path_to_validate}"
if not path_to_validate.is_file():
return f"Path is not a file: {path_to_validate}"
return True
def _validate_valid_vault_regex(self, text: str) -> bool | str:
"""Validate a valid regex.
@@ -276,9 +293,11 @@ class Questions:
choices=[
questionary.Separator("-------------------------------"),
{"name": "Vault Actions", "value": "vault_actions"},
{"name": "Export Metadata", "value": "export_metadata"},
{"name": "Inspect Metadata", "value": "inspect_metadata"},
{"name": "Filter Notes in Scope", "value": "filter_notes"},
questionary.Separator("-------------------------------"),
{"name": "Bulk changes from imported CSV", "value": "import_from_csv"},
{"name": "Add Metadata", "value": "add_metadata"},
{"name": "Delete Metadata", "value": "delete_metadata"},
{"name": "Rename Metadata", "value": "rename_metadata"},
@@ -423,7 +442,7 @@ class Questions:
return self.ask_selection(
choices=choices,
question="Select the location for the metadata",
question=question,
)
def ask_new_key(self, question: str = "New key name") -> str: # pragma: no cover
@@ -475,15 +494,27 @@ class Questions:
question, validate=self._validate_number, style=self.style, qmark="INPUT |"
).ask()
def ask_path(self, question: str = "Enter a path") -> str: # pragma: no cover
def ask_path(
self, question: str = "Enter a path", valid_file: bool = False
) -> str: # pragma: no cover
"""Ask the user for a path.
Args:
question (str, optional): The question to ask. Defaults to "Enter a path".
valid_file (bool, optional): Whether the path should be a valid file. Defaults to False.
Returns:
str: A path.
"""
if valid_file:
return questionary.path(
question,
only_directories=False,
style=self.style,
validate=self._validate_path_is_file,
qmark="INPUT |",
).ask()
return questionary.path(question, style=self.style, qmark="INPUT |").ask()
def ask_selection(
@@ -498,7 +529,6 @@ class Questions:
Returns:
any: The selected item value.
"""
choices.insert(0, questionary.Separator())
return questionary.select(
question,
choices=choices,

View File

@@ -6,6 +6,7 @@ import re
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import rich.repr
import typer
@@ -47,7 +48,7 @@ class Vault:
config: VaultConfig,
dry_run: bool = False,
filters: list[VaultFilter] = [],
):
) -> None:
self.config = config.config
self.vault_path: Path = config.path
self.name = self.vault_path.name
@@ -329,7 +330,7 @@ class Vault:
match export_format:
case "csv":
with open(export_file, "w", encoding="UTF8") as f:
with export_file.open(mode="w", encoding="UTF8") as f:
writer = csv.writer(f)
writer.writerow(["Metadata Type", "Key", "Value"])
@@ -357,9 +358,47 @@ class Vault:
"tags": self.metadata.tags,
}
with open(export_file, "w", encoding="UTF8") as f:
with export_file.open(mode="w", encoding="UTF8") as f:
json.dump(dict_to_dump, f, indent=4, ensure_ascii=False, sort_keys=True)
def export_notes_to_csv(self, path: str) -> None:
"""Export notes and their associated metadata to a csv file. This is useful as a template for importing metadata changes to a vault.
Args:
path (str): Path to write csv file to.
"""
export_file = Path(path).expanduser().resolve()
if not export_file.parent.exists():
alerts.error(f"Path does not exist: {export_file.parent}")
raise typer.Exit(code=1)
with export_file.open(mode="w", encoding="UTF8") as f:
writer = csv.writer(f)
writer.writerow(["path", "type", "key", "value"])
for _note in self.all_notes:
for key, value in _note.frontmatter.dict.items():
for v in value:
writer.writerow(
[_note.note_path.relative_to(self.vault_path), "frontmatter", key, v]
)
for key, value in _note.inline_metadata.dict.items():
for v in value:
writer.writerow(
[
_note.note_path.relative_to(self.vault_path),
"inline_metadata",
key,
v,
]
)
for tag in _note.inline_tags.list:
writer.writerow(
[_note.note_path.relative_to(self.vault_path), "tag", "", f"{tag}"]
)
def get_changed_notes(self) -> list[Note]:
"""Return a list of notes that have changes.
@@ -471,7 +510,7 @@ class Vault:
return num_changed
def transpose_metadata( # noqa: PLR0913
def transpose_metadata(
self,
begin: MetadataType,
end: MetadataType,
@@ -510,3 +549,54 @@ class Vault:
self._rebuild_vault_metadata()
return num_changed
def update_from_dict(self, dictionary: dict[str, Any]) -> int:
"""Update note metadata from a dictionary. This is a destructive operation. All metadata in the specified notes not in the dictionary will be removed.
Requires a dictionary with the note path as the key and a dictionary of metadata as the value. Each key must have a list of associated dictionaries in the following format:
{
'type': 'frontmatter|inline_metadata|tag',
'key': 'string',
'value': 'string'
}
Args:
dictionary (dict[str, Any]): Dictionary to update metadata from.
Returns:
int: Number of notes that had metadata updated.
"""
num_changed = 0
for _note in self.all_notes:
path = _note.note_path.relative_to(self.vault_path)
if str(path) in dictionary:
log.info(f"Updating metadata for '{path}'")
num_changed += 1
_note.delete_all_metadata()
for row in dictionary[str(path)]:
if row["type"].lower() == "frontmatter":
_note.add_metadata(
area=MetadataType.FRONTMATTER, key=row["key"], value=row["value"]
)
if row["type"].lower() == "inline_metadata":
_note.add_metadata(
area=MetadataType.INLINE,
key=row["key"],
value=row["value"],
location=self.insert_location,
)
if row["type"].lower() == "tag" or row["type"].lower() == "tags":
_note.add_metadata(
area=MetadataType.TAGS,
value=row["value"],
location=self.insert_location,
)
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed

View File

@@ -58,7 +58,8 @@ def test_usage(capsys):
assert captured.out == "USAGE | This prints in usage\n"
alerts.usage(
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua"
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
width=80,
)
captured = capsys.readouterr()
assert "USAGE | Lorem ipsum dolor sit amet" in captured.out
@@ -106,7 +107,7 @@ def test_logging(capsys, tmp_path, verbosity, log_to_file) -> None:
if verbosity >= 3:
assert logging.is_trace() is True
captured = capsys.readouterr()
assert captured.out == ""
assert not captured.out
assert logging.is_trace("trace text") is True
captured = capsys.readouterr()
@@ -127,7 +128,7 @@ def test_logging(capsys, tmp_path, verbosity, log_to_file) -> None:
if verbosity >= 2:
assert logging.is_debug() is True
captured = capsys.readouterr()
assert captured.out == ""
assert not captured.out
assert logging.is_debug("debug text") is True
captured = capsys.readouterr()
@@ -148,7 +149,7 @@ def test_logging(capsys, tmp_path, verbosity, log_to_file) -> None:
if verbosity >= 1:
assert logging.is_info() is True
captured = capsys.readouterr()
assert captured.out == ""
assert not captured.out
assert logging.is_info("info text") is True
captured = capsys.readouterr()
@@ -164,11 +165,11 @@ def test_logging(capsys, tmp_path, verbosity, log_to_file) -> None:
log.info("This is Info logging")
captured = capsys.readouterr()
assert captured.out == ""
assert not captured.out
assert logging.is_default() is True
captured = capsys.readouterr()
assert captured.out == ""
assert not captured.out
assert logging.is_default("default text") is True
captured = capsys.readouterr()

View File

@@ -613,7 +613,7 @@ def test_transpose_metadata_1(test_application, mocker, capsys) -> None:
assert "SUCCESS | Transposed Inline Metadata to Frontmatter in 5 notes" in captured
def test_transpose_metadata_2(test_application, mocker, capsys) -> None:
def test_transpose_metadata_2(test_application, mocker) -> None:
"""Transpose metadata.
GIVEN a test application

View File

@@ -0,0 +1,530 @@
# type: ignore
"""Test the Frontmatter object from metadata.py."""
import pytest
from obsidian_metadata.models.metadata import Frontmatter
FRONTMATTER_CONTENT: str = """
---
tags:
- tag_1
- tag_2
-
- 📅/tag_3
frontmatter_Key1: "frontmatter_Key1_value"
frontmatter_Key2: ["note", "article"]
shared_key1: "shared_key1_value"
---
more content
---
horizontal: rule
---
"""
INLINE_CONTENT = """\
repeated_key:: repeated_key_value1
#inline_tag_top1,#inline_tag_top2
**bold_key1**:: bold_key1_value
**bold_key2:: bold_key2_value**
link_key:: [[link_key_value]]
tag_key:: #tag_key_value
emoji_📅_key:: emoji_📅_key_value
**#bold_tag**
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. [in_text_key1:: in_text_key1_value] Ut enim ad minim veniam, quis nostrud exercitation [in_text_key2:: in_text_key2_value] ullamco laboris nisi ut aliquip ex ea commodo consequat. #in_text_tag Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
```python
#ffffff
# This is sample text [no_key:: value]with tags and metadata
#in_codeblock_tag1
#ffffff;
in_codeblock_key:: in_codeblock_value
The quick brown fox jumped over the #in_codeblock_tag2
```
repeated_key:: repeated_key_value2
"""
def test_frontmatter_create_1() -> None:
"""Test frontmatter creation.
GIVEN valid frontmatter content
WHEN a Frontmatter object is created
THEN parse the YAML frontmatter and add it to the object
"""
frontmatter = Frontmatter(INLINE_CONTENT)
assert frontmatter.dict == {}
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.dict == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.dict_original == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
def test_frontmatter_create_2() -> None:
"""Test frontmatter creation error.
GIVEN invalid frontmatter content
WHEN a Frontmatter object is created
THEN raise ValueError
"""
fn = """---
tags: tag
invalid = = "content"
---
"""
with pytest.raises(AttributeError):
Frontmatter(fn)
def test_frontmatter_create_3():
"""Test frontmatter creation error.
GIVEN empty frontmatter content
WHEN a Frontmatter object is created
THEN set the dict to an empty dict
"""
content = "---\n\n---"
frontmatter = Frontmatter(content)
assert frontmatter.dict == {}
def test_frontmatter_create_4():
"""Test frontmatter creation error.
GIVEN empty frontmatter content with a yaml marker
WHEN a Frontmatter object is created
THEN set the dict to an empty dict
"""
content = "---\n-\n---"
frontmatter = Frontmatter(content)
assert frontmatter.dict == {}
def test_frontmatter_add_1():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with an existing key
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("frontmatter_Key1") is False
def test_frontmatter_add_2():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with an existing key and existing value
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("frontmatter_Key1", "frontmatter_Key1_value") is False
def test_frontmatter_add_3():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with a new key
THEN return True and add the key to the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("added_key") is True
assert "added_key" in frontmatter.dict
def test_frontmatter_add_4():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with a new key and a new value
THEN return True and add the key and the value to the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("added_key", "added_value") is True
assert frontmatter.dict["added_key"] == ["added_value"]
def test_frontmatter_add_5():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with an existing key and a new value
THEN return True and add the value to the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("frontmatter_Key1", "new_value") is True
assert frontmatter.dict["frontmatter_Key1"] == ["frontmatter_Key1_value", "new_value"]
def test_frontmatter_add_6():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with an existing key and a list of new values
THEN return True and add the values to the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("frontmatter_Key1", ["new_value", "new_value2"]) is True
assert frontmatter.dict["frontmatter_Key1"] == [
"frontmatter_Key1_value",
"new_value",
"new_value2",
]
def test_frontmatter_add_7():
"""Test frontmatter add() method.
GIVEN a Frontmatter object
WHEN the add() method is called with an existing key and a list of values including an existing value
THEN return True and add the new values to the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert (
frontmatter.add("frontmatter_Key1", ["frontmatter_Key1_value", "new_value", "new_value2"])
is True
)
assert frontmatter.dict["frontmatter_Key1"] == [
"frontmatter_Key1_value",
"new_value",
"new_value2",
]
def test_frontmatter_contains_1():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key
THEN return True if the key is found
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("frontmatter_Key1") is True
def test_frontmatter_contains_2():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key
THEN return False if the key is not found
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("no_key") is False
def test_frontmatter_contains_3():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key and a value
THEN return True if the key and value is found
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("frontmatter_Key2", "article") is True
def test_frontmatter_contains_4():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key and a value
THEN return False if the key and value is not found
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("frontmatter_Key2", "no value") is False
def test_frontmatter_contains_5():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key regex
THEN return True if a key matches the regex
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains(r"\d$", is_regex=True) is True
def test_frontmatter_contains_6():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key regex
THEN return False if no key matches the regex
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains(r"^\d", is_regex=True) is False
def test_frontmatter_contains_7():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key and value regex
THEN return True if a value matches the regex
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("key", r"\w\d_", is_regex=True) is True
def test_frontmatter_contains_8():
"""Test frontmatter contains() method.
GIVEN a Frontmatter object
WHEN the contains() method is called with a key and value regex
THEN return False if a value does not match the regex
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("key", r"_\d", is_regex=True) is False
def test_frontmatter_delete_1():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with a key that does not exist
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete("no key") is False
def test_frontmatter_delete_2():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with an existing key and a value that does not exist
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete("tags", "no value") is False
def test_frontmatter_delete_3():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with a regex that does not match any keys
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete(r"\d{3}") is False
def test_frontmatter_delete_4():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with an existing key and a regex that does not match any values
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete("tags", r"\d{5}") is False
def test_frontmatter_delete_5():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with an existing key and an existing value
THEN return True and delete the value from the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete("tags", "tag_2") is True
assert "tag_2" not in frontmatter.dict["tags"]
assert "tags" in frontmatter.dict
def test_frontmatter_delete_6():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with an existing key
THEN return True and delete the key from the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete("tags") is True
assert "tags" not in frontmatter.dict
def test_frontmatter_delete_7():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with a regex that matches a key
THEN return True and delete the matching keys from the dict
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete(r"front\w+") is True
assert "frontmatter_Key1" not in frontmatter.dict
assert "frontmatter_Key2" not in frontmatter.dict
def test_frontmatter_delete_8():
"""Test frontmatter delete() method.
GIVEN a Frontmatter object
WHEN the delete() method is called with an existing key and a regex that matches values
THEN return True and delete the matching values
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.delete("tags", r"\w+_[23]") is True
assert "tag_2" not in frontmatter.dict["tags"]
assert "📅/tag_3" not in frontmatter.dict["tags"]
assert "tag_1" in frontmatter.dict["tags"]
def test_frontmatter_delete_all():
"""Test Frontmatter delete_all method.
GIVEN Frontmatter with multiple keys
WHEN delete_all is called
THEN all keys and values are deleted
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
frontmatter.delete_all()
assert frontmatter.dict == {}
def test_frontmatter_has_changes_1():
"""Test frontmatter has_changes() method.
GIVEN a Frontmatter object
WHEN no changes have been made to the object
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.has_changes() is False
def test_frontmatter_has_changes_2():
"""Test frontmatter has_changes() method.
GIVEN a Frontmatter object
WHEN changes have been made to the object
THEN return True
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
frontmatter.dict["new key"] = ["new value"]
assert frontmatter.has_changes() is True
def test_frontmatter_rename_1():
"""Test frontmatter rename() method.
GIVEN a Frontmatter object
WHEN the rename() method is called with a key
THEN return False if the key is not found
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.rename("no key", "new key") is False
def test_frontmatter_rename_2():
"""Test frontmatter rename() method.
GIVEN a Frontmatter object
WHEN the rename() method is called with an existing key and non-existing value
THEN return False
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.rename("tags", "no tag", "new key") is False
def test_frontmatter_rename_3():
"""Test frontmatter rename() method.
GIVEN a Frontmatter object
WHEN the rename() method is called with an existing key
THEN return True and rename the key
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.rename("frontmatter_Key1", "new key") is True
assert "frontmatter_Key1" not in frontmatter.dict
assert frontmatter.dict["new key"] == ["frontmatter_Key1_value"]
def test_frontmatter_rename_4():
"""Test frontmatter rename() method.
GIVEN a Frontmatter object
WHEN the rename() method is called with an existing key and value
THEN return True and rename the value
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.rename("tags", "tag_2", "new tag") is True
assert "tag_2" not in frontmatter.dict["tags"]
assert "new tag" in frontmatter.dict["tags"]
def test_frontmatter_rename_5():
"""Test frontmatter rename() method.
GIVEN a Frontmatter object
WHEN the rename() method is called with an existing key and value and the new value already exists
THEN return True and remove the old value leaving one instance of the new value
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.rename("tags", "tag_1", "tag_2") is True
assert "tag_1" not in frontmatter.dict["tags"]
assert frontmatter.dict["tags"] == ["tag_2", "📅/tag_3"]
def test_frontmatter_to_yaml_1():
"""Test Frontmatter to_yaml method.
GIVEN a dictionary
WHEN the to_yaml method is called
THEN return a string with the yaml representation of the dictionary
"""
new_frontmatter: str = """\
tags:
- tag_1
- tag_2
- 📅/tag_3
frontmatter_Key1: frontmatter_Key1_value
frontmatter_Key2:
- article
- note
shared_key1: shared_key1_value
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.to_yaml() == new_frontmatter
def test_frontmatter_to_yaml_2():
"""Test Frontmatter to_yaml method.
GIVEN a dictionary
WHEN the to_yaml method is called with sort_keys=True
THEN return a string with the sorted yaml representation of the dictionary
"""
new_frontmatter_sorted: str = """\
frontmatter_Key1: frontmatter_Key1_value
frontmatter_Key2:
- article
- note
shared_key1: shared_key1_value
tags:
- tag_1
- tag_2
- 📅/tag_3
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.to_yaml(sort_keys=True) == new_frontmatter_sorted

View File

@@ -0,0 +1,426 @@
# type: ignore
"""Test inline metadata from metadata.py."""
from obsidian_metadata.models.metadata import InlineMetadata
FRONTMATTER_CONTENT: str = """
---
tags:
- tag_1
- tag_2
-
- 📅/tag_3
frontmatter_Key1: "frontmatter_Key1_value"
frontmatter_Key2: ["note", "article"]
shared_key1: "shared_key1_value"
---
more content
---
horizontal: rule
---
"""
INLINE_CONTENT = """\
key1:: value1
key1:: value2
key1:: value3
key2:: value1
Paragraph of text with an [inline_key:: value1] and [inline_key:: value2] and [inline_key:: value3] which should do it.
> blockquote_key:: value1
> blockquote_key:: value2
- list_key:: value1
- list_key:: value2
1. list_key:: value1
2. list_key:: value2
"""
def test__grab_inline_metadata_1():
"""Test grab inline metadata.
GIVEN content that has no inline metadata
WHEN grab_inline_metadata is called
THEN an empty dict is returned
"""
content = """
---
frontmatter_key1: frontmatter_key1_value
---
not_a_key: not_a_value
```
key:: in_codeblock
```
"""
inline = InlineMetadata(content)
assert inline.dict == {}
def test__grab_inline_metadata_2():
"""Test grab inline metadata.
GIVEN content that has inline metadata
WHEN grab_inline_metadata is called
THEN the inline metadata is parsed and returned as a dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.dict == {
"blockquote_key": ["value1", "value2"],
"inline_key": ["value1", "value2", "value3"],
"key1": ["value1", "value2", "value3"],
"key2": ["value1"],
"list_key": ["value1", "value2", "value1", "value2"],
}
def test_inline_metadata_add_1():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with an existing key
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("key1") is False
def test_inline_metadata_add_2():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with an existing key and existing value
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("key1", "value1") is False
def test_inline_metadata_add_3():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with a new key
THEN return True and add the key to the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("added_key") is True
assert "added_key" in inline.dict
def test_inline_metadata_add_4():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with a new key and a new value
THEN return True and add the key and the value to the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("added_key", "added_value") is True
assert inline.dict["added_key"] == ["added_value"]
def test_inline_metadata_add_5():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with an existing key and a new value
THEN return True and add the value to the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("key1", "new_value") is True
assert inline.dict["key1"] == ["value1", "value2", "value3", "new_value"]
def test_inline_metadata_add_6():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with an existing key and a list of new values
THEN return True and add the values to the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("key2", ["new_value", "new_value2"]) is True
assert inline.dict["key2"] == ["new_value", "new_value2", "value1"]
def test_inline_metadata_add_7():
"""Test InlineMetadata add() method.
GIVEN a InlineMetadata object
WHEN the add() method is called with an existing key and a list of values including an existing value
THEN return True and add the new values to the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("key1", ["value1", "new_value", "new_value2"]) is True
assert inline.dict["key1"] == ["new_value", "new_value2", "value1", "value2", "value3"]
def test_inline_metadata_contains_1():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key
THEN return True if the key is found
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("key1") is True
def test_inline_metadata_contains_2():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key
THEN return False if the key is not found
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("no_key") is False
def test_inline_metadata_contains_3():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key and a value
THEN return True if the key and value is found
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("key1", "value1") is True
def test_inline_metadata_contains_4():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key and a value
THEN return False if the key and value is not found
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("key1", "no value") is False
def test_inline_metadata_contains_5():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key regex
THEN return True if a key matches the regex
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains(r"\d$", is_regex=True) is True
def test_inline_metadata_contains_6():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key regex
THEN return False if no key matches the regex
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains(r"^\d", is_regex=True) is False
def test_inline_metadata_contains_7():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key and value regex
THEN return True if a value matches the regex
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains(r"key\d", r"\w\d", is_regex=True) is True
def test_inline_metadata_contains_8():
"""Test InlineMetadata contains() method.
GIVEN a InlineMetadata object
WHEN the contains() method is called with a key and value regex
THEN return False if a value does not match the regex
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("key1", r"_\d", is_regex=True) is False
def test_inline_metadata_delete_1():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with a key that does not exist
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete("no key") is False
def test_inline_metadata_delete_2():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with an existing key and a value that does not exist
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete("key1", "no value") is False
def test_inline_metadata_delete_3():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with a regex that does not match any keys
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete(r"\d{3}") is False
def test_inline_metadata_delete_4():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with an existing key and a regex that does not match any values
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete("key1", r"\d{5}") is False
def test_inline_metadata_delete_5():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with an existing key and an existing value
THEN return True and delete the value from the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete("key1", "value1") is True
assert "value1" not in inline.dict["key1"]
assert "key1" in inline.dict
def test_inline_metadata_delete_6():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with an existing key
THEN return True and delete the key from the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete("key1") is True
assert "key1" not in inline.dict
def test_inline_metadata_delete_7():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with a regex that matches a key
THEN return True and delete the matching keys from the dict
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete(r"key\w+") is True
assert "key1" not in inline.dict
assert "key2" not in inline.dict
def test_inline_metadata_delete_8():
"""Test InlineMetadata delete() method.
GIVEN a InlineMetadata object
WHEN the delete() method is called with an existing key and a regex that matches values
THEN return True and delete the matching values
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.delete("key1", r"\w+\d") is True
assert "value1" not in inline.dict["key1"]
assert "value2" not in inline.dict["key1"]
assert "value3" not in inline.dict["key1"]
def test_inline_metadata_has_changes_1():
"""Test InlineMetadata has_changes() method.
GIVEN a InlineMetadata object
WHEN no changes have been made to the object
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.has_changes() is False
def test_inline_metadata_has_changes_2():
"""Test InlineMetadata has_changes() method.
GIVEN a InlineMetadata object
WHEN changes have been made to the object
THEN return True
"""
inline = InlineMetadata(INLINE_CONTENT)
inline.dict["new key"] = ["new value"]
assert inline.has_changes() is True
def test_inline_metadata_rename_1():
"""Test InlineMetadata rename() method.
GIVEN a InlineMetadata object
WHEN the rename() method is called with a key
THEN return False if the key is not found
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.rename("no key", "new key") is False
def test_inline_metadata_rename_2():
"""Test InlineMetadata rename() method.
GIVEN a InlineMetadata object
WHEN the rename() method is called with an existing key and non-existing value
THEN return False
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.rename("key1", "no value", "new value") is False
def test_inline_metadata_rename_3():
"""Test InlineMetadata rename() method.
GIVEN a InlineMetadata object
WHEN the rename() method is called with an existing key
THEN return True and rename the key
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.rename("key1", "new key") is True
assert "key1" not in inline.dict
assert inline.dict["new key"] == ["value1", "value2", "value3"]
def test_inline_metadata_rename_4():
"""Test InlineMetadata rename() method.
GIVEN a InlineMetadata object
WHEN the rename() method is called with an existing key and value
THEN return True and rename the value
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.rename("key1", "value1", "new value") is True
assert "value1" not in inline.dict["key1"]
assert "new value" in inline.dict["key1"]
def test_inline_metadata_rename_5():
"""Test InlineMetadata rename() method.
GIVEN a InlineMetadata object
WHEN the rename() method is called with an existing key and value and the new value already exists
THEN return True and remove the old value leaving one instance of the new value
"""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.rename("key1", "value1", "value2") is True
assert inline.dict["key1"] == ["value2", "value3"]

View File

@@ -6,8 +6,6 @@ import pytest
from obsidian_metadata.models.enums import MetadataType
from obsidian_metadata.models.metadata import (
Frontmatter,
InlineMetadata,
InlineTags,
VaultMetadata,
)
@@ -68,371 +66,6 @@ repeated_key:: repeated_key_value2
"""
def test_frontmatter_create() -> None:
"""Test frontmatter creation."""
frontmatter = Frontmatter(INLINE_CONTENT)
assert frontmatter.dict == {}
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.dict == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.dict_original == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
def test_frontmatter_create_error() -> None:
"""Test frontmatter creation error.
GIVEN frontmatter content
WHEN frontmatter is invalid
THEN raise ValueError
"""
fn = """---
tags: tag
invalid = = "content"
---
"""
with pytest.raises(AttributeError):
Frontmatter(fn)
def test_frontmatter_contains() -> None:
"""Test frontmatter contains."""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.contains("frontmatter_Key1") is True
assert frontmatter.contains("frontmatter_Key2", "article") is True
assert frontmatter.contains("frontmatter_Key3") is False
assert frontmatter.contains("frontmatter_Key2", "no value") is False
assert frontmatter.contains(r"\d$", is_regex=True) is True
assert frontmatter.contains(r"^\d", is_regex=True) is False
assert frontmatter.contains("key", r"_\d", is_regex=True) is False
assert frontmatter.contains("key", r"\w\d_", is_regex=True) is True
def test_frontmatter_add() -> None:
"""Test frontmatter add."""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("frontmatter_Key1") is False
assert frontmatter.add("added_key") is True
assert frontmatter.dict == {
"added_key": [],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key", "added_value") is True
assert frontmatter.dict == {
"added_key": ["added_value"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key", "added_value_2") is True
assert frontmatter.dict == {
"added_key": ["added_value", "added_value_2"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key", ["added_value_3", "added_value_4"]) is True
assert frontmatter.dict == {
"added_key": ["added_value", "added_value_2", "added_value_3", "added_value_4"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key2", ["added_value_1", "added_value_2"]) is True
assert frontmatter.dict == {
"added_key": ["added_value", "added_value_2", "added_value_3", "added_value_4"],
"added_key2": ["added_value_1", "added_value_2"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key3", "added_value_1") is True
assert frontmatter.dict == {
"added_key": ["added_value", "added_value_2", "added_value_3", "added_value_4"],
"added_key2": ["added_value_1", "added_value_2"],
"added_key3": ["added_value_1"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key3", "added_value_1") is False
def test_frontmatter_rename() -> None:
"""Test frontmatter rename."""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.dict == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.rename("no key", "new key") is False
assert frontmatter.rename("tags", "no tag", "new key") is False
assert frontmatter.has_changes() is False
assert frontmatter.rename("tags", "tag_2", "new tag") is True
assert frontmatter.dict["tags"] == ["new tag", "tag_1", "📅/tag_3"]
assert frontmatter.rename("tags", "old_tags") is True
assert frontmatter.dict["old_tags"] == ["new tag", "tag_1", "📅/tag_3"]
assert "tags" not in frontmatter.dict
assert frontmatter.has_changes() is True
def test_frontmatter_delete() -> None:
"""Test Frontmatter delete method."""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.dict == {
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.delete("no key") is False
assert frontmatter.delete("tags", "no value") is False
assert frontmatter.delete(r"\d{3}") is False
assert frontmatter.has_changes() is False
assert frontmatter.delete("tags", "tag_2") is True
assert frontmatter.dict["tags"] == ["tag_1", "📅/tag_3"]
assert frontmatter.delete("tags") is True
assert "tags" not in frontmatter.dict
assert frontmatter.has_changes() is True
assert frontmatter.delete("shared_key1", r"\w+") is True
assert frontmatter.dict["shared_key1"] == []
assert frontmatter.delete(r"\w.tter") is True
assert frontmatter.dict == {"shared_key1": []}
def test_frontmatter_yaml_conversion():
"""Test Frontmatter to_yaml method."""
new_frontmatter: str = """\
tags:
- tag_1
- tag_2
- 📅/tag_3
frontmatter_Key1: frontmatter_Key1_value
frontmatter_Key2:
- article
- note
shared_key1: shared_key1_value
"""
new_frontmatter_sorted: str = """\
frontmatter_Key1: frontmatter_Key1_value
frontmatter_Key2:
- article
- note
shared_key1: shared_key1_value
tags:
- tag_1
- tag_2
- 📅/tag_3
"""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.to_yaml() == new_frontmatter
assert frontmatter.to_yaml(sort_keys=True) == new_frontmatter_sorted
def test_inline_metadata_add() -> None:
"""Test inline add."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("bold_key1") is False
assert inline.add("bold_key1", "bold_key1_value") is False
assert inline.add("added_key") is True
assert inline.dict == {
"added_key": [],
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.add("added_key1", "added_value") is True
assert inline.dict == {
"added_key": [],
"added_key1": ["added_value"],
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.dict == {
"added_key": [],
"added_key1": ["added_value"],
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.add("added_key", "added_value")
assert inline.dict == {
"added_key": ["added_value"],
"added_key1": ["added_value"],
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.add("repeated_key", "repeated_key_value1") is False
assert inline.add("repeated_key", "new_value") is True
def test_inline_metadata_contains() -> None:
"""Test inline metadata contains method."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("bold_key1") is True
assert inline.contains("bold_key2", "bold_key2_value") is True
assert inline.contains("bold_key3") is False
assert inline.contains("bold_key2", "no value") is False
assert inline.contains(r"\w{4}_key", is_regex=True) is True
assert inline.contains(r"^\d", is_regex=True) is False
assert inline.contains("1$", r"\d_value", is_regex=True) is True
assert inline.contains("key", r"^\d_value", is_regex=True) is False
def test_inline_metadata_create() -> None:
"""Test inline metadata creation."""
inline = InlineMetadata(FRONTMATTER_CONTENT)
assert inline.dict == {}
inline = InlineMetadata(INLINE_CONTENT)
assert inline.dict == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.dict_original == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
def test_inline_metadata_delete() -> None:
"""Test inline metadata delete."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.dict == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.delete("no key") is False
assert inline.delete("repeated_key", "no value") is False
assert inline.has_changes() is False
assert inline.delete("repeated_key", "repeated_key_value1") is True
assert inline.dict["repeated_key"] == ["repeated_key_value2"]
assert inline.delete("repeated_key") is True
assert "repeated_key" not in inline.dict
assert inline.has_changes() is True
assert inline.delete(r"\d{3}") is False
assert inline.delete(r"bold_key\d") is True
assert inline.dict == {
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"tag_key": ["tag_key_value"],
}
assert inline.delete("emoji_📅_key", ".*📅.*") is True
assert inline.dict == {
"emoji_📅_key": [],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"tag_key": ["tag_key_value"],
}
def test_inline_metadata_rename() -> None:
"""Test inline metadata rename."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.dict == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.rename("no key", "new key") is False
assert inline.rename("repeated_key", "no value", "new key") is False
assert inline.has_changes() is False
assert inline.rename("repeated_key", "repeated_key_value1", "new value") is True
assert inline.dict["repeated_key"] == ["new value", "repeated_key_value2"]
assert inline.rename("repeated_key", "old_key") is True
assert inline.dict["old_key"] == ["new value", "repeated_key_value2"]
assert "repeated_key" not in inline.dict
assert inline.has_changes() is True
def test_inline_tags_add() -> None:
"""Test inline tags add."""
tags = InlineTags(INLINE_CONTENT)

View File

@@ -229,16 +229,17 @@ def test_add_metadata_method_10(sample_note):
"""Test add_metadata() method.
GIVEN a note object
WHEN add_metadata() is with a new tag
WHEN add_metadata() is called with a new tag
THEN the tag is added to the InlineTags object and the file content
"""
note = Note(note_path=sample_note)
assert "new_tag" not in note.inline_tags.list
assert "new_tag2" not in note.inline_tags.list
assert (
note.add_metadata(MetadataType.TAGS, value="new_tag", location=InsertLocation.TOP) is True
note.add_metadata(MetadataType.TAGS, value="new_tag2", location=InsertLocation.BOTTOM)
is True
)
assert "new_tag" in note.inline_tags.list
assert "#new_tag" in note.file_content
assert "new_tag2" in note.inline_tags.list
assert "#new_tag2" in note.file_content
def test_commit_1(sample_note, tmp_path) -> None:
@@ -263,7 +264,7 @@ def test_commit_1(sample_note, tmp_path) -> None:
assert "Heading 1" not in note2.file_content
def test_commit_2(sample_note, tmp_path) -> None:
def test_commit_2(sample_note) -> None:
"""Test commit() method.
GIVEN a note object with commit() called
@@ -313,6 +314,24 @@ def test_contains_metadata(sample_note) -> None:
assert note.contains_metadata(r"bottom_key\d$", r"bottom_key\d_value", is_regex=True) is True
def test_delete_all_metadata(sample_note):
"""Test delete_all_metadata() method.
GIVEN a note object
WHEN delete_all_metadata() is called
THEN all tags, frontmatter, and inline metadata are deleted
"""
note = Note(note_path=sample_note)
note.delete_all_metadata()
assert note.inline_tags.list == []
assert note.frontmatter.dict == {}
assert note.inline_metadata.dict == {}
assert note.file_content == Regex("consequat. Duis")
assert "codeblock_key:: some text" in note.file_content
assert "#ffffff" in note.file_content
assert "---" not in note.file_content
def test_delete_inline_tag(sample_note) -> None:
"""Test delete_inline_tag method.
@@ -696,10 +715,9 @@ def test_transpose_metadata_4(sample_note):
"intext_key": ["intext_value"],
"key📅": ["📅_key_value"],
"shared_key1": [
"shared_key1_value",
"shared_key1_value3",
"shared_key1_value",
"shared_key1_value2",
"shared_key1_value3",
],
"shared_key2": ["shared_key2_value1", "shared_key2_value2"],
"tags": [

View File

@@ -6,17 +6,7 @@ import pytest
from obsidian_metadata.models.patterns import Patterns
TAG_CONTENT: str = "#1 #2 **#3** [[#4]] [[#5|test]] #6#notag #7_8 #9/10 #11-12 #13; #14, #15. #16: #17* #18(#19) #20[#21] #22\\ #23& #24# #25 **#26** #📅/tag [link](#no_tag) https://example.com/somepage.html_#no_url_tags"
INLINE_METADATA: str = """
**1:: 1**
2_2:: [[2_2]] | 2
asdfasdf [3:: 3] asdfasdf [7::7] asdf
[4:: 4] [5:: 5]
> 6:: 6
**8**:: **8**
10::
📅11:: 11/📅/11
emoji_📅_key:: 📅emoji_📅_key_value
"""
FRONTMATTER_CONTENT: str = """
---
tags:
@@ -150,17 +140,64 @@ def test_find_inline_tags():
def test_find_inline_metadata():
"""Test find_inline_metadata regex."""
pattern = Patterns()
content = """
**1:: 1**
2_2:: [[2_2]] | 2
asdfasdf [3:: 3] asdfasdf [7::7] asdf
[4:: 4] [5:: 5]
> 6:: 6
**8**:: **8**
10::
📅11:: 11/📅/11
emoji_📅_key::emoji_📅_key_value
key1:: value1
key1:: value2
key1:: value3
indented_key:: value1
Paragraph of text with an [inline_key:: value1] and [inline_key:: value2] and [inline_key:: value3] which should do it.
> blockquote_key:: value1
> blockquote_key:: value2
result = pattern.find_inline_metadata.findall(INLINE_METADATA)
- list_key:: value1
- list_key:: [[value2]]
1. list_key:: value1
2. list_key:: value2
| table_key:: value1 | table_key:: value2 |
---
frontmatter_key1: frontmatter_key1_value
---
not_a_key: not_a_value
paragraph metadata:: key in text
"""
result = pattern.find_inline_metadata.findall(content)
assert result == [
("", "", "1", "1**"),
("", "", "2_2", "[[2_2]] | 2"),
("3", "3", "", ""),
("7", "7", "", ""),
("", "", "4", "4] [5:: 5]"),
("", "", "6", "6"),
("", "", "8**", "**8**"),
("", "", "11", "11/📅/11"),
("", "", "emoji_📅_key", "📅emoji_📅_key_value"),
("", "", "emoji_📅_key", "emoji_📅_key_value"),
("", "", "key1", "value1"),
("", "", "key1", "value2"),
("", "", "key1", "value3"),
("", "", "indented_key", "value1"),
("inline_key", "value1", "", ""),
("inline_key", "value2", "", ""),
("inline_key", "value3", "", ""),
("", "", "blockquote_key", "value1"),
("", "", "blockquote_key", "value2"),
("", "", "list_key", "value1"),
("", "", "list_key", "[[value2]]"),
("", "", "list_key", "value1"),
("", "", "list_key", "value2"),
("", "", "table_key", "value1 | table_key:: value2 |"),
("", "", "metadata", "key in text"),
]

View File

@@ -1,13 +1,18 @@
# type: ignore
"""Test the utilities module."""
import pytest
import typer
from obsidian_metadata._utils import (
clean_dictionary,
dict_contains,
dict_keys_to_lower,
dict_values_to_lists_strings,
remove_markdown_sections,
validate_csv_bulk_imports,
)
from tests.helpers import Regex, remove_ansi
def test_dict_contains() -> None:
@@ -25,6 +30,17 @@ def test_dict_contains() -> None:
assert dict_contains(d, r"key\d", "value5", is_regex=True) is True
def test_dict_keys_to_lower() -> None:
"""Test the dict_keys_to_lower() function.
GIVEN a dictionary with mixed case keys
WHEN the dict_keys_to_lower() function is called
THEN the dictionary keys should be converted to lowercase
"""
test_dict = {"Key1": "Value1", "KEY2": "Value2", "key3": "Value3"}
assert dict_keys_to_lower(test_dict) == {"key1": "Value1", "key2": "Value2", "key3": "Value3"}
def test_dict_values_to_lists_strings():
"""Test converting dictionary values to lists of strings."""
dictionary = {
@@ -106,3 +122,125 @@ def test_clean_dictionary():
new_dict = clean_dictionary(dictionary)
assert new_dict == {"key": ["value", "value2", "value3"]}
def test_validate_csv_bulk_imports_1(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file missing the `path` column
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
PATH,type,key,value
note1.md,type,key,value"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_2(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file missing the `type` column
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,Type,key,value
note1.md,type,key,value"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_3(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file missing the `key` column
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,value
note1.md,type,key,value"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_4(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file missing the `value` column
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,key,values
note1.md,type,key,value"""
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_5(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a csv file with only headers
WHEN the validate_csv_bulk_imports function is called
THEN an exception should be raised
"""
csv_path = tmp_path / "test.csv"
csv_content = "path,type,key,value"
csv_path.write_text(csv_content)
with pytest.raises(typer.BadParameter):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])
def test_validate_csv_bulk_imports_6(tmp_path, capsys):
"""Test the validate_csv_bulk_imports function.
GIVEN a valid csv file
WHEN a path is given that does not exist in the vault
THEN show the user a warning
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,key,value
note1.md,type,key,value
note2.md,type,key,value
"""
csv_path.write_text(csv_content)
csv_dict = validate_csv_bulk_imports(csv_path=csv_path, note_paths=["note1.md"])
captured = remove_ansi(capsys.readouterr().out)
assert "WARNING | 'note2.md' does not exist in vault." in captured
assert csv_dict == {"note1.md": [{"key": "key", "type": "type", "value": "value"}]}
def test_validate_csv_bulk_imports_7(tmp_path):
"""Test the validate_csv_bulk_imports function.
GIVEN a valid csv file
WHEN no paths match paths in the vault
THEN exit the program
"""
csv_path = tmp_path / "test.csv"
csv_content = """\
path,type,key,value
note1.md,type,key,value
note2.md,type,key,value
"""
csv_path.write_text(csv_content)
with pytest.raises(typer.Exit):
validate_csv_bulk_imports(csv_path=csv_path, note_paths=[])

View File

@@ -239,7 +239,7 @@ def test_commit_changes_2(test_vault, tmp_path):
assert "new_key: new_key_value" not in committed_content
def test_backup_1(test_vault, tmp_path, capsys):
def test_backup_1(test_vault, capsys):
"""Test the backup method.
GIVEN a vault object
@@ -431,6 +431,38 @@ def test_export_json(tmp_path, test_vault):
assert '"frontmatter": {' in export_file.read_text()
def test_export_notes_to_csv_1(tmp_path, test_vault):
"""Test export_notes_to_csv() method.
GIVEN a vault object
WHEN the export_notes_to_csv method is called with a path
THEN the notes are exported to a CSV file
"""
vault = Vault(config=test_vault)
export_file = Path(f"{tmp_path}/export.csv")
vault.export_notes_to_csv(path=export_file)
assert export_file.exists() is True
assert "path,type,key,value" in export_file.read_text()
assert "test1.md,frontmatter,shared_key1,shared_key1_value" in export_file.read_text()
assert "test1.md,inline_metadata,shared_key1,shared_key1_value" in export_file.read_text()
assert "test1.md,tag,,shared_tag" in export_file.read_text()
assert "test1.md,frontmatter,tags,📅/frontmatter_tag3" in export_file.read_text()
assert "test1.md,inline_metadata,key📅,📅_key_value" in export_file.read_text()
def test_export_notes_to_csv_2(test_vault):
"""Test export_notes_to_csv() method.
GIVEN a vault object
WHEN the export_notes_to_csv method is called with a path where the parent directory does not exist
THEN an error is raised
"""
vault = Vault(config=test_vault)
export_file = Path("/I/do/not/exist/export.csv")
with pytest.raises(typer.Exit):
vault.export_notes_to_csv(path=export_file)
def test_get_filtered_notes_1(sample_vault) -> None:
"""Test filtering notes.
@@ -688,3 +720,60 @@ def test_transpose_metadata(test_vault) -> None:
)
== 0
)
def test_update_from_dict_1(test_vault):
"""Test update_from_dict() method.
GIVEN a vault object and an update dictionary
WHEN no dictionary keys match paths in the vault
THEN no notes are updated and 0 is returned
"""
vault = Vault(config=test_vault)
update_dict = {
"path1": {"type": "frontmatter", "key": "new_key", "value": "new_value"},
"path2": {"type": "frontmatter", "key": "new_key", "value": "new_value"},
}
assert vault.update_from_dict(update_dict) == 0
assert vault.get_changed_notes() == []
def test_update_from_dict_2(test_vault):
"""Test update_from_dict() method.
GIVEN a vault object and an update dictionary
WHEN the dictionary is empty
THEN no notes are updated and 0 is returned
"""
vault = Vault(config=test_vault)
update_dict = {}
assert vault.update_from_dict(update_dict) == 0
assert vault.get_changed_notes() == []
def test_update_from_dict_3(test_vault):
"""Test update_from_dict() method.
GIVEN a vault object and an update dictionary
WHEN a dictionary key matches a path in the vault
THEN the note is updated to match the dictionary values
"""
vault = Vault(config=test_vault)
update_dict = {
"test1.md": [
{"type": "frontmatter", "key": "new_key", "value": "new_value"},
{"type": "inline_metadata", "key": "new_key2", "value": "new_value"},
{"type": "tags", "key": "", "value": "new_tag"},
]
}
assert vault.update_from_dict(update_dict) == 1
assert vault.get_changed_notes()[0].note_path.name == "test1.md"
assert vault.get_changed_notes()[0].frontmatter.dict == {"new_key": ["new_value"]}
assert vault.get_changed_notes()[0].inline_metadata.dict == {"new_key2": ["new_value"]}
assert vault.get_changed_notes()[0].inline_tags.list == ["new_tag"]
assert vault.metadata.frontmatter == {"new_key": ["new_value"]}
assert vault.metadata.inline_metadata == {"new_key2": ["new_value"]}
assert vault.metadata.tags == ["new_tag"]