diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b6c8e33 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# https://help.github.com/articles/dealing-with-line-endings/ +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/01-feature-request.yml b/.github/ISSUE_TEMPLATE/01-feature-request.yml new file mode 100644 index 0000000..af7a6d7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-feature-request.yml @@ -0,0 +1,87 @@ +name: Feature Request +description: Suggest an enhancement for this product +title: "[feat] " +labels: + - enhancement + - triage +assignees: + - codejedi365 +type: feature +body: + - type: markdown + attributes: + value: | + This is the cj365-flatdict package repository. + + * Before you create a new feature request, please check the [FAQ](https://codejedi365.github.io/flatdict/misc/faq.html) + to see if your question is already answered. + + * Check to make sure someone else hasn't already opened a similar [feature request](https://github.com/codejedi365/flatdict/issues) + + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: | + By submitting this issue, you agree to follow the [Code of Conduct](https://codejedi365.github.io/flatdict/misc/code_of_conduct.html) + and understand the expectations for behavior in this community. + options: + - label: I have read and agree to the [Code of Conduct](https://codejedi365.github.io/flatdict/misc/code_of_conduct.html) + validations: + required: true + + - id: description + type: textarea + attributes: + label: High-level Description + description: | + 1. Please provide a high-level description of what you would like to see in one sentence (a TL;DR). + 2. Then, follow up with a more detailed explanation of the feature/workflow you are proposing. + 3. Please do not focus on the specifics of the code, or define the solution here, + but describe why this feature is important or how it would improve the product. You will + be able to provide your recommended solution at the end of the form. + validations: + required: true + # minLength: 50 + # maxLength: 1000 + + - id: use-cases + type: textarea + attributes: + label: Use Cases + description: | + - Please provide specific use cases or scenarios where this feature would be beneficial. + - This could include examples of workflows, user stories, or any other context that + helps illustrate the need for this feature. + validations: + required: true + # minLength: 10 + + - id: implementation-possible + type: textarea + attributes: + label: Possible Implementation + description: | + - If you have ideas on how this feature could be implemented, please share them here. + - This could include technical details, design considerations, or any other relevant + information that could help guide the development of this feature. + validations: + required: true + # minLength: 10 + + - id: implementation-alternatives + type: textarea + attributes: + label: Alternative Solutions + description: | + - Please describe any alternative solutions or workarounds you have considered. + - This could include existing features, third-party tools, or any other methods + that could achieve similar outcomes. + validations: + required: false + + - type: markdown + attributes: + value: | + Thank you for taking the time to improve the **cj365-flatdict**! We will work to respond within + the next few business days. diff --git a/.github/ISSUE_TEMPLATE/02-bug-report.yml b/.github/ISSUE_TEMPLATE/02-bug-report.yml new file mode 100644 index 0000000..3f4f7dc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-bug-report.yml @@ -0,0 +1,124 @@ +name: Bug Report +description: Something isn't working as expected +title: "[bug] " +labels: + - bug + - triage +assignees: + - codejedi365 +type: bug +body: + - type: markdown + attributes: + value: | + This is the cj365-flatdict package repository. + + * Before you create a new issue, please check the [FAQ](https://codejedi365.github.io/flatdict/misc/faq.html), + the [Q&A Discussions](https://github.com/codejedi365/flatdict/discussions/categories/q-a), + and existing issues to see if your topic is already addressed. + + * Check to make sure someone else hasn't already opened a similar [issue](https://github.com/codejedi365/flatdict/issues) + + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: | + By submitting this issue, you agree to follow the [Code of Conduct](https://codejedi365.github.io/flatdict/misc/code_of_conduct.html) + and understand the expectations for behavior in this community. + options: + - label: I have read and agree to the [Code of Conduct](https://codejedi365.github.io/flatdict/misc/code_of_conduct.html) + validations: + required: true + + - id: description + type: textarea + attributes: + label: High-level Description + description: | + 1. Please provide a high-level description of the issue/problem you are experiencing. + 2. This should be a short summary of the problem, not a detailed explanation, the + following fields will elaborate on the issue. + 3. Please do not focus on the specifics of the code, or define the solution here, + but describe why the output of the program is incorrect or unexpected. You will + be able to provide your recommended solution at the end of the form. + validations: + required: true + # minLength: 50 + # maxLength: 1000 + + - id: expected-behavior + type: textarea + attributes: + label: Expected Behavior + description: | + Please provide a short description of what you expected to happen. + validations: + required: true + # minLength: 10 + + - id: actual-behavior + type: textarea + attributes: + label: Actual Behavior + description: | + - Please provide a short description of what actually happened. + - DO NOT include the full stack trace here, that will be + included at the end of the form. + validations: + required: true + # minLength: 10 + + - type: markdown + attributes: + value: "### Environment" + + - id: environment-os + type: input + attributes: + label: Operating System + description: | + Please provide the python version, operating system and version you are using. + placeholder: ex. Python 3.11 on Windows 11, Python 3.10 on Debian Bookworm, etc. + validations: + required: true + + - id: logs + type: textarea + attributes: + label: Execution Log + description: | + 1. Please update the action you were performing element in our template below + 2. Please insert your workflow input and resulting output between the code fence (```) so we can maintain + a clean format when its rendered to html. + value: | +
+ + {Action} + + + ```log + <-- INSERT LOG HERE + ``` + +
+ validations: + required: true + + - id: additional-info + type: textarea + attributes: + label: Additional Information, Context, or Recommendations + description: | + - Please provide any additional information that may be relevant to the issue. This + could include links to documentation, related issues, or anything else + that may help us understand the problem better. + - If you have a recommended solution, please include it here as well. + validations: + required: false + + - type: markdown + attributes: + value: | + Thank you for taking the time to improve the **cj365-flatdict**! We will work to respond within + the next few business days. diff --git a/.github/ISSUE_TEMPLATE/03-documentation.yml b/.github/ISSUE_TEMPLATE/03-documentation.yml new file mode 100644 index 0000000..ca877f2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03-documentation.yml @@ -0,0 +1,58 @@ +name: Documentation +description: Make a suggestion or report a discrepancy within the documentation +title: "[docs] " +labels: + - documentation + - triage +assignees: + - codejedi365 +type: task +body: + - type: markdown + attributes: + value: | + This is the cj365-flatdict package repository. + + * Before you create a new issue, please check the [FAQ](https://codejedi365.github.io/flatdict/misc/faq.html) + to see if your question is already answered. + + * Check to make sure someone else hasn't already opened a similar [issue](https://github.com/codejedi365/flatdict/issues) + + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: | + By submitting this issue, you agree to follow the [Code of Conduct](https://codejedi365.github.io/flatdict/misc/code_of_conduct.html) + and understand the expectations for behavior in this community. + options: + - label: I have read and agree to the [Code of Conduct](https://codejedi365.github.io/flatdict/misc/code_of_conduct.html) + validations: + required: true + + - id: doc-location-info + type: textarea + attributes: + label: Which article is affected or What content is missing? + description: | + Please link to the article you'd like to see updated or a comment about + where the missing content should be located. + validations: + required: true + + - id: doc-update + type: textarea + attributes: + label: What part(s) of the article would you like to see updated? + description: | + - Give as much detail as you can to help us understand the change you want to see. + - Why should the docs be changed? What use cases does it support? + - What is the expected outcome or behavior? + validations: + required: true + + - type: markdown + attributes: + value: | + Thank you for taking the time to improve the **cj365-flatdict**! We will work to respond within + the next few business days. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0cd7b44 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,10 @@ +blank_issues_enabled: false + +contact_links: + - name: Community Support Discussions + url: https://github.com/codejedi365/flatdict/discussions/categories/q-a + about: Please ask and answer questions here. + + - name: Security Vulnerabilities & Concerns + url: https://codejedi365.github.io/flatdict/misc/security_policy.html + about: Please report security vulnerabilities. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..90bd9da --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,49 @@ + + +## Purpose + + + + +## Rationale + + + + +## How did you test? + + + + +## How to Verify + + + + +--- + +## PR Completion Checklist + +- [ ] Reviewed & followed the [Contributor Guidelines](https://codejedi365.github.io/flatdict/contributing/contributing_guide.html) + +- [ ] Changes Implemented & Validation pipeline succeeds + +- [ ] Commits follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard + and are separated into the proper commit type and scope (recommended order: test, build, feat/fix, docs) + +- [ ] Appropriate Unit tests added/updated + +- [ ] Appropriate End-to-End tests added/updated + +- [ ] Appropriate Documentation added/updated and syntax validated for sphinx build (see Contributor Guidelines) diff --git a/.github/changed-files-spec.yml b/.github/changed-files-spec.yml new file mode 100644 index 0000000..45b15e7 --- /dev/null +++ b/.github/changed-files-spec.yml @@ -0,0 +1,17 @@ +--- + +build: + - MANIFEST.in + - scripts/build.sh + - scripts/utils.sh + - scripts/envsubst_version.py +docs: + - docs/** + - README.rst + - CONTRIBUTING.rst +src: + - src/cj365/** + - pyproject.toml +tests: + - tests/fixtures/** + - tests/*.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a0d2d79 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,169 @@ +# Copilot Instructions + +This document explains how GitHub Copilot-like automated agents should interact with +the FlatDict repository. + +## Project Overview + +The FlatDict project is a Python library designed to help users work with nested +dictionaries by flattening and inflating them. It provides a simple and consistent +API for manipulating complex dictionary structures. + +**Key Components:** +- **FlatDict Class**: The class that handles nested dictionaries only. +- **FlatterDict Class**: The class that handles both nested dictionaries and sequences. + +The FlatDict distribution library (i.e. source code) maintains Python 3.6 compatibility +although development infrastructure requires Python 3.8+. + +## Development Setup + +### Prerequisites + +- Python 3.10+ +- [Optional] asdf cli version manager for managing multiple runtimes + +### Development Installation + +```bash +# Set up for development +bash ./scripts/dev_setup.sh +``` + +### Making Changes + +Minimal PR checklist: + +- [ ] Ensure all tests pass locally and that the local quality gate is passed. + + ```bash + # Run Lint/Formatting checks + ruff check --output-format=full --exit-non-zero-on-fix + ruff format --check + + # Run type checks + mypy . + + # Run tests with coverage and fail if coverage is below 95% + pytest -vv --cov=cj365.flatdict --cov-context=test --cov-report=term-missing --cov-fail-under=95 + ``` + +- [ ] If you added dependencies: update `pyproject.toml` and mention them in the PR. +- [ ] Review the helpful tips at the bottom of this document to ensure best practices. +- [ ] Verify that commit messages follow the Commit Message Conventions section below. + +## Commit Message Conventions + +This project uses **Conventional Commits** specification and is versioned by +[`python-semantic-release`](https://python-semantic-release.readthedocs.io/en/stable). +Review `src/docs/source/misc/changelog.rst` for reference of how the conventional +commits and specific rules this project uses are used in practice to communicate +changes to users. + +It is highly important to separate the code changes into their respective commit types +and scopes to ensure that the changelog is generated correctly and that users can +understand the changes in each release. The commit message format is strictly enforced +and should be followed for all commits. + +When submitting a pull request, it is recommended to commit any end-2-end test cases +first as a `test` type commit, then the implementation changes as `feat`, `fix`, etc. +This order allows reviewers to run the test which demonstrates the failure case before +validating the implementation changes by doing a `git merge origin/` to run the +test again and see it pass. Unit test cases will need to be committed after the source +code implementation changes as they will not run without the implementation code. +Documentation changes should be committed last and the commit scope should be a short +reference to the page its modifying (e.g. `docs(github-actions): ` or +`docs(configuration): `). Commit types should be chosen based on reference +to the default branch as opposed to its previous commits on the branch. For example, if +you are fixing a bug in a feature that was added in the same branch, the commit type +should be `refactor` instead of `fix` since the bug was introduced in the same branch +and is not present in the default branch. + +### Format + +``` +(): + + + +[optional footer(s)] +``` + +Scopes by the specification are optional but for this project, they are required and +only by exception can they be omitted. + +Footers include: + +- `BREAKING CHANGE: ` for breaking changes + +- `NOTICE: ` for additional release information that should be included + in the changelog to give users more context about the release + +- `Resolves: #` for linking to bug fixes. Use `Implements: #` + for new features. + +You should not have a breaking change and a notice in the same commit. If you have a +breaking change, the breaking change description should include all relevant information +about the change and how to update. + +### Types + +- `feat`: New feature (minor version bump) +- `fix`: Bug fix (patch version bump) +- `perf`: Performance improvement (patch version bump) +- `docs`: Documentation only changes +- `style`: Code style changes (formatting, missing semicolons, etc.) +- `refactor`: Code refactoring without feature changes or bug fixes +- `test`: Adding or updating tests +- `build`: Changes to build system or dependencies +- `ci`: Changes to CI configuration +- `chore`: Other changes that don't modify src or test files + +### Breaking Changes + +- Add `!` after the scope: `feat(scope)!: breaking change` and add + `BREAKING CHANGE:` in footer with detailed description of what was changed, + why, and how to update. + +### Notices + +- Add `NOTICE: ` in footer to include important information about the + release that should be included in the changelog. This is for things that require + more explanation than a simple commit message and are not breaking changes. + +### Scopes + +Use scopes as categories to indicate the area of change. They are most important for the +types of changes that are included in the changelog (bug fixes, features, performance +improvements, documentation, build dependencies) to tell the user what area was changed. + +## Important Files + +- `.github/release-templates/`: Project-specific Jinja2 templates for changelog and release notes + +## Documentation + +- Source in `docs/source` directory +- Uses Sphinx with Furo theme +- Build locally: `bash ./scripts/build_docs.sh` +- Hot Reload in browser (port 9000): `bash ./scripts/watch_docs.sh` + +## Helpful Tips + +- Never add real secrets, tokens, or credentials to source, commits, fixtures, or logs. + +- All proposed changes must include tests (unit and/or e2e as appropriate) and pass the + local quality gate before creating a PR. + +- When creating a Pull Request, create a PR description that fills out the + PR template found in `.github/PULL_REQUEST_TEMPLATE.md`. This will help + reviewers understand the changes and the impact of the PR. + +- If creating an issue, fill out one of the issue templates found in + `.github/ISSUE_TEMPLATE/` related to the type of issue (bug, feature request, etc.). + This will help maintainers understand the issue and its impact. + +- When adding new features, consider how they will affect the changelog and + versioning. Make as few breaking changes as possible by adding backwards compatibility + and if you do make a breaking change, be sure to include a detailed description in the + `BREAKING CHANGE` footer of the commit message. diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..dc769b4 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,34 @@ +--- +version: 2 +updates: + + # Maintain dependencies for Python + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + day: wednesday + time: 18:00 + labels: + - dependencies + - triage + commit-message: + prefix: build + include: scope + rebase-strategy: auto + versioning-strategy: increase-if-necessary + + # Maintain dependencies for GitHub Actions + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: wednesday + time: 18:00 + labels: + - dependencies + - triage + rebase-strategy: auto + commit-message: + prefix: ci + include: scope diff --git a/.github/release-templates/.components/changelog_4.0.4.rst.j2 b/.github/release-templates/.components/changelog_4.0.4.rst.j2 new file mode 100644 index 0000000..8013362 --- /dev/null +++ b/.github/release-templates/.components/changelog_4.0.4.rst.j2 @@ -0,0 +1,266 @@ +{# + This file overrides what would be generated normally because the commits are + not conformable to the standard commit message format. +#} +.. _changelog-v4.0.4: + +v4.0.4 (2024-08-28) +=================== + +๐Ÿชฒ Bug Fixes +------------ + +- move to main branch and update release (`PR#10`_) + +- fix for versions and deploy trigger (`PR#11`_) + +- update to use deploy key (`PR#12`_) + +.. _PR#10: https://github.com/dennishenry/flatdict/pull/10 +.. _PR#11: https://github.com/dennishenry/flatdict/pull/11 +.. _PR#12: https://github.com/dennishenry/flatdict/pull/12 + + +.. _changelog-v4.0.3: + +v4.0.3 (2024-08-28) +=================== + +๐Ÿชฒ Bug Fixes +------------ + +- update deployment (`PR#3`_) + +- updating workflow (`PR#4`_) + +- move to new pypi publish (`PR#5`_) + +- updating configurations (`PR#6`_) + +- updating project name (`PR#7`_) + +- updating module name (`PR#8`_) + +- final changes for 4.0.3 (`PR#9`_) + +.. _PR#3: https://github.com/dennishenry/flatdict/pull/3 +.. _PR#4: https://github.com/dennishenry/flatdict/pull/4 +.. _PR#5: https://github.com/dennishenry/flatdict/pull/5 +.. _PR#6: https://github.com/dennishenry/flatdict/pull/6 +.. _PR#7: https://github.com/dennishenry/flatdict/pull/7 +.. _PR#8: https://github.com/dennishenry/flatdict/pull/8 +.. _PR#9: https://github.com/dennishenry/flatdict/pull/9 + + +.. _changelog-v4.0.2: + +v4.0.2 (2024-08-28) +=================== + +๐Ÿชฒ Bug Fixes +------------ + +- Fixes for building wheel + + +.. _changelog-v4.0.1: + +v4.0.1 (2020-02-13) +=================== + +๐Ÿชฒ Bug Fixes +------------ + +- Gracefully fail to install if setuptools is too old + + +.. _changelog-v4.0.0: + +v4.0.0 (2020-02-12) +=================== + +- FIXED deprecation warning from Python 3.9 (`PR#40`_) + +- FIXED keep order of received dict and it's nested objects (`PR#38`_) + +- Removes compatibility with Python 2.7 and Python 3.4 + +.. _PR#38: https://github.com/gmr/flatdict/pull/38 +.. _PR#40: https://github.com/gmr/flatdict/pull/40 + + +.. _changelog-v3.4.0: + +v3.4.0 (2019-07-24) +=================== + +- FIXED sort order with regard to a nested list of dictionaries (`PR#33`_) + +.. _PR#33: https://github.com/gmr/flatdict/pull/33 + + +.. _changelog-3.3.0: + +v3.3.0 (2019-07-17) +=================== + +- FIXED ``FlatDict.setdefault()`` to match dict behavior (`PR#32`_) + +- FIXED empty nested Flatterdict (`PR#30`_) + +- CHANGED functionality to allow setting and updating nests within iterables (`PR#29`_) + +.. _PR#29: https://github.com/gmr/flatdict/pull/29 +.. _PR#30: https://github.com/gmr/flatdict/pull/30 +.. _PR#32: https://github.com/gmr/flatdict/pull/32 + + +.. _changelog-3.2.1: + +v3.2.1 (2019-06-10) +=================== + +- FIXED docs generation for readthedocs.io + + +.. _changelog-3.2.0: + +v3.2.0 (2019-06-10) +=================== + +- FIXED List Flattening does not return list when an odd number of depth in the dictionary (`PR#27`_) + +- CHANGED FlatterDict to allow for deeply nested dicts and lists when invoking ``FlatterDict.as_dict()`` (`PR#28`_) + +- Flake8 cleanup/improvements + +- Distribution/packaging updates to put metadata into setup.cfg + +.. _PR#27: https://github.com/gmr/flatdict/pull/27 +.. _PR#28: https://github.com/gmr/flatdict/pull/28 + + +.. _changelog-3.1.0: + +v3.1.0 (2018-10-30) +=================== + +- FIXED ``FlatDict`` behavior with empty iteratable values + +- CHANGED behavior when casting to str or repr (`PR#23`_) + +.. _PR#23: https://github.com/gmr/flatdict/pull/23 + + +.. _changelog-3.0.1: + +v3.0.1 (2018-07-01) +=================== + +- Add 3.7 to Trove Classifiers + +- Add Python 2.7 unicode string compatibility (`PR#22`_) + +.. _PR#22: https://github.com/gmr/flatdict/pull/22 + + +.. _changelog-v3.0.0: + +v3.0.0 (2018-03-06) +=================== + +- CHANGED ``FlatDict.as_dict`` to return the nested data structure based upon delimiters, coercing ``FlatDict`` objects to ``dict``. + +- CHANGED ``FlatDict`` to extend ``collections.MutableMapping`` instead of dict + +- CHANGED ``dict(FlatDict())`` to return a shallow ``dict`` instance with the delimited keys as strings + +- CHANGED ``FlatDict.__eq__`` to only evaluate against dict or the same class + +- FIXED ``FlatterDict`` behavior to match expectations from pre-2.0 releases. + + +.. _changelog-2.0.1: + +v2.0.1 (2018-01-18) +=================== + +- FIXED metadata for pypi upload + + +.. _changelog-2.0.0: + +v2.0.0 (2018-01-18) +=================== + +- Code efficiency refactoring and cleanup + +- Rewrote a majority of the tests, now at 100% coverage + +- ADDED ``FlatDict.__eq__`` and ``FlatDict.__ne__`` (`PR#13`_) + +- ADDED ``FlatterDict`` class that performs the list, set, and tuple coercion that was added in v1.20 + +- REMOVED coercion of lists and tuples from ``FlatDict`` that was added in 1.2.0. Alternative to (`PR#12`_) + +- REMOVED ``FlatDict.has_key()`` as it duplicates of ``FlatDict.__contains__`` + +- ADDED Python 3.5 and 3.6 to support matrix + +- REMOVED support for Python 2.6 and Python 3.2, 3.3 + +- CHANGED ``FlatDict.set_delimiter`` to raise a ``ValueError`` if a key already exists with the delimiter value in it. (`PR#8`_) + +.. _PR#8: https://github.com/gmr/flatdict/pull/8 +.. _PR#12: https://github.com/gmr/flatdict/pull/12 +.. _PR#13: https://github.com/gmr/flatdict/pull/13 + + +.. _changelog-v1.2.0: + +v1.2.0 (2015-06-25) +=================== + +- ADDED Support lists and tuples as well as dicts. (`PR#4`_) + +.. _PR#4: https://github.com/gmr/flatdict/pull/4 + + +.. _changelog-1.1.3: + +v1.1.3 (2015-01-04) +=================== + +- ADDED Python wheel support + + +.. _changelog-1.1.2: + +v1.1.2 (2013-10-09) +=================== + +- Documentation and CI updates + +- CHANGED use of ``dict()`` to a dict literal ``{}`` + + +.. _changelog-1.1.1: + +v1.1.1 (2012-08-17) +=================== + +- ADDED ``FlatDict.as_dict()`` + +- ADDED Python 3 support + +- ADDED ``FlatDict.set_delimiter()`` + +- Bugfixes and improvements from `naiquevin `_ + + +.. _changelog-1.1.0: + +v1.1.0 (2012-08-17) +=================== + +- ADDED ``FlatDict.as_dict()`` diff --git a/.github/release-templates/.components/changelog_header.rst.j2 b/.github/release-templates/.components/changelog_header.rst.j2 new file mode 100644 index 0000000..829078c --- /dev/null +++ b/.github/release-templates/.components/changelog_header.rst.j2 @@ -0,0 +1,10 @@ +.. _changelog: + +{% if ctx.changelog_mode == "update" +%}{# # Modified insertion flag to insert a changelog header directly + # which convienently puts the insertion flag incognito when reading raw RST +#}{{ + insertion_flag ~ "\n" + +}}{% endif +%} diff --git a/.github/release-templates/.components/changelog_init.rst.j2 b/.github/release-templates/.components/changelog_init.rst.j2 new file mode 100644 index 0000000..ecfa180 --- /dev/null +++ b/.github/release-templates/.components/changelog_init.rst.j2 @@ -0,0 +1,39 @@ +{# +This changelog template initializes a full changelog for the project, +it follows the following logic: + 1. Header + 2. Any Unreleased Details (uncommon) + 3. all previous releases except the very first release + 4. the first release + +#}{# + # # Header +#}{% include "changelog_header.rst.j2" +-%}{# + # # Any Unreleased Details (uncommon) +#}{% include "unreleased_changes.rst.j2" +-%}{# + # # Since this is initialization, we are generating all the previous + # # release notes per version. The very first release notes is specialized. + # # We also have non-conformative commits, so insert manual write-ups. +#}{% if releases | length > 0 +%}{% for release in releases +%}{% if loop.last +%}{{ "\n" +}}{% include "first_release.rst.j2" +-%}{{ "\n" +}}{# +#}{% elif release.version == "1.1.0" +%}{# # Append 1.1.0 through 4.0.4 non-generated changelog only once +#}{{ "\n" +}}{% include "changelog_4.0.4.rst.j2" +-%}{{ "\n\n" +}}{# +#}{% elif release.version > "4.0.4" +%}{{ "\n" +}}{% include "versioned_changes.rst.j2" +-%}{{ "\n" +}}{% endif +%}{% endfor +%}{% endif +%} diff --git a/.github/release-templates/.components/changelog_update.rst.j2 b/.github/release-templates/.components/changelog_update.rst.j2 new file mode 100644 index 0000000..002c45d --- /dev/null +++ b/.github/release-templates/.components/changelog_update.rst.j2 @@ -0,0 +1,71 @@ +{# +This Update changelog template uses the following logic: + + 1. Read previous changelog file (ex. project_root/CHANGELOG.md) + 2. Split on insertion flag (ex. ) + 3. Print top half of previous changelog + 3. New Changes (unreleased commits & newly released) + 4. Print bottom half of previous changelog + + Note: if a previous file was not found, it does not write anything at the bottom + but render does NOT fail + +#}{% set prev_changelog_contents = prev_changelog_file | read_file | safe +%}{% set changelog_parts = prev_changelog_contents.split(insertion_flag, maxsplit=1) +%}{# +#}{% if changelog_parts | length < 2 +%}{# # insertion flag was not found, check if the file was empty or did not exist +#}{% if prev_changelog_contents | length > 0 +%}{# # File has content but no insertion flag, therefore, file will not be updated +#}{{ changelog_parts[0] +}}{% else +%}{# # File was empty or did not exist, therefore, it will be created from scratch +#}{% include "changelog_init.rst.j2" +%}{% endif +%}{% else +%}{# + # Previous Changelog Header + # - Depending if there is header content, then it will separate the insertion flag + # with a newline from header content, otherwise it will just print the insertion flag +#}{% set prev_changelog_top = changelog_parts[0] | trim +%}{% if prev_changelog_top | length > 0 +%}{{ + "%s\n\n%s\n" | format(prev_changelog_top, insertion_flag | trim) + +}}{% else +%}{{ + "%s\n" | format(insertion_flag | trim) + +}}{% endif +%}{# + # Any Unreleased Details (uncommon) +#}{% include "unreleased_changes.rst.j2" +-%}{# +#}{% if releases | length > 0 +%}{# # Latest Release Details +#}{% set release = releases[0] +%}{# +#}{% if releases | length == 1 and ctx.mask_initial_release +%}{# # First Release detected +#}{{ "\n" +}}{%- include "first_release.rst.j2" +-%}{{ "\n" +}}{# +#}{% elif release.version.as_semver_tag() ~ " (" not in changelog_parts[1] +%}{# # The release version is not already in the changelog so we add it +#}{{ "\n" +}}{%- include "versioned_changes.rst.j2" +-%}{{ "\n" +}}{# +#}{% endif +%}{% endif +%}{# + # Previous Changelog Footer + # - skips printing footer if empty, which happens when the insertion_flag + # was at the end of the file (ignoring whitespace) +#}{% set previous_changelog_bottom = changelog_parts[1] | trim +%}{% if previous_changelog_bottom | length > 0 +%}{{ "\n%s\n" | format(previous_changelog_bottom) +}}{% endif +%}{% endif +%} diff --git a/.github/release-templates/.components/changes.md.j2 b/.github/release-templates/.components/changes.md.j2 new file mode 100644 index 0000000..6cdef2d --- /dev/null +++ b/.github/release-templates/.components/changes.md.j2 @@ -0,0 +1,127 @@ +{% from 'macros.common.j2' import apply_alphabetical_ordering_by_brk_descriptions +%}{% from 'macros.common.j2' import apply_alphabetical_ordering_by_descriptions +%}{% from 'macros.common.j2' import apply_alphabetical_ordering_by_release_notices +%}{% from 'macros.common.j2' import emoji_map, format_breaking_changes_description +%}{% from 'macros.common.j2' import format_release_notice, section_heading_order +%}{% from 'macros.common.j2' import section_heading_translations +%}{% from 'macros.md.j2' import format_commit_summary_line +%}{# +EXAMPLE: + +### โœจ Features + +- Add new feature ([#10](https://domain.com/namespace/repo/pull/10), + [`abcdef0`](https://domain.com/namespace/repo/commit/HASH)) + +- **scope**: Add new feature ([`abcdef0`](https://domain.com/namespace/repo/commit/HASH)) + +### ๐Ÿชฒ Bug Fixes + +- Fix bug ([#11](https://domain.com/namespace/repo/pull/11), + [`abcdef1`](https://domain.com/namespace/repo/commit/HASH)) + +### ๐Ÿ’ฅ Breaking Changes + +- With the change _____, the change causes ___ effect. Ultimately, this section + it is a more detailed description of the breaking change. With an optional + scope prefix like the commit messages above. + +- **scope**: this breaking change has a scope to identify the part of the code that + this breaking change applies to for better context. + +### ๐Ÿ’ก Additional Release Information + +- This is a release note that provides additional information about the release + that is not a breaking change or a feature/bug fix. + +- **scope**: this release note has a scope to identify the part of the code that + this release note applies to for better context. + +#}{% set max_line_width = max_line_width | default(100) +%}{% set hanging_indent = hanging_indent | default(2) +%}{# +#}{% for type_ in section_heading_order if type_ in commit_objects +%}{# PREPROCESS COMMITS (order by description & format description line) +#}{% set ns = namespace(commits=commit_objects[type_]) +%}{% set _ = apply_alphabetical_ordering_by_descriptions(ns) +%}{# +#}{% set commit_descriptions = [] +%}{# +#}{% for commit in ns.commits +%}{# # Generate the commit summary line and format it for Markdown +#}{% set description = "- %s" | format(format_commit_summary_line(commit)) +%}{% set description = description | autofit_text_width(max_line_width, hanging_indent) +%}{% set _ = commit_descriptions.append(description) +%}{% endfor +%}{# + # # PRINT SECTION (header & commits) +#}{{ "\n" +}}{{ "### %s %s\n" | format(emoji_map[type_], type_ | title) +}}{{ "\n" +}}{{ "%s\n" | format(commit_descriptions | unique | join("\n\n")) +}}{% endfor +%}{# + # # Determine if any commits have a breaking change or release notice + # # commit_objects is a dictionary of strings to a list of commits { "features", [ParsedCommit(), ...] } +#}{% set breaking_commits = [] +%}{% set notice_commits = [] +%}{% for commits in commit_objects.values() +%}{% set valid_commits = commits | rejectattr("error", "defined") | list +%}{# # Filter out breaking change commits that have no breaking descriptions +#}{% set _ = breaking_commits.extend( + valid_commits | selectattr("breaking_descriptions.0") + ) +%}{# # Filter out ParsedCommits commits that have no release notices +#}{% set _ = notice_commits.extend( + valid_commits | selectattr("release_notices.0") + ) +%}{% endfor +%}{# +#}{% if breaking_commits | length > 0 +%}{# PREPROCESS COMMITS +#}{% set brk_ns = namespace(commits=breaking_commits) +%}{% set _ = apply_alphabetical_ordering_by_brk_descriptions(brk_ns) +%}{# +#}{% set brking_descriptions = [] +%}{# +#}{% for commit in brk_ns.commits +%}{% set full_description = "- %s" | format( + format_breaking_changes_description(commit).split("\n\n") | join("\n\n- ") + ) +%}{% set _ = brking_descriptions.append( + full_description | autofit_text_width(max_line_width, hanging_indent) + ) +%}{% endfor +%}{# + # # PRINT BREAKING CHANGE DESCRIPTIONS (header & descriptions) +#}{{ "\n" +}}{{ "### %s Breaking Changes\n" | format(emoji_map["breaking"]) +}}{{ + "\n%s\n" | format(brking_descriptions | unique | join("\n\n")) +}}{# +#}{% endif +%}{# +#}{% if notice_commits | length > 0 +%}{# PREPROCESS COMMITS +#}{% set notice_ns = namespace(commits=notice_commits) +%}{% set _ = apply_alphabetical_ordering_by_release_notices(notice_ns) +%}{# +#}{% set release_notices = [] +%}{# +#}{% for commit in notice_ns.commits +%}{% set full_description = "- %s" | format( + format_release_notice(commit).split("\n\n") | join("\n\n- ") + ) +%}{% set _ = release_notices.append( + full_description | autofit_text_width(max_line_width, hanging_indent) + ) +%}{% endfor +%}{# + # # PRINT RELEASE NOTICE INFORMATION (header & descriptions) +#}{{ "\n" +}}{{ "### %s Additional Release Information\n" | format(emoji_map["release_note"]) +}}{{ + "\n%s\n" | format(release_notices | unique | join("\n\n")) +}}{# +#}{% endif +%} diff --git a/.github/release-templates/.components/changes.rst.j2 b/.github/release-templates/.components/changes.rst.j2 new file mode 100644 index 0000000..9751108 --- /dev/null +++ b/.github/release-templates/.components/changes.rst.j2 @@ -0,0 +1,174 @@ +{% from 'macros.common.j2' import apply_alphabetical_ordering_by_brk_descriptions +%}{% from 'macros.common.j2' import apply_alphabetical_ordering_by_descriptions +%}{% from 'macros.common.j2' import apply_alphabetical_ordering_by_release_notices +%}{% from 'macros.common.j2' import emoji_map, format_breaking_changes_description +%}{% from 'macros.common.j2' import format_release_notice, section_heading_order +%}{% from 'macros.common.j2' import section_heading_translations +%}{% from 'macros.rst.j2' import extract_issue_link_references, extract_pr_link_reference +%}{% from 'macros.rst.j2' import format_commit_summary_line, format_link_reference +%}{% from 'macros.rst.j2' import generate_heading_underline +%}{# + +โœจ Features +----------- + +* Add new feature (`#10`_, `8a7b8ec`_) + +* **scope**: Add another feature (`abcdef0`_) + +๐Ÿชฒ Bug Fixes +------------ + +* Fix bug (`#11`_, `8a7b8ec`_) + +๐Ÿ’ฅ Breaking Changes +------------------- + +* With the change _____, the change causes ___ effect. Ultimately, this section + it is a more detailed description of the breaking change. With an optional + scope prefix like the commit messages above. + +* **scope**: this breaking change has a scope to identify the part of the code that + this breaking change applies to for better context. + +๐Ÿ’ก Additional Release Information +--------------------------------- + +* This is a release note that provides additional information about the release + that is not a breaking change or a feature/bug fix. + +* **scope**: this release note has a scope to identify the part of the code that + this release note applies to for better context. + +.. _8a7B8ec: https://domain.com/owner/repo/commit/8a7b8ec +.. _abcdef0: https://domain.com/owner/repo/commit/abcdef0 +.. _PR#10: https://domain.com/namespace/repo/pull/10 +.. _PR#11: https://domain.com/namespace/repo/pull/11 + +#}{% set max_line_width = max_line_width | default(100) +%}{% set hanging_indent = hanging_indent | default(2) +%}{# +#}{% set post_paragraph_links = [] +%}{# +#}{% for type_ in section_heading_order if type_ in commit_objects +%}{# # PREPARE SECTION HEADER +#}{% set section_header = "%s %s" | format( + emoji_map[type_], type_ | title + ) +%}{# + # # PREPROCESS COMMITS +#}{% set ns = namespace(commits=commit_objects[type_]) +%}{% set _ = apply_alphabetical_ordering_by_descriptions(ns) +%}{# +#}{% set commit_descriptions = [] +%}{# +#}{% for commit in ns.commits +%}{# # Extract PR/MR reference if it exists and store it for later +#}{% set pr_link_reference = extract_pr_link_reference(commit) | default("", true) +%}{% if pr_link_reference != "" +%}{% set _ = post_paragraph_links.append(pr_link_reference) +%}{% endif +%}{# + # # Extract Issue references if they exists and store it for later +#}{% set issue_urls_ns = namespace(urls=[]) +%}{% set _ = extract_issue_link_references(issue_urls_ns, commit) +%}{% set _ = post_paragraph_links.extend(issue_urls_ns.urls) +%}{# + # # Always generate a commit hash reference link and store it for later +#}{% set commit_hash_link_reference = format_link_reference( + commit.hexsha | commit_hash_url, + commit.short_hash + ) +%}{% set _ = post_paragraph_links.append(commit_hash_link_reference) +%}{# + # # Generate the commit summary line and format it for RST +#}{% set description = "* %s" | format(format_commit_summary_line(commit)) +%}{% set description = description | convert_md_to_rst +%}{% set description = description | autofit_text_width(max_line_width, hanging_indent) +%}{% set _ = commit_descriptions.append(description) +%}{% endfor +%}{# + # # PRINT SECTION (Header & Commits) + # Note: Must add an additional character to the section header when determining the underline because of + # the emoji character which can serve as 2 characters in length. +#}{{ "\n" +}}{{ section_header ~ "\n" +}}{{ generate_heading_underline(section_header ~ " ", '-') ~ "\n" +}}{{ + "\n%s\n" | format(commit_descriptions | unique | join("\n\n")) + +}}{% endfor +%}{# + # # Determine if any commits have a breaking change or release notice + # # commit_objects is a dictionary of strings to a list of commits { "features", [ParsedCommit(), ...] } +#}{% set breaking_commits = [] +%}{% set notice_commits = [] +%}{% for commits in commit_objects.values() +%}{% set valid_commits = commits | rejectattr("error", "defined") | list +%}{# # Filter out breaking change commits that have no breaking descriptions +#}{% set _ = breaking_commits.extend( + valid_commits | selectattr("breaking_descriptions.0") + ) +%}{# # Filter out ParsedCommits commits that have no release notices +#}{% set _ = notice_commits.extend( + valid_commits | selectattr("release_notices.0") + ) +%}{% endfor +%}{# +#}{% if breaking_commits | length > 0 +%}{# # PREPROCESS COMMITS +#}{% set brk_ns = namespace(commits=breaking_commits) +%}{% set _ = apply_alphabetical_ordering_by_brk_descriptions(brk_ns) +%}{# +#}{% set brking_descriptions = [] +%}{# +#}{% for commit in brk_ns.commits +%}{% set full_description = "* %s" | format( + format_breaking_changes_description(commit).split("\n\n") | join("\n\n* ") + ) +%}{% set _ = brking_descriptions.append( + full_description | convert_md_to_rst | autofit_text_width(max_line_width, hanging_indent) + ) +%}{% endfor +%}{# + # # PRINT BREAKING CHANGE DESCRIPTIONS (header & descriptions) +#}{{ "\n" +}}{{ "%s Breaking Changes\n" | format(emoji_map["breaking"]) +}}{{ '-------------------\n' +}}{{ + "\n%s\n" | format(brking_descriptions | unique | join("\n\n")) +}}{# +#}{% endif +%}{# +#}{% if notice_commits | length > 0 +%}{# PREPROCESS COMMITS +#}{% set notice_ns = namespace(commits=notice_commits) +%}{% set _ = apply_alphabetical_ordering_by_release_notices(notice_ns) +%}{# +#}{% set release_notices = [] +%}{# +#}{% for commit in notice_ns.commits +%}{% set full_description = "* %s" | format( + format_release_notice(commit).split("\n\n") | join("\n\n* ") + ) +%}{% set _ = release_notices.append( + full_description | convert_md_to_rst | autofit_text_width(max_line_width, hanging_indent) + ) +%}{% endfor +%}{# + # # PRINT RELEASE NOTICE INFORMATION (header & descriptions) +#}{{ "\n" +}}{{ "%s Additional Release Information\n" | format(emoji_map["release_note"]) +}}{{ "---------------------------------\n" +}}{{ + "\n%s\n" | format(release_notices | unique | join("\n\n")) +}}{# +#}{% endif +%}{# + # + # # PRINT POST PARAGRAPH LINKS +#}{% if post_paragraph_links | length > 0 +%}{# # Print out any PR/MR or Issue URL references that were found in the commit messages +#}{{ "\n%s\n" | format(post_paragraph_links | unique | sort | join("\n")) +}}{% endif +%} diff --git a/.github/release-templates/.components/first_release.md.j2 b/.github/release-templates/.components/first_release.md.j2 new file mode 100644 index 0000000..d0e44f7 --- /dev/null +++ b/.github/release-templates/.components/first_release.md.j2 @@ -0,0 +1,18 @@ +{# EXAMPLE: + +## vX.X.X (YYYY-MMM-DD) + +_This release is published under the MIT License._ # Release Notes Only + +- Initial Release + +#}{{ +"## %s (%s)\n" | format( + release.version.as_semver_tag(), + release.tagged_date.strftime("%Y-%m-%d") +) +}}{% if license_name is defined and license_name +%}{{ "\n_This release is published under the %s License._\n" | format(license_name) +}}{% endif +%} +- Initial Release diff --git a/.github/release-templates/.components/first_release.rst.j2 b/.github/release-templates/.components/first_release.rst.j2 new file mode 100644 index 0000000..5c08066 --- /dev/null +++ b/.github/release-templates/.components/first_release.rst.j2 @@ -0,0 +1,22 @@ +{% from "macros.rst.j2" import generate_heading_underline +%}{# + +.. _changelog-vX.X.X: + +vX.X.X (YYYY-MMM-DD) +==================== + +* Initial Release + +#}{% set version_header = "%s (%s)" | format( + release.version.as_semver_tag(), + release.tagged_date.strftime("%Y-%m-%d") + ) +%} + +{{- ".. _changelog-%s:" | format(release.version.as_semver_tag()) }} + +{{ version_header }} +{{ generate_heading_underline(version_header, "=") }} + +* Initial Release diff --git a/.github/release-templates/.components/macros.common.j2 b/.github/release-templates/.components/macros.common.j2 new file mode 100644 index 0000000..5ec7ff6 --- /dev/null +++ b/.github/release-templates/.components/macros.common.j2 @@ -0,0 +1,160 @@ +{# TODO: move to configuration for user to modify #} +{% set section_heading_translations = { + 'feat': 'features', + 'fix': 'bug fixes', + 'perf': 'performance improvements', + 'docs': 'documentation', + 'build': 'build system', + 'refactor': 'refactoring', + 'test': 'testing', + 'ci': 'continuous integration', + 'chore': 'chores', + 'style': 'code style', + } +%} + +{% set section_heading_order = section_heading_translations.values() %} + +{% set emoji_map = { + 'breaking': '๐Ÿ’ฅ', + 'features': 'โœจ', + 'bug fixes': '๐Ÿชฒ', + 'performance improvements': 'โšก', + 'documentation': '๐Ÿ“–', + 'build system': 'โš™๏ธ', + 'refactoring': 'โ™ป๏ธ', + 'testing': 'โœ…', + 'continuous integration': '๐Ÿค–', + 'chores': '๐Ÿงน', + 'code style': '๐ŸŽจ', + 'unknown': 'โ—', + 'release_note': '๐Ÿ’ก', +} %} + + +{# + MACRO: Capitalize the first letter of a string only +#}{% macro capitalize_first_letter_only(sentence) +%}{{ (sentence[0] | upper) ~ sentence[1:] +}}{% endmacro +%} + + +{# + MACRO: format a commit descriptions list by: + - Capitalizing the first line of the description + - Adding an optional scope prefix + - Joining the rest of the descriptions with a double newline +#}{% macro format_attr_paragraphs(commit, attribute) +%}{# NOTE: requires namespace because of the way Jinja2 handles variable scoping with loops +#}{% set ns = namespace(full_description="") +%}{# +#}{% if commit.error is undefined +%}{% for paragraph in commit | attr(attribute) +%}{% if paragraph | trim | length > 0 +%}{# +#}{% set ns.full_description = [ + ns.full_description, + capitalize_first_letter_only(paragraph) | trim | safe, + ] | join("\n\n") +%}{# +#}{% endif +%}{% endfor +%}{# +#}{% set ns.full_description = ns.full_description | trim +%}{# +#}{% if commit.scope +%}{% set ns.full_description = "**%s**: %s" | format( + commit.scope, ns.full_description + ) +%}{% endif +%}{% endif +%}{# +#}{{ ns.full_description +}}{% endmacro +%} + + +{# + MACRO: format the breaking changes description by: + - Capitalizing the description + - Adding an optional scope prefix +#}{% macro format_breaking_changes_description(commit) +%}{{ format_attr_paragraphs(commit, 'breaking_descriptions') +}}{% endmacro +%} + + +{# + MACRO: format the release notice by: + - Capitalizing the description + - Adding an optional scope prefix +#}{% macro format_release_notice(commit) +%}{{ format_attr_paragraphs(commit, "release_notices") +}}{% endmacro +%} + + +{# + MACRO: order commits alphabetically by scope and attribute + - Commits are sorted based on scope and then the attribute alphabetically + - Commits without scope are placed first and sorted alphabetically by the attribute + - parameter: ns (namespace) object with a commits list + - parameter: attr (string) attribute to sort by + - returns None but modifies the ns.commits list in place +#}{% macro order_commits_alphabetically_by_scope_and_attr(ns, attr) +%}{% set ordered_commits = [] +%}{# + # # Eliminate any ParseError commits from input set +#}{% set filtered_commits = ns.commits | rejectattr("error", "defined") | list +%}{# + # # grab all commits with no scope and sort alphabetically by attr +#}{% for commit in filtered_commits | rejectattr("scope") | sort(attribute=attr) +%}{% set _ = ordered_commits.append(commit) +%}{% endfor +%}{# + # # grab all commits with a scope and sort alphabetically by the scope and then attr +#}{% for commit in filtered_commits | selectattr("scope") | sort(attribute=(['scope', attr] | join(","))) +%}{% set _ = ordered_commits.append(commit) +%}{% endfor +%}{# + # # Return the ordered commits +#}{% set ns.commits = ordered_commits +%}{% endmacro +%} + + +{# + MACRO: apply smart ordering of commits objects based on alphabetized summaries and then scopes + - Commits are sorted based on the commit type and the commit message + - Commits are grouped by the commit type + - parameter: ns (namespace) object with a commits list + - returns None but modifies the ns.commits list in place +#}{% macro apply_alphabetical_ordering_by_descriptions(ns) +%}{% set _ = order_commits_alphabetically_by_scope_and_attr(ns, 'descriptions.0') +%}{% endmacro +%} + + +{# + MACRO: apply smart ordering of commits objects based on alphabetized breaking changes and then scopes + - Commits are sorted based on the commit type and the commit message + - Commits are grouped by the commit type + - parameter: ns (namespace) object with a commits list + - returns None but modifies the ns.commits list in place +#}{% macro apply_alphabetical_ordering_by_brk_descriptions(ns) +%}{% set _ = order_commits_alphabetically_by_scope_and_attr(ns, 'breaking_descriptions.0') +%}{% endmacro +%} + + +{# + MACRO: apply smart ordering of commits objects based on alphabetized release notices and then scopes + - Commits are sorted based on the commit type and the commit message + - Commits are grouped by the commit type + - parameter: ns (namespace) object with a commits list + - returns None but modifies the ns.commits list in place +#}{% macro apply_alphabetical_ordering_by_release_notices(ns) +%}{% set _ = order_commits_alphabetically_by_scope_and_attr(ns, 'release_notices.0') +%}{% endmacro +%} diff --git a/.github/release-templates/.components/macros.md.j2 b/.github/release-templates/.components/macros.md.j2 new file mode 100644 index 0000000..bbccd9c --- /dev/null +++ b/.github/release-templates/.components/macros.md.j2 @@ -0,0 +1,74 @@ +{% from 'macros.common.j2' import capitalize_first_letter_only %} + + +{# + MACRO: format a inline link reference in Markdown +#}{% macro format_link(link, label) +%}{{ "[%s](%s)" | format(label, link) +}}{% endmacro +%} + + +{# + MACRO: commit message links or PR/MR links of commit +#}{% macro commit_msg_links(commit) +%}{% if commit.error is undefined +%}{# + # # Initialize variables +#}{% set link_references = [] +%}{% set summary_line = capitalize_first_letter_only( + commit.descriptions[0] | safe + ) +%}{# +#}{% if commit.linked_merge_request != "" +%}{# # Add PR references with a link to the PR +#}{% set _ = link_references.append( + format_link( + commit.linked_merge_request | pull_request_url, + "PR" ~ commit.linked_merge_request + ) + ) +%}{% endif +%}{# + # # DEFAULT: Always include the commit hash as a link +#}{% set _ = link_references.append( + format_link( + commit.hexsha | commit_hash_url, + "`%s`" | format(commit.short_hash) + ) + ) +%}{# +#}{% set formatted_links = "" +%}{% if link_references | length > 0 +%}{% set formatted_links = " (%s)" | format(link_references | join(", ")) +%}{% endif +%}{# + # Return the modified summary_line +#}{{ summary_line ~ formatted_links +}}{% endif +%}{% endmacro +%} + + +{# + MACRO: format commit summary line +#}{% macro format_commit_summary_line(commit) +%}{# # Check for Parsing Error +#}{% if commit.error is undefined +%}{# + # # Add any message links to the commit summary line +#}{% set summary_line = commit_msg_links(commit) +%}{# +#}{% if commit.scope +%}{% set summary_line = "**%s**: %s" | format(commit.scope, summary_line) +%}{% endif +%}{# + # # Return the modified summary_line +#}{{ summary_line +}}{# +#}{% else +%}{# # Return the first line of the commit if there was a Parsing Error +#}{{ (commit.commit.message | string).split("\n", maxsplit=1)[0] +}}{% endif +%}{% endmacro +%} diff --git a/.github/release-templates/.components/macros.rst.j2 b/.github/release-templates/.components/macros.rst.j2 new file mode 100644 index 0000000..11c61d6 --- /dev/null +++ b/.github/release-templates/.components/macros.rst.j2 @@ -0,0 +1,126 @@ +{% from 'macros.common.j2' import capitalize_first_letter_only %} + + +{# + MACRO: format a post-paragraph link reference in RST +#}{% macro format_link_reference(link, label) +%}{{ ".. _%s: %s" | format(label, link) +}}{% endmacro +%} + + +{# MACRO: generate a heading underline that matches the exact length of the header #} +{% macro generate_heading_underline(header, underline_char) +%}{% set header_underline = [] +%}{% for _ in header +%}{% set __ = header_underline.append(underline_char) +%}{% endfor +%}{# # Print out the header underline +#}{{ header_underline | join +}}{% endmacro +%} + + +{# + MACRO: formats a commit message for a non-inline RST link for a commit hash and/or PR/MR +#}{% macro commit_msg_links(commit) +%}{% if commit.error is undefined +%}{# + # # Initialize variables +#}{% set closes_statement = "" +%}{% set link_references = [] +%}{% set summary_line = capitalize_first_letter_only( + commit.descriptions[0] | safe + ) +%}{# +#}{% if commit.linked_issues | length > 0 +%}{% set closes_statement = ", closes `%s`_" | format( + commit.linked_issues | join("`_, `") + ) +%}{% endif +%}{# +#}{% if commit.linked_merge_request != "" +%}{# # Add PR references with a link to the PR +#}{% set _ = link_references.append("`PR%s`_" | format(commit.linked_merge_request)) +%}{% endif +%}{# + # DEFAULT: Always include the commit hash as a link +#}{% set _ = link_references.append("`%s`_" | format(commit.short_hash)) +%}{# +#}{% set formatted_links = "" +%}{% if link_references | length > 0 +%}{% set formatted_links = " (%s)" | format(link_references | join(", ")) +%}{% endif +%}{# + # Return the modified summary_line +#}{{ summary_line ~ closes_statement ~ formatted_links +}}{% endif +%}{% endmacro +%} + + +{# + MACRO: format commit summary line +#}{% macro format_commit_summary_line(commit) +%}{# # Check for Parsing Error +#}{% if commit.error is undefined +%}{# + # # Add any message links to the commit summary line +#}{% set summary_line = commit_msg_links(commit) +%}{# +#}{% if commit.scope +%}{% set summary_line = "**%s**: %s" | format(commit.scope, summary_line) +%}{% endif +%}{# + # # Return the modified summary_line +#}{{ summary_line +}}{# +#}{% else +%}{# # Return the first line of the commit if there was a Parsing Error +#}{{ (commit.commit.message | string).split("\n", maxsplit=1)[0] +}}{% endif +%}{% endmacro +%} + + +{# + MACRO: Extract issue references from a parsed commit object + - Stores the issue urls in the namespace object +#}{% macro extract_issue_link_references(ns, commit) +%}{% set issue_urls = [] +%}{# +#}{% if commit.linked_issues is defined and commit.linked_issues | length > 0 +%}{% for issue_num in commit.linked_issues +%}{# # Create an issue reference url +#}{% set _ = issue_urls.append( + format_link_reference( + issue_num | issue_url, + issue_num, + ) + ) +%}{% endfor +%}{% endif +%}{# + # # Store the issue urls in the namespace object +#}{% set ns.urls = issue_urls +%}{% endmacro +%} + + +{# + MACRO: Create & return an non-inline RST link from a commit message + - Returns empty string if no PR/MR identifier is found +#}{% macro extract_pr_link_reference(commit) +%}{% if commit.error is undefined +%}{% set summary_line = commit.descriptions[0] +%}{# +#}{% if commit.linked_merge_request != "" +%}{# # Create a PR/MR reference url +#}{{ format_link_reference( + commit.linked_merge_request | pull_request_url, + "PR" ~ commit.linked_merge_request, + ) +}}{% endif +%}{% endif +%}{% endmacro +%} diff --git a/.github/release-templates/.components/unreleased_changes.rst.j2 b/.github/release-templates/.components/unreleased_changes.rst.j2 new file mode 100644 index 0000000..7941465 --- /dev/null +++ b/.github/release-templates/.components/unreleased_changes.rst.j2 @@ -0,0 +1,10 @@ +{% if unreleased_commits | length > 0 %} +.. _changelog-unreleased: + +Unreleased +========== +{% set commit_objects = unreleased_commits +%}{% include "changes.rst.j2" +-%}{{ "\n" +}}{% endif +%} diff --git a/.github/release-templates/.components/versioned_changes.md.j2 b/.github/release-templates/.components/versioned_changes.md.j2 new file mode 100644 index 0000000..f63b599 --- /dev/null +++ b/.github/release-templates/.components/versioned_changes.md.j2 @@ -0,0 +1,20 @@ +{# EXAMPLE: + +## vX.X.X (YYYY-MMM-DD) + +_This release is published under the MIT License._ # Release Notes Only + +{{ change_sections }} + +#}{{ +"## %s (%s)\n" | format( + release.version.as_semver_tag(), + release.tagged_date.strftime("%Y-%m-%d") +) +}}{% if license_name is defined and license_name +%}{{ "\n_This release is published under the %s License._\n" | format(license_name) +}}{% endif +%}{# +#}{% set commit_objects = release["elements"] +%}{% include "changes.md.j2" +-%} diff --git a/.github/release-templates/.components/versioned_changes.rst.j2 b/.github/release-templates/.components/versioned_changes.rst.j2 new file mode 100644 index 0000000..7ec1877 --- /dev/null +++ b/.github/release-templates/.components/versioned_changes.rst.j2 @@ -0,0 +1,25 @@ +{% from 'macros.rst.j2' import generate_heading_underline %}{# + +.. _changelog-X.X.X: + +vX.X.X (YYYY-MMM-DD) +==================== + +{{ change_sections }} + +#}{% set version_header = "%s (%s)" | format( + release.version.as_semver_tag(), + release.tagged_date.strftime("%Y-%m-%d") + ) +%}{# +#}{{ + +".. _changelog-%s:" | format(release.version.as_semver_tag()) }} + +{{ version_header }} +{{ generate_heading_underline(version_header, "=") }} +{# + +#}{% set commit_objects = release["elements"] +%}{% include "changes.rst.j2" +-%} diff --git a/.github/release-templates/.release_notes.md.j2 b/.github/release-templates/.release_notes.md.j2 new file mode 100644 index 0000000..ee9fde4 --- /dev/null +++ b/.github/release-templates/.release_notes.md.j2 @@ -0,0 +1,119 @@ +{% from ".components/macros.md.j2" import format_link +%}{# EXAMPLE: + +## v1.0.0 (2020-01-01) + +_This release is published under the MIT License._ + +### โœจ Features + +- Add new feature ([PR#10](https://domain.com/namespace/repo/pull/10), [`abcdef0`](https://domain.com/namespace/repo/commit/HASH)) + +- **scope**: Add new feature ([`abcdef0`](https://domain.com/namespace/repo/commit/HASH)) + +### ๐Ÿชฒ Bug Fixes + +- Fix bug ([PR#11](https://domain.com/namespace/repo/pull/11), [`abcdef1`](https://domain.com/namespace/repo/commit/HASH)) + +### ๐Ÿ’ฅ Breaking Changes + +- With the change _____, the change causes ___ effect. Ultimately, this section it is a more detailed description of the breaking change. With an optional scope prefix like the commit messages above. + +- **scope**: this breaking change has a scope to identify the part of the code that this breaking change applies to for better context. + +### ๐Ÿ’ก Additional Release Information + +- This is a release note that provides additional information about the release that is not a breaking change or a feature/bug fix. + +- **scope**: this release note has a scope to identify the part of the code that this release note applies to for better context. + +### โœ… Resolved Issues + +- [#000](https://domain.com/namespace/repo/issues/000): _Title_ + +--- + +**Detailed Changes**: [vX.X.X...vX.X.X](https://domain.com/namespace/repo/compare/vX.X.X...vX.X.X) + +--- + +**Installable artifacts are available from**: + +- [PyPi Registry](https://pypi.org/project/package_name/x.x.x) +- [GitHub Release Assets](https://github.com/namespace/repo/releases/tag/vX.X.X) + +#}{# # Set line width to 1000 to avoid wrapping as GitHub will handle it +#}{% set max_line_width = max_line_width | default(1000) +%}{% set hanging_indent = hanging_indent | default(2) +%}{% set license_name = license_name | default("", True) +%}{% set releases = context.history.released.values() | list +%}{% set curr_release_index = releases.index(release) +%}{# +#}{% if mask_initial_release and curr_release_index == releases | length - 1 +%}{# # On a first release, generate our special message +#}{% include ".components/first_release.md.j2" +%}{% else +%}{# # Not the first release so generate notes normally +#}{% include ".components/versioned_changes.md.j2" +-%}{# + # # If there are any commits that resolve issues, list out the issues with links +#}{% set issue_resolving_commits = [] +%}{% for commits in release["elements"].values() +%}{% set _ = issue_resolving_commits.extend( + commits | rejectattr("error", "defined") | selectattr("linked_issues") + ) +%}{% endfor +%}{% if issue_resolving_commits | length > 0 +%}{{ + "\n### โœ… Resolved Issues\n" +}}{# +#}{% set issue_numbers = [] +%}{% for linked_issues in issue_resolving_commits | map(attribute="linked_issues") +%}{% set _ = issue_numbers.extend(linked_issues) +%}{% endfor +%}{% for issue_num in issue_numbers | unique | sort_numerically +%}{{ + "\n- %s: _Title_\n" | format(format_link(issue_num | issue_url, issue_num)) +}}{# +#}{% endfor +%}{% endif +%}{# +#}{% set prev_release_index = curr_release_index + 1 +%}{# +#}{% if 'compare_url' is filter and prev_release_index < releases | length +%}{% set prev_version_tag = releases[prev_release_index].version.as_tag() +%}{% set new_version_tag = release.version.as_tag() +%}{% set version_compare_url = prev_version_tag | compare_url(new_version_tag) +%}{% set detailed_changes_link = '[{}...{}]({})'.format( + prev_version_tag, new_version_tag, version_compare_url + ) +%}{{ "\n" +}}{{ "---\n" +}}{{ "\n" +}}{{ "**Detailed Changes**: %s" | format(detailed_changes_link) +}}{{ "\n" +}}{% endif +%}{% endif +%}{# +#} +--- + +**Installable artifacts are available from**: + +{{ + "- %s" | format( + format_link( + repo_name | create_pypi_url(release.version | string), + "PyPi Registry", + ) + ) +}} + +{{ + "- %s" | format( + format_link( + release.version.as_tag() | create_release_url, + "{vcs_name} Release Assets" | format_w_official_vcs_name, + ) + ) +}} diff --git a/.github/release-templates/CHANGELOG.rst.j2 b/.github/release-templates/CHANGELOG.rst.j2 new file mode 100644 index 0000000..5f8553a --- /dev/null +++ b/.github/release-templates/CHANGELOG.rst.j2 @@ -0,0 +1,22 @@ +{# + This changelog template controls which changelog creation occurs + based on which mode is provided. + + Modes: + - init: Initialize a full changelog from scratch + - update: Insert new version details where the placeholder exists in the current changelog + +#}{% set this_file = "CHANGELOG.rst" +%}{% set insertion_flag = ctx.changelog_insertion_flag +%}{% set unreleased_commits = ctx.history.unreleased +%}{% set releases = ctx.history.released.values() | list +%}{# +#}{% if ctx.changelog_mode == "init" +%}{% include ".components/changelog_init.rst.j2" +%}{# +#}{% elif ctx.changelog_mode == "update" +%}{% set prev_changelog_file = this_file +%}{% include ".components/changelog_update.rst.j2" +%}{# +#}{% endif +%} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3ffd501 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: # Target branches + - main + - releases/** + +# default token permissions = none +permissions: {} + +# If a new push is made to the branch, cancel the previous run +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + + lint-commits: + # condition: Execute IFF it is protected branch update, or a PR that is NOT in a draft state + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-latest + steps: + - name: Setup | Checkout Repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + + - name: Lint | Commit Messages + uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1 + + + eval-changes: + name: Evaluate changes + # condition: Execute IFF it is protected branch update, or a PR that is NOT in a draft state + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-latest + + steps: + - name: Setup | Checkout Repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + + - name: Evaluate | Check common file types for changes + id: core-changed-files + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 #v47.0.1 + with: + files_yaml_from_source_file: .github/changed-files-spec.yml + + - name: Evaluate | Check specific file types for changes + id: ci-changed-files + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 #v47.0.1 + with: + files_yaml: | + ci: + - .github/workflows/ci.yml + - .github/workflows/validate.yml + + - name: Evaluate | Detect if any of the combinations of file sets have changed + id: all-changes + run: | + printf '%s\n' "any_changed=false" >> $GITHUB_OUTPUT + if [ "${{ steps.core-changed-files.outputs.build_any_changed }}" == "true" ] || \ + [ "${{ steps.ci-changed-files.outputs.ci_any_changed }}" == "true" ] || \ + [ "${{ steps.core-changed-files.outputs.docs_any_changed }}" == "true" ] || \ + [ "${{ steps.core-changed-files.outputs.src_any_changed }}" == "true" ] || \ + [ "${{ steps.core-changed-files.outputs.tests_any_changed }}" == "true" ]; then + printf '%s\n' "any_changed=true" >> $GITHUB_OUTPUT + fi + + outputs: + # essentially casts the string output to a boolean for GitHub + any-file-changes: ${{ steps.all-changes.outputs.any_changed }} + build-changes: ${{ steps.core-changed-files.outputs.build_any_changed }} + ci-changes: ${{ steps.ci-changed-files.outputs.ci_any_changed }} + doc-changes: ${{ steps.core-changed-files.outputs.docs_any_changed }} + src-changes: ${{ steps.core-changed-files.outputs.src_any_changed }} + test-changes: ${{ steps.core-changed-files.outputs.tests_any_changed }} + + + validate: + needs: eval-changes + uses: ./.github/workflows/validate.yml + with: + # Test across 2 OS's but the lowest supported minor version and the latest stable minor + # version (just in case). + python-versions-linux: '["3.9", "3.14"]' + # Since windows is billed higher, we are only going to run it on the oldest version of python we + # support. The older version will be the most likely area to fail as newer minor versions maintain + # compatibility. + python-versions-windows: '["3.9"]' + files-changed: ${{ needs.eval-changes.outputs.any-file-changes }} + build-files-changed: ${{ needs.eval-changes.outputs.build-changes }} + ci-files-changed: ${{ needs.eval-changes.outputs.ci-changes }} + doc-files-changed: ${{ needs.eval-changes.outputs.doc-changes }} + src-files-changed: ${{ needs.eval-changes.outputs.src-changes }} + test-files-changed: ${{ needs.eval-changes.outputs.test-changes }} + permissions: {} + secrets: {} diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 0000000..b0f3252 --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,218 @@ +--- +name: CI/CD + +on: + push: + branches: + - main + - releases/** + + +# default token permissions = none +permissions: {} + + +jobs: + + eval-changes: + name: Evaluate changes + runs-on: ubuntu-latest + + steps: + - name: Setup | Checkout Repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + + - name: Evaluate | Check common file types for changes + id: core-changed-files + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 #v47.0.1 + with: + base_sha: ${{ github.event.push.before }} + files_yaml_from_source_file: .github/changed-files-spec.yml + + - name: Evaluate | Check specific file types for changes + id: ci-changed-files + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 #v47.0.1 + with: + base_sha: ${{ github.event.push.before }} + files_yaml: | + ci: + - .github/workflows/cicd.yml + - .github/workflows/validate.yml + + - name: Evaluate | Detect if any of the combinations of file sets have changed + id: all-changes + run: | + printf '%s\n' "any_changed=false" >> $GITHUB_OUTPUT + if [ "${{ steps.core-changed-files.outputs.build_any_changed }}" == "true" ] || \ + [ "${{ steps.ci-changed-files.outputs.ci_any_changed }}" == "true" ] || \ + [ "${{ steps.core-changed-files.outputs.docs_any_changed }}" == "true" ] || \ + [ "${{ steps.core-changed-files.outputs.src_any_changed }}" == "true" ] || \ + [ "${{ steps.core-changed-files.outputs.tests_any_changed }}" == "true" ]; then + printf '%s\n' "any_changed=true" >> $GITHUB_OUTPUT + fi + + outputs: + any-file-changes: ${{ steps.all-changes.outputs.any_changed }} + build-changes: ${{ steps.core-changed-files.outputs.build_any_changed }} + ci-changes: ${{ steps.ci-changed-files.outputs.ci_any_changed }} + doc-changes: ${{ steps.core-changed-files.outputs.docs_any_changed }} + src-changes: ${{ steps.core-changed-files.outputs.src_any_changed }} + test-changes: ${{ steps.core-changed-files.outputs.tests_any_changed }} + + + validate: + uses: ./.github/workflows/validate.yml + needs: eval-changes + concurrency: + group: ${{ github.workflow }}-validate-${{ github.ref_name }} + cancel-in-progress: true + with: + # Test across 2 OS's of the lowest supported minor version and the latest stable minor version. + python-versions-linux: '["3.9", "3.14"]' + python-versions-windows: '["3.9", "3.14"]' + files-changed: ${{ needs.eval-changes.outputs.any-file-changes }} + build-files-changed: ${{ needs.eval-changes.outputs.build-changes }} + ci-files-changed: ${{ needs.eval-changes.outputs.ci-changes }} + doc-files-changed: ${{ needs.eval-changes.outputs.doc-changes }} + src-files-changed: ${{ needs.eval-changes.outputs.src-changes }} + test-files-changed: ${{ needs.eval-changes.outputs.test-changes }} + permissions: {} + secrets: {} + + + release: + name: Semantic Release + runs-on: ubuntu-latest + needs: validate + if: ${{ needs.validate.outputs.new-release-detected == 'true' }} + + concurrency: + group: ${{ github.workflow }}-release-${{ github.ref_name }} + cancel-in-progress: false + + permissions: + contents: write + + env: + GITHUB_ACTIONS_AUTHOR_NAME: github-actions + GITHUB_ACTIONS_AUTHOR_EMAIL: actions@users.noreply.github.com + + steps: + # Note: We checkout the repository at the branch that triggered the workflow + # with the entire history to ensure to match PSR's release branch detection + # and history evaluation. + # However, we forcefully reset the branch to the workflow sha because it is + # possible that the branch was updated while the workflow was running. This + # prevents accidentally releasing un-evaluated changes. + - name: Setup | Checkout Repository on Release Branch + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Setup | Force release branch to be at workflow sha + run: | + git reset --hard ${{ github.sha }} + + - name: Setup | Download Build Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + id: artifact-download + with: + name: ${{ needs.validate.outputs.distribution-artifacts }} + path: dist + + - name: Release | Bump Version in Docs + if: needs.validate.outputs.new-release-is-prerelease == 'false' + env: + NEW_VERSION: ${{ needs.validate.outputs.new-release-version }} + NEW_RELEASE_TAG: ${{ needs.validate.outputs.new-release-tag }} + run: | + python -m scripts.envsubst_version + git add . + + - name: Release | Python Semantic Release + id: release + uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + verbosity: 1 + build: false + + - name: Release | Add distribution artifacts to GitHub Release Assets + uses: python-semantic-release/publish-action@310a9983a0ae878b29f3aac778d7c77c1db27378 # v10.5.3 + if: steps.release.outputs.released == 'true' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ steps.release.outputs.tag }} + + outputs: + released: ${{ steps.release.outputs.released || 'false' }} + new-release-version: ${{ steps.release.outputs.version }} + new-release-tag: ${{ steps.release.outputs.tag }} + + + deploy-pypi: + name: Deploy to PyPI + runs-on: ubuntu-latest + if: ${{ needs.release.outputs.released == 'true' && github.repository == 'codejedi365/flatdict' }} + + needs: + - validate + - release + + environment: + name: pypi + url: https://pypi.org/project/cj365-flatdict/ + + permissions: + # https://docs.github.com/en/rest/overview/permissions-required-for-github-apps?apiVersion=2022-11-28#metadata + id-token: write # needed for PyPI upload + + steps: + - name: Setup | Download Build Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + id: artifact-download + with: + name: ${{ needs.validate.outputs.distribution-artifacts }} + path: dist + + # see https://docs.pypi.org/trusted-publishers/ + - name: Deploy | Publish package distributions to PyPI + id: pypi-publish + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: dist + print-hash: true + verbose: true + + + deploy-pages: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + if: ${{ needs.release.outputs.released == 'true' && github.repository == 'codejedi365/flatdict' }} + + concurrency: + group: ${{ github.workflow }}-pages-${{ github.ref_name }} + cancel-in-progress: false + + needs: + - validate + - release + + permissions: + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Setup | Configure Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + + - name: Deploy | GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml deleted file mode 100644 index 7a2fde6..0000000 --- a/.github/workflows/deploy.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: Deployment -on: - workflow_dispatch: - release: - types: [ released ] -jobs: - deploy: - runs-on: ubuntu-latest - container: python:3.12-alpine - environment: - name: pypi - url: https://pypi.org/p/flatdict2 - permissions: - id-token: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Build package - run: pip install build && python -m build - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/manual-docs-publish.yml b/.github/workflows/manual-docs-publish.yml new file mode 100644 index 0000000..373899a --- /dev/null +++ b/.github/workflows/manual-docs-publish.yml @@ -0,0 +1,95 @@ +name: Publish Docs (Manual) + +on: + # Enable execution directly from Actions page + workflow_dispatch: + + +# default token permissions = none +permissions: {} + +env: + COMMON_PYTHON_VERSION: '3.11' + +jobs: + + build-docs: + name: Build Docs + runs-on: ubuntu-latest + + steps: + - name: Setup | Checkout Repository at workflow sha + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Setup | Force correct release branch on workflow sha + run: | + git checkout -B ${{ github.ref_name }} + + - name: Setup | Install Python ${{ env.COMMON_PYTHON_VERSION }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ env.COMMON_PYTHON_VERSION }} + cache: 'pip' + + - name: Setup | Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + pip install -e .[build] + + - name: Build | Build next version artifacts + id: version + uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3 + with: + github_token: "" + verbosity: 1 + build: true + changelog: true + commit: false + push: false + tag: false + vcs_release: false + + - name: Test | Fail if new release detected + if: steps.version.outputs.released == 'true' + run: | + printf >&2 '%s\n' "::error::Docs should not be independently published for a new release." + exit 1 + + - name: Build | Build documentation + id: build-docs + run: bash ./scripts/build_docs.sh + + - name: Upload | Documentation + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + with: + path: ${{ steps.build-docs.outputs.DIST_DOCS_DIR }} + retention-days: 1 + + + publish-docs: + name: Publish Docs + needs: build-docs + runs-on: ubuntu-latest + + concurrency: + group: ${{ github.workflow }}-pages-${{ github.ref_name }} + cancel-in-progress: false + + permissions: + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Setup | Configure Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + + - name: Deploy | GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml new file mode 100644 index 0000000..5b93769 --- /dev/null +++ b/.github/workflows/manual.yml @@ -0,0 +1,150 @@ +name: CI (Manual) + +on: + # Enable execution directly from Actions page + workflow_dispatch: + inputs: + linux: + description: 'Test on Linux?' + type: boolean + required: true + default: true + windows: + description: 'Test on Windows?' + type: boolean + required: true + default: true + python3-14: + description: 'Test Python 3.14?' + type: boolean + required: true + default: true + python3-13: + description: 'Test Python 3.13?' + type: boolean + required: true + default: true + python3-12: + description: 'Test Python 3.12?' + type: boolean + required: true + default: true + python3-11: + description: 'Test Python 3.11?' + type: boolean + required: true + default: true + python3-10: + description: 'Test Python 3.10?' + type: boolean + required: true + default: true + python3-9: + description: 'Test Python 3.9?' + type: boolean + required: true + default: true + python3-8: + description: 'Test Python 3.8?' + type: boolean + required: true + default: true + + +# default token permissions = none +permissions: {} + +env: + COMMON_PYTHON_VERSION: '3.14' + +jobs: + + eval-input: + name: Evaluate inputs + runs-on: ubuntu-latest + + steps: + - name: Setup | Install Python ${{ env.COMMON_PYTHON_VERSION }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ env.COMMON_PYTHON_VERSION }} + + - name: Setup | Write file + uses: DamianReeves/write-file-action@6929a9a6d1807689191dcc8bbe62b54d70a32b42 #v1.3 + with: + path: .github/manual_eval_input.py + write-mode: overwrite + contents: | + import json, os + + version_list = list(filter(None, [ + "3.9" if str(os.getenv("INPUT_PY3_9", False)).lower() == str(True).lower() else None, + "3.10" if str(os.getenv("INPUT_PY3_10", False)).lower() == str(True).lower() else None, + "3.11" if str(os.getenv("INPUT_PY3_11", False)).lower() == str(True).lower() else None, + "3.12" if str(os.getenv("INPUT_PY3_12", False)).lower() == str(True).lower() else None, + "3.13" if str(os.getenv("INPUT_PY3_13", False)).lower() == str(True).lower() else None, + "3.14" if str(os.getenv("INPUT_PY3_14", False)).lower() == str(True).lower() else None, + ])) + + linux_versions = ( + version_list + if str(os.getenv("INPUT_LINUX", False)).lower() == str(True).lower() + else [] + ) + windows_versions = ( + version_list + if str(os.getenv("INPUT_WINDOWS", False)).lower() == str(True).lower() + else [] + ) + compatibility_versions = list(filter(None, [ + "3.8" if str(os.getenv("INPUT_PY3_8", False)).lower() == str(True).lower() else None, + ])) + + print(f"PYTHON_VERSIONS_LINUX={json.dumps(linux_versions)}") + print(f"PYTHON_VERSIONS_WINDOWS={json.dumps(windows_versions)}") + print(f"PYTHON_COMPATIBILITY_VERSIONS={json.dumps(compatibility_versions)}") + + + - name: Evaluate | Generate Test Matrix + id: test-matrix + env: + INPUT_PY3_8: ${{ inputs.python3-8 }} + INPUT_PY3_9: ${{ inputs.python3-9 }} + INPUT_PY3_10: ${{ inputs.python3-10 }} + INPUT_PY3_11: ${{ inputs.python3-11 }} + INPUT_PY3_12: ${{ inputs.python3-12 }} + INPUT_PY3_13: ${{ inputs.python3-13 }} + INPUT_PY3_14: ${{ inputs.python3-14 }} + INPUT_LINUX: ${{ inputs.linux }} + INPUT_WINDOWS: ${{ inputs.windows }} + run: | + if ! vars="$(python3 .github/manual_eval_input.py)"; then + printf '%s\n' "::error::Failed to evaluate input" + exit 1 + fi + printf '%s\n' "$vars" + printf '%s\n' "$vars" >> $GITHUB_OUTPUT + + outputs: + python-compatibility-versions: ${{ steps.test-matrix.outputs.PYTHON_COMPATIBILITY_VERSIONS }} + python-versions-linux: ${{ steps.test-matrix.outputs.PYTHON_VERSIONS_LINUX }} + python-versions-windows: ${{ steps.test-matrix.outputs.PYTHON_VERSIONS_WINDOWS }} + + + validate: + needs: eval-input + uses: ./.github/workflows/validate.yml + with: + python-compatibility-versions: ${{ needs.eval-input.outputs.python-compatibility-versions }} + python-versions-linux: ${{ needs.eval-input.outputs.python-versions-linux }} + python-versions-windows: ${{ needs.eval-input.outputs.python-versions-windows }} + # There is no way to check for file changes on a manual workflow so + # we just assume everything has changed + build-files-changed: true + ci-files-changed: true + doc-files-changed: true + src-files-changed: true + test-files-changed: true + files-changed: true + permissions: {} + secrets: {} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 2a91951..0000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: Release - -on: - push: - branches: - - main - -jobs: - release: - runs-on: ubuntu-latest - container: - image: python:3.12-alpine - permissions: - contents: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Extract version - id: get_version - run: | - version=$(grep "__version__ =" flatdict2.py | sed -E "s/__version__ = '([0-9]+\.[0-9]+\.[0-9]+)'/\1/") - echo "version=$version" >> $GITHUB_OUTPUT - - - name: Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.get_version.outputs.version }} - draft: false - prerelease: false - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.DEPLOY_KEY }} \ No newline at end of file diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..be972a2 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,115 @@ +name: 'Stale Bot' +on: + schedule: + # Execute Daily at 7:15 AM UTC + - cron: '15 9 * * *' + +# Default token permissions = None +permissions: {} + + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + actions: write # required to delete/update cache + env: + STALE_ISSUE_WARNING_DAYS: 90 + STALE_ISSUE_CLOSURE_DAYS: 7 + STALE_PR_WARNING_DAYS: 60 + STALE_PR_CLOSURE_DAYS: 10 + UNRESPONSIVE_WARNING_DAYS: 14 + UNRESPONSIVE_CLOSURE_DAYS: 7 + REMINDER_WINDOW: 90 + OPERATIONS_RATE_LIMIT: 330 # 1000 api/hr / 3 jobs + steps: + + - name: Stale Issues/PRs + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + with: + # default: 30, GitHub Actions API Rate limit is 1000/hr + operations-per-run: ${{ env.OPERATIONS_RATE_LIMIT }} + # exempt-all-milestones: false (default) + # exempt-all-assignees: false (default) + stale-issue-label: stale + days-before-issue-stale: ${{ env.STALE_ISSUE_WARNING_DAYS }} + days-before-issue-close: ${{ env.STALE_ISSUE_CLOSURE_DAYS }} + exempt-issue-labels: confirmed, help-wanted, info + stale-issue-message: > + This issue is stale because it has not been confirmed or planned by the maintainers + and has been open ${{ env.STALE_ISSUE_WARNING_DAYS }} days with no recent activity. + It will be closed in ${{ env.STALE_ISSUE_CLOSURE_DAYS }} days, if no further + activity occurs. Thank you for your contributions. + close-issue-message: > + This issue was closed due to lack of activity. + + # PR Configurations + stale-pr-label: stale + days-before-pr-stale: ${{ env.STALE_PR_WARNING_DAYS }} + days-before-pr-close: ${{ env.STALE_PR_CLOSURE_DAYS }} + exempt-pr-labels: confirmed, dependabot + stale-pr-message: > + This PR is stale because it has not been confirmed or considered ready for merge + by the maintainers but has been open ${{ env.STALE_PR_WARNING_DAYS }} days with + no recent activity. It will be closed in ${{ env.STALE_PR_CLOSURE_DAYS }} days, + if no further activity occurs. Please make sure to add the proper testing, docs, + and descriptions of changes before your PR can be merged. Thank you for your + contributions. + close-pr-message: > + This PR was closed due to lack of activity. + + - name: Unresponsive Issues/PRs + # Closes issues rapidly when submitter is unresponsive. The timer is initiated + # by maintainer by placing the awaiting-reply label on the issue or PR. From + # that point the submitter has 14 days before a reminder/warning is given. If + # no response has been received within 3 weeks, the issue is closed. There are + # no exemptions besides removing the awaiting-reply label. + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + with: + # GitHub Actions API Rate limit is 1000/hr + operations-per-run: ${{ env.OPERATIONS_RATE_LIMIT }} + only-labels: awaiting-reply + stale-issue-label: unresponsive + stale-pr-label: unresponsive + remove-stale-when-updated: awaiting-reply + days-before-stale: ${{ env.UNRESPONSIVE_WARNING_DAYS }} + days-before-close: ${{ env.UNRESPONSIVE_CLOSURE_DAYS }} + stale-issue-message: > + This issue has not received a response in ${{ env.UNRESPONSIVE_WARNING_DAYS }} days. + If no response is received in ${{ env.UNRESPONSIVE_CLOSURE_DAYS }} days, it will be + closed. We look forward to hearing from you. + close-issue-message: > + This issue was closed because no response was received. + stale-pr-message: > + This PR has not received a response in ${{ env.UNRESPONSIVE_WARNING_DAYS }} days. + If no response is received in ${{ env.UNRESPONSIVE_CLOSURE_DAYS }} days, it will be + closed. We look forward to hearing from you. + close-pr-message: > + This PR was closed because no response was received. + + - name: Reminders on Confirmed Issues/PRs + # Posts a reminder when confirmed issues are not updated in a timely manner. + # The timer is initiated by a maintainer by placing the confirmed label on + # the issue or PR (which prevents stale closure), however, to prevent it being + # forgotten completely, this job will post a reminder message to the maintainers + # No closures will occur and there are no exemptions besides removing the confirmed + # label. + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + with: + # GitHub Actions API Rate limit is 1000/hr + operations-per-run: ${{ env.OPERATIONS_RATE_LIMIT }} + only-labels: confirmed + stale-issue-label: needs-update + stale-pr-label: needs-update + days-before-stale: ${{ env.REMINDER_WINDOW }} + days-before-close: -1 # never close + stale-issue-message: > + It has been ${{ env.REMINDER_WINDOW }} days since the last update on this confirmed + issue. @codejedi365 can you provide an update on the status of this + issue? + stale-pr-message: > + It has been ${{ env.REMINDER_WINDOW }} days since the last update on this confirmed + PR. @codejedi365 can you provide an update on the status of this PR? diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml deleted file mode 100644 index 51a80df..0000000 --- a/.github/workflows/testing.yaml +++ /dev/null @@ -1,41 +0,0 @@ -name: Testing -on: - push: - paths-ignore: - - 'docs/**' - - 'setup.*' - - '*.md' - - '*.rst' - branches-ignore: [ main ] - tags-ignore: ["*"] -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] - container: - image: python:${{ matrix.python }}-alpine - steps: - - name: Checkout repository - uses: actions/checkout@v1 - - - name: Install codecov dependancies - run: apk add curl gnupg coreutils git - - - name: Bootstrap the test environment - run: pip install -r requires/testing.txt && mkdir -p build - - - name: Run flake8 tests - run: flake8 - - - name: Run unittests - run: coverage run && coverage report && coverage xml - - - name: Upload Coverage - uses: codecov/codecov-action@v4 - with: - token: ${{secrets.CODECOV_TOKEN}} - file: build/coverage.xml - slug: dennishenry/flatdict2 - os: alpine diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..139d31f --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,372 @@ +--- +name: Validation Pipeline + +on: + # Enable workflow as callable from another workflow + workflow_call: + inputs: + python-compatibility-versions: + description: | + The lowest minor versions of python that we claim compatibility with. + This is used to run a canary test on the lowest version to quickly detect + if there are any breaking changes that would affect compatibility. + required: false + default: '["3.8"]' + type: string + python-versions-linux: + description: 'Python versions to test on Linux (JSON array)' + required: true + type: string + python-versions-windows: + description: 'Python versions to test on Windows (JSON array)' + required: true + type: string + files-changed: + description: 'Boolean string result for if any files have changed' + type: string + required: false + default: 'false' + build-files-changed: + description: 'Boolean string result for if build files have changed' + type: string + required: false + default: 'false' + ci-files-changed: + description: 'Boolean string result for if CI files have changed' + type: string + required: false + default: 'false' + doc-files-changed: + description: 'Boolean string result for if documentation files have changed' + type: string + required: false + default: 'false' + src-files-changed: + description: 'Boolean string result for if source files have changed' + type: string + required: false + default: 'false' + test-files-changed: + description: 'Boolean string result for if test files have changed' + type: string + required: false + default: 'false' + outputs: + new-release-detected: + description: Boolean string result for if new release is available + value: ${{ jobs.build.outputs.new-release-detected }} + new-release-version: + description: Version string for the new release + value: ${{ jobs.build.outputs.new-release-version }} + new-release-tag: + description: Tag string for the new release + value: ${{ jobs.build.outputs.new-release-tag }} + new-release-is-prerelease: + description: Boolean string result for if new release is a pre-release + value: ${{ jobs.build.outputs.new-release-is-prerelease }} + distribution-artifacts: + description: Artifact Download name for the distribution artifacts + value: ${{ jobs.build.outputs.distribution-artifacts }} + # secrets: none required ATT + + +# set default Token permissions = none +permissions: {} + + +env: + COMMON_PYTHON_VERSION: '3.14' + + +jobs: + + build: + name: Build + runs-on: ubuntu-latest + if: ${{ inputs.build-files-changed == 'true' || inputs.src-files-changed == 'true' || inputs.test-files-changed == 'true' || inputs.ci-files-changed == 'true' }} + + steps: + - name: Setup | Checkout Repository at workflow sha + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Setup | Force correct release branch on workflow sha + run: | + git checkout -B ${{ github.ref_name }} + + - name: Setup | Install Python ${{ env.COMMON_PYTHON_VERSION }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ env.COMMON_PYTHON_VERSION }} + cache: 'pip' + + - name: Setup | Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + pip install -e .[build] + + - name: Build | Build next version artifacts + id: version + uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3 + env: + COLUMNS: 150 + with: + github_token: "" + verbosity: 1 + build: true + changelog: true + commit: false + push: false + tag: false + vcs_release: false + + - name: Build | Annotate next version + if: steps.version.outputs.released == 'true' + run: | + printf '%s\n' "::notice::Next release will be '${{ steps.version.outputs.tag }}'" + + - name: Build | Create non-versioned distribution artifact + if: steps.version.outputs.released == 'false' + run: python -m build . + + - name: Build | Set distribution artifact variables + id: build + run: | + printf '%s\n' "dist_dir=dist/*" >> $GITHUB_OUTPUT + printf '%s\n' "artifacts_name=dist" >> $GITHUB_OUTPUT + + - name: Build | Build documentation + id: build-docs + run: bash ./scripts/build_docs.sh + + - name: Upload | Distribution Artifacts + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: ${{ steps.build.outputs.artifacts_name }} + path: ${{ steps.build.outputs.dist_dir }} + if-no-files-found: error + retention-days: 2 + + - name: Upload | Documentation + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + with: + path: ${{ steps.build-docs.outputs.DIST_DOCS_DIR }} + retention-days: 1 + + outputs: + new-release-detected: ${{ steps.version.outputs.released }} + new-release-version: ${{ steps.version.outputs.version }} + new-release-tag: ${{ steps.version.outputs.tag }} + new-release-is-prerelease: ${{ steps.version.outputs.is_prerelease }} + distribution-artifacts: ${{ steps.build.outputs.artifacts_name }} + + + canary-test: + name: ${{ format('Py{0} Canary Test', matrix.python-version) }} + runs-on: ubuntu-latest + needs: build + if: ${{ inputs.src-files-changed == 'true' || inputs.test-files-changed == 'true' || inputs.ci-files-changed == 'true' }} + strategy: + matrix: + python-version: ${{ fromJson(inputs.python-compatibility-versions) }} + + steps: + - name: Setup | Checkout Repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + + - name: Setup | Download Distribution Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: ${{ needs.build.outputs.distribution-artifacts }} + path: ./dist + + - name: Setup | Docker Setup + uses: docker/setup-docker-action@e43656e248c0bd0647d3f5c195d116aacf6fcaf4 # v4.7.0 + + - name: Test | Run canary test script + run: | + docker run \ + -v "${{ github.workspace }}:/workspace" \ + -w /workspace \ + python:${{ matrix.python-version }}-slim-bullseye \ + bash -c 'pip install dist/cj365_flatdict-*.whl && python3 ./tests/eval_old_python.py' + + + test-linux: + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} tests + runs-on: ${{ matrix.os }} + needs: build + if: ${{ inputs.src-files-changed == 'true' || inputs.test-files-changed == 'true' || inputs.ci-files-changed == 'true' }} + strategy: + matrix: + python-version: ${{ fromJson(inputs.python-versions-linux) }} + os: + - ubuntu-latest + + steps: + - name: Setup | Checkout Repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + + - name: Setup | Install Python ${{ matrix.python-version }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Setup | Download Distribution Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: ${{ needs.build.outputs.distribution-artifacts }} + path: ./dist + + - name: Setup | Install dependencies + id: install + # To ensure we are testing our installed package (not the src code), we must + # uninstall the editable install (symlink) first then install the distribution artifact. + # Lastly, we ask python to give us the installation location of our distribution artifact + # so that we can use it in the pytest command for coverage + run: | + python -m pip install --upgrade pip setuptools wheel + pip install -e .[test] + pip install pytest-github-actions-annotate-failures + pip uninstall -y cj365-flatdict + pip install dist/cj365_flatdict-*.whl + python -c 'import pathlib, cj365.flatdict; print(f"PKG_INSTALLED_DIR={pathlib.Path(cj365.flatdict.__file__).resolve().parent}")' >> $GITHUB_OUTPUT + + - name: Test | Run pytest + id: tests + env: + COLUMNS: 150 + run: | + pytest \ + -vv \ + --cov=${{ steps.install.outputs.PKG_INSTALLED_DIR }} \ + --cov-context=test \ + --cov-report=term-missing \ + --cov-fail-under=95 \ + --junit-xml=tests/reports/pytest-results.xml + + - name: Report | Upload Test Results + uses: mikepenz/action-junit-report@e08919a3b1fb83a78393dfb775a9c37f17d8eea6 # v6.0.1 + if: ${{ always() && steps.tests.outcome != 'skipped' }} + with: + report_paths: ./tests/reports/*.xml + annotate_only: true + + + test-windows: + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} tests + runs-on: ${{ matrix.os }} + needs: build + if: ${{ inputs.src-files-changed == 'true' || inputs.test-files-changed == 'true' || inputs.ci-files-changed == 'true' }} + strategy: + matrix: + python-version: ${{ fromJson(inputs.python-versions-windows) }} + os: [windows-latest] + + steps: + - name: Setup | Checkout Repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + + - name: Setup | Install Python ${{ matrix.python-version }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Setup | Download Distribution Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: ${{ needs.build.outputs.distribution-artifacts }} + path: dist + + - name: Setup | Install dependencies + id: install + # To ensure we are testing our installed package (not the src code), we must + # uninstall the editable install (symlink) first then install the distribution artifact. + # Lastly, we ask python to give us the installation location of our distribution artifact + # so that we can use it in the pytest command for coverage + shell: pwsh + run: | + $ErrorActionPreference = 'stop' + python -m pip install --upgrade pip setuptools wheel + pip install -e .[test] + pip install pytest-github-actions-annotate-failures + pip uninstall -y cj365-flatdict + $psrWheelFile = Get-ChildItem dist/cj365_flatdict-*.whl -File | Select-Object -Index 0 + pip install "$psrWheelFile" + python -c 'import pathlib, cj365.flatdict; print(f"PKG_INSTALLED_DIR={pathlib.Path(cj365.flatdict.__file__).resolve().parent}")' | Tee-Object -Variable cmdOutput + echo $cmdOutput >> $env:GITHUB_OUTPUT + + - name: Test | Run pytest + id: tests + shell: pwsh + # env: + # COLUMNS: 150 + # Because GHA is currently broken on Windows to pass these varables, we do it manually + run: | + $env:COLUMNS = 150 + pytest -vv --junit-xml=tests/reports/pytest-results.xml + + - name: Report | Upload Test Results + uses: mikepenz/action-junit-report@e08919a3b1fb83a78393dfb775a9c37f17d8eea6 # v6.0.1 + if: ${{ always() && steps.tests.outcome != 'skipped' }} + with: + report_paths: ./tests/reports/*.xml + annotate_only: true + + + lint: + name: Lint + if: ${{ inputs.files-changed == 'true' }} + runs-on: ubuntu-latest + + steps: + - name: Setup | Checkout Repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + + - name: Setup | Install Python ${{ env.COMMON_PYTHON_VERSION }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ env.COMMON_PYTHON_VERSION }} + cache: 'pip' + + - name: Setup | Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + pip install -e .[dev,mypy,test] + # needs test because we run mypy over the tests as well and without the dependencies + # mypy will throw import errors + + - name: Lint | Ruff Evaluation + id: lint + run: | + ruff check \ + --output-format=full \ + --exit-non-zero-on-fix + + - name: Type-Check | MyPy Evaluation + id: type-check + if: ${{ always() && steps.lint.outcome != 'skipped' }} + run: | + mypy . + + - name: Format-Check | Ruff Evaluation + id: format-check + if: ${{ always() && steps.type-check.outcome != 'skipped' }} + run: | + ruff format --check diff --git a/.gitignore b/.gitignore index bd235e4..c752e92 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,46 @@ -*.pyc -__pycache__ -.idea -build -dist +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# Distribution / packaging +.Python +.venv +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +venv/ + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ .coverage -coverage -env -env27 -*.egg-info +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Sphinx documentation +docs/_build/ +docs/api/modules/ + +.pytest_cache +.mypy_cache +.python-version +*.swp diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 00f0107..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,94 +0,0 @@ -# Changelog - -## 4.0.2 (2024-08-28) - -- Fixes for building wheel - -## 4.0.1 (2020-02-13) - -- Gracefully fail to install if setuptools is too old - -## 4.0.0 (2020-02-12) - -- FIXED deprecation warning from Python 3.9 (#40 [nugend](https://github.com/nugend)) -- FIXED keep order of received dict and it's nested objects (#38 [wsantos](https://github.com/wsantos)) -- Drops Python 2 support and Python 3.4 - -## 3.4.0 (2019-07-24) - -- FIXED sort order with regard to a nested list of dictionaries (#33 [wsantos](https://github.com/wsantos)) - -## 3.3.0 (2019-07-17) - -- FIXED FlatDict.setdefault() to match dict behavior (#32 [abmyii](https://github.com/abmyii)) -- FIXED empty nested Flatterdict (#30 [wsantos](https://github.com/wsantos)) -- CHANGED functionality to allow setting and updating nests within iterables (#29 [mileslucas](https://github.com/mileslucas)) - -## 3.2.1 (2019-06-10) - -- FIXED docs generation for readthedocs.io - -## 3.2.0 (2019-06-10) - -- FIXED List Flattening does not return list when an odd number of depth in the dictionary (#27 [mileslucas](https://github.com/mileslucas)) -- CHANGED FlatterDict to allow for deeply nested dicts and lists when invoking `FlatterDict.as_dict()` (#28 [mileslucas](https://github.com/mileslucas)) -- Flake8 cleanup/improvements -- Distribution/packaging updates to put metadata into setup.cfg - -## 3.1.0 (2018-10-30) - -- FIXED `FlatDict` behavior with empty iteratable values -- CHANGED behavior when casting to str or repr (#23) - -## 3.0.1 (2018-07-01) - -- Add 3.7 to Trove Classifiers -- Add Python 2.7 unicode string compatibility (#22 [nvllsvm](https://github.com/nvllsvm)) - -## 3.0.0 (2018-03-06) - -- CHANGED `FlatDict.as_dict` to return the nested data structure based upon delimiters, coercing `FlatDict` objects to `dict`. -- CHANGED `FlatDict` to extend `collections.MutableMapping` instead of dict -- CHANGED `dict(FlatDict())` to return a shallow `dict` instance with the delimited keys as strings -- CHANGED `FlatDict.__eq__` to only evaluate against dict or the same class -- FIXED `FlatterDict` behavior to match expectations from pre-2.0 releases. - -## 2.0.1 (2018-01-18) - -- FIXED metadata for pypi upload - -## 2.0.0 (2018-01-18) - -- Code efficiency refactoring and cleanup -- Rewrote a majority of the tests, now at 100% coverage -- ADDED `FlatDict.__eq__` and `FlatDict.__ne__` (#13 - [arm77](https://github.com/arm77)) -- ADDED `FlatterDict` class that performs the list, set, and tuple coercion that was added in v1.20 -- REMOVED coercion of lists and tuples from `FlatDict` that was added in 1.2.0. Alternative to (#12 - [rj-jesus](https://github.com/rj-jesus)) -- REMOVED `FlatDict.has_key()` as it duplicates of `FlatDict.__contains__` -- ADDED Python 3.5 and 3.6 to support matrix -- REMOVED support for Python 2.6 and Python 3.2, 3.3 -- CHANGED `FlatDict.set_delimiter` to raise a `ValueError` if a key already exists with the delimiter value in it. (#8) - -## 1.2.0 (2015-06-25) - -- ADDED Support lists and tuples as well as dicts. (#4 - [alex-hutton](https://github.com/alex-hutton)) - -## 1.1.3 (2015-01-04) - -- ADDED Python wheel support - -## 1.1.2 (2013-10-09) - -- Documentation and CI updates -- CHANGED use of `dict()` to a dict literal `{}` - -## 1.1.1 (2012-08-17) - -- ADDED `FlatDict.as_dict()` -- ADDED Python 3 support -- ADDED `FlatDict.set_delimiter()` -- Bugfixes and improvements from [naiquevin](https://github.com/naiquevin) - -## 1.0.0 (2012-08-10) - -- Initial release diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..eca9dd1 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,275 @@ +.. _changelog: + +========= +CHANGELOG +========= + +.. _changelog-v4.0.4: + +v4.0.4 (2024-08-28) +=================== + +๐Ÿชฒ Bug Fixes +------------ + +- move to main branch and update release (`PR#10`_) + +- fix for versions and deploy trigger (`PR#11`_) + +- update to use deploy key (`PR#12`_) + +.. _PR#10: https://github.com/dennishenry/flatdict/pull/10 +.. _PR#11: https://github.com/dennishenry/flatdict/pull/11 +.. _PR#12: https://github.com/dennishenry/flatdict/pull/12 + + +.. _changelog-v4.0.3: + +v4.0.3 (2024-08-28) +=================== + +๐Ÿชฒ Bug Fixes +------------ + +- update deployment (`PR#03`_) + +- updating workflow (`PR#04`_) + +- move to new pypi publish (`PR#05`_) + +- updating configurations (`PR#06`_) + +- updating project name (`PR#07`_) + +- updating module name (`PR#08`_) + +- final changes for 4.0.3 (`PR#09`_) + +.. _PR#03: https://github.com/dennishenry/flatdict/pull/3 +.. _PR#04: https://github.com/dennishenry/flatdict/pull/4 +.. _PR#05: https://github.com/dennishenry/flatdict/pull/5 +.. _PR#06: https://github.com/dennishenry/flatdict/pull/6 +.. _PR#07: https://github.com/dennishenry/flatdict/pull/7 +.. _PR#08: https://github.com/dennishenry/flatdict/pull/8 +.. _PR#09: https://github.com/dennishenry/flatdict/pull/9 + + +.. _changelog-v4.0.2: + +v4.0.2 (2024-08-28) +=================== + +๐Ÿชฒ Bug Fixes +------------ + +- Fixes for building wheel + + +.. _changelog-v4.0.1: + +v4.0.1 (2020-02-13) +=================== + +๐Ÿชฒ Bug Fixes +------------ + +- Gracefully fail to install if setuptools is too old + + +.. _changelog-v4.0.0: + +v4.0.0 (2020-02-12) +=================== + +- FIXED deprecation warning from Python 3.9 (`PR#40`_) + +- FIXED keep order of received dict and it's nested objects (`PR#38`_) + +- Removes compatibility with Python 2.7 and Python 3.4 + +.. _PR#38: https://github.com/gmr/flatdict/pull/38 +.. _PR#40: https://github.com/gmr/flatdict/pull/40 + + +.. _changelog-v3.4.0: + +v3.4.0 (2019-07-24) +=================== + +- FIXED sort order with regard to a nested list of dictionaries (`PR#33`_) + +.. _PR#33: https://github.com/gmr/flatdict/pull/33 + + +.. _changelog-3.3.0: + +v3.3.0 (2019-07-17) +=================== + +- FIXED ``FlatDict.setdefault()`` to match dict behavior (`PR#32`_) + +- FIXED empty nested Flatterdict (`PR#30`_) + +- CHANGED functionality to allow setting and updating nests within iterables (`PR#29`_) + +.. _PR#29: https://github.com/gmr/flatdict/pull/29 +.. _PR#30: https://github.com/gmr/flatdict/pull/30 +.. _PR#32: https://github.com/gmr/flatdict/pull/32 + + +.. _changelog-3.2.1: + +v3.2.1 (2019-06-10) +=================== + +- FIXED docs generation for readthedocs.io + + +.. _changelog-3.2.0: + +v3.2.0 (2019-06-10) +=================== + +- FIXED List Flattening does not return list when an odd number of depth in the dictionary (`PR#27`_) + +- CHANGED FlatterDict to allow for deeply nested dicts and lists when invoking ``FlatterDict.as_dict()`` (`PR#28`_) + +- Flake8 cleanup/improvements + +- Distribution/packaging updates to put metadata into setup.cfg + +.. _PR#27: https://github.com/gmr/flatdict/pull/27 +.. _PR#28: https://github.com/gmr/flatdict/pull/28 + + +.. _changelog-3.1.0: + +v3.1.0 (2018-10-30) +=================== + +- FIXED ``FlatDict`` behavior with empty iteratable values + +- CHANGED behavior when casting to str or repr (`PR#23`_) + +.. _PR#23: https://github.com/gmr/flatdict/pull/23 + + +.. _changelog-3.0.1: + +v3.0.1 (2018-07-01) +=================== + +- Add 3.7 to Trove Classifiers + +- Add Python 2.7 unicode string compatibility (`PR#22`_) + +.. _PR#22: https://github.com/gmr/flatdict/pull/22 + + +.. _changelog-v3.0.0: + +v3.0.0 (2018-03-06) +=================== + +- CHANGED ``FlatDict.as_dict`` to return the nested data structure based upon delimiters, coercing ``FlatDict`` objects to ``dict``. + +- CHANGED ``FlatDict`` to extend ``collections.MutableMapping`` instead of dict + +- CHANGED ``dict(FlatDict())`` to return a shallow ``dict`` instance with the delimited keys as strings + +- CHANGED ``FlatDict.__eq__`` to only evaluate against dict or the same class + +- FIXED ``FlatterDict`` behavior to match expectations from pre-2.0 releases. + + +.. _changelog-2.0.1: + +v2.0.1 (2018-01-18) +=================== + +- FIXED metadata for pypi upload + + +.. _changelog-2.0.0: + +v2.0.0 (2018-01-18) +=================== + +- Code efficiency refactoring and cleanup + +- Rewrote a majority of the tests, now at 100% coverage + +- ADDED ``FlatDict.__eq__`` and ``FlatDict.__ne__`` (`PR#13`_) + +- ADDED ``FlatterDict`` class that performs the list, set, and tuple coercion that was added in v1.20 + +- REMOVED coercion of lists and tuples from ``FlatDict`` that was added in 1.2.0. + +- REMOVED ``FlatDict.has_key()`` as it duplicates of ``FlatDict.__contains__`` + +- ADDED Python 3.5 and 3.6 to support matrix + +- REMOVED support for Python 2.6 and Python 3.2, 3.3 + +- CHANGED ``FlatDict.set_delimiter`` to raise a ``ValueError`` if a key already exists with the delimiter value in it. (`PR#8`_) + +.. _PR#8: https://github.com/gmr/flatdict/pull/8 +.. _PR#13: https://github.com/gmr/flatdict/pull/13 + + +.. _changelog-v1.2.0: + +v1.2.0 (2015-06-25) +=================== + +- ADDED Support lists and tuples as well as dicts. (`PR#4`_) + +.. _PR#4: https://github.com/gmr/flatdict/pull/4 + + +.. _changelog-1.1.3: + +v1.1.3 (2015-01-04) +=================== + +- ADDED Python wheel support + + +.. _changelog-1.1.2: + +v1.1.2 (2013-10-09) +=================== + +- Documentation and CI updates + +- CHANGED use of ``dict()`` to a dict literal ``{}`` + + +.. _changelog-1.1.1: + +v1.1.1 (2012-08-17) +=================== + +- ADDED ``FlatDict.as_dict()`` + +- ADDED Python 3 support + +- ADDED ``FlatDict.set_delimiter()`` + +- Bugfixes and improvements from `naiquevin `_ + + +.. _changelog-1.1.0: + +v1.1.0 (2012-08-17) +=================== + +- ADDED ``FlatDict.as_dict()`` + + +.. _changelog-1.0.0: + +v1.0.0 (2012-08-10) +=================== + +- Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index d81be84..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,42 +0,0 @@ -# Contributing - -## Setting up a development environment - -Use of virtual environments will allow for isolated installation of testing requirements: - -```bash -./bootstrap -``` - -## Running Tests - -```bash -source env/bin/activate -flake8 -coverage run && coverage report -``` - -`coverage xml` && `coverage html` are configured to output reports in the `build` directory. - -## Test Coverage - -To contribute to `flatdict2`, please make sure that any new features or changes to existing functionality **include test coverage**. - -*Pull requests that add or change code without coverage have a much lower chance of being accepted.* - -**Pull requests that fail flake8 tests as configured will not be accepted.** - -## Code Formatting - -Please format your code using [yapf](http://pypi.python.org/pypi/yapf) -with ``pep8`` style prior to issuing your pull request. - -## Versioning - -`flatdict2` subscribes to [semver](https://semver.org) style versioning. - -Given a version number `MAJOR.MINOR.PATCH` increment the: - -- `MAJOR` version when you make incompatible API changes, -- `MINOR` version when you add functionality in a backwards-compatible manner, and -- `PATCH` version when you make backwards-compatible bug fixes. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..e63150b --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,79 @@ +.. _contributing_guide: + +Contributing +------------ + +If you want to contribute that is awesome. Remember to be nice to others in issues and reviews. + +Please remember to write tests for the cool things you create or fix. + +Unsure about something? No worries, `open an issue`_. + +.. _open an issue: https://github.com/codejedi365/flatdict/issues/new + + +Commit messages +~~~~~~~~~~~~~~~ + +Since python-semantic-release is released with python-semantic-release we need the commit messages +to adhere to the `Conventional Commits Specification`_. Although scopes are optional, scopes are +expected where applicable. Changes should be committed separately with the commit type they represent, +do not combine them all into one commit. + +If you are unsure how to describe the change correctly just try and ask about it in your pr. If we +think it should be something else or there is a pull-request without tags we will help out in +adding or changing them. + +.. _Conventional Commits Specification: https://www.conventionalcommits.org/en/v1.0.0 + + +Releases +~~~~~~~~ + +This package is released by python-semantic-release on each master build, thus if there are changes +that should result in a new release it will happen if the build is green. + + +Development +~~~~~~~~~~~ + +Install this module and the development dependencies + +.. code-block:: bash + + bash scripts/dev_setup.sh + +And if you'd like to build the documentation locally + +.. code-block:: bash + + bash scripts/watch_docs.sh + + +Testing +~~~~~~~ + +To test your modifications locally: + +.. code-block:: bash + + # Run type-checking, all tests across all supported Python versions + tox + + # Run all tests for your current installed Python version (with full error output) + pytest -vv + + +Building +~~~~~~~~ + +This project is designed to be versioned and built using the ``tool.semantic_release`` +configuration in ``pyproject.toml``. The setting ``tool.semantic_release.build_command`` defines +the command to run to build the package. + +The following is a copy of the ``build_command`` setting which can be run manually to build the +package locally: + +.. code-block:: bash + + bash scripts/build.sh diff --git a/LICENSE b/LICENSE index b0e19d4..b49d08f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,25 +1,41 @@ -Copyright (c) 2013-2020 Gavin M. Roy +Copyright (c) 2026, codejedi365 All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +This project is a fork of dennishenry/flatdict2 (https://github.com/dennishenry/flatdict2). +Modifications and additional work are licensed under the BSD 3-Clause License. - * Redistributions of source code must retain the above copyright notice, this +-------------------------------------------------------------------------- +Derived Work Copyright (c) 2020-2024, Dennis Henry +All rights reserved. + +This project is a fork of gmr/flatdict (https://github.com/gmr/flatdict). +Modifications and additional work are licensed under the BSD 3-Clause License. + +-------------------------------------------------------------------------- +Original Work Copyright (c) 2013-2020, Gavin M. Roy +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - * Neither the name of the copyright holder nor the names of its contributors - may be used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. -IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, -INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, -BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE -OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF -ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in index 8d929e6..fb8c198 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,4 @@ -include CHANGELOG.md README.rst LICENSE +# include docs & testing into sdist, ignored for wheel build +graft docs/ +prune docs/_build/ +graft tests/ diff --git a/README.rst b/README.rst index 5b42fb9..2bd4acb 100644 --- a/README.rst +++ b/README.rst @@ -1,82 +1,36 @@ +======== FlatDict ======== -|Version| |Status| |Coverage| |License| - -``FlatDict`` and ``FlatterDict`` are a dict classes that allows for single level, -delimited key/value pair mapping of nested dictionaries. You can interact with -``FlatDict`` and ``FlatterDict`` like a normal dictionary and access child -dictionaries as you normally would or with the composite key. - -*For example:* - -.. code-block:: python - - value = flatdict2.FlatDict({'foo': {'bar': 'baz', 'qux': 'corge'}}) - -*would be the same as:* - -.. code-block:: python - - value == {'foo:bar': 'baz', 'foo:qux': 'corge'} - -*values can be accessed as:* - -.. code-block:: python - - print(foo['foo:bar']) - - # or - - print(foo['foo']['bar']) - -Additionally, lists and tuples are also converted into dicts using ``enumerate()``, -using the ``FlatterDict`` class. - -*For example:* - -.. code-block:: python - - value = flatdict2.FlatterDict({'list': ['a', 'b', 'c']}) - -*will be the same as:* - -.. code-block:: python - - value == {'list:0': 'a', 'list:1': 'b', 'list:2': 'c'} - -API ---- - -Documentation is available at https://flatdict.readthedocs.io - -Versioning ----------- -This package attempts to use semantic versioning. API changes are indicated -by the major version, non-breaking improvements by the minor, and bug fixes -in the revision. +|PyPI Version| |Last Release| |Monthly Downloads| |License| |Issues| -It is recommended that you pin your targets to greater or equal to the current -version and less than the next major version. +FlatDict is a Python library for interacting with nested dictionaries and +lists as a single-level dictionary with delimited keys. -Installation ------------- +This library provides the :py:class:`~cj365.flatdict.FlatDict` class for +flattening nested dictionaries, and the :py:class:`~cj365.flatdict.FlatterDict` +class for flattening nested dictionaries and sequences (lists and tuples). -.. code-block:: bash +The official documentation can be found at `codejedi365.github.io/flatdict`_. - $ pip install flatdict2 +.. _codejedi365.github.io/flatdict: https://codejedi365.github.io/flatdict -Note that as of 4.0, setuptools 39.2 or higher is required for installation. +.. |PyPI Version| image:: https://img.shields.io/pypi/v/cj365-flatdict?label=PyPI&logo=pypi + :target: https://pypi.org/project/cj365-flatdict/ + :alt: pypi -.. |Version| image:: https://img.shields.io/pypi/v/flatdict2.svg? - :target: https://pypi.python.org/pypi/flatdict2 +.. |Last Release| image:: https://img.shields.io/github/release-date/codejedi365/flatdict?display_date=published_at + :target: https://github.com/codejedi365/flatdict/releases/latest + :alt: GitHub Release Date -.. |Status| image:: https://github.com/dennishenry/flatdict2/workflows/Testing/badge.svg - :target: https://github.com/dennishenry/flatdict2/actions - :alt: Build Status +.. |Monthly Downloads| image:: https://img.shields.io/pypi/dm/cj365-flatdict + :target: https://pypistats.org/packages/cj365-flatdict + :alt: PyPI - Downloads -.. |Coverage| image:: https://img.shields.io/codecov/c/github/dennishenry/flatdict2.svg? - :target: https://codecov.io/github/dennishenry/flatdict2?branch=main +.. |License| image:: https://img.shields.io/pypi/l/cj365-flatdict?color=blue + :target: https://github.com/codejedi365/flatdict/blob/main/LICENSE + :alt: PyPI - License -.. |License| image:: https://img.shields.io/pypi/l/flatdict2.svg? - :target: https://flatdict.readthedocs.org +.. |Issues| image:: https://img.shields.io/github/issues/codejedi365/flatdict + :target: https://github.com/codejedi365/flatdict/issues + :alt: GitHub Issues diff --git a/bootstrap b/bootstrap deleted file mode 100755 index 362c953..0000000 --- a/bootstrap +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env sh -python3 -m venv env -source env/bin/activate -pip install --upgrade pip -pip install -r requires/testing.txt -mkdir -p build diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 44c478e..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,20 +0,0 @@ -import datetime -import os -import sys - -import pkg_resources - -sys.path.insert(0, os.path.abspath('..')) -master_doc = 'index' -project = 'flatdict2' -release = version = pkg_resources.get_distribution(project).version -copyright = '{}, Dennis Henry'.format(datetime.date.today().year) - -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.autosummary', - 'sphinx.ext.viewcode' -] - -templates_path = ['_templates'] -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index e7a2648..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,137 +0,0 @@ -FlatDict2 -======== -|Version| |Status| |Coverage| |License| - -``flatdict2`` is a Python module for interacting with nested dicts as a single -level dict with delimited keys. ``flatdict2`` supports Python 3.6+. - -Jump to :ref:`installation`, :ref:`example`, or :ref:`docs`. - -*For example:* - -.. code-block:: python - - value = flatdict2.FlatDict({'foo': {'bar': 'baz', 'qux': 'corge'}}) - -*can be accessed as:* - -.. code-block:: python - - value == {'foo:bar': 'baz', 'foo:qux': 'corge'} - -*values can be accessed as:* - -.. code-block:: python - - print(foo['foo:bar']) - - # or - - print(foo['foo']['bar']) - -Additionally, lists and tuples are also converted into dicts using enumerate(), -using the :py:class:`~flatdict2.FlatterDict` class. - -*For example:* - -.. code-block:: python - - value = flatdict2.FlatterDict({'list': ['a', 'b', 'c']}) - -*will be flattened as follows:* - -.. code-block:: python - - value == {'list:0': 'a', 'list:1': 'b', 'list:2': 'c'} - -.. _installation: - -Installation ------------- - -.. code-block:: bash - - $ pip install flatdict2 - -Versioning ----------- - -This package attempts to use semantic versioning. API changes are indicated -by the major version, non-breaking improvements by the minor, and bug fixes -in the revision. - -It is recommended that you pin your targets to greater or equal to the current -version and less than the next major version. - -.. _example: - -Example Use ------------ - -:py:class:`flatdict2.FlatDict` - -.. code-block:: python - - import pprint - - import flatdict2 - - flat = flatdict2.FlatDict( - {'foo': {'bar': {'baz': 0, - 'qux': 1, - 'corge': 2}, - 'grault': {'baz': 3, - 'qux': 4, - 'corge': 5}}, - 'garply': {'foo': 0, 'bar': 1, 'baz': 2, 'qux': {'corge': 3}}}) - - print(flat['foo:bar:baz']) - - flat['test:value:key'] = 10 - - del flat['test'] - - for key in flat: - print(key) - - for value in flat.itervalues(): - print(value) - - pprint.pprint(flat.as_dict()) - - pprint.pprint(dict(flat)) - - print(flat == flat.as_dict()) - -:py:class:`flatdict2.FlatterDict` - -.. code-block:: python - - import flatdict2 - - value = flatdict2.FlatterDict({'list': ['a', 'b', 'c']}) - for key, value in value.items(): - print(key, value) - -.. _docs: - -API Documentation ------------------ - -.. automodule:: flatdict2 - :members: - :undoc-members: - :inherited-members: - -.. |Version| image:: https://img.shields.io/pypi/v/flatdict2.svg? - :target: https://pypi.python.org/pypi/flatdict2 - -.. |Status| image:: https://github.com/dennishenry/flatdict2/workflows/Testing/badge.svg - :target: https://github.com/dennishenry/flatdict2/actions - :alt: Build Status - -.. |Coverage| image:: https://img.shields.io/codecov/c/github/dennishenry/flatdict2.svg? - :target: https://codecov.io/github/dennishenry/flatdict2?branch=main - -.. |License| image:: https://img.shields.io/pypi/l/flatdict2.svg? - :target: https://flatdict.readthedocs.org diff --git a/docs/source/_static/.gitkeep b/docs/source/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/_templates/.gitkeep b/docs/source/_templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst new file mode 100644 index 0000000..be998cc --- /dev/null +++ b/docs/source/api/index.rst @@ -0,0 +1,16 @@ +.. _api: + +API Documentation +================= + +This section provides detailed API documentation for the classes included in the +``cj365.flatdict`` package: + +- ``__dist_name__``: The distribution name of the package +- ``__version__``: The current version of the package + +.. toctree:: + :maxdepth: 1 + + modules/FlatDict + modules/FlatterDict diff --git a/docs/source/api/modules/FlatDict.rst b/docs/source/api/modules/FlatDict.rst new file mode 100644 index 0000000..50374dc --- /dev/null +++ b/docs/source/api/modules/FlatDict.rst @@ -0,0 +1,14 @@ + +``FlatDict`` Class +------------------ + +.. automodule:: cj365.flatdict.flat_dict + :members: + :undoc-members: + :inherited-members: + +Examples +-------- + +For examples of how to use the :py:class:`~cj365.flatdict.flat_dict.FlatDict` class, check out the +:ref:`Example Use ` section. diff --git a/docs/source/api/modules/FlatterDict.rst b/docs/source/api/modules/FlatterDict.rst new file mode 100644 index 0000000..d40aa58 --- /dev/null +++ b/docs/source/api/modules/FlatterDict.rst @@ -0,0 +1,14 @@ + +``FlatterDict`` Class +--------------------- + +.. automodule:: cj365.flatdict.flatter_dict + :members: + :undoc-members: + :inherited-members: + +Examples +-------- + +For examples of how to use the :py:class:`~cj365.flatdict.flatter_dict.FlatterDict` class, check out the +:ref:`Example Use ` section. diff --git a/docs/source/concepts/examples/flatdict_examples.rst b/docs/source/concepts/examples/flatdict_examples.rst new file mode 100644 index 0000000..e1b7eb4 --- /dev/null +++ b/docs/source/concepts/examples/flatdict_examples.rst @@ -0,0 +1,108 @@ +.. _examples-flatdict: + +FlatDict Examples +-------------------- + +All of the following examples assume you have installed the ``cj365-flatdict`` package +and unless otherwise noted will start with the following data structure: + +.. code-block:: python + + from cj365.flatdict import FlatDict + + data = { + 'foo': { + 'bar': 'baz', + 'qux': ["a", 'b'] + } + } + flat_dict = FlatDict(data, delimiter='.') + +It is important to note that FlatDict implements the Mapping protocol, so it +supports all the standard dictionary methods and behaviors, such as iteration, +membership testing, and more. For example, you can check if a key exists in the +FlatDict: + +.. code-block:: python + + print('foo.bar' in flat_dict) + # Output: True + + print('foo.qux' in flat_dict) + # Output: True + + print('nonexistent-key' in flat_dict) + # Output: False + + # It will also support existence of the parent keys + print('foo' in flat_dict) + # Output: True + +Dictionary methods like ``.keys()``, ``.values()``, and ``.items()`` will also work +as expected in Python 3, and will return View iterator objects that reflect the +current state of the FlatDict. For example: + +.. code-block:: python + + print(flat_dict.keys()) + # Output: dict_keys(['foo.bar', 'foo.qux']) + + print(flat_dict.values()) + # Output: dict_values(['baz', ['a', 'b']]) + + print(flat_dict.items()) + # Output: dict_items([ + # ('foo.bar', 'baz'), ('foo.qux', ['a', 'b']), + # ]) + +Dictionary methods like ``.pop()`` and ``.update()`` will also work as expected, and +will maintain the integrity of the nested structure. For example, the ``.pop()`` method +will remove the specified key and return its value, while also ensuring that the +integrity of the nested structure is maintained. For example: + +.. code-block:: python + + popped_value = flat_dict.pop('foo.bar') + print(popped_value) + # Output: baz + + print(flat_dict) + # Output: {'foo.qux': ['a', 'b']} + + flat_dict.update({'foo.bar': 'new_value'}) + print(flat_dict) + # Output: {'foo.qux': ['a', 'b'], 'foo.bar': 'new_value'} + +The ``.update()`` method will also work with nested dictionaries, allowing you to update +multiple keys at once while maintaining the nested structure. For example: + +.. code-block:: python + + flat_dict.update({ + 'foo.bar': 'new_value', + 'foo.new_key': 'new_value2' + }) + print(flat_dict) + # Output: {'foo.qux': ['a', 'b'], 'foo.bar': 'new_value', 'foo.new_key': 'new_value2'} + +FlatDict also supports the equality operator (``==``) for comparing two FlatDict +instances, as well as comparing a FlatDict instance to a regular dictionary. When +comparing two FlatDict instances, they are considered equal if they have the same +keys and corresponding values, regardless of the order of the keys. When comparing +a FlatDict instance to a regular dictionary, they are considered equal if the +FlatDict can be inflated to a nested dictionary that is equal to the regular dictionary +when using the standard equality operator (``==``). For example: + +.. code-block:: python + + flat_dict2 = FlatDict({'foo.qux': ['a', 'b'], 'foo.bar': 'baz'}) + regular_dict = {'foo': {'bar': 'baz', 'qux': ['a', 'b']}} + + print(flat_dict == flat_dict2) + # Output: True + + print(flat_dict == regular_dict) + # Output: True + +To see the full API reference, see the :py:class:`~cj365.flatdict.flat_dict.FlatDict` +documentation. diff --git a/docs/source/concepts/examples/flatterdict_examples.rst b/docs/source/concepts/examples/flatterdict_examples.rst new file mode 100644 index 0000000..5e43a77 --- /dev/null +++ b/docs/source/concepts/examples/flatterdict_examples.rst @@ -0,0 +1,133 @@ +.. _examples-flatterdict: + +FlatterDict Examples +-------------------- + +All of the following examples assume you have installed the ``cj365-flatdict`` package +and unless otherwise noted will start with the following data structure: + +.. code-block:: python + + from cj365.flatdict import FlatterDict + + data = { + 'list': ['a', 'b', 'c'], + 'set': {'x', 'y', 'z'} + } + flatter_dict = FlatterDict(data, delimiter='.') + +It is important to note that FlatterDict implements the Mapping protocol, so it +supports all the standard dictionary methods and behaviors, such as iteration, +membership testing, and more. For example, you can check if a key exists in the +FlatterDict: + +.. code-block:: python + + print('list.0' in flatter_dict) + # Output: True + + print('set.1' in flatter_dict) + # Output: True + + print('nonexistent-key' in flatter_dict) + # Output: False + + # It will also support existence of the parent keys + print('list' in flatter_dict) + # Output: True + +Dictionary methods like ``.keys()``, ``.values()``, and ``.items()`` will also work +as expected in Python 3, and will return View iterator objects that reflect the +current state of the FlatterDict. For example: + +.. code-block:: python + + print(flatter_dict.keys()) + # Output: dict_keys(['list.0', 'list.1', 'list.2', 'set.0', 'set.1', 'set.2']) + + print(flatter_dict.values()) + # Output: dict_values(['a', 'b', 'c', 'x', 'y', 'z']) + + print(flatter_dict.items()) + # Output: dict_items([ + # ('list.0', 'a'), ('list.1', 'b'), ('list.2', 'c'), + # ('set.0', 'x'), ('set.1', 'y'), ('set.2', 'z'), + # ]) + +Dictionary methods like ``.pop()`` and ``.update()`` are a bit more complex due to the +nested structure and the handling of sequences. The ``.pop()`` method will remove the +specified key and return its value, while also ensuring that the integrity of the nested +structure is maintained. If the popped key is part of a sequence, the method will also +update the keys of the remaining items in the sequence to reflect their new positions. If +the popped key is a parent key, it will remove all child keys as well. When the popped key +is not found, it will return None or a specified default value. If the popped key is a +regular key, it will simply remove that key and return its value. All of scenarios described +above are demonstrated in the following example: + +.. code-block:: python + + # Popping a regular key + print(flatter_dict.pop('list.1')) + # Output: b + # New state of flatter_dict: {'list': ['a', 'c'], 'set': {'x', 'y', 'z'}} + + # Popping a parent key (will return an ordered tuple of the child values) + print(flatter_dict.pop('set')) + # Output: ('x', 'y', 'z') + # New state of flatter_dict: {'list': ['a', 'c']} + + # Popping a non-existent key with default value + print(flatter_dict.pop('nonexistent-key', 'default-value')) + # Output: default-value + + # Popping a non-existent key without default value + print(flatter_dict.pop('another-nonexistent-key')) + # Output: None + +The ``.update()`` method allows you to update the FlatterDict with another dictionary or an +iterable of key-value pairs. When updating with a dictionary, it will handle nested structures +and sequences appropriately, ensuring that the keys are updated to reflect their new positions +if necessary. When updating with an iterable of key-value pairs, it will simply add or update +the specified keys and values without any special handling. The following example demonstrates +both scenarios: + +.. code-block:: python + + # Updating with a dictionary (will handle nested structures and sequences) + flatter_dict.update({ + 'list': ['a', 'new_value', 'c'], + 'set': {'r', 's', 't'}, + }) + # New state of flatter_dict: {'list': ['a', 'new_value', 'c'], 'set': {'r', 's', 't'}} + + # Updating with an iterable of key-value pairs (no special handling) + flatter_dict.update([ + ('list.1', 'another_new_value'), + ('set.1', 'another_new_value'), + ]) + # New state of flatter_dict: { + # 'list': ['a', 'another_new_value', 'c'], + # 'set': {'r', 'another_new_value', 't'} + # } + +FlatterDict also supports the equality operator (``==``) with other FlatterDict instances, +as well as comparisons with the same type of initial data structure (nested dictionaries, +lists, tuples, and sets). When comparing with another FlatterDict instance, it will check if +the keys and values are the same, and check the delimiter as well. When comparing with +the same type of initial data structure, it will inflate itself and compare the resulting +nested structure with the other object. The following example demonstrates these comparisons: + +.. code-block:: python + + flatter_dict2 = FlatterDict(data, delimiter='.') + + # Comparing two FlatterDict instances + print(flatter_dict == flatter_dict2) + # Output: True + + # Comparing with the same type of initial data structure + print(flatter_dict == data) + # Output: True + +To see the full API reference, see the :py:class:`~cj365.flatdict.flatter_dict.FlatterDict` +documentation. diff --git a/docs/source/concepts/examples/index.rst b/docs/source/concepts/examples/index.rst new file mode 100644 index 0000000..ae54b42 --- /dev/null +++ b/docs/source/concepts/examples/index.rst @@ -0,0 +1,9 @@ + +Examples +-------- + +.. toctree:: + :maxdepth: 1 + + FlatDict + FlatterDict diff --git a/docs/source/concepts/index.rst b/docs/source/concepts/index.rst new file mode 100644 index 0000000..defe9a7 --- /dev/null +++ b/docs/source/concepts/index.rst @@ -0,0 +1,10 @@ +.. _concepts: + +Concepts +======== + +.. toctree:: + :maxdepth: 2 + + installation + examples/index diff --git a/docs/source/concepts/installation.rst b/docs/source/concepts/installation.rst new file mode 100644 index 0000000..68ca79d --- /dev/null +++ b/docs/source/concepts/installation.rst @@ -0,0 +1,27 @@ +.. _installation: + +Installation +============ + +This package adheres to `Semantic Versioning (SemVer)`_. API changes are indicated +by the major version, non-breaking improvements by the minor version, and bug fixes +in the patch version. + +.. code-block:: bash + + # Install the latest version of flatdict from PyPI + python3 -m pip install cj365-flatdict + +**RECOMMENDATION:** Pin your dependencies to the current major version to avoid +unexpected breaking changes! See the example below. + +.. code-block:: toml + + # pyproject.toml + [project] + # ... + dependencies = [ + "cj365-flatdict ~= 5.0" # Adjust the version as needed + ] + +.. _Semantic Versioning (SemVer): https://semver.org/ diff --git a/docs/source/concepts/quick_reference.rst b/docs/source/concepts/quick_reference.rst new file mode 100644 index 0000000..de09aeb --- /dev/null +++ b/docs/source/concepts/quick_reference.rst @@ -0,0 +1,89 @@ +Quick Reference +=============== + +This section provides a quick reference guide to the main features and usage of the FlatDict package. + +FlatDict +-------- + +:py:class:`~cj365.flatdict.flat_dict.FlatDict` provides a dictionary-like interface +for working with nested dictionaries using delimited keys. It allows you to access, +update, and manipulate nested dictionaries as if they were flat dictionaries. + +.. code-block:: python + + from cj365.flatdict import FlatDict + + data = { + 'foo': { + 'bar': 'baz', + 'qux': ["a", 'b'] + } + } + flat_dict = FlatDict(data, delimiter='.') + + # printing all keys + print(flat_dict.keys()) + # Output: dict_keys(['foo.bar', 'foo.qux']) + + # Accessing values using delimited keys + print(flat_dict['foo.bar']) + # Output: baz + + print(flat_dict['foo.qux']) + # Output: ['a', 'b'] + + # Updating values using delimited keys + flat_dict['foo.bar'] = 'new_value' + print(flat_dict['foo.bar']) + # Output: new_value + + # Converting back to nested dictionary + nested_dict = flat_dict.inflate() + print(nested_dict) + # Output: {'foo': {'bar': 'new_value', 'qux': ['a', 'b']}} + +To see more examples and use cases of :py:class:`~cj365.flatdict.flat_dict.FlatDict`, +check out the :ref:`Example Use ` section. + +FlatterDict +----------- + +:py:class:`~cj365.flatdict.flatter_dict.FlatterDict` provides a similar interface but +also handles sequences and sets as child-dict instances with the offset as the key. It +allows you to work with nested dictionaries that contain lists and sets as if they were +flat dictionaries. + +.. code-block:: python + + from cj365.flatdict import FlatterDict + + data = { + 'list': ['a', 'b', 'c'], + 'set': {'x', 'y', 'z'} + } + flatter_dict = FlatterDict(data, delimiter='.') + + # printing all keys + print(flatter_dict.keys()) + # Output: dict_keys(['list.0', 'list.1', 'list.2', 'set.0', 'set.1', 'set.2']) + + # Accessing values using delimited keys + print(flatter_dict['list.0']) + # Output: a + + print(flatter_dict['set.0']) + # Output: x + + # Updating values using delimited keys + flatter_dict['list.1'] = 'new_value' + print(flatter_dict['list.1']) + # Output: new_value + + # Converting back to nested dictionary + nested_dict = flatter_dict.inflate() + print(nested_dict) + # Output: {'list': ['a', 'new_value', 'c'], 'set': {'x', 'y', 'z'}} + +To see more examples and use cases of :py:class:`~cj365.flatdict.flatter_dict.FlatterDict`, +check out the :ref:`Example Use ` section. diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..6ab8de2 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,28 @@ +from datetime import datetime, timezone +from importlib.metadata import metadata, version as get_version + +import cj365.flatdict + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.coverage", + "sphinx.ext.doctest", + "sphinx.ext.viewcode", +] + +project = distribution_name = cj365.flatdict.__dist_name__ +version = get_version(distribution_name) +release = f"v{version}" + +meta = metadata(distribution_name) +maintainer_name = meta["Maintainer-email"].split("<", maxsplit=1)[0].strip() +current_year = datetime.now(tz=timezone.utc).astimezone().year +copyright = f"{current_year}, {maintainer_name}" + +html_theme = "furo" +master_doc = "index" +exclude_patterns = ["_build"] +pygments_style = "sphinx" +source_suffix = ".rst" +templates_path = ["source/_templates"] diff --git a/docs/source/contributing/contributing_guide.rst b/docs/source/contributing/contributing_guide.rst new file mode 100644 index 0000000..b1cd2f3 --- /dev/null +++ b/docs/source/contributing/contributing_guide.rst @@ -0,0 +1 @@ +.. include:: ../../../CONTRIBUTING.rst diff --git a/docs/source/contributing/index.rst b/docs/source/contributing/index.rst new file mode 100644 index 0000000..049da35 --- /dev/null +++ b/docs/source/contributing/index.rst @@ -0,0 +1,37 @@ +.. _contributing: + +Contributing +============ + +Love FlatDict? Want to help out? There are many ways you can contribute to the project! + +You can help by: + +- Reporting bugs and issues +- Suggesting new features +- Improving the documentation +- Reviewing pull requests +- Contributing code +- Helping with translations +- Spreading the word about FlatDict +- Participating in discussions +- Testing new features and providing feedback + +No matter how you choose to contribute, please check out our +:ref:`Contributing Guidelines ` and know we appreciate your help! + +**Check out all the folks whom already contributed to FlatDict and become one of them today!** + +|contributors| + +.. |contributors| image:: https://contributors-img.web.app/image?repo=codejedi365/flatdict + :target: https://github.com/codejedi365/flatdict/graphs/contributors + + +.. toctree:: + :hidden: + :maxdepth: 1 + + Contributing Guide + Code of Conduct <../misc/code_of_conduct> + Security Policy <../misc/security_policy> diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..76025ea --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,58 @@ +======== +FlatDict +======== + +|PyPI Version| |Last Release| |Monthly Downloads| |License| |Issues| + +FlatDict is a Python library for interacting with nested dictionaries and +lists as a single-level dictionary with delimited keys. + +This library provides the :py:class:`~cj365.flatdict.FlatDict` class for +flattening nested dictionaries, and the :py:class:`~cj365.flatdict.FlatterDict` +class for flattening nested dictionaries and sequences (lists and tuples). + + +.. |PyPI Version| image:: https://img.shields.io/pypi/v/cj365-flatdict?label=PyPI&logo=pypi + :target: https://pypi.org/project/cj365-flatdict/ + :alt: pypi + +.. |Last Release| image:: https://img.shields.io/github/release-date/codejedi365/flatdict?display_date=published_at + :target: https://github.com/codejedi365/flatdict/releases/latest + :alt: GitHub Release Date + +.. |Monthly Downloads| image:: https://img.shields.io/pypi/dm/cj365-flatdict + :target: https://pypistats.org/packages/cj365-flatdict + :alt: PyPI - Downloads + +.. |License| image:: https://img.shields.io/pypi/l/cj365-flatdict?color=blue + :target: https://github.com/codejedi365/flatdict/blob/main/LICENSE + :alt: PyPI - License + +.. |Issues| image:: https://img.shields.io/github/issues/codejedi365/flatdict + :target: https://github.com/codejedi365/flatdict/issues + :alt: GitHub Issues + + +Documentation Contents +====================== + +.. toctree:: + :maxdepth: 1 + + What's New + Concepts + API + FAQ + Contributing + View on GitHub +.. upgrading/index + +---- + +.. include:: concepts/installation.rst + :start-after: .. _installation: + +---- + +.. _quick_reference: +.. include:: concepts/quick_reference.rst diff --git a/docs/source/misc/changelog.rst b/docs/source/misc/changelog.rst new file mode 100644 index 0000000..58dc03f --- /dev/null +++ b/docs/source/misc/changelog.rst @@ -0,0 +1 @@ +.. include:: ../../../CHANGELOG.rst diff --git a/docs/source/misc/code_of_conduct.rst b/docs/source/misc/code_of_conduct.rst new file mode 100644 index 0000000..0963b16 --- /dev/null +++ b/docs/source/misc/code_of_conduct.rst @@ -0,0 +1,58 @@ +=============== +Code of Conduct +=============== + +This project is committed to fostering a welcoming, inclusive, and respectful environment +for all contributors and users. We expect everyone participating in this repository to +adhere to the following guidelines. + +Reporting Issues +================ + +- Please use the issue templates provided in ``.github/ISSUE_TEMPLATE/`` for bug reports, + feature requests, or documentation improvements. + +- Be clear and concise in your descriptions. Include steps to reproduce, expected behavior, + and relevant logs or screenshots when applicable. + +- Do not include sensitive information, credentials, or secrets in issues or comments. + +- Treat maintainers and other contributors with respect and patience. + +Contributing +============ + +When contributing to this repository, please follow these guidelines: + +- Review the :ref:`contributing_guide` before submitting changes. + +- All contributions must follow the project's code style, commit message conventions, + and include appropriate tests. + +- Use inclusive and professional language in all communications. + +- Respect review feedback and collaborate constructively. + +- If you encounter behavior that violates this Code of Conduct, report it to the + maintainers via the contact information in :ref:`contributing_guide`. + +Unacceptable Behavior +===================== + +The following behaviors are not tolerated: + +- Harassment, discrimination, or exclusion of any kind. + +- Use of offensive, derogatory, or inappropriate language. + +- Sharing of private or confidential information. + +- Disruptive or aggressive conduct in discussions, reviews, or issues. + +Enforcement +=========== + +Violations of this Code of Conduct may result in removal of content, restriction of +repository access, or other actions as deemed appropriate by the maintainers. + +Thank you for helping us create a positive and productive community! diff --git a/docs/source/misc/faq.rst b/docs/source/misc/faq.rst new file mode 100644 index 0000000..8c83658 --- /dev/null +++ b/docs/source/misc/faq.rst @@ -0,0 +1,85 @@ +.. _misc-faq: + +Frequently Asked Questions (FAQ) +================================ + +.. _@codejedi365: https://github.com/codejedi365 +.. _cj365-flatdict: https://pypi.org/project/cj365-flatdict +.. _flatdict: https://pypi.org/project/flatdict/ +.. _flatdict2: https://pypi.org/project/flatdict2/ +.. _PEP 517: https://www.python.org/dev/peps/pep-0517/ + +#. What is the difference between `flatdict`_, `flatdict2`_, and `cj365-flatdict`_ packages? + + `flatdict`_ is the original package written for Python 2.7 by Gavin M. Roy in 2012, + which provides the basic functionality for flattening nested dictionaries and lists. + Improvements and maintenance of this package stopped in 2020 at v4.0.1, until the + v4.1.0 was released in 2026, which resolved the problem with installation by pip + ``>=25.3`` with the latest build standard `PEP 517`_. + + `flatdict2`_ is a fork created by Dennis Henry in 2020 of the original `flatdict`_ + package to publish a wheel on PyPI instead of just a source distribution but it did + not modernize the codebase. The source dist is not installable by pip 25.3 due to the + old build standard but the wheel is installable by pip 25.3 and provides the same + functionality as `flatdict`_. + + `cj365-flatdict`_ is a complete rewrite of the original `flatdict`_ package by + `@codejedi365`_ in 2026, which modernized the ``v4.0.4`` codebase to Python3 + expectations, enhanced the flexibility of updating nested values, and modernized + the build infrastructure to support the latest build standards and tools. Performance + optimizations and testing rigor were also added to ensure the package is robust, + performant, and memory efficient. The default delimiter was also changed from a + colon (``:``) to a period (``.``) to align with common conventions for representing + nested keys in Python and other programming languages. + + +#. Why does FlatterDict exist when FlatDict already exists? + + :py:class:`~cj365.flatdict.flat_dict.FlatDict` is designed to enable updating of + nested dictionaries only while :py:class:`~cj365.flatdict.flatter_dict.FlatterDict` + is designed to enable updating of nested dictionaries and sequences (lists and + tuples). If you only desire to extract or update lists, tuples, or sets wholesale + as values, then :py:class:`~cj365.flatdict.flat_dict.FlatDict` will be sufficient + for your needs. However, if you need to extract or update individual items within + lists, tuples, or sets, then :py:class:`~cj365.flatdict.flatter_dict.FlatterDict` + would be the appropriate choice. + + Integers, floats, strings, booleans, None, and other non-dict and non-sequence + types are treated as simple values in both classes and cannot be updated with + delimited keys. + + +#. How does FlatterDict differ from FlatDict? + + :py:class:`~cj365.flatdict.flatter_dict.FlatterDict` is able to take a delimited + key that includes an offset integer for a list, tuple, or set and update the + value at that offset within the sequence. For example, if you have a list + ``my_list = [1, 2, 3]`` and you want to update the value at index 1 to be 42, you + could use :py:class:`~cj365.flatdict.flatter_dict.FlatterDict` to do this with a + delimited key like ``my_list.1``. This would update the list to be + ``my_list = [1, 42, 3]``. + + In contrast, :py:class:`~cj365.flatdict.flat_dict.FlatDict` would treat ``my_list.1`` + as an invalid key since it does not support updating of individual items within + sequences. Instead, you would need to update the entire list at once with a key + like ``my_list`` and provide the new list value. + + +#. Why can't I use customized classes as values in FlatDict or FlatterDict? + + :py:class:`~cj365.flatdict.flat_dict.FlatDict` will be able to handle customized + classes as values unless they specifically extend the built-in ``dict`` class. + These classes will be treated as simple values and cannot be updated with delimited + keys. However, if a customized class extends the built-in ``dict`` class, the + behavior of :py:class:`~cj365.flatdict.flat_dict.FlatDict` can be unpredictable + since it is designed to work with standard Python data types and may not be + compatible with the internal structure and behavior of customized classes that + extend ``dict``. + + :py:class:`~cj365.flatdict.flatter_dict.FlatterDict` will not be able to handle + customized sequence classes because under the hood we convert sequences to + index-keyed dictionaries to provide the delimited key updating functionality. In + the :py:meth:`~cj365.flatdict.flatter_dict.FlatterDict.inflate()` method, we + attempt to convert these index-keyed dictionaries back to their original sequence + types but if the original sequence type is a customized class, we will not be able + to convert it back and it will be inflated as a memory-efficient tuple instead. diff --git a/docs/source/misc/security_policy.rst b/docs/source/misc/security_policy.rst new file mode 100644 index 0000000..bde0e67 --- /dev/null +++ b/docs/source/misc/security_policy.rst @@ -0,0 +1,25 @@ +.. _security_policy: + +Security Policy +=============== + +The FlatDict project takes security seriously. If you discover a security vulnerability +in FlatDict, please report it to us immediately so we can address it as quickly as possible. + +Reporting a Vulnerability +------------------------- + +To report a security vulnerability, please follow these steps: + +1. **Do not publicly disclose the vulnerability until it has been addressed.** + +2. Send an email to the `FlatDict maintainer `_ + with the following information: + + - A detailed description of the vulnerability. + - Steps to reproduce the vulnerability. + - Any potential impact or severity of the vulnerability. + - Any suggested fixes or mitigations, if you have any. + +3. We will acknowledge receipt of your report within 48 hours and will work with you to + address the issue. diff --git a/flatdict2.py b/flatdict2.py deleted file mode 100644 index 6411646..0000000 --- a/flatdict2.py +++ /dev/null @@ -1,489 +0,0 @@ -"""FlatDict is a dict object that allows for single level, delimited -key/value pair mapping of nested dictionaries. - -""" -try: - from collections.abc import MutableMapping -except ImportError: # pragma: nocover - from collections import MutableMapping -import sys - -__version__ = '4.0.4' - -NO_DEFAULT = object() - - -class FlatDict(MutableMapping): - """:class:`~flatdict2.FlatDict` is a dictionary object that allows for - single level, delimited key/value pair mapping of nested dictionaries. - The default delimiter value is ``:`` but can be changed in the constructor - or by calling :meth:`FlatDict.set_delimiter`. - - """ - _COERCE = dict - - def __init__(self, value=None, delimiter=':', dict_class=dict): - super(FlatDict, self).__init__() - self._values = dict_class() - self._delimiter = delimiter - self.update(value) - - def __contains__(self, key): - """Check to see if the key exists, checking for both delimited and - not delimited key values. - - :param mixed key: The key to check for - - """ - if self._has_delimiter(key): - pk, ck = key.split(self._delimiter, 1) - return pk in self._values and ck in self._values[pk] - return key in self._values - - def __delitem__(self, key): - """Delete the item for the specified key, automatically dealing with - nested children. - - :param mixed key: The key to use - :raises: KeyError - - """ - if key not in self: - raise KeyError - if self._has_delimiter(key): - pk, ck = key.split(self._delimiter, 1) - del self._values[pk][ck] - if not self._values[pk]: - del self._values[pk] - else: - del self._values[key] - - def __eq__(self, other): - """Check for equality against the other value - - :param other: The value to compare - :type other: FlatDict - :rtype: bool - :raises: TypeError - - """ - if isinstance(other, dict): - return self.as_dict() == other - elif not isinstance(other, self.__class__): - raise TypeError - return self.as_dict() == other.as_dict() - - def __ne__(self, other): - """Check for inequality against the other value - - :param other: The value to compare - :type other: dict or FlatDict - :rtype: bool - - """ - return not self.__eq__(other) - - def __getitem__(self, key): - """Get an item for the specified key, automatically dealing with - nested children. - - :param mixed key: The key to use - :rtype: mixed - :raises: KeyError - - """ - values = self._values - key = [key] if isinstance(key, int) else key.split(self._delimiter) - for part in key: - values = values[part] - return values - - def __iter__(self): - """Iterate over the flat dictionary key and values - - :rtype: Iterator - :raises: RuntimeError - - """ - return iter(self.keys()) - - def __len__(self): - """Return the number of items. - - :rtype: int - - """ - return len(self.keys()) - - def __reduce__(self): - """Return state information for pickling - - :rtype: tuple - - """ - return type(self), (self.as_dict(), self._delimiter) - - def __repr__(self): - """Return the string representation of the instance. - - :rtype: str - - """ - return '<{} id={} {}>"'.format(self.__class__.__name__, id(self), - str(self)) - - def __setitem__(self, key, value): - """Assign the value to the key, dynamically building nested - FlatDict items where appropriate. - - :param mixed key: The key for the item - :param mixed value: The value for the item - :raises: TypeError - - """ - if isinstance(value, self._COERCE) and not isinstance(value, FlatDict): - value = self.__class__(value, self._delimiter) - if self._has_delimiter(key): - pk, ck = key.split(self._delimiter, 1) - if pk not in self._values: - self._values[pk] = self.__class__({ck: value}, self._delimiter) - return - elif not isinstance(self._values[pk], FlatDict): - raise TypeError( - 'Assignment to invalid type for key {}'.format(pk)) - self._values[pk][ck] = value - else: - self._values[key] = value - - def __str__(self): - """Return the string value of the instance. - - :rtype: str - - """ - return '{{{}}}'.format(', '.join( - ['{!r}: {!r}'.format(k, self[k]) for k in self.keys()])) - - def as_dict(self): - """Return the :class:`~flatdict2.FlatDict` as a :class:`dict` - - :rtype: dict - - """ - out = {} - for key in self.keys(): - if self._has_delimiter(key): - pk, ck = key.split(self._delimiter, 1) - if self._has_delimiter(ck): - ck = ck.split(self._delimiter, 1)[0] - if isinstance(self._values[pk], FlatDict) and pk not in out: - out[pk] = {} - if isinstance(self._values[pk][ck], FlatDict): - out[pk][ck] = self._values[pk][ck].as_dict() - else: - out[pk][ck] = self._values[pk][ck] - else: - out[key] = self._values[key] - return out - - def clear(self): - """Remove all items from the flat dictionary.""" - self._values.clear() - - def copy(self): - """Return a shallow copy of the flat dictionary. - - :rtype: flatdict2.FlatDict - - """ - return self.__class__(self.as_dict(), delimiter=self._delimiter) - - def get(self, key, d=None): - """Return the value for key if key is in the flat dictionary, else - default. If default is not given, it defaults to ``None``, so that this - method never raises :exc:`KeyError`. - - :param mixed key: The key to get - :param mixed d: The default value - :rtype: mixed - - """ - try: - return self.__getitem__(key) - except KeyError: - return d - - def items(self): - """Return a copy of the flat dictionary's list of ``(key, value)`` - pairs. - - .. note:: CPython implementation detail: Keys and values are listed in - an arbitrary order which is non-random, varies across Python - implementations, and depends on the flat dictionary's history of - insertions and deletions. - - :rtype: list - - """ - return [(k, self.__getitem__(k)) for k in self.keys()] - - def iteritems(self): - """Return an iterator over the flat dictionary's (key, value) pairs. - See the note for :meth:`flatdict2.FlatDict.items`. - - Using ``iteritems()`` while adding or deleting entries in the flat - dictionary may raise :exc:`RuntimeError` or fail to iterate over all - entries. - - :rtype: Iterator - :raises: RuntimeError - - """ - for item in self.items(): - yield item - - def iterkeys(self): - """Iterate over the flat dictionary's keys. See the note for - :meth:`flatdict2.FlatDict.items`. - - Using ``iterkeys()`` while adding or deleting entries in the flat - dictionary may raise :exc:`RuntimeError` or fail to iterate over all - entries. - - :rtype: Iterator - :raises: RuntimeError - - """ - for key in self.keys(): - yield key - - def itervalues(self): - """Return an iterator over the flat dictionary's values. See the note - :meth:`flatdict2.FlatDict.items`. - - Using ``itervalues()`` while adding or deleting entries in the flat - dictionary may raise a :exc:`RuntimeError` or fail to iterate over all - entries. - - :rtype: Iterator - :raises: RuntimeError - - """ - for value in self.values(): - yield value - - def keys(self): - """Return a copy of the flat dictionary's list of keys. - See the note for :meth:`flatdict2.FlatDict.items`. - - :rtype: list - - """ - keys = [] - - for key, value in self._values.items(): - if isinstance(value, (FlatDict, dict)): - nested = [ - self._delimiter.join([str(key), str(k)]) - for k in value.keys()] - keys += nested if nested else [key] - else: - keys.append(key) - - return keys - - def pop(self, key, default=NO_DEFAULT): - """If key is in the flat dictionary, remove it and return its value, - else return default. If default is not given and key is not in the - dictionary, :exc:`KeyError` is raised. - - :param mixed key: The key name - :param mixed default: The default value - :rtype: mixed - - """ - if key not in self and default != NO_DEFAULT: - return default - value = self[key] - self.__delitem__(key) - return value - - def setdefault(self, key, default): - """If key is in the flat dictionary, return its value. If not, - insert key with a value of default and return default. - default defaults to ``None``. - - :param mixed key: The key name - :param mixed default: The default value - :rtype: mixed - - """ - if key not in self: - self.__setitem__(key, default) - return self.__getitem__(key) - - def set_delimiter(self, delimiter): - """Override the default or passed in delimiter with a new value. If - the requested delimiter already exists in a key, a :exc:`ValueError` - will be raised. - - :param str delimiter: The delimiter to use - :raises: ValueError - - """ - for key in self.keys(): - if delimiter in key: - raise ValueError('Key {!r} collides with delimiter {!r}', key, - delimiter) - self._delimiter = delimiter - for key in self._values.keys(): - if isinstance(self._values[key], FlatDict): - self._values[key].set_delimiter(delimiter) - - def update(self, other=None, **kwargs): - """Update the flat dictionary with the key/value pairs from other, - overwriting existing keys. - - ``update()`` accepts either another flat dictionary object or an - iterable of key/value pairs (as tuples or other iterables of length - two). If keyword arguments are specified, the flat dictionary is then - updated with those key/value pairs: ``d.update(red=1, blue=2)``. - - :param iterable other: Iterable of key, value pairs - :rtype: None - - """ - [self.__setitem__(k, v) for k, v in dict(other or kwargs).items()] - - def values(self): - """Return a copy of the flat dictionary's list of values. See the note - for :meth:`flatdict2.FlatDict.items`. - - :rtype: list - - """ - return [self.__getitem__(k) for k in self.keys()] - - def _has_delimiter(self, key): - """Checks to see if the key contains the delimiter. - - :rtype: bool - - """ - return isinstance(key, str) and self._delimiter in key - - -class FlatterDict(FlatDict): - """Like :class:`~flatdict2.FlatDict` but also coerces lists and sets - to child-dict instances with the offset as the key. Alternative to - the implementation added in v1.2 of FlatDict. - - """ - _COERCE = list, tuple, set, dict, FlatDict - _ARRAYS = list, set, tuple - - def __init__(self, value=None, delimiter=':', dict_class=dict): - self.original_type = type(value) - if self.original_type in self._ARRAYS: - value = {str(i): v for i, v in enumerate(value)} - super(FlatterDict, self).__init__(value, delimiter, dict_class) - - def __setitem__(self, key, value): - """Assign the value to the key, dynamically building nested - FlatDict items where appropriate. - - :param mixed key: The key for the item - :param mixed value: The value for the item - :raises: TypeError - - """ - if isinstance(value, self._COERCE) and \ - not isinstance(value, FlatterDict): - value = self.__class__(value, self._delimiter) - if self._has_delimiter(key): - pk, ck = key.split(self._delimiter, 1) - if pk not in self._values: - self._values[pk] = self.__class__({ck: value}, self._delimiter) - return - if getattr(self._values[pk], 'original_type', - None) in self._ARRAYS: - try: - k, cck = ck.split(self._delimiter, 1) - int(k) - except ValueError: - raise TypeError( - 'Assignment to invalid type for key {}{}{}'.format( - pk, self._delimiter, ck)) - self._values[pk][k][cck] = value - return - elif not isinstance(self._values[pk], FlatterDict): - raise TypeError( - 'Assignment to invalid type for key {}'.format(pk)) - self._values[pk][ck] = value - else: - self._values[key] = value - - def as_dict(self): - """Return the :class:`~flatdict2.FlatterDict` as a nested - :class:`dict`. - - :rtype: dict - - """ - out = {} - for key in self.keys(): - if self._has_delimiter(key): - pk, ck = key.split(self._delimiter, 1) - if self._has_delimiter(ck): - ck = ck.split(self._delimiter, 1)[0] - if isinstance(self._values[pk], FlatterDict) and pk not in out: - if self._values[pk].original_type == tuple: - out[pk] = tuple(self._child_as_list(pk)) - elif self._values[pk].original_type == list: - out[pk] = self._child_as_list(pk) - elif self._values[pk].original_type == set: - out[pk] = set(self._child_as_list(pk)) - elif self._values[pk].original_type == dict: - out[pk] = self._values[pk].as_dict() - else: - if isinstance(self._values[key], FlatterDict): - out[key] = self._values[key].original_type() - else: - out[key] = self._values[key] - return out - - def _child_as_list(self, pk, ck=None): - """Returns a list of values from the child FlatterDict instance - with string based integer keys. - - :param str pk: The parent key - :param str ck: The child key, optional - :rtype: list - - """ - if ck is None: - subset = self._values[pk] - else: - subset = self._values[pk][ck] - # Check if keys has delimiter, which implies deeply nested dict - keys = subset.keys() - if any(self._has_delimiter(k) for k in keys): - out = [] - split_keys = {k.split(self._delimiter)[0] for k in keys} - for k in sorted(split_keys, key=lambda x: int(x)): - if subset[k].original_type == tuple: - out.append(tuple(self._child_as_list(pk, k))) - elif subset[k].original_type == list: - out.append(self._child_as_list(pk, k)) - elif subset[k].original_type == set: - out.append(set(self._child_as_list(pk, k))) - elif subset[k].original_type == dict: - out.append(subset[k].as_dict()) - return out - - # Python prior 3.6 does not guarantee insertion order, remove it after - # EOL python 3.5 - 2020-09-13 - if sys.version_info[0:2] < (3, 6): # pragma: nocover - return [subset[k] for k in sorted(keys, key=lambda x: int(x))] - else: - return [subset[k] for k in keys] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6f4d491 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,153 @@ +[project] +name = "cj365-flatdict" +version = "4.0.4" +description = "Python module for interacting with nested dicts as a single level dict with delimited keys." +requires-python = "~= 3.8" +classifiers = [ + "Topic :: Software Development :: Libraries", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +readme = "README.rst" +license = "BSD-3-Clause" +license-files = ["LICENSE"] +authors = [ + { name = "codejedi365", email = "codejedi365+flatdictpy@gmail.com" }, + { name = "Dennis Henry", email = "auruspex@gmail.com" }, + { name = "Gavin M. Roy", email = "gavinmroy@gmail.com" }, +] +maintainers = [{ name = "codejedi365", email = "codejedi365+flatdictpy@gmail.com" }] +dependencies = [ + "Deprecated ~= 1.3", +] + + +[project.urls] +changelog = "https://codejedi365.github.io/flatdict/misc/changelog.html" +documentation = "https://codejedi365.github.io/flatdict" +homepage = "https://codejedi365.github.io/flatdict" +issues = "https://github.com/codejedi365/flatdict/issues" +repository = "https://github.com/codejedi365/flatdict.git" + + +[build-system] +requires = [ + # Python 3.9+ for building + "setuptools >= 75.4.0, <83.0", + "wheel ~= 0.46.3", +] +build-backend = "setuptools.build_meta" + + +[project.optional-dependencies] +build = [ + "build ~= 1.2", + "tomlkit ~= 0.14.0", +] +dev = [ + "mypy == 1.17.0", + "ruff == 0.15.0", + "types-Deprecated ~= 1.3", +] +docs = [ + "furo ~= 2025.9", + "Sphinx ~= 7.4", + "sphinx-autobuild == 2024.2.4", + "sphinx-copybutton ~= 0.5.2", + "sphinx-lint ~= 1.0.2", +] +test = [ + "pytest ~= 8.3", + "pytest-clarity ~= 1.0", + "pytest-cov >= 5.0.0, < 7.0.0", + "pytest-dependency ~= 0.6.1", + "pytest-env ~= 1.0", + "pytest-order ~= 1.3", + "pytest-pretty ~= 1.2", +] + + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["src"] + + +[[tool.mypy.overrides]] +module = "pytest_dependency" +ignore_missing_imports = true + + +[tool.pytest.ini_options] +env = [ + "PYTHONHASHSEED = 100000" +] +addopts = [ + "-ra", + "--diff-symbols", + "--durations=5", +] +python_files = "*_test.py" +testpaths = ["tests"] + + +[tool.semantic_release] +add_partial_tags = false +logging_use_named_masks = true +commit_parser = "conventional" +commit_parser_options = { parse_squash_commits = true, ignore_merge_commits = true } +commit_message = """\ +chore: release v{version} + +Automatically generated by python-semantic-release +""" +build_command = "bash scripts/build.sh" +version_variables = [] +version_toml = ["pyproject.toml:project.version"] + +[tool.semantic_release.changelog] +exclude_commit_patterns = [ + '''chore(?:\([^)]*?\))?: .+''', + '''ci(?:\([^)]*?\))?: .+''', + '''refactor(?:\([^)]*?\))?: .+''', + '''style(?:\([^)]*?\))?: .+''', + '''test(?:\([^)]*?\))?: .+''', + '''build\((?!deps\): .+)''', + '''Merged? .*''', + '''Initial Commit.*''', +] +insertion_flag = "=========\nCHANGELOG\n=========" +mode = "update" +template_dir = ".github/release-templates" + +[tool.semantic_release.branches.main] +match = "^main$" +prerelease = false + +[tool.semantic_release.branches.alpha] +match = "^(feat|fix|perf)/.+" +prerelease = true +prerelease_token = "alpha" + +[tool.semantic_release.branches.dev] +match = ".+" +prerelease = true +prerelease_token = "dev" + +[tool.semantic_release.remote] +type = "github" +token = { env = "GH_TOKEN" } + +[tool.semantic_release.publish] +upload_to_vcs_release = true diff --git a/requires/testing.txt b/requires/testing.txt deleted file mode 100644 index 5495228..0000000 --- a/requires/testing.txt +++ /dev/null @@ -1,9 +0,0 @@ -codecov -coverage -flake8 -flake8-comprehensions -flake8-deprecated -flake8-import-order -flake8-quotes -flake8-rst-docstrings -flake8-tuple diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..cd922c7 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,227 @@ +#!/bin/bash + +set -eu -o pipefail + +function load_env() { + set -eu -o pipefail + + if [ "${UTILITIES_SH_LOADED:-false}" = "false" ]; then + local __FILE__="" + __FILE__="$(realpath "${BASH_SOURCE[0]}")" + + local __DIR__="" + __DIR__="$(realpath "$(dirname "$__FILE__")")" + + local ROOT_DIR="" + ROOT_DIR="$(realpath "$(dirname "$__DIR__")")" + + # shellcheck source=scripts/utils.sh + source "$ROOT_DIR/scripts/utils.sh" + + load_base_env + fi +} + +function with_temp_working_dir() { + local working_dir="${1:?"Working directory not specified, but is required!"}" + pushd "$working_dir" >/dev/null || return 1 + explicit_run_cmd "${@:2}" || return 1 + popd >/dev/null || return 1 +} + +function build_sdist() { + local status_msg="${1:?"Status message not specified, but is required!"}" + local output_dir="${2:?"Output directory not specified, but is required!"}" + + if ! explicit_run_cmd_w_status_wrapper \ + "$status_msg" \ + python3 -m build --sdist . --outdir "$output_dir" ">/dev/null"; + then + return 1 + fi + find "$output_dir" -type f -name "*.tar.gz" -exec \ + sh -c 'printf "%s\n" "Successfully built $1"' shell {} \; +} + +function unpack_sdist() { + local sdist_file="${1:?"Source distribution file not specified, but is required!"}" + local output_dir="${2:?"Output directory not specified, but is required!"}" + + mkdir -p "$output_dir" + if ! explicit_run_cmd_w_status_wrapper \ + "Unpacking sdist code into '$output_dir'" \ + tar -xzf "$sdist_file" -C "$output_dir" --strip-components=1; + then + return 1 + fi +} + + +function strip_optional_dependencies { + local -r pyproject_file="${1:?'param[1]: Path to pyproject.toml file is required'}" + local -r exclude_groups=("${@:2}") + local python_snippet="\ + from pathlib import Path + from sys import argv, exit + try: + import tomlkit + except ModuleNotFoundError: + print('Failed Import: Missing build requirement \'tomlkit\'.') + exit(1) + + pyproject_file = Path(argv[1]) + config = tomlkit.loads(pyproject_file.read_text()) + proj_config = config.get('project', {}) + + if not (opt_deps := proj_config.get('optional-dependencies', {})): + exit(0) + + if not (dep_group_to_remove := argv[2:]): + exit(0) + + for group in dep_group_to_remove: + if group in opt_deps: + opt_deps.pop(group) + + if not opt_deps: + proj_config.pop('optional-dependencies') + + pyproject_file.write_text(tomlkit.dumps(config)) + " + # make whitespace nice for python (remove indent) + python_snippet="$(printf '%s\n' "$python_snippet" | sed -E 's/([ ]{4,8}|\t)(.*)/\2/')" + + if [ "${#exclude_groups[@]}" -eq 0 ]; then + error "At least one dependency group to exclude must be specified!" + return 1 + fi + + python3 -c "$python_snippet" "$pyproject_file" "${exclude_groups[@]}" || return 1 +} + +remove_empty_init_files() { + local dirpath="${1:-.}" + + # SNIPPET: Remove empty __init__.py files + local python_snippet='\ + from pathlib import Path + from sys import exit, argv, stderr + + if len(argv) < 2 or not (dirpath := Path(argv[1])).is_dir(): + print("Usage: ", file=stderr) + exit(1) + + for filepath in dirpath.resolve().rglob("__init__.py"): + if not filepath.is_file(): + continue + if not filepath.read_text().strip(): + filepath.unlink() + print(f"Removed {filepath}") + ' + # make whitespace nice for python + python_snippet="$(printf '%s\n' "$python_snippet" | sed -E 's/([ ]{4,8}|\t)(.*)/\2/')" + + python3 -c "$python_snippet" "$dirpath" || return 1 +} + +function build_production_whl() { + # Assumes the current working directory is the directory to modify + local dest_dir="${1:?"param[1]: output directory not specified, but required!"}" + + # Strip out development dependencies + explicit_run_cmd_w_status_wrapper \ + "Masking development dependencies" \ + strip_optional_dependencies "pyproject.toml" "build" "dev" "docs" "test" || return 1 + + # Optimize code for runtime + explicit_run_cmd_w_status_wrapper \ + "Removing empty '__init__.py' files" \ + remove_empty_init_files "src" || return 1 + + # Remove editable info from the source directory before wheel build + rm -rf src/*.egg-info/ + + # Remove any unit test files and test configurations + rm -rf src/**/*_test.py src/**/conftest.py + + # Build the wheel into the output directory + explicit_run_cmd_w_status_wrapper \ + "Constructing wheel package" \ + python3 -m build --wheel . --outdir "$dest_dir" || return 1 +} + +function build_wheel_from_sdist() { + local build_dir="${1:?"param[1]: Build directory not specified, but is required!"}" + local dest_dir="${2:?"param[2]: Output directory not specified, but is required!"}" + local tmp_src_dir="$build_dir/sdist" + + unpack_sdist "$build_dir/*.tar.gz" "$tmp_src_dir" || return 1 + + with_temp_working_dir "$tmp_src_dir" build_production_whl "$dest_dir" || return 1 + + rm -rf "$tmp_src_dir" +} + +function build_production_package() { + local dest_dir + local output_dir="${1:?"param[1]: Output directory not specified, but required!"}" + local build_dir="build" + + # If the output directory is not an absolute path, make it absolute + if ! stdout "$output_dir" | grep -q -E '^/'; then + dest_dir="$(realpath ".")/$output_dir" + else + dest_dir="$output_dir" + fi + + # Clean up any existing output directory + if [ -d "$dest_dir" ]; then + rm -rf "$dest_dir" + fi + + # Clean up any existing build directory + if [ -d "$build_dir" ]; then + rm -rf "$build_dir" + fi + + build_sdist "Bundling source code" "$build_dir" || return 1 + + explicit_run_cmd_w_status_wrapper \ + "Building production wheel from sdist" \ + build_wheel_from_sdist "$build_dir" "$dest_dir" || return 1 + + rm -rf "$build_dir" +} + +function main() { + set -eu -o pipefail + + cd "$PROJ_ROOT_DIR" + + if ! explicit_run_cmd_w_status_wrapper \ + "Verifying Python environment" \ + verify_python "$MINIMUM_PYTHON_VERSION"; + then + info "Please run the dev setup script and activate the virtual environment first." + return 1 + fi + + explicit_run_cmd_w_status_wrapper \ + "Verifying build dependencies exist" \ + python3 -m pip install -e ".[build]" ">/dev/null" + + explicit_run_cmd_w_status_wrapper \ + "Building production package" \ + build_production_package "dist" +} + +######################################################################## +# CONDITIONAL AUTO-EXECUTE # +######################################################################## + +if ! (return 0 2>/dev/null); then + # Since this script is not being sourced, run the main function + unset -v UTILITIES_SH_LOADED # Ensure utils are reloaded when called from another script + load_env + main "$@" +fi diff --git a/scripts/build_docs.sh b/scripts/build_docs.sh new file mode 100644 index 0000000..eb0a05a --- /dev/null +++ b/scripts/build_docs.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +set -eu -o pipefail + +function load_env() { + set -eu -o pipefail + + if [ "${UTILITIES_SH_LOADED:-false}" = "false" ]; then + local __FILE__="" + __FILE__="$(realpath "${BASH_SOURCE[0]}")" + + local __DIR__="" + __DIR__="$(realpath "$(dirname "$__FILE__")")" + + local ROOT_DIR="" + ROOT_DIR="$(realpath "$(dirname "$__DIR__")")" + + # shellcheck source=scripts/utils.sh + source "$ROOT_DIR/scripts/utils.sh" + + load_base_env + fi + SPHINX_BUILD_EXE="$VENV_DIR/bin/sphinx-build" + DIST_DOCS_DIR="$DIST_DIR/docs/html" + + if [ "${CI:-false}" = "true" ]; then + SPHINX_BUILD_EXE="$(basename "$SPHINX_BUILD_EXE")" + fi +} + +function main() { + set -eu -o pipefail + + cd "$PROJ_ROOT_DIR" + + if ! explicit_run_cmd_w_status_wrapper \ + "Verifying Python environment" \ + verify_python "$MINIMUM_PYTHON_VERSION"; + then + info "Please run the dev setup script and activate the virtual environment first." + return 1 + fi + + explicit_run_cmd_w_status_wrapper \ + "Verifying build dependencies exist" \ + python3 -m pip install -e ".[docs]" ">/dev/null" + + if [ ! -f "$SPHINX_BUILD_EXE" ] && ! is_command "$(basename "$SPHINX_BUILD_EXE")"; then + printf '%s\n' "$(basename "$SPHINX_BUILD_EXE") was not found in environment!" + return 1 + fi + + rm -rf docs/_build/html docs/source/api/modules "$DIST_DOCS_DIR" + + explicit_run_cmd_w_status_wrapper \ + "Building documentation" \ + "$SPHINX_BUILD_EXE" docs/source "$DIST_DOCS_DIR" + + if [ -n "${GITHUB_OUTPUT:-}" ]; then + printf "DIST_DOCS_DIR=%s\n" "$DIST_DOCS_DIR" >> "$GITHUB_OUTPUT" + fi +} + +######################################################################## +# CONDITIONAL AUTO-EXECUTE # +######################################################################## + +if ! (return 0 2>/dev/null); then + # Since this script is not being sourced, run the main function + unset -v UTILITIES_SH_LOADED # Ensure utils are reloaded when called from another script + load_env + main "$@" +fi diff --git a/scripts/dev_setup.sh b/scripts/dev_setup.sh new file mode 100644 index 0000000..3caf3e6 --- /dev/null +++ b/scripts/dev_setup.sh @@ -0,0 +1,161 @@ +#!/bin/bash + +set -eu -o pipefail + +function load_env() { + set -eu -o pipefail + + if [ "${UTILITIES_SH_LOADED:-false}" = "false" ]; then + local __FILE__="" + __FILE__="$(realpath "${BASH_SOURCE[0]}")" + + local __DIR__="" + __DIR__="$(realpath "$(dirname "$__FILE__")")" + + local ROOT_DIR="" + ROOT_DIR="$(realpath "$(dirname "$__DIR__")")" + + # shellcheck source=scripts/utils.sh + source "$ROOT_DIR/scripts/utils.sh" + + load_base_env + fi + + PIP_EXE="$VENV_DIR/bin/pip" + BUILD_SCRIPT="$SCRIPTS_DIR/build.sh" +} + +function show_base_usage() { + set -eu + stdout "Usage: $(basename "$__FILE__") [OPTIONS]" + stdout "" + stdout "A command line tool to automate the development environment setup for the project." + stdout "" + stdout "Options:" + stdout " --build Run the build script after setting up the development environment" + stdout " --clean Remove the virtual environment before setting up a new one" + stdout " --help Show this help message and exit" + stdout "" +} + +function main() { + set -eu -o pipefail + + # Defaults + local CLEAN="false" + local RUN_BUILD="false" + export PIP_DISABLE_PIP_VERSION_CHECK="true" + + local -r args=("$@") + local arg="" + + if [ -n "${args[*]:-}" ]; then + for arg in "${args[@]}"; do + case "$arg" in + --help) + show_base_usage + return 0 + ;; + --clean) + CLEAN="true" + ;; + --build) + RUN_BUILD="true" + ;; + *) + error "Unknown argument: $arg" + show_base_usage + return 1 + ;; + esac + done + fi + + cd "$PROJ_ROOT_DIR" || exit 1 + + if [ "$CLEAN" = "true" ]; then + rm -rf "${VENV_DIR:?}" + fi + + local VENV_EXISTS="false" + if [ -f "$VENV_DIR/pyvenv.cfg" ]; then + VENV_EXISTS="true" + fi + + if [ "$VENV_EXISTS" = "false" ]; then + python3_exe="" + if is_command asdf; then + info "Finding the python3 executable from asdf..." + if ! python3_exe="$(realpath "$(asdf which python)")"; then + warning "Unable to determine python version from asdf" + else + info "asdf python3 executable: $python3_exe" + fi + fi + + if [ -z "$python3_exe" ]; then + info "Finding the system python3 executable from PATH..." + if ! python3_exe="$(realpath "$(which python3)")"; then + error "Unable to find python3 executable on system!" + return 1 + fi + info "Found python3 executable: $python3_exe" + fi + + verify_python_version "$python3_exe" "$MINIMUM_PYTHON_VERSION" + + explicit_run_cmd_w_status_wrapper \ + "Creating virtual environment for project" \ + "$python3_exe" -m venv "$VENV_DIR" + fi + + if ! [ -f "$PIP_EXE" ]; then + error "Unable to find pip executable at '$PIP_EXE'." + return 1 + fi + + explicit_run_cmd_w_status_wrapper \ + "Updating virtual environment default build tools" \ + "$PIP_EXE" install --upgrade pip setuptools wheel + + local pip_args=("-e" ".[build,dev,docs,test]") + + explicit_run_cmd_w_status_wrapper \ + "Installing editable project and development dependencies" \ + "$PIP_EXE" install "${pip_args[@]}"; + + if [ "$VENV_EXISTS" = "false" ]; then + local localized_venv_path="${VENV_DIR/$PROJ_ROOT_DIR/}" + stdout "######################################################################" + stdout "# #" + stdout "# Virtual environment created successfully! Activate it with: #" + stdout "# #" + stdout "# source $localized_venv_path/bin/activate #" + stdout "# #" + stdout "# Then you can import the package with: #" + stdout "# #" + stdout "# from cj365.flatdict import FlatDict, FlatterDict #" + stdout "# #" + stdout "######################################################################" + fi + + if [ "$RUN_BUILD" = "true" ]; then + export PATH="$VENV_DIR/bin:$PATH" + explicit_run_cmd_w_status_wrapper \ + "Building project" \ + bash "$BUILD_SCRIPT" + fi + + info "Project Development Setup...DONE" +} + +######################################################################## +# CONDITIONAL AUTO-EXECUTE # +######################################################################## + +if ! (return 0 2>/dev/null); then + # Since this script is not being sourced, run the main function + unset -v UTILITIES_SH_LOADED # Ensure utils are reloaded when called from another script + load_env + main "$@" +fi diff --git a/scripts/envsubst_version.py b/scripts/envsubst_version.py new file mode 100644 index 0000000..472fef1 --- /dev/null +++ b/scripts/envsubst_version.py @@ -0,0 +1,48 @@ +# ruff: noqa: T201, allow print statements in non-prod scripts +from __future__ import annotations + +from os import getenv +from pathlib import Path +from re import compile as regexp + +# Constants +PROJ_DIR = Path(__file__).resolve().parent.parent +DOCS_DIR = PROJ_DIR / "docs" +SRC_DIR = PROJ_DIR / "src" +version_replace_pattern = regexp(r"\$(NEW_VERSION|{NEW_VERSION})") +tag_replace_pattern = regexp(r"\$(NEW_RELEASE_TAG|{NEW_RELEASE_TAG})") + + +def envsubst(filepath: Path, version: str, release_tag: str) -> None: + file_content = filepath.read_text() + + found = False + for pattern, replacement in [ + (version_replace_pattern, version), + (tag_replace_pattern, release_tag), + ]: + if not found and (found := bool(pattern.search(file_content))): + print(f"Applying envsubst to {filepath}") + + file_content = pattern.sub(replacement, file_content) + + filepath.write_text(file_content) + + +if __name__ == "__main__": + new_release_tag = getenv("NEW_RELEASE_TAG") + new_version = getenv("NEW_VERSION") + + if not new_release_tag: + print("NEW_RELEASE_TAG environment variable is not set") + exit(1) + + if not new_version: + print("NEW_VERSION environment variable is not set") + exit(1) + + for doc_file in DOCS_DIR.rglob("*.rst"): + envsubst(filepath=doc_file, version=new_version, release_tag=new_release_tag) + + for src_file in SRC_DIR.rglob("*"): + envsubst(filepath=src_file, version=new_version, release_tag=new_release_tag) diff --git a/scripts/utils.sh b/scripts/utils.sh new file mode 100644 index 0000000..f5e0a86 --- /dev/null +++ b/scripts/utils.sh @@ -0,0 +1,152 @@ +#!/bin/bash + +function load_base_env() { + set -eu -o pipefail + + local __FILE__="" + __FILE__="$(realpath "${BASH_SOURCE[0]}")" + + PROJ_ROOT_DIR="$(realpath "$(dirname "$(realpath "$(dirname "$__FILE__")")")")" + export PROJ_ROOT_DIR + + export DIST_DIR="$PROJ_ROOT_DIR/dist" + export SCRIPTS_DIR="$PROJ_ROOT_DIR/scripts" + export VENV_DIR="$PROJ_ROOT_DIR/.venv" + export PROJECT_CONFIG_FILE="$PROJ_ROOT_DIR/pyproject.toml" + export MINIMUM_PYTHON_VERSION="3.9" + export PIP_DISABLE_PIP_VERSION_CHECK="true" +} + +function stdout { printf "%b\n" "$*"; } +function stderr { stdout "$@" >&2; } +function info { stdout "[+] $*"; } + +function warning { + local prefix="[!] " + if [ "${CI:-false}" = "true" ] && [ -n "${GITHUB_ACTIONS:-}" ]; then + prefix="::notice::" + fi + stderr "${prefix}WARNING: $*"; +} + +function error { + local prefix="[-] " + if [ "${CI:-false}" = "true" ] && [ -n "${GITHUB_ACTIONS:-}" ]; then + prefix="::error::" + fi + stderr "${prefix}ERROR: $*"; +} + +function is_command { + local cmd="${1:?"param[1]: missing command to check."}" + command -v "$cmd" >/dev/null || { + error "Command '$cmd' not found." + return 1 + } +} + +function explicit_run_cmd { + local cmd="${1:?"param[1]: command not specified, but is required!"}" + set -- "${@:2}" # shift off the first argument + local args="$*" + + # Default as a function call + local log_msg="$cmd($args)" + + # Needs to run in bash because zsh which will return 0 for a defined function + if bash -c "which $cmd >/dev/null"; then + log_msg="${SHELL:-/bin/sh} -c '$cmd $args'" + fi + + stderr " $log_msg" + eval "$cmd $args" +} + +function explicit_run_cmd_w_status_wrapper { + local status_msg="${1:?"param[1]: status message not specified, but is required!"}" + local cmd="${2:?"param[2]: command not specified, but is required!"}" + set -- "${@:3}" # shift off the first two arguments + + if [ -z "$cmd" ]; then + error "Command not specified, but is required!" + return 1 + fi + + info "${status_msg}..." + if ! explicit_run_cmd "$cmd" "$@"; then + error "${status_msg}...FAILED" + return 1 + fi + info "${status_msg}...DONE" +} + +function verify_python_version() { + local python3_exe="${1:?"param[1]: path to python3 executable is required"}" + local min_version="${2:?"param[2]: minimum python version is required"}" + + if ! [[ "$min_version" =~ ^v?[0-9]+(\.[0-9]+){0,2}$ ]]; then + error "Invalid minimum python version format: '$min_version'. Expected format: 'X', 'X.Y', or 'X.Y.Z'" + return 1 + fi + + local min_major_version="" + min_major_version="$(stdout "$min_version" | cut -d. -f1 | tr -d 'v')" + + local min_minor_version="" + min_minor_version="$(stdout "$min_version" | cut -d. -f2)" + min_minor_version="${min_minor_version:-0}" + + local min_patch_version="" + min_patch_version="$(stdout "$min_version" | cut -d. -f3)" + min_patch_version="${min_patch_version:-0}" + + local python_version_str="" + if ! python_version_str="$("$python3_exe" --version 2>&1 | awk '{print $2}')"; then + error "Failed to get python version string from '$python3_exe'" + return 1 + fi + + local python_major_version="" + python_major_version="$(stdout "$python_version_str" | cut -d. -f1)" + + local python_minor_version="" + python_minor_version="$(stdout "$python_version_str" | cut -d. -f2)" + + local python_patch_version="" + python_patch_version="$(stdout "$python_version_str" | cut -d. -f3)" + + if [ "$python_major_version" -ne "$min_major_version" ]; then + error "Python major version mismatch! Required version: $min_major_version, Found version: $python_version_str" + return 1 + fi + + if [ "$python_minor_version" -lt "$min_minor_version" ] || [ "$python_patch_version" -lt "$min_patch_version" ]; then + error "Python version ^${min_major_version}.${min_minor_version}.${min_patch_version}+ is required! Found version: $python_version_str" + return 1 + fi +} + +function verify_python() { + set -eu -o pipefail + local -r min_python_version="${1:?"param[1]: minimum python version parameter is required!"}" + + is_command "python3" || { + error "Python 3 is not detected. Script requires Python $min_python_version+!" + return 1 + } + + local python3_exe="" + python3_exe="$(which python3)" + + if [ "${CI:-false}" = "true" ]; then + info "Running in CI environment, skipping Python virtual environment verification." + + elif ! [ -f "$(dirname "$python3_exe")/../pyvenv.cfg" ]; then + error "No virtual environment detected." + return 1 + fi + + verify_python_version "$python3_exe" "$min_python_version" +} + +export UTILITIES_SH_LOADED="true" diff --git a/scripts/watch_docs.sh b/scripts/watch_docs.sh new file mode 100644 index 0000000..123ddf4 --- /dev/null +++ b/scripts/watch_docs.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +if command -v realpath >/dev/null 2>&1; then + PROJ_ROOT=$(realpath "$(dirname "${BASH_SOURCE[0]}")/..") +elif command -v readlink >/dev/null 2>&1; then + PROJ_ROOT=$(readlink -f "$(dirname "${BASH_SOURCE[0]}")/..") +else + PROJ_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +fi + +[ -z "$VIRTUAL_ENV" ] && VIRTUAL_ENV=".venv" +SPHINX_AUTOBUILD_EXE="$VIRTUAL_ENV/bin/sphinx-autobuild" + +cd "$PROJ_ROOT" || exit 1 + +if [ ! -f "$SPHINX_AUTOBUILD_EXE" ]; then + printf '%s\n' "sphinx-autobuild is not installed in the virtual environment. Please install the docs extras." + exit 1 +fi + +rm -rf docs/_build/html + +exec "$SPHINX_AUTOBUILD_EXE" docs/source docs/_build/html --open-browser --port 9000 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 533b408..0000000 --- a/setup.cfg +++ /dev/null @@ -1,53 +0,0 @@ -[metadata] -name = flatdict2 -version = attr: flatdict2.__version__ -description = Python module for interacting with nested dicts as a single level dict with delimited keys. -long_description = file: README.rst, LICENSE.rst -url = https://github.com/dennishenry/flatdict2 -author = Dennis Henry -author_email = auruspex@gmail.com -license = BSD 3-Clause License -classifiers = - Topic :: Software Development :: Libraries - Development Status :: 5 - Production/Stable - Intended Audience :: Developers - License :: OSI Approved :: BSD License - Operating System :: POSIX - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: Implementation :: CPython - Programming Language :: Python :: Implementation :: PyPy - -[options] -include_package_data = True -py_modules = flatdict2 -zip_safe = True - -[options.package_data] -* = CHANGELOG.md, README.rst, LICENSE - -[flake8] -application-import-names = flatdict2 -exclude = ci, docs, env -ignore = RST304 -import-order-style = google - -[coverage:run] -branch = True -command_line = -m unittest discover - -[coverage:report] -show_missing = True -omit = - tests.py - -[coverage:html] -directory = build/coverage - -[coverage:xml] -output = build/coverage.xml diff --git a/setup.py b/setup.py deleted file mode 100644 index 45dd44e..0000000 --- a/setup.py +++ /dev/null @@ -1,7 +0,0 @@ -import pkg_resources -import setuptools - -setuptools_version = pkg_resources.parse_version(setuptools.__version__) -if setuptools_version < pkg_resources.parse_version('39.2'): - raise SystemExit('setuptools 39.2 or greater required for installation') -setuptools.setup() diff --git a/src/cj365/__init__.py b/src/cj365/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cj365/flatdict/__init__.py b/src/cj365/flatdict/__init__.py new file mode 100644 index 0000000..2e8f2cf --- /dev/null +++ b/src/cj365/flatdict/__init__.py @@ -0,0 +1,22 @@ +""" +This module provides the `FlatDict` and `FlatterDict` classes for +managing nested dictionaries with flattened keys. The `FlatDict` class +allows for simple key-value storage with support for nested structures +using a specified delimiter. The `FlatterDict` class extends this +functionality to handle lists and sets as child-dict instances with +the offset as the key. +""" +# +-------------------------------------------------------------------+ +# | Module: cj365.flatdict | +# | Author: codejedi365, 2026 | +# | Licensed under the BSD-3-Clause License. | +# +-------------------------------------------------------------------+ + +from importlib.metadata import version as get_metadata_version + +from cj365.flatdict.flat_dict import FlatDict +from cj365.flatdict.flatter_dict import FlatterDict + +__dist_name__ = __name__.replace("_", "-").replace(".", "-") +__version__ = get_metadata_version(__dist_name__) +__all__ = ["FlatDict", "FlatterDict"] diff --git a/src/cj365/flatdict/flat_dict.py b/src/cj365/flatdict/flat_dict.py new file mode 100644 index 0000000..8048724 --- /dev/null +++ b/src/cj365/flatdict/flat_dict.py @@ -0,0 +1,520 @@ +""" +This module contains the implementation of the :class:`FlatDict` class, +which provides a dictionary-like interface for working with nested dictionaries +using delimited keys. +""" +# +-------------------------------------------------------------------+ +# | Module: cj365.flatdict.flat_dict | +# | Author: codejedi365, 2026 | +# | * Portions Copyright (c) 2020-2024, Dennis Henry | +# | * Portions Copyright (c) 2013-2020, Gavin M. Roy | +# | Licensed under the BSD-3-Clause License. | +# +-------------------------------------------------------------------+ + +from __future__ import annotations + +from contextlib import suppress +from copy import deepcopy +from functools import reduce +from typing import ( + MutableMapping, # deprecate in favor of collections.abc.MutableMapping in Python 3.9+ +) +from typing import TYPE_CHECKING, cast, overload, Any +from deprecated.sphinx import deprecated + +if TYPE_CHECKING: # pragma: no cover + from typing import ( + Iterator, + Iterable, + ItemsView, + KeysView, + NamedTuple, + TypedDict, + ValuesView, + ) + from _typeshed import SupportsKeysAndGetItem + from typing_extensions import Self + + class FlatDictState(TypedDict): + data: dict[Any, Any] + delimiter: str + + +class FlatDict(MutableMapping[str, Any]): + """ + A dictionary object that allows for single level, delimited key/value pair + mapping of nested dictionaries. + """ + + _delimiter: str + _flat_dict: dict[str, Any] + _inflated_dict: dict[Any, Any] | None + _meta_keys: tuple[str, ...] | None + + def __init__( + self, + value: dict[Any, Any] | NamedTuple | FlatDict | None = None, + delimiter: str = ".", + ): + """ + Initialize a new FlatDict instance. + + :param value: The initial data to populate the FlatDict with. Can be a + nested dictionary, a NamedTuple, another FlatDict, or None. + + :param delimiter: The delimiter to use for the keys in the flat dictionary. + + :raises ValueError: if the delimiter is an empty string + + --- + + The default delimiter value is a period (``.``) but can be changed in the constructor + or by calling :meth:`FlatDict.set_delimiter`. + + **WARNING**: keys containing the delimiter are not allowed and will raise a + :exc:`ValueError` on assignment or when setting the delimiter. + """ + super().__init__() + + if not delimiter: + msg = "Delimiter cannot be an empty string" + raise ValueError(msg) + + data = {} + + if isinstance(value, FlatDict): + data = value.inflate() + + elif ( + isinstance(value, tuple) + and hasattr(value, "_fields") + and hasattr(value, "_asdict") + ): + data = value._asdict() + + elif value is not None: + data = cast("dict[Any, Any]", value) + + if any(delimiter in key for key in data.keys()): + data = FlatDict.unflatten(data, delimiter) + + self.__setstate__({"data": data, "delimiter": delimiter}) + + @property + def delimiter(self) -> str: + """The key delimiter used for the flat dictionary.""" + return self._delimiter + + @delimiter.setter + def delimiter(self, value: str) -> None: + self.set_delimiter(value) + + @property + def meta_keys(self) -> tuple[str, ...]: + """The keys that exist as parent keys to nested dictionaries""" + if self._meta_keys is None: + self._meta_keys = self._get_meta_keys() + return self._meta_keys + + @deprecated( + reason="Use the 'inflate' method instead, will be removed in a future version", + version="$NEW_VERSION", + ) + def as_dict(self) -> dict[Any, Any]: + return self.inflate() + + def clear(self): + """Remove all items from the flat dictionary.""" + self._flat_dict.clear() + self._meta_keys = None + self._inflated_dict = None + + def copy(self) -> FlatDict: + """Return a deep copy of the flat dictionary.""" + return self.__class__(deepcopy(self.inflate()), delimiter=self._delimiter) + + def get(self, key: str, default: Any = None) -> Any: + """ + Retrieves the value for a delimited-key if key exists, otherwise returns the default. + + If default is not given, it defaults to ``None``, so this method never raises :exc:`KeyError`. + + :param key: The key name (with delimiters if necessary) + :param default: The value to return if the key is not found + """ + try: + return self.__getitem__(key) + except KeyError: + return default + + def inflate(self) -> dict[Any, Any]: + """ + Inflates the flat dictionary into a nested dictionary structure. + + :returns: A nested dictionary representing the inflated structure of the flat dictionary + """ + if self._inflated_dict is None: + self._inflated_dict = self.unflatten(self._flat_dict, self.delimiter) + return self._inflated_dict + + def items(self) -> ItemsView[str, Any]: + """ + Return a view of the flat dictionary's items (key-value pairs). + + This viewer will automatically reflect any changes to the flat dictionary, + including changes to the flat dictionary and any nested dictionaries that + would affect the items. + """ + return self._flat_dict.items() + + def keys(self) -> KeysView[str]: + """ + Return a view of the flat dictionary's keys. + + This viewer will automatically reflect any changes to the flat dictionary, + including changes to the flat dictionary and any nested dictionaries that + would affect the keys. + """ + return self._flat_dict.keys() + + def pop(self, key: str, default: Any = None) -> Any: + """ + Remove the specified key and return the corresponding value. + If the key is not found, return the default value. + + :param key: The delimited-key of the value to remove + :param default: The value to return if the key is not found + :returns: The value for the key if it exists, otherwise the default + """ + if key not in self: + return default + + value = self[key] + self.__delitem__(key) + return value + + def setdefault(self, key: str, default: Any = None) -> Any: + """ + Safely retrieve a delimited-key value, or insert the default value if the key does not exist. + + :param key: The key name (with delimiters if necessary) + :param default: The value to set and return if the key is not found + :returns: The value for the key if it exists, otherwise the default + """ + if key not in self: + self.__setitem__(key, default) + + return self.__getitem__(key) + + def set_delimiter(self, delimiter: str) -> Self: + """ + Set the key delimiter for the flat dictionary + + :param delimiter: The delimiter to use + :raises ValueError: if the delimiter collides with an existing key + """ + # Validates the new delimiter and converts existing cached flat dict + new_flat_dict = self.flatten(self.inflate(), delimiter) + self.clear() + self._flat_dict.update(new_flat_dict) + self._delimiter = delimiter + return self + + @overload + def update(self, arg: SupportsKeysAndGetItem[str, Any], /, **kwargs: Any) -> None: + """ + Update the flat dictionary with new key/value pairs. + + :param arg: A mapping object with string keys and any type of values. + :param kwargs: Additional key/value pairs to update the flat dictionary with. + :returns: None + """ + ... + + @overload + def update(self, arg: Iterable[tuple[str, Any]], /, **kwargs: Any) -> None: + """ + Update the flat dictionary with new key/value pairs. + + :param arg: An iterable of key/value tuple pairs. + :param kwargs: Additional key/value pairs to update the flat dictionary with. + :returns: None + """ + ... + + @overload + def update(self, /, **kwargs: Any) -> None: + """ + Update the flat dictionary with the key/value pairs defined as kwargs. + + :param kwargs: Key/value pairs to update the flat dictionary with. + :returns: None + """ + ... + + def update(self, arg: Any = None, /, **kwargs: Any) -> None: + """ + Update the flat dictionary with the key/value pairs from arg and kwargs. + + :param arg: The argument can be either a mapping or an iterable of key/value pairs. + :param kwargs: Additional key/value pairs to update the flat dictionary with. + :returns: None + """ + params = {**kwargs} + if arg is not None: + if hasattr(arg, "keys") and hasattr(arg, "__getitem__"): + params.update( + { + k: arg[k] + for k in cast("SupportsKeysAndGetItem[str, Any]", arg).keys() + } + ) + else: + params.update({k: v for k, v in arg}) + + flattened_params = self.flatten(params, self.delimiter) + + if matching_meta_keys := flattened_params.keys() & set(self.meta_keys): + for key in matching_meta_keys: + self[key] = flattened_params[key] + + # only handle nested keys, top-level keys will be handled by the standard update + differing_parent_keys: set[str] = reduce( + lambda acc, key: self._reduce_to_parent_key(acc, key, self.delimiter), + set(flattened_params.keys()) - set(self.keys()), + set(), + ) + + while differing_parent_keys: + for parent_key in differing_parent_keys: + if parent_key in self._flat_dict: + # if the parent key exists but is not a dictionary, must remove the parent key + self._flat_dict.pop(parent_key) + + differing_parent_keys = reduce( + lambda acc, key: self._reduce_to_parent_key(acc, key, self.delimiter), + differing_parent_keys, + set(), + ) + + new_flat_dict = {**self._flat_dict, **flattened_params} + self.clear() + self._flat_dict.update(new_flat_dict) + + def values(self) -> ValuesView[Any]: + """ + Return a view of the flat dictionary's values. + + This viewer will automatically reflect any changes to the flat dictionary, + including changes to the flat dictionary and any nested dictionaries that + would affect the values. + """ + return self._flat_dict.values() + + def __contains__(self, key: object) -> bool: + return any((bool(key in self._flat_dict), bool(key in self.meta_keys))) + + def __delitem__(self, key: object) -> None: + key_str = str(key) + + if key_str in self._flat_dict: + del self._flat_dict[key_str] + self._meta_keys = None + self._inflated_dict = None + return + + if key_str in self.meta_keys: + pointer = inflated_dict = self.inflate() + key_parts = key_str.split(self.delimiter) + + for k in key_parts[:-1]: + pointer = pointer[k] + + del pointer[key_parts[-1]] + + new_flat_dict = self.flatten(inflated_dict, self.delimiter) + self.clear() + self._flat_dict.update(new_flat_dict) + return + + msg = f"Key {key!r} not found in FlatDict" + raise KeyError(msg) + + def __eq__(self, other: object) -> bool: + if isinstance(other, dict): + return self.inflate() == other + + if isinstance(other, self.__class__): + return all( + (self.delimiter == other.delimiter, self._flat_dict == other._flat_dict) + ) + + msg = f"Comparison to incompatible type: {type(other).__name__!r}" + raise TypeError(msg) + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) + + def __getitem__(self, key: object) -> Any: + key_str = str(key) + + if key_str in self._flat_dict: + return self._flat_dict[key_str] + + if key_str in self.meta_keys: + inflated_dict = self.inflate() + key_parts = key_str.split(self.delimiter) + + for part in key_parts[:-1]: + inflated_dict = inflated_dict[part] + + return inflated_dict[key_parts[-1]] + + msg = f"Key {key!r} not found in FlatDict" + raise KeyError(msg) + + def __iter__(self) -> Iterator[str]: + return self._flat_dict.__iter__() + + def __len__(self) -> int: + return len(self.keys()) + + def __getstate__(self) -> FlatDictState: + return {"data": self.inflate(), "delimiter": self.delimiter} + + def __setstate__(self, state: FlatDictState) -> None: + self._delimiter = state["delimiter"] + self._inflated_dict = None + self._meta_keys = None + + if not hasattr(self, "_flat_dict"): + self._flat_dict = {} + + self._flat_dict.clear() + self._flat_dict.update(self.flatten(state["data"], self.delimiter)) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} id={id(self)} data={str(self)}>" + + def __setitem__(self, key: str, value: Any) -> None: + pointer = inflated_dict = self.inflate() + key_parts = key.split(self.delimiter) + + for k in key_parts[:-1]: + if k not in pointer or not isinstance(pointer[k], dict): + pointer[k] = {} + pointer = pointer[k] + + pointer[key_parts[-1]] = value + + new_flat_dict = self.flatten(inflated_dict, self.delimiter) + self.clear() + self._flat_dict.update(new_flat_dict) + + def __str__(self) -> str: + return f"{{{str.join(', ', [f'{k!r}: {self[k]!r}' for k in self.keys()])}}}" + + def _get_meta_keys(self) -> tuple[str, ...]: + meta_keys: set[str] = set() + parent_dict_keys: set[str] = reduce( + lambda acc, key: self._reduce_to_parent_key(acc, key, self.delimiter), + set(self.keys()), + set(), + ) + + while parent_dict_keys: + meta_keys |= parent_dict_keys + + parent_dict_keys = reduce( + lambda acc, key: self._reduce_to_parent_key(acc, key, self.delimiter), + parent_dict_keys, + set(), + ) + + return tuple(sorted(meta_keys)) + + @staticmethod + def _reduce_to_parent_key( + accumulator: set[str], key: str, delimiter: str + ) -> set[str]: + parent_key = str.join(delimiter, key.split(delimiter)[:-1]) + return accumulator | {parent_key} if parent_key else accumulator + + @staticmethod + def flatten(value: dict[Any, Any] | FlatDict, delimiter: str) -> dict[str, Any]: + """ + Flattens a nested dictionary into a single level dictionary with delimited keys. + + :param value: The nested dictionary or FlatDict to flatten + :param delimiter: The delimiter to use for the keys in the flat dictionary + :returns: A flat dictionary with delimited keys representing the nested structure of the input + + :raises ValueError: if the delimiter is an empty string or if any keys in the + input collide with the delimiter + """ + if not delimiter: + msg = "Delimiter cannot be an empty string" + raise ValueError(msg) + + val = value.inflate() if isinstance(value, FlatDict) else (value or {}) + + flat_dict = {} + for k, v in val.items(): + if delimiter in k: + msg = f"Key {k!r} collides with the delimiter {delimiter!r}" + raise ValueError(msg) + + if not isinstance(v, dict): + flat_dict[k] = v + continue + + for sub_k, sub_v in FlatDict.flatten(v, delimiter).items(): + flat_dict[f"{k}{delimiter}{sub_k}"] = sub_v + + return flat_dict + + @staticmethod + def unflatten(value: dict[str, Any], delimiter: str) -> dict[Any, Any]: + """ + Inflates a flat dictionary with delimited keys into a nested dictionary. + + :param value: The flat dictionary to unflatten + :param delimiter: The delimiter used in the flat dictionary keys + :returns: A nested dictionary representing the inflated structure + + :raises ValueError: if the delimiter is an empty string + """ + if not delimiter: + msg = "Delimiter cannot be an empty string" + raise ValueError(msg) + + inflated_dict: dict[Any, Any] = {} + + def convert_type(val: str) -> int | float | str: + with suppress(ValueError): + return int(val) + + with suppress(ValueError): + return float(val) + + return val + + for k, v in value.items(): + current_key_str = k + pointer = inflated_dict + + # Keep splitting the key until there are no more delimiters, building + # out the nested dict structure as we go + while len(key_parts := current_key_str.split(delimiter, maxsplit=1)) > 1: + next_key = convert_type(key_parts[0]) + + if next_key not in pointer: + pointer[next_key] = {} + + pointer = pointer[next_key] + current_key_str = key_parts[-1] + + # No more delimiters in key, now we can assign the value to the final key + pointer[convert_type(current_key_str)] = v + + return inflated_dict diff --git a/src/cj365/flatdict/flatter_dict.py b/src/cj365/flatdict/flatter_dict.py new file mode 100644 index 0000000..cb0a3f9 --- /dev/null +++ b/src/cj365/flatdict/flatter_dict.py @@ -0,0 +1,779 @@ +""" +This module contains the implementation of the :class:`FlatterDict` class, +which provides a dictionary-like interface for working with nested dictionaries +using delimited keys but it is designed to handle lists and sets as child-dict +instances with the offset as the key. +""" +# +-------------------------------------------------------------------+ +# | Module: cj365.flatdict.flatter_dict | +# | Author: codejedi365, 2026 | +# | * Portions Copyright (c) 2020-2024, Dennis Henry | +# | * Portions Copyright (c) 2013-2020, Gavin M. Roy | +# | Licensed under the BSD-3-Clause License. | +# +-------------------------------------------------------------------+ + +from __future__ import annotations + +from contextlib import suppress +from copy import deepcopy +from functools import reduce +from typing import ( + MutableMapping, # deprecate in favor of collections.abc.MutableMapping in Python 3.9+ +) +from typing import TYPE_CHECKING, cast, overload, Any, Sequence +from deprecated.sphinx import deprecated + +from cj365.flatdict.flat_dict import FlatDict + +if TYPE_CHECKING: # pragma: no cover + from typing import ( + Iterator, + Iterable, + ItemsView, + KeysView, + NamedTuple, + TypedDict, + ValuesView, + ) + from _typeshed import SupportsKeysAndGetItem + from typing_extensions import Self + + class FlatterDictState(TypedDict): + data: Sequence[Any] | set[Any] | dict[Any, Any] + delimiter: str + root_type: type + + +# _COERCE = list, tuple, set, dict, FlatDict +_ARRAYS = list, set, tuple + + +class FlatterDict(MutableMapping[str, Any]): + """ + A dictionary object that allows for single level, delimited key/value pair + mapping of nested dictionaries, lists, or sets. + """ + + _delimiter: str + _flat_dict: dict[str, Any] + _inflated_obj: Sequence[Any] | set[Any] | dict[Any, Any] | None + _meta_keys: tuple[str, ...] | None + _root_type: type + + def __init__( + self, + value: Sequence[Any] + | set[Any] + | NamedTuple + | dict[Any, Any] + | FlatDict + | FlatterDict + | None = None, + delimiter: str = ".", + ): + """ + Initialize a FlatterDict instance. + + :param value: The initial value for the flatter dictionary, can + be a nested structure of dicts, lists, sets, or tuples + + :param delimiter: The key delimiter to use for the flatter dictionary, defaults to "." + + :raises ValueError: if the delimiter is an empty string + + --- + + The default delimiter value is a period (``.``) but can be changed in the constructor + or by calling :meth:`FlatterDict.set_delimiter`. + + **WARNING**: keys containing the delimiter will be treated as nested keys and + will be inflated accordingly prior to assignment. This may lead to unexpected behavior, + so make sure to choose a delimiter that does not collide with your keys. + """ + super().__init__() + + if not delimiter: + msg = "Delimiter cannot be an empty string" + raise ValueError(msg) + + # Eliminates null + val = value if value is not None else {} + + # If the value is already a FlatterDict or FlatDict, we can just inflate it to get the underlying data + if isinstance(val, (FlatterDict, FlatDict)): + val = val.inflate() + + if ( # isinstance check for NamedTuple + isinstance(val, tuple) + and hasattr(val, "_fields") + and hasattr(val, "_asdict") + ): + val = cast("NamedTuple", val)._asdict() + + if isinstance(val, dict) and any(delimiter in key for key in val.keys()): + val = FlatterDict.unflatten(val, delimiter) + + data: dict[Any, Any] = ( + self._convert_iterable_to_dict(val) + if isinstance(val, (Sequence, set)) + else val + ) + + self.__setstate__( + {"data": data, "delimiter": delimiter, "root_type": type(val)} + ) + + @property + def delimiter(self) -> str: + """The key delimiter used for the flat dictionary.""" + return self._delimiter + + @delimiter.setter + def delimiter(self, value: str) -> None: + self.set_delimiter(value) + + @property + def meta_keys(self) -> tuple[str, ...]: + """ + The keys that exist as parent keys to nested dictionaries or sequences + and are not themselves present in the flat dictionary. + """ + if self._meta_keys is None: + self._meta_keys = self._get_meta_keys() + return self._meta_keys + + @deprecated( + reason="Use the 'inflate' method instead, 'as_dict()' will be removed in a future version", + version="$NEW_VERSION", + ) + def as_dict(self) -> Sequence[Any] | set[Any] | dict[Any, Any]: + return self.inflate() + + def clear(self): + """Remove all items from the flatter dictionary.""" + self._flat_dict.clear() + self._meta_keys = None + self._inflated_obj = None + + def copy(self) -> FlatterDict: + """Return a deep copy of the flatter dictionary.""" + return self.__class__(deepcopy(self.inflate()), delimiter=self._delimiter) + + def get(self, key: str, default: Any = None) -> Any: + """ + Retrieves the value for a delimited-key if key exists, otherwise returns the default. + + If default is not given, it defaults to ``None``, so this method never raises :exc:`KeyError`. + + :param key: The key name (with delimiters if necessary) + :param default: The value to return if the key is not found + """ + try: + return self.__getitem__(key) + except KeyError: + return default + + def inflate(self) -> Sequence[Any] | set[Any] | dict[Any, Any]: + """ + Inflates the flatter dictionary into a nested data type structure. + + Any lists, sets, tuples, or dictionaries will be inflated as their respective types, + with the keys of the flat dictionary used as indices for lists and sets and as keys for dictionaries. + + :returns: A nested data type representing the inflated structure of the flatter dictionary + """ + if self._inflated_obj is None: + self._inflated_obj = obj = self.unflatten(self._flat_dict, self.delimiter) + + if isinstance(obj, dict): + if issubclass(self._root_type, dict): + return self._root_type(obj) + + elif issubclass(self._root_type, list): + # when FlatterDict is empty but initialized as a list + return list(obj.values()) + + elif issubclass(self._root_type, set): + return set(obj.values()) + + elif issubclass(self._root_type, tuple) and not hasattr( + self._root_type, "_asdict" + ): + # when FlatterDict is empty but initialized as a tuple + return tuple(obj.values()) + + # elif issubclass(self._root_type, tuple): # NamedTuple + # return self._root_type(**obj) + + elif isinstance(obj, tuple): + if issubclass(self._root_type, set): + return set(obj) + + elif issubclass(self._root_type, list): + return list(obj) + + elif issubclass(self._root_type, tuple): + return tuple(obj) + + return self._inflated_obj + + def items(self) -> ItemsView[str, Any]: + """ + Return a view of the flatter dictionary's items (key-value pairs). + + This viewer will automatically reflect any changes to the flatter dictionary, + including changes to the flatter dictionary and any nested data types that + would affect the items. + """ + return self._flat_dict.items() + + def keys(self) -> KeysView[str]: + """ + Return a view of the flatter dictionary's keys. + + This viewer will automatically reflect any changes to the flatter dictionary, + including changes to the flatter dictionary and any nested data types that + would affect the keys. + """ + return self._flat_dict.keys() + + def pop(self, key: str, default: Any = None) -> Any: + """ + Remove the specified key and return the corresponding value. + If the key is not found, return the default value. + + :param key: The delimited-key of the value to remove + :param default: The value to return if the key is not found + :returns: The value for the key if it exists, otherwise the default + """ + if key not in self: + return default + + value = self[key] + self.__delitem__(key) + return value + + def setdefault(self, key: str, default: Any = None) -> Any: + """ + Safely retrieve a delimited-key value, or insert the default value if the key does not exist. + + :param key: The key name (with delimiters if necessary) + :param default: The value to set and return if the key is not found + :returns: The value for the key if it exists, otherwise the default + """ + if key not in self: + self.__setitem__(key, default) + + return self.__getitem__(key) + + def set_delimiter(self, delimiter: str) -> Self: + """ + Set the key delimiter for the flatter dictionary + + :param delimiter: The delimiter to use + :raises ValueError: if the delimiter collides with an existing key + """ + # Validates the new delimiter and converts existing cached flat dict + new_flat_dict = self.flatten(self.inflate(), delimiter) + self.clear() + self._flat_dict.update(new_flat_dict) + self._delimiter = delimiter + return self + + @overload + def update(self, arg: SupportsKeysAndGetItem[str, Any], /, **kwargs: Any) -> None: + """ + Update the flatter dictionary with new key/value pairs. + + :param arg: A mapping object with string keys and any type of values. + :param kwargs: Additional key/value pairs to update the flatter dictionary with. + :returns: None + """ + ... + + @overload + def update(self, arg: Iterable[tuple[str, Any]], /, **kwargs: Any) -> None: + """ + Update the flatter dictionary with new key/value pairs. + + :param arg: An iterable of key/value tuple pairs. + :param kwargs: Additional key/value pairs to update the flatter dictionary with. + :returns: None + """ + ... + + @overload + def update(self, /, **kwargs: Any) -> None: + """ + Update the flatter dictionary with the key/value pairs defined as kwargs. + + :param kwargs: Key/value pairs to update the flatter dictionary with. + :returns: None + """ + ... + + def update(self, arg: Any = None, /, **kwargs: Any) -> None: + """ + Update the flatter dictionary with the key/value pairs from arg and kwargs. + + :param arg: The argument can be either a mapping or an iterable of key/value pairs. + :param kwargs: Additional key/value pairs to update the flatter dictionary with. + :returns: None + """ + params = {**kwargs} + if arg is not None: + if hasattr(arg, "keys") and hasattr(arg, "__getitem__"): + params.update( + { + k: arg[k] + for k in cast("SupportsKeysAndGetItem[str, Any]", arg).keys() + } + ) + else: + params.update({k: v for k, v in arg}) + + if not params: + return + + if issubclass(self._root_type, (Sequence, set)) and any( + k.isalpha() for k in params.keys() + ): + msg = "Cannot update a Set-initialized or Sequence-initialized FlatterDict with non-integer keys" + raise ValueError(msg) + + flattened_params = self.flatten(params, self.delimiter) + + if matching_meta_keys := flattened_params.keys() & self.meta_keys: + for key in matching_meta_keys: + self[key] = flattened_params[key] + + # only handle nested keys, top-level keys will be handled by the standard update + differing_parent_keys: set[str] = reduce( + lambda acc, key: self._reduce_to_parent_key(acc, key, self.delimiter), + set(flattened_params.keys()) - set(self.keys()), + set(), + ) + + while differing_parent_keys: + for parent_key in differing_parent_keys: + if parent_key in self._flat_dict: + # if the parent key exists but is not a dictionary, must remove the parent key + self._flat_dict.pop(parent_key) + + differing_parent_keys = reduce( + lambda acc, key: self._reduce_to_parent_key(acc, key, self.delimiter), + differing_parent_keys, + set(), + ) + + new_flat_dict = {**self._flat_dict, **flattened_params} + self.clear() + self._flat_dict.update(new_flat_dict) + + def values(self) -> ValuesView[Any]: + """ + Return a view of the flatter dictionary's values. + + This viewer will automatically reflect any changes to the flatter dictionary, + including changes to the flatter dictionary and any nested data types that + would affect the values. + """ + return self._flat_dict.values() + + def __contains__(self, key: object) -> bool: + return any((bool(key in self._flat_dict), bool(key in self.meta_keys))) + + def __delitem__(self, key: object) -> None: + key_str = str(key) + + if key_str not in self: + err_msg = f"Key {key!r} not found in {self.__class__.__name__!r}" + raise KeyError(err_msg) + + # Recursive function to modify the inflated object + def delete_in_object( + obj: Any, delimiter: str, key_name: str + ) -> Sequence[Any] | set[Any] | dict[Any, Any]: + key_parts = key_name.split(delimiter, 1) + + if isinstance(obj, Sequence) and not isinstance(obj, str): + # GOAL: when the current object is a sequence, we want to delete the item at the + # index specified by the current key part and then shift the remaining items down + # to fill the gap. + index = int(key_parts[0]) + + # Rebuild the sequence skipping over the item at the index if the current key part + # matches the index, otherwise recursively dig into the next level of nesting + # Note: we wrap it in a list, so that it will unpack back into the correct position + # or if its an empty list then it will unpack as nothing effectively deleting the + # item at the index + new_sequence = [ + *obj[:index], + *( + [] + if len(key_parts) == 1 + else [delete_in_object(obj[index], delimiter, key_parts[1])] + ), + *obj[index + 1 :], + ] + + if len(new_sequence) == 0: + # maintain type if the sequence is now empty after deletion + return type(obj)() + + # Convert the modified sequence to a dictionary with integer keys to maintain + # the correct structure when flattened + return { + str(k): v + for k, v in self._convert_iterable_to_dict(new_sequence).items() + } + + elif not isinstance(obj, dict): + err_msg = ( + f"Cannot delete nested key on object of type {type(obj).__name__}" + ) + raise KeyError(err_msg) + + if len(key_parts) == 1: + del obj[key_name] + return obj + + # Dig into the next level of nesting to find the key to delete, then bubble back up the + # modified object structure to be flattened and set as the new flat dictionary + next_key, remaining_key = key_parts + obj[next_key] = delete_in_object(obj[next_key], delimiter, remaining_key) + return obj + + # Recursively modify the inflated object with the new value, + # then flatten the modified object to create the new flat dictionary + new_flat_dict = self.flatten( + delete_in_object(self.inflate(), self.delimiter, key_str), + self.delimiter, + ) + + # reset & update the flat dictionary (maintains KeyView, ItemsView, ValuesView references) + self.clear() + self._flat_dict.update(new_flat_dict) + + def __eq__(self, other: object) -> bool: + if isinstance(other, dict): + return self.inflate() == other + + if isinstance(other, Sequence) and not isinstance(other, str): + return self.inflate() == other + + if isinstance(other, self.__class__): + return all( + (self.delimiter == other.delimiter, self._flat_dict == other._flat_dict) + ) + + msg = f"Comparison to incompatible type: {type(other).__name__!r}" + raise TypeError(msg) + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) + + def __getitem__(self, key: object) -> Any: + key_str = str(key) + + if key_str in self._flat_dict: + return self._flat_dict[key_str] + + if key_str in self.meta_keys: + inflated_obj = self.inflate() + + def get_value_from_key_in_inflated( + inflated: Sequence[Any] | set[Any] | dict[Any, Any], + key_parts: list[str], + ) -> Any: + msg = ( + "Cannot access nested keys of type {}, attempted to access {} in {}" + ) + for part in key_parts: + if isinstance(inflated, dict): + inflated = inflated[part] + continue + + if isinstance(inflated, Sequence) and not isinstance(inflated, str): + inflated = inflated[int(part)] + continue + + msg = msg.format( + repr(type(inflated).__name__), + repr(key_str), + repr(self.__class__.__name__), + ) + raise KeyError(msg) + return inflated + + return get_value_from_key_in_inflated( + inflated_obj, key_str.split(self.delimiter) + ) + + msg = f"Key {key!r} not found in {self.__class__.__name__!r}" + raise KeyError(msg) + + def __iter__(self) -> Iterator[str]: + return self._flat_dict.__iter__() + + def __len__(self) -> int: + return len(self.keys()) + + def __getstate__(self) -> FlatterDictState: + return { + "data": self.inflate(), + "delimiter": self.delimiter, + "root_type": self._root_type, + } + + def __setstate__(self, state: FlatterDictState) -> None: + self._delimiter = state["delimiter"] + self._inflated_obj = None + self._meta_keys = None + self._root_type = state["root_type"] + + if not hasattr(self, "_flat_dict"): + self._flat_dict = {} + + self._flat_dict.clear() + self._flat_dict.update(self.flatten(state["data"], self.delimiter)) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} id={id(self)} data={str(self)}>" + + def __setitem__(self, key: str, value: Any) -> None: + val = value + + if isinstance(val, (FlatDict, FlatterDict)): + val = val.inflate() + + if isinstance(val, dict): + val = self.flatten(val, self.delimiter) + + if isinstance(val, (Sequence, set)) and not isinstance(val, str): + if len(val) > 0: + val = self.flatten(val, self.delimiter) + + if key in self._flat_dict: + if not isinstance(val, dict): + self._flat_dict[key] = val + self._inflated_obj = None + return + + self._flat_dict.pop(key) + new_flat_dict = { + **self._flat_dict, + **{f"{key}{self.delimiter}{k}": v for k, v in val.items()}, + } + self.clear() + self._flat_dict.update(new_flat_dict) + return + + def modify_object( + obj: Any, delimiter: str, key_name: str, replacement_value: Any + ) -> Sequence[Any] | set[Any] | dict[Any, Any]: + key_parts = key_name.split(delimiter, 1) + + if isinstance(obj, Sequence) and not isinstance(obj, str): + if not key_parts[0].isdigit(): + err_msg = f"Cannot set key on sequence with non-integer key {key_parts[0]!r}" + raise KeyError(err_msg) + + # if the next key is beyond the end of the existing sequence, we need to extend the sequence + # with None values to allow for the new key to be set at the correct index + obj = [*obj, *[None for _ in range(int(key_parts[0]) - len(obj))]] + + # convert the sequence to a dictionary with integer keys for the next level of nesting + # Since we extended the sequence above, when inflated it will turn back into a sequence + obj = { + str(k): v for k, v in self._convert_iterable_to_dict(obj).items() + } + + elif not isinstance(obj, dict): + err_msg = ( + f"Cannot set nested key on object of type {type(obj).__name__}" + ) + raise KeyError(err_msg) + + if len(key_parts) == 1: + obj[key_name] = replacement_value + return obj + + next_key, remaining_key = key_parts + + if ( + next_key not in obj + or not isinstance(obj[next_key], (dict, Sequence)) + or isinstance(obj[next_key], str) + ): + obj[next_key] = {} + + obj[next_key] = modify_object( + obj[next_key], delimiter, remaining_key, replacement_value + ) + return obj + + # Recursively modify the inflated object with the new value, + # then flatten the modified object to create the new flat dictionary + new_flat_dict = self.flatten( + modify_object(self.inflate(), self.delimiter, key, val), + self.delimiter, + ) + + # reset & update the flat dictionary (maintains KeyView, ItemsView, ValuesView references) + self.clear() + self._flat_dict.update(new_flat_dict) + + def __str__(self) -> str: + return f"{{{str.join(', ', [f'{k!r}: {self[k]!r}' for k in self.keys()])}}}" + + def _get_meta_keys(self) -> tuple[str, ...]: + meta_keys: set[str] = set() + parent_dict_keys: set[str] = reduce( + lambda acc, key: self._reduce_to_parent_key(acc, key, self.delimiter), + set(self.keys()), + set(), + ) + + while parent_dict_keys: + meta_keys |= parent_dict_keys + + parent_dict_keys = reduce( + lambda acc, key: self._reduce_to_parent_key(acc, key, self.delimiter), + parent_dict_keys, + set(), + ) + + return tuple(sorted(meta_keys)) + + @staticmethod + def _convert_iterable_to_dict(value: Sequence[Any] | set[Any]) -> dict[int, Any]: + return {i: v for i, v in enumerate(value)} + + @staticmethod + def _reduce_to_parent_key( + accumulator: set[str], key: str, delimiter: str + ) -> set[str]: + parent_key = str.join(delimiter, key.split(delimiter)[:-1]) + return accumulator | {parent_key} if parent_key else accumulator + + @staticmethod + def flatten( + value: Sequence[Any] | set[Any] | dict[Any, Any] | FlatDict | FlatterDict, + delimiter: str, + ) -> dict[str, Any]: + """ + Flattens a nested data type structure into a flatter dictionary with delimited keys. + + :param value: The nested data structure to flatten, can be a dictionary or a FlatDict + :param delimiter: The delimiter to use for the keys in the flatter dictionary + :returns: A flat dictionary with delimited keys representing the nested structure + + :raises ValueError: if the delimiter is an empty string or if any keys in the nested + structure collide with the delimiter + """ + if not delimiter: + msg = "Delimiter cannot be an empty string" + raise ValueError(msg) + + val = value.inflate() if isinstance(value, (FlatDict, FlatterDict)) else value + + data = ( + FlatterDict._convert_iterable_to_dict(val) + if isinstance(val, (Sequence, set)) + else val + ) + + flat_dict: dict[str, Any] = {} + + for k, v in data.items(): + key_str = str(k) + + if delimiter in key_str: + msg = f"Key {k!r} collides with the delimiter {delimiter!r}" + raise ValueError(msg) + + if isinstance(v, (FlatDict, FlatterDict)): + v = v.inflate() + + if isinstance(v, (Sequence, set)) and not isinstance(v, str): + if not v: + # maintain type if an empty sequence or set + flat_dict[key_str] = v + continue + + # sets are unordered, so we need to convert to a sorted list first to ensure consistent ordering in the flat dict keys + v = sorted(v) if isinstance(v, set) else v + v = FlatterDict._convert_iterable_to_dict(v) + + if not isinstance(v, dict) or not v: + flat_dict[key_str] = v + continue + + for sub_k, sub_v in FlatterDict.flatten(v, delimiter).items(): + flat_dict[f"{key_str}{delimiter}{sub_k}"] = sub_v + + return flat_dict + + @staticmethod + def unflatten( + value: dict[str, Any], delimiter: str + ) -> Sequence[Any] | set[Any] | dict[Any, Any]: + """ + Inflates a flatter dictionary into a nested data type structure. + + :param value: The flat dictionary to unflatten + :param delimiter: The delimiter used in the flatter dictionary keys + :returns: A nested data type representing the inflated structure of the flatter dictionary + + :raises ValueError: if the delimiter is an empty string + """ + if not delimiter: + msg = "Delimiter cannot be an empty string" + raise ValueError(msg) + + inflated_dict: dict[Any, Any] = {} + + def convert_type(val: str) -> int | float | str: + with suppress(ValueError): + return int(val) + + with suppress(ValueError): + return float(val) + + return val + + for k, v in value.items(): + current_key_str = k + pointer = inflated_dict + + # Keep splitting the key until there are no more delimiters, building + # out the nested dict structure as we go + while len(key_parts := current_key_str.split(delimiter, maxsplit=1)) > 1: + next_key = convert_type(key_parts[0]) + + if next_key not in pointer: + pointer[next_key] = {} + + pointer = pointer[next_key] + current_key_str = key_parts[-1] + + # No more delimiters in key, now we can assign the value to the final key + pointer[convert_type(current_key_str)] = v + + # Convert any dictionaries with contiguous integer keys starting from 0 into tuples + def convert_dicts_to_tuples( + obj: Any, + ) -> Sequence[Any] | set[Any] | dict[Any, Any]: + if not isinstance(obj, dict) or len(obj) == 0: + return obj + + if all(isinstance(k, int) and k >= 0 for k in obj.keys()) and set( + obj.keys() + ) == set(range(len(obj))): + return tuple(convert_dicts_to_tuples(v) for _, v in sorted(obj.items())) + + # recursively convert any nested dictionaries that are sequences + return {k: convert_dicts_to_tuples(v) for k, v in obj.items()} + + return convert_dicts_to_tuples(inflated_dict) diff --git a/src/cj365/flatdict/py.typed b/src/cj365/flatdict/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests.py b/tests.py deleted file mode 100644 index a6eca70..0000000 --- a/tests.py +++ /dev/null @@ -1,537 +0,0 @@ -""" -Unittests for flatdict2.FlatDict - -""" -import pickle -import random -import unittest -import uuid - -import flatdict2 - - -class FlatDictTests(unittest.TestCase): - - TEST_CLASS = flatdict2.FlatDict - - FLAT_EXPECTATION = { - 'foo:bar:baz': 0, - 'foo:bar:qux': 1, - 'foo:bar:corge': 2, - 'foo:grault:baz': 3, - 'foo:grault:qux': 4, - 'foo:grault:corge': 5, - 'foo:list': ['F', 'O', 'O'], - 'foo:empty_list': [], - 'foo:set': {10, 20, 30}, - 'foo:empty_set': set(), - 'foo:tuple': ('F', 0, 0), - 'foo:empty_tuple': (), - 'garply:foo': 0, - 'garply:bar': 1, - 'garply:baz': 2, - 'garply:qux:corge': 3, - 'fred': 4, - 'xyzzy': 'plugh', - 'thud': 5, - 'waldo:fred': 6, - 'waldo:wanda': 7 - } - - KEYS = [ - 'foo:bar:baz', 'foo:bar:qux', 'foo:bar:corge', 'foo:grault:baz', - 'foo:grault:qux', 'foo:grault:corge', 'foo:list', 'foo:empty_list', - 'foo:set', 'foo:empty_set', 'foo:tuple', 'foo:empty_tuple', - 'garply:foo', 'garply:bar', 'garply:baz', 'garply:qux:corge', 'fred', - 'xyzzy', 'thud', 'waldo:fred', 'waldo:wanda' - ] - - VALUES = { - 'foo': { - 'bar': { - 'baz': 0, - 'qux': 1, - 'corge': 2 - }, - 'grault': { - 'baz': 3, - 'qux': 4, - 'corge': 5 - }, - 'list': ['F', 'O', 'O'], - 'empty_list': [], - 'set': {10, 20, 30}, - 'empty_set': set(), - 'tuple': ('F', 0, 0), - 'empty_tuple': () - - }, - 'garply': { - 'foo': 0, - 'bar': 1, - 'baz': 2, - 'qux': { - 'corge': 3 - } - }, - 'fred': 4, - 'xyzzy': 'plugh', - 'thud': 5, - 'waldo:fred': 6, - 'waldo:wanda': 7 - } - - AS_DICT = { - 'foo': { - 'bar': { - 'baz': 0, - 'qux': 1, - 'corge': 2 - }, - 'grault': { - 'baz': 3, - 'qux': 4, - 'corge': 5 - }, - 'list': ['F', 'O', 'O'], - 'empty_list': [], - 'set': {10, 20, 30}, - 'empty_set': set(), - 'tuple': ('F', 0, 0), - 'empty_tuple': (), - }, - 'garply': { - 'foo': 0, - 'bar': 1, - 'baz': 2, - 'qux': { - 'corge': 3 - } - }, - 'fred': 4, - 'xyzzy': 'plugh', - 'thud': 5, - 'waldo': { - 'fred': 6, - 'wanda': 7 - } - } - - def setUp(self): - self.value = self.TEST_CLASS(self.VALUES, ':') - - def test_contains_true(self): - self.assertTrue(all(k in self.value for k in self.KEYS)) - - def test_contains_false(self): - self.assertNotIn(str(uuid.uuid4()), self.value['foo']) - - def test_contains_nested_true(self): - self.assertIn('bar', self.value['foo']) - - def test_contains_nested_false(self): - self.assertIn('bar', self.value['garply']) - - def test_raises_key_error(self): - self.assertRaises(KeyError, self.value.__getitem__, 'grault') - - def test_del_item(self): - offset = random.randint(0, len(self.KEYS) - 1) - del self.value[self.KEYS[offset]] - self.assertNotIn(self.KEYS[offset], self.value) - - def test_del_top(self): - del self.value['foo'] - for key in [k for k in self.KEYS if k.startswith('foo:')]: - self.assertNotIn(key, self.value) - - def test_as_dict(self): - self.assertDictEqual(self.value.as_dict(), self.AS_DICT) - - def test_cast_to_dict(self): - self.assertDictEqual(dict(self.value), self.FLAT_EXPECTATION) - - def test_casting_items_to_dict(self): - self.assertEqual(dict(self.value.items()), self.FLAT_EXPECTATION) - - def test_missing_key_on_del(self): - with self.assertRaises(KeyError): - del self.value[str(uuid.uuid4())] - - def test_missing_key_on_get(self): - with self.assertRaises(KeyError): - self.assertIsNotNone(self.value[str(uuid.uuid4())]) - - def test_del_all_for_prefix(self): - for key in [k for k in self.KEYS if k.startswith('garply')]: - del self.value[key] - self.assertNotIn('garply', self.value) - - def test_iter_keys(self): - self.assertListEqual(sorted(self.KEYS), - sorted(k for k in iter(self.value))) - - def test_repr_value(self): - value = self.TEST_CLASS({'foo': 'bar', 'baz': {'qux': 'corgie'}}) - self.assertIn(str(value), repr(value)) - self.assertEqual( - repr(value)[0:len(self.TEST_CLASS.__name__) + 1], - '<{}'.format(self.TEST_CLASS.__name__)) - - def test_str_value(self): - val = self.TEST_CLASS({'foo': 1, 'baz': {'qux': 'corgie'}}) - self.assertIn("'foo': 1", str(val)) - self.assertIn("'baz:qux': 'corgie'", str(val)) - - def test_incorrect_assignment_raises(self): - value = self.TEST_CLASS({'foo': ['bar'], 'qux': 1}) - with self.assertRaises(TypeError): - value['foo:bar'] = 'baz' - with self.assertRaises(TypeError): - value['qux:baz'] = 'corgie' - - def test_clear(self): - self.value.clear() - self.assertDictEqual(self.value.as_dict(), {}) - - def test_get(self): - self.assertEqual(self.value.get('foo:bar:baz'), 0) - - def test_get_none_for_missing_key(self): - self.assertIsNone(self.value.get(str(uuid.uuid4()))) - - def test_copy(self): - copied = self.value.copy() - self.assertNotEqual(id(self.value), id(copied)) - self.assertDictEqual(self.value.as_dict(), copied.as_dict()) - - def test_eq(self): - self.assertEqual(self.value, self.value.copy()) - - def test_eq_dict(self): - self.assertEqual(self.value, self.value.as_dict()) - - def test_not_eq(self): - value = self.TEST_CLASS({'foo': ['bar']}) - self.assertFalse(self.value == value) - - def test_ne(self): - value = self.TEST_CLASS({'foo': ['bar']}) - self.assertTrue(self.value != value) - - def test_eq_value_error(self): - with self.assertRaises(TypeError): - self.assertTrue(self.value == 123) - - def test_iter_items(self): - items = [(k, v) for k, v in self.value.iteritems()] - self.assertListEqual(self.value.items(), items) - - def test_iterkeys(self): - keys = sorted(self.value.iterkeys()) - self.assertListEqual(keys, sorted(self.KEYS)) - - def test_itervalues(self): - values = list(self.value.itervalues()) - self.assertListEqual(values, self.value.values()) - - def test_pop(self): - self.assertEqual(1, self.value.pop('foo:bar:qux')) - self.assertNotIn('foo:bar:qux', self.value) - - def test_pop_top(self): - expectation = self.value.__class__(self.VALUES['foo']) - self.assertEqual(expectation, self.value.pop('foo')) - self.assertNotIn('foo', self.value) - - def test_pop_default(self): - default = str(uuid.uuid4()) - self.assertEqual(self.value.pop(str(uuid.uuid4()), default), default) - - def test_pop_no_default(self): - with self.assertRaises(KeyError): - self.value.pop(str(uuid.uuid4())) - - def test_set_default(self): - value = self.TEST_CLASS() - value.setdefault('foo:bar:qux', 9999) - self.assertEqual(value['foo:bar:qux'], 9999) - - def test_set_default_already_set(self): - self.value.setdefault('foo:bar:qux', 9999) - self.assertEqual(self.value['foo:bar:qux'], 1) - - def test_set_default_already_set_false_or_none(self): - value = self.TEST_CLASS({'foo': False}) - value.setdefault('foo', None) - self.assertEqual(value['foo'], False) - - def test_set_delimiter(self): - self.value.set_delimiter('-') - self.assertListEqual( - sorted(k.replace(':', '-') for k in self.KEYS), - sorted(self.value.keys())) - self.assertListEqual( - sorted(str(self.value[k.replace(':', '-')]) for k in self.KEYS), - sorted(str(v) for v in self.value.values())) - - def test_update(self): - expectation = self.TEST_CLASS(self.value.as_dict()) - expectation['foo:bar:baz'] = 4 - expectation['foo:bar:qux'] = 5 - expectation['foo:bar:corgie'] = 6 - expectation['foo:bar:waldo'] = 7 - self.value.update({ - 'foo:bar:baz': 4, - 'foo:bar:qux': 5, - 'foo:bar:corgie': 6, - 'foo:bar:waldo': 7 - }) - self.assertEqual(self.value, expectation) - - def test_set_delimiter_collision(self): - value = self.TEST_CLASS({'foo_bar': {'qux': 1}}) - with self.assertRaises(ValueError): - value.set_delimiter('_') - - def test_pickling(self): - pickled = pickle.dumps(self.value) - self.assertEqual(pickle.loads(pickled), self.value) - - def test_empty_dict_as_value(self): - expectation = {'foo': {'bar': {}}} - flat = self.TEST_CLASS(expectation) - value = flat.as_dict() - self.assertDictEqual(value, expectation) - - -class FlatterDictTests(FlatDictTests): - - TEST_CLASS = flatdict2.FlatterDict - - FLAT_EXPECTATION = { - 'foo:bar:baz': 0, - 'foo:bar:qux': 1, - 'foo:bar:corge': 2, - 'foo:bar:list:0': -1, - 'foo:bar:list:1': -2, - 'foo:bar:list:2': -3, - 'foo:grault:baz': 3, - 'foo:grault:qux': 4, - 'foo:grault:corge': 5, - 'foo:list:0': 'F', - 'foo:list:1': 'O', - 'foo:list:2': 'O', - 'foo:list:3': '', - 'foo:list:4': 'B', - 'foo:list:5': 'A', - 'foo:list:6': 'R', - 'foo:list:7': '', - 'foo:list:8': 'L', - 'foo:list:9': 'I', - 'foo:list:10': 'S', - 'foo:list:11': 'T', - 'foo:set:0': 10, - 'foo:set:1': 20, - 'foo:set:2': 30, - 'foo:tuple:0': 'F', - 'foo:tuple:1': 0, - 'foo:tuple:2': 0, - 'foo:abc:def': True, - 'garply:foo': 0, - 'garply:bar': 1, - 'garply:baz': 2, - 'garply:qux:corge': 3, - 'fred': 4, - 'xyzzy': 'plugh', - 'thud': 5, - 'waldo:fred': 6, - 'waldo:wanda': 7, - 'neighbors:0:left': 'john', - 'neighbors:0:right': 'michelle', - 'neighbors:1:left': 'steven', - 'neighbors:1:right': 'wynona', - 'double_nest:0:0': 1, - 'double_nest:0:1': 2, - 'double_nest:1:0': 3, - 'double_nest:1:1': 4, - 'double_nest:2:0': 5, - 'double_nest:2:1': 6, - } - - KEYS = [ - 'foo:bar:baz', - 'foo:bar:qux', - 'foo:bar:corge', - 'foo:bar:list:0', - 'foo:bar:list:1', - 'foo:bar:list:2', - 'foo:grault:baz', - 'foo:grault:qux', - 'foo:grault:corge', - 'foo:list:0', - 'foo:list:1', - 'foo:list:2', - 'foo:list:3', - 'foo:list:4', - 'foo:list:5', - 'foo:list:6', - 'foo:list:7', - 'foo:list:8', - 'foo:list:9', - 'foo:list:10', - 'foo:list:11', - 'foo:set:0', - 'foo:set:1', - 'foo:set:2', - 'foo:tuple:0', - 'foo:tuple:1', - 'foo:tuple:2', - 'foo:abc:def', - 'garply:foo', - 'garply:bar', - 'garply:baz', - 'garply:qux:corge', - 'fred', - 'xyzzy', - 'thud', - 'waldo:fred', - 'waldo:wanda', - 'neighbors:0:left', - 'neighbors:0:right', - 'neighbors:1:left', - 'neighbors:1:right', - 'double_nest:0:0', - 'double_nest:0:1', - 'double_nest:1:0', - 'double_nest:1:1', - 'double_nest:2:0', - 'double_nest:2:1', - ] - - VALUES = { - 'foo': { - 'bar': { - 'baz': 0, - 'qux': 1, - 'corge': 2, - 'list': [-1, -2, -3] - }, - 'grault': { - 'baz': 3, - 'qux': 4, - 'corge': 5 - }, - 'list': ['F', 'O', 'O', '', 'B', 'A', 'R', '', 'L', 'I', 'S', 'T'], - 'set': {10, 20, 30}, - 'tuple': ('F', 0, 0), - 'abc': { - 'def': True - } - }, - 'garply': { - 'foo': 0, - 'bar': 1, - 'baz': 2, - 'qux': { - 'corge': 3 - } - }, - 'fred': 4, - 'xyzzy': 'plugh', - 'thud': 5, - 'waldo:fred': 6, - 'waldo:wanda': 7, - 'neighbors': [{ - 'left': 'john', - 'right': 'michelle' - }, { - 'left': 'steven', - 'right': 'wynona' - }], - 'double_nest': [ - [1, 2], - (3, 4), - {5, 6}, - ] - } - - AS_DICT = { - 'foo': { - 'bar': { - 'baz': 0, - 'qux': 1, - 'corge': 2, - 'list': [-1, -2, -3] - }, - 'grault': { - 'baz': 3, - 'qux': 4, - 'corge': 5 - }, - 'list': ['F', 'O', 'O', '', 'B', 'A', 'R', '', 'L', 'I', 'S', 'T'], - 'set': {10, 20, 30}, - 'tuple': ('F', 0, 0), - 'abc': { - 'def': True - } - }, - 'garply': { - 'foo': 0, - 'bar': 1, - 'baz': 2, - 'qux': { - 'corge': 3 - } - }, - 'fred': - 4, - 'xyzzy': - 'plugh', - 'thud': - 5, - 'waldo': { - 'fred': 6, - 'wanda': 7 - }, - 'neighbors': [{ - 'left': 'john', - 'right': 'michelle' - }, { - 'left': 'steven', - 'right': 'wynona' - }], - 'double_nest': [ - [1, 2], - (3, 4), - {5, 6}, - ] - } - - def test_set_item(self): - vals = {'double_nest': [[1, 2], [3, 4]]} - d = self.TEST_CLASS(vals) - new_vals = {'double_nest': [[-1, 2], [3, 4]]} - d['double_nest:0:0'] = -1 - self.assertEqual(d.as_dict(), new_vals) - - def test_update_nest(self): - vals = {'double_nest': [[1, 2], [3, 4]]} - d = self.TEST_CLASS(vals) - new_vals = {'double_nest': [[-1, 2], [3, 4]]} - d.update(new_vals) - self.assertEqual(d.as_dict(), new_vals) - - def test_set_nest_dict(self): - vals = {'dicts': [{'a': 1, 'b': 2}, {'c': 3, 'd': 4}]} - d = self.TEST_CLASS(vals) - vals['dicts'][0]['a'] = -1 - d['dicts:0:a'] = -1 - self.assertEqual(d.as_dict(), vals) - - def test_update_nest_dict(self): - vals = {'dicts': [{'a': 1, 'b': 2}, {'c': 3, 'd': 4}]} - d = self.TEST_CLASS(vals) - vals['dicts'][0]['a'] = -1 - d.update(vals) - self.assertEqual(d.as_dict(), vals) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/eval_old_python.py b/tests/eval_old_python.py new file mode 100644 index 0000000..464dc66 --- /dev/null +++ b/tests/eval_old_python.py @@ -0,0 +1,345 @@ +from cj365.flatdict import FlatDict, FlatterDict + + +def test_flatdict(): + """Test core FlatDict functionality for Python 3.6 compatibility.""" + print("Testing FlatDict...") + + # Test initialization with nested dict + nested = {"a": {"b": {"c": 1}}, "d": 2} + fd = FlatDict(nested) + assert fd["a.b.c"] == 1 + assert fd["d"] == 2 + print(" โœ“ Nested dict initialization") + + # Test delimiter property + assert fd.delimiter == "." + fd.delimiter = ":" + assert fd.delimiter == ":" + assert fd["a:b:c"] == 1 + fd.set_delimiter(".") + print(" โœ“ Delimiter property and setter") + + # Test meta_keys + assert "a" in fd.meta_keys + assert "a.b" in fd.meta_keys + print(" โœ“ Meta keys") + + # Test inflate + inflated = fd.inflate() + assert inflated == nested + print(" โœ“ Inflate") + + # Test __setitem__ and __getitem__ + fd["e.f.g"] = 3 + assert fd["e.f.g"] == 3 + assert fd["e.f"] == {"g": 3} + print(" โœ“ Set and get items") + + # Test __contains__ + assert "a.b.c" in fd + assert "nonexistent" not in fd + print(" โœ“ Contains check") + + # Test get with default + assert fd.get("nonexistent", "default") == "default" + assert fd.get("a.b.c") == 1 + print(" โœ“ Get with default") + + # Test setdefault + fd.setdefault("new.key", 42) + assert fd["new.key"] == 42 + fd.setdefault("new.key", 99) + assert fd["new.key"] == 42 + print(" โœ“ Setdefault") + + # Test keys, values, items + keys = list(fd.keys()) + values = list(fd.values()) + items = list(fd.items()) + assert len(keys) > 0 + assert len(values) == len(keys) + assert len(items) == len(keys) + print(" โœ“ Keys, values, items") + + # Test __len__ + length = len(fd) + assert length == len(keys) + print(" โœ“ Length") + + # Test __iter__ + for key in fd: + assert key in keys + print(" โœ“ Iteration") + + # Test pop + val = fd.pop("new.key", None) + assert val == 42 + assert "new.key" not in fd + print(" โœ“ Pop") + + # Test update + fd.update({"x": 1, "y": {"z": 2}}) + assert fd["x"] == 1 + assert fd["y.z"] == 2 + print(" โœ“ Update") + + # Test __delitem__ + fd["temp"] = "delete_me" + assert "temp" in fd + del fd["temp"] + assert "temp" not in fd + print(" โœ“ Delete item") + + # Test copy + fd_copy = fd.copy() + assert fd_copy == fd + assert fd_copy is not fd + print(" โœ“ Copy") + + # Test __eq__ and __ne__ + fd2 = FlatDict(fd.inflate()) + assert fd == fd2 + assert not (fd != fd2) + print(" โœ“ Equality") + + # Test clear + fd_temp = FlatDict({"a": 1, "b": 2}) + fd_temp.clear() + assert len(fd_temp) == 0 + print(" โœ“ Clear") + + # Test flatten static method + flat = FlatDict.flatten({"a": {"b": 1}}, ".") + assert flat == {"a.b": 1} + print(" โœ“ Flatten static method") + + # Test unflatten static method + unflat = FlatDict.unflatten({"a.b": 1}, ".") + assert unflat == {"a": {"b": 1}} + print(" โœ“ Unflatten static method") + + # Test __repr__ and __str__ + repr_str = repr(fd) + assert "FlatDict" in repr_str + str_str = str(fd) + assert "{" in str_str + print(" โœ“ Repr and str") + + # Test __getstate__ and __setstate__ + state = fd.__getstate__() + fd_restored = FlatDict() + fd_restored.__setstate__(state) + assert fd_restored == fd + print(" โœ“ Getstate and setstate") + + print("FlatDict tests passed!\n") + + +def test_flatterdict(): + """Test core FlatterDict functionality for Python 3.6 compatibility.""" + print("Testing FlatterDict...") + + # Test initialization with nested dict + nested = {"a": {"b": {"c": 1}}, "d": 2} + fld = FlatterDict(nested) + assert fld["a.b.c"] == 1 + assert fld["d"] == 2 + print(" โœ“ Nested dict initialization") + + # Test initialization with list + list_data = [1, 2, {"a": 3}] + fld_list = FlatterDict(list_data) + assert fld_list["0"] == 1 + assert fld_list["1"] == 2 + assert fld_list["2.a"] == 3 + print(" โœ“ List initialization") + + # Test initialization with tuple + tuple_data = (1, 2, 3) + fld_tuple = FlatterDict(tuple_data) + assert fld_tuple["0"] == 1 + assert fld_tuple["2"] == 3 + print(" โœ“ Tuple initialization") + + # Test initialization with set + set_data = {1, 2, 3} + fld_set = FlatterDict(set_data) + assert len(fld_set) == 3 + print(" โœ“ Set initialization") + + # Test delimiter property + assert fld.delimiter == "." + fld.delimiter = ":" + assert fld.delimiter == ":" + assert fld["a:b:c"] == 1 + fld.set_delimiter(".") + print(" โœ“ Delimiter property and setter") + + # Test meta_keys + assert "a" in fld.meta_keys + assert "a.b" in fld.meta_keys + print(" โœ“ Meta keys") + + # Test inflate + inflated = fld.inflate() + assert inflated == nested + print(" โœ“ Inflate") + + # Test inflate with list + inflated_list = fld_list.inflate() + assert inflated_list == list_data + print(" โœ“ Inflate list") + + # Test __setitem__ and __getitem__ + fld["e.f.g"] = 3 + assert fld["e.f.g"] == 3 + assert fld["e.f"] == {"g": 3} + print(" โœ“ Set and get items") + + # Test __setitem__ with list + fld["h"] = [1, 2, 3] + assert fld["h.0"] == 1 + assert fld["h.1"] == 2 + print(" โœ“ Set list item") + + # Test __contains__ + assert "a.b.c" in fld + assert "nonexistent" not in fld + print(" โœ“ Contains check") + + # Test get with default + assert fld.get("nonexistent", "default") == "default" + assert fld.get("a.b.c") == 1 + print(" โœ“ Get with default") + + # Test setdefault + fld.setdefault("new.key", 42) + assert fld["new.key"] == 42 + fld.setdefault("new.key", 99) + assert fld["new.key"] == 42 + print(" โœ“ Setdefault") + + # Test keys, values, items + keys = list(fld.keys()) + values = list(fld.values()) + items = list(fld.items()) + assert len(keys) > 0 + assert len(values) == len(keys) + assert len(items) == len(keys) + print(" โœ“ Keys, values, items") + + # Test __len__ + length = len(fld) + assert length == len(keys) + print(" โœ“ Length") + + # Test __iter__ + for key in fld: + assert key in keys + print(" โœ“ Iteration") + + # Test pop + val = fld.pop("new.key", None) + assert val == 42 + assert "new.key" not in fld + print(" โœ“ Pop") + + # Test update + fld.update({"x": 1, "y": {"z": 2}}) + assert fld["x"] == 1 + assert fld["y.z"] == 2 + print(" โœ“ Update") + + # Test update with list + fld.update({"z": [10, 20]}) + assert fld["z.0"] == 10 + assert fld["z.1"] == 20 + print(" โœ“ Update with list") + + # Test __delitem__ + fld["temp"] = "delete_me" + assert "temp" in fld + del fld["temp"] + assert "temp" not in fld + print(" โœ“ Delete item") + + # Test copy + fld_copy = fld.copy() + assert fld_copy == fld + assert fld_copy is not fld + print(" โœ“ Copy") + + # Test __eq__ and __ne__ + fld2 = FlatterDict(fld.inflate()) + assert fld == fld2 + assert not (fld != fld2) + print(" โœ“ Equality") + + # Test equality with list + fld_list2 = FlatterDict([1, 2, {"a": 3}]) + assert fld_list == fld_list2 + print(" โœ“ Equality with list") + + # Test clear + fld_temp = FlatterDict({"a": 1, "b": 2}) + fld_temp.clear() + assert len(fld_temp) == 0 + print(" โœ“ Clear") + + # Test flatten static method + flat = FlatterDict.flatten({"a": {"b": 1}}, ".") + assert flat == {"a.b": 1} + print(" โœ“ Flatten static method") + + # Test flatten with list + flat_list = FlatterDict.flatten([1, 2, 3], ".") + assert flat_list == {"0": 1, "1": 2, "2": 3} + print(" โœ“ Flatten list") + + # Test unflatten static method + unflat = FlatterDict.unflatten({"a.b": 1}, ".") + assert unflat == {"a": {"b": 1}} + print(" โœ“ Unflatten static method") + + # Test __repr__ and __str__ + repr_str = repr(fld) + assert "FlatterDict" in repr_str + str_str = str(fld) + assert "{" in str_str + print(" โœ“ Repr and str") + + # Test __getstate__ and __setstate__ + state = fld.__getstate__() + fld_restored = FlatterDict() + fld_restored.__setstate__(state) + assert fld_restored == fld + print(" โœ“ Getstate and setstate") + + # Test nested list structures + nested_list = {"items": [{"name": "a"}, {"name": "b"}]} + fld_nested = FlatterDict(nested_list) + assert fld_nested["items.0.name"] == "a" + assert fld_nested["items.1.name"] == "b" + print(" โœ“ Nested list structures") + + print("FlatterDict tests passed!\n") + + +if __name__ == "__main__": + print("=" * 60) + print("Python 3.6 Compatibility Test Suite") + print("=" * 60 + "\n") + + try: + test_flatdict() + test_flatterdict() + print("=" * 60) + print("All tests passed! โœ“") + print("=" * 60) + except Exception as e: + print("\n" + "=" * 60) + print("Test failed! โœ—") + print("::error:: Error: {}".format(str(e))) + print("=" * 60) + raise diff --git a/tests/flatdict_test.py b/tests/flatdict_test.py new file mode 100644 index 0000000..73e9e8d --- /dev/null +++ b/tests/flatdict_test.py @@ -0,0 +1,603 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, NamedTuple + +import pytest +from pytest_dependency import depends + +from cj365.flatdict import FlatDict + +if TYPE_CHECKING: # pragma: no cover + from typing import Any + + +class Point(NamedTuple): + x: int + y: int + + +def test_flatdict_init_from_NamedTuple(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatdict_dunder_getitem.__name__], scope="module") + + point = Point(x=1, y=2) + + # Test that initializing a FlatDict from a NamedTuple correctly + # converts it to a dictionary + flat_dict = FlatDict(point, delimiter=".") + assert point.x == flat_dict["x"] + assert point.y == flat_dict["y"] + + +def test_flatdict_init_from_flatdict(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatdict_dunder_getitem.__name__], scope="module") + + data = {"a": 1, "b": 2} + flat_dict = FlatDict(data) + new_flat_dict = FlatDict(flat_dict) + assert data["a"] == new_flat_dict["a"] + assert data["b"] == new_flat_dict["b"] + + +def test_flatdict_init_from_flattened_dict(): + assert FlatDict({"a": 1, "b.c": 2}, delimiter=".") + + +def test_flatdict_init_fails_w_empty_delimiter(): + with pytest.raises(ValueError, match="Delimiter cannot be an empty string"): + assert FlatDict({"a": 1, "b": {"c": 2}}, delimiter="") + + +def test_flatdict_flatten_delimiter_collision(): + with pytest.raises(ValueError, match="Key 'b.c' collides with the delimiter '.'"): + assert FlatDict.flatten({"a": 1, "b.c": 2}, delimiter=".") + + +def test_flatdict_unflatten_fails_w_empty_delimiter(): + with pytest.raises(ValueError, match="Delimiter cannot be an empty string"): + assert FlatDict.unflatten({"a": 1, "b.c": 2}, delimiter="") + + +def test_flatdict_clear(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [test_flatdict_dunder_equality.__name__, test_flatdict_dunder_len.__name__], + scope="module", + ) + + flat_dict = FlatDict({"a": 1, "b": 2}) + + # Test that clearing a FlatDict removes all items + # and results in an empty FlatDict + flat_dict.clear() + assert len(flat_dict) == 0 + assert flat_dict == {} + + +def test_flatdict_clear_nested(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [test_flatdict_dunder_equality.__name__, test_flatdict_dunder_len.__name__], + scope="module", + ) + + flat_dict = FlatDict({"a": {"b": 1}, "c": 2}) + + # Test that clearing a FlatDict with nested dictionaries + # removes all items and results in an empty FlatDict + flat_dict.clear() + assert len(flat_dict) == 0 + assert flat_dict == {} + + +def test_flatdict_copy(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatdict_dunder_equality.__name__], scope="module") + + flat_dict = FlatDict({"a": 1, "b": {"c": 2}}) + + # Test that copying a FlatDict creates a new FlatDict + # with the same content but different instance (ie. a deep copy) + flat_dict_copy = flat_dict.copy() + + # Evaluate (Expected -> Actual) + assert flat_dict_copy == flat_dict + assert flat_dict_copy is not flat_dict + + +def test_flatdict_copy_nested(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatdict_dunder_equality.__name__, + test_flatdict_dunder_getitem.__name__, + ], + scope="module", + ) + + flat_dict = FlatDict({"a": {"b": {"c": [2]}}}) + + # Test that copying a FlatDict with nested dictionaries + # creates a new FlatDict with the same content but different + # nested dictionary instances (ie. a deep copy) + flat_dict_copy = flat_dict.copy() + + # Evaluate (Expected -> Actual) + assert flat_dict == flat_dict_copy + assert flat_dict is not flat_dict_copy + assert flat_dict["a"] == flat_dict_copy["a"] + assert flat_dict["a"] is not flat_dict_copy["a"] + assert flat_dict["a"]["b"] == flat_dict_copy["a"]["b"] + assert flat_dict["a"]["b"] is not flat_dict_copy["a"]["b"] + assert flat_dict["a"]["b"]["c"] == flat_dict_copy["a"]["b"]["c"] + assert flat_dict["a"]["b"]["c"] is not flat_dict_copy["a"]["b"]["c"] + + +def test_flatdict_copy_empty(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [test_flatdict_dunder_equality.__name__, test_flatdict_dunder_len.__name__], + scope="module", + ) + + flat_dict = FlatDict() + + # Test that copying an empty FlatDict returns an empty FlatDict + flat_dict_copy = flat_dict.copy() + assert flat_dict == flat_dict_copy + assert flat_dict is not flat_dict_copy + assert 0 == len(flat_dict_copy) + + +def test_flatdict_get(): + flat_dict = FlatDict({"a": 1, "b": {"c": 2}}, delimiter=".") + + # Test that get returns the correct values for existing keys + assert flat_dict.get("a") == 1 + assert flat_dict.get("b") == {"c": 2} + assert flat_dict.get("b.c") == 2 + + # Test that get returns None for non-existent keys + assert flat_dict.get("d") is None + assert flat_dict.get("d", "default") == "default" + + +def test_flatdict_inflate(): + expected = {"a": 1, "b": {"c": [2, 3]}} + flat_dict = FlatDict(expected, delimiter=".") + + # Test that inflating the FlatDict returns a dictionary equal to the original nested structure + inflated = flat_dict.inflate() + + # Evaluate (Expected -> Actual) + assert expected == inflated + assert expected is not inflated + assert expected["b"] == inflated["b"] + assert expected["b"] is not inflated["b"] + assert expected["b"]["c"] == inflated["b"]["c"] + assert expected["b"]["c"] is inflated["b"]["c"] + + +def test_flatdict_inflate_empty(): + expected = {} + flat_dict = FlatDict() + + # Test that inflating an empty FlatDict returns an empty dictionary + inflated = flat_dict.inflate() + assert expected == inflated + assert inflated is not expected + + +def test_flatdict_items(): + flat_dict = FlatDict({"a": 1, "b": {"c": 2}}, delimiter=".") + + # Test that the items method returns the correct set of key-value pairs + items = flat_dict.items() + assert set(items) == {("a", 1), ("b.c", 2)} + + +def test_flatdict_keys(): + flat_dict = FlatDict({"a": 1, "b": {"c": 2}}, delimiter=".") + + # Test that the keys method returns the correct set of keys + keys = flat_dict.keys() + assert set(keys) == {"a", "b.c"} + + +def test_flatdict_pop(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatdict_dunder_equality.__name__, + test_flatdict_dunder_contains.__name__, + ], + scope="module", + ) + + flat_dict = FlatDict({"a": 1, "b": {"c": 2, "d": 3}}, delimiter=".") + + # Test popping a nested key + value = flat_dict.pop("b.d") + assert value == 3 + assert "b.d" not in flat_dict + + # Test popping a top-level key of a nested dictionary removing + # the entire dictionary + value = flat_dict.pop("b") + assert value == {"c": 2} + assert "b.c" not in flat_dict + + # Test popping a non-existent key without a default value + value = flat_dict.pop("b") + assert value is None + + # Test popping a non-existent key with a default value + value = flat_dict.pop("b", "default") + assert value == "default" + assert "b" not in flat_dict + + # Test popping a top-level key + value = flat_dict.pop("a") + assert value == 1 + assert "a" not in flat_dict + + # Test that popping the last key results in an empty FlatDict + assert flat_dict == {} + + +def test_flatdict_setdefault(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatdict_dunder_getitem.__name__], scope="module") + + values = [1, 2] + flat_dict = FlatDict({"a": values[0], "b": {"c": values[1]}}, delimiter=".") + + # Test that setdefault returns the existing value for an existing key + value = flat_dict.setdefault("a", "default") + assert value == values[0] + assert flat_dict["a"] == values[0] + + # Test that setdefault returns the default value for a non-existent key and sets it + value = flat_dict.setdefault("d", "default") + assert value == "default" + assert flat_dict["d"] == "default" + + # Test that setdefault does not overwrite an existing nested key + value = flat_dict.setdefault("b.c", "default") + assert value == values[1] + assert flat_dict["b.c"] == values[1] + + +def test_flatdict_set_delimiter(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatdict_dunder_getitem.__name__], scope="module") + + data: dict[str, Any] = {"a": 1, "b": {"c": 2}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that changing the delimiter updates the internal structure + # and allows access with the new delimiter + flat_dict.delimiter = "/" + assert flat_dict["a"] == data["a"] + assert flat_dict["b"] == data["b"] + assert flat_dict["b/c"] == data["b"]["c"] + + with pytest.raises(KeyError, match="Key 'b.c' not found in FlatDict"): + assert flat_dict["b.c"] + + with pytest.raises(ValueError, match="Delimiter cannot be an empty string"): + flat_dict.set_delimiter("") + + +def test_flatdict_update_merge(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatdict_dunder_getitem.__name__], scope="module") + + data = {"a": 1, "b": {"c": [2]}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that updating the FlatDict with a new dictionary correctly updates the content + flat_dict.update({"b": {"d": 3}, "e": 4}) + assert flat_dict["a"] == 1 + assert flat_dict["b"] == {"c": [2], "d": 3} + assert flat_dict["b.c"] == [2] + assert flat_dict["b.d"] == 3 + assert flat_dict["e"] == 4 + + # Test updating the FlatDict with kwargs + flat_dict.update(f=5, g={"h": 6}) + assert flat_dict["f"] == 5 + assert flat_dict["g"] == {"h": 6} + assert flat_dict["g.h"] == 6 + + # Test updating the FlatDict with iterable of key-value pairs + flat_dict.update([("i", 7), ("j", {"k": 8})]) + assert flat_dict["i"] == 7 + assert flat_dict["j.k"] == 8 + + +def test_flatdict_update_overwrite(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatdict_dunder_getitem.__name__], scope="module") + + data = {"a": 1, "b": {"c": [2]}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that updating the FlatDict with a new dictionary correctly overwrites existing keys + flat_dict.update({"a": 3, "b": {"c": [4]}}) + assert flat_dict["a"] == 3 + assert flat_dict["b"] == {"c": [4]} + assert flat_dict["b.c"] == [4] + + +def test_flatdict_update_restructure(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatdict_dunder_getitem.__name__], scope="module") + + data = {"a": 1, "b": {"c": [2]}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that updating the FlatDict with a new dictionary correctly restructures the content + flat_dict.update({"b": 3}) + assert flat_dict["a"] == 1 + assert flat_dict["b"] == 3 + with pytest.raises(KeyError): + assert flat_dict["b.c"] + + # Test that updating the FlatDict with a new dictionary correctly restructures the content again + flat_dict.update({"b": {"d": 4}}) + assert flat_dict["a"] == 1 + assert flat_dict["b"] == {"d": 4} + assert flat_dict["b.d"] == 4 + + +def test_flatdict_update_empty(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatdict_dunder_getitem.__name__], scope="module") + + data = {"a": 1, "b": {"c": [2]}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that updating the FlatDict with an empty dictionary does not change the content + flat_dict.update({}) + assert flat_dict["a"] == 1 + assert flat_dict["b"] == {"c": [2]} + assert flat_dict["b.c"] == [2] + + +@pytest.mark.order("third") +@pytest.mark.dependency +def test_flatdict_values(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatdict_dunder_setitem.__name__], scope="module") + + initial_values = [1, 2] + data = {"a": initial_values[0], "b": {"c": initial_values[1]}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that the values method returns the correct set of values + # corresponding to the flattened keys + values = flat_dict.values() + actual_values = list(values) + assert initial_values == actual_values + + # Test use of ValuesViewer when the FlatDict is modified + # after calling values() + flat_dict["d"] = 3 # requires __setitem__ + actual_values = list(values) + expected = [*initial_values, 3] + assert expected == actual_values + + +@pytest.mark.order("first") +@pytest.mark.dependency +def test_flatdict_dunder_contains(): + data = {"a": 1, "b": {"c": 2}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that the __contains__ method correctly identifies existing keys + assert "a" in flat_dict + assert "b.c" in flat_dict + + # Test that the __contains__ method correctly identifies non-existent keys + assert "d" not in flat_dict + assert "b.d" not in flat_dict + assert "a.c" not in flat_dict + + # Test that the __contains__ method correctly identifies meta keys + # substrings of existing keys (higher level dictionary keys) + assert "b" in flat_dict + + +def test_flatdict_dunder_delitem(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatdict_dunder_contains.__name__], scope="module") + + data = {"a": 1, "b": {"c": {"d": [2]}}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that the __delitem__ method correctly deletes existing keys + del flat_dict["b.c"] + assert "b.c.d" not in flat_dict + assert "b.c" not in flat_dict + + # Test that the __delitem__ method raises KeyError for non-existent keys + with pytest.raises(KeyError): + del flat_dict["e"] + + with pytest.raises(KeyError): + del flat_dict["b.e"] + + with pytest.raises(KeyError): + del flat_dict["a.c"] + + +@pytest.mark.order("first") +@pytest.mark.dependency +def test_flatdict_dunder_equality(): + data = {"a": 1, "b": {"c": 2}} + other_data = {"a": 1, "b": {"c": 3}} + flat_dict1 = FlatDict(data, delimiter=".") + flat_dict2 = FlatDict(data, delimiter=".") + flat_dict3 = FlatDict(other_data, delimiter=".") + flat_dict4 = FlatDict(data, delimiter="/") + + # Test that two FlatDict instances with the same content are equal + assert flat_dict1 == flat_dict2 + assert flat_dict1 is not flat_dict2 + + # Test that a FlatDict instance is equal to a regular dict with the same content + assert flat_dict1 == data + + # Test that two FlatDict instances with different content are not equal + assert bool(flat_dict1 == flat_dict3) is False + assert flat_dict1 != flat_dict3 + + # Test that a FlatDict instance is not equal to a regular dict with different content + assert bool(flat_dict1 == other_data) is False + assert flat_dict1 != other_data + + # Test that two FlatDict instances with the same content but different delimiters are not equal + assert bool(flat_dict1 == flat_dict4) is False + assert flat_dict1 != flat_dict4 + + # Test that a comparison with a non-dict type throws a TypeError + with pytest.raises(TypeError): + assert flat_dict1 == 1 + + +@pytest.mark.order("first") +@pytest.mark.dependency +def test_flatdict_dunder_getitem(): + data = {"a": 1, "b": {"c": {"d": [2]}}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that the __getitem__ method correctly retrieves existing keys + assert data["a"] == flat_dict["a"] + assert data["b"]["c"] == flat_dict["b.c"] + assert data["b"]["c"]["d"] == flat_dict["b.c.d"] + + # Test that the __getitem__ method raises KeyError for non-existent keys + with pytest.raises(KeyError): + flat_dict["e"] + + with pytest.raises(KeyError): + flat_dict["b.e"] + + with pytest.raises(KeyError): + flat_dict["a.c"] + + +def test_flatdict_dunder_iter(): + data = {"a": 1, "b": {"c": 2}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that the __iter__ method returns an iterator over the flattened keys + expected_key_order = ["a", "b.c"] + actual_key_order = [] + for key in iter(flat_dict): + actual_key_order.append(key) + + assert expected_key_order == actual_key_order + + +@pytest.mark.order("first") +@pytest.mark.dependency +def test_flatdict_dunder_len(): + data = {"a": 1, "b": {"c": 2}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that the __len__ method returns the correct number of flattened keys + assert len(flat_dict) == 2 + + +def test_flatdict_dunder_repr(): + data = {"a": 1, "b": {"c": 2}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that the __repr__ method returns a string representation of the FlatDict + expected_repr = f"" + assert expected_repr == repr(flat_dict) + + +@pytest.mark.order("second") +@pytest.mark.dependency +def test_flatdict_dunder_setitem(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatdict_dunder_equality.__name__, + test_flatdict_dunder_getitem.__name__, + ], + scope="module", + ) + + # requires __getitem__ to test nested assignment + flat_dict = FlatDict(delimiter=".") + + # Test that the __setitem__ method correctly sets values for new keys + flat_dict["a"] = 1 + assert flat_dict["a"] == 1 + + flat_dict["b.c"] = 2 + assert flat_dict["b.c"] == 2 + + # Test that the __setitem__ method correctly updates values for existing keys + flat_dict["a"] = 3 + assert flat_dict["a"] == 3 + + flat_dict["b.c"] = 4 + assert flat_dict["b.c"] == 4 + + # Test that the __setitem__ method handles assignment over existing non-dict keys + flat_dict["b"] = 5 + assert flat_dict["b"] == 5 + + # Test that the __setitem__ method handles dict assignment over existing non-dict keys + flat_dict["b"] = {"d": 6} + assert flat_dict["b.d"] == 6 + + +def test_flatdict_dunder_str(): + data = {"a": 1, "b": {"c": 2}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that the __str__ method returns a string representation of the FlatDict + expected_str = "{'a': 1, 'b.c': 2}" + assert expected_str == str(flat_dict) + + +def test_flatdict_pickle(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatdict_dunder_equality.__name__, + test_flatdict_dunder_getitem.__name__, + ], + scope="module", + ) + + import pickle + + data = {"a": 1, "b": {"c": [2, 3]}} + flat_dict = FlatDict(data, delimiter=".") + + # Test that a FlatDict instance can be pickled and unpickled correctly + pickled = pickle.dumps(flat_dict) + unpickled = pickle.loads(pickled) + + # Evaluate (Expected -> Actual) + assert flat_dict == unpickled + assert flat_dict is not unpickled + assert flat_dict["b"] == unpickled["b"] + assert flat_dict["b"] is not unpickled["b"] + assert flat_dict["b"]["c"] == unpickled["b"]["c"] + assert flat_dict["b"]["c"] is not unpickled["b"]["c"] diff --git a/tests/flatterdict_test.py b/tests/flatterdict_test.py new file mode 100644 index 0000000..b003907 --- /dev/null +++ b/tests/flatterdict_test.py @@ -0,0 +1,1132 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, NamedTuple, cast + +import pytest +from pytest_dependency import depends + +from cj365.flatdict import FlatterDict +from cj365.flatdict.flat_dict import FlatDict + +if TYPE_CHECKING: # pragma: no cover + from typing import Any + + +class Vector(NamedTuple): + velocity: int + direction: tuple[int, int] + + +def test_flatterdict_init_from_NamedTuple(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatterdict_dunder_getitem.__name__], scope="module") + + vector = Vector(velocity=1, direction=(2, 3)) + + # Test that initializing a FlatterDict from a NamedTuple with an + # internal tuple correctly converts it to a dictionary + flatter_dict = FlatterDict(vector, delimiter=".") + assert vector.velocity == flatter_dict["velocity"] + assert vector.direction == ( + flatter_dict["direction.0"], + flatter_dict["direction.1"], + ) + + +def test_flatterdict_init_from_flatterdict(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatterdict_dunder_getitem.__name__], scope="module") + + data: dict[str, Any] = {"a": 1, "b": (2, 3)} + flat_dict = FlatterDict(data) + new_flat_dict = FlatterDict(flat_dict) + + assert data["a"] == new_flat_dict["a"] + assert data["b"][0] == new_flat_dict["b.0"] + assert data["b"][1] == new_flat_dict["b.1"] + + +def test_flatterdict_init_from_flatdict(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatterdict_dunder_getitem.__name__], scope="module") + + data: dict[str, Any] = {"a": 1, "b": (2, 3)} + flat_dict = FlatDict(data) + flatter_dict = FlatterDict(flat_dict) + + assert data["a"] == flatter_dict["a"] + assert data["b"][0] == flatter_dict["b.0"] + assert data["b"][1] == flatter_dict["b.1"] + + +def test_flatterdict_init_from_flattened_dict(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatterdict_inflate.__name__], scope="module") + + delimiter = "." + flat_dict = FlatterDict({"a": 1, f"b{delimiter}0": 2}, delimiter=delimiter) + inflated_data = flat_dict.inflate() + expected_data = {"a": 1, "b": (2,)} + + assert expected_data == inflated_data + + +def test_flatterdict_init_fails_w_empty_delimiter(): + with pytest.raises(ValueError, match="Delimiter cannot be an empty string"): + assert FlatterDict({"a": 1, "b": {"c": (2, 3)}}, delimiter="") + + +def test_flatterdict_flatten_delimiter_collision(): + with pytest.raises(ValueError, match="Key 'b.0' collides with the delimiter '.'"): + assert FlatterDict.flatten({"a": 1, "b.0": 2}, delimiter=".") + + +def test_flatterdict_unflatten_fails_w_empty_delimiter(): + with pytest.raises(ValueError, match="Delimiter cannot be an empty string"): + assert FlatterDict.unflatten({"a": 1, "b.0": 2}, delimiter="") + + +def test_flatterdict_clear(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatterdict_dunder_equality.__name__, + test_flatterdict_dunder_len.__name__, + ], + scope="module", + ) + + expected_length = 0 + empty_dict: dict[Any, Any] = {} + empty_list: list[Any] = [] + + # Test that clearing a FlatterDict removes all items + # and results in an empty FlatterDict + flat_dict = FlatterDict({"a": 1, "b": 2}) + flat_dict.clear() + assert expected_length == len(flat_dict) + assert empty_dict == flat_dict + + # Test that clearing a FlatterDict initialized from a Sequence removes all items + # and results in an empty FlatterDict + flat_dict = FlatterDict([{"c": 3}, {"d": 4}]) + flat_dict.clear() + assert expected_length == len(flat_dict) + assert empty_list == flat_dict + + +def test_flatterdict_clear_nested(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatterdict_dunder_equality.__name__, + test_flatterdict_dunder_len.__name__, + ], + scope="module", + ) + + expected_length = 0 + empty_dict: dict[Any, Any] = {} + empty_list: list[Any] = [] + + # Test that clearing a FlatterDict with nested dictionaries + # removes all items and results in an empty FlatterDict + flat_dict = FlatterDict({"a": {"b": 1}, "c": (2, 3)}) + flat_dict.clear() + assert expected_length == len(flat_dict) + assert empty_dict == flat_dict + + flat_dict = FlatterDict([{"a": {"b": 1}}, {"c": (2, 3)}]) + flat_dict.clear() + assert expected_length == len(flat_dict) + assert empty_list == flat_dict + + +def test_flatterdict_copy_list(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatterdict_dunder_equality.__name__, + test_flatterdict_dunder_getitem.__name__, + ], + scope="module", + ) + + # Values must be greater than 256 since python interns small integers, + # which would cause the copy to not be a deep copy. The string concatenation is to ensure + # that the string is not interned since string literals are also interned. + flat_dict = FlatterDict([{"a": [100, 200]}, {"b": {"c": 300}}]) + + # Test that copying a FlatterDict creates a new FlatterDict + # with the same content but different instance (ie. a deep copy) + flat_dict_copy = flat_dict.copy() + + # Evaluate (Expected -> Actual) + assert flat_dict == flat_dict_copy + assert flat_dict is not flat_dict_copy + assert flat_dict["0"] == flat_dict_copy["0"] + assert flat_dict["0"] is not flat_dict_copy["0"] + assert flat_dict["0.a"] == flat_dict_copy["0.a"] + assert flat_dict["0.a"] is not flat_dict_copy["0.a"] + assert flat_dict["0.a.0"] == flat_dict_copy["0.a.0"] + assert flat_dict["0.a.1"] == flat_dict_copy["0.a.1"] + assert flat_dict["1"] == flat_dict_copy["1"] + assert flat_dict["1"] is not flat_dict_copy["1"] + assert flat_dict["1.b"] == flat_dict_copy["1.b"] + assert flat_dict["1.b"] is not flat_dict_copy["1.b"] + assert flat_dict["1.b.c"] == flat_dict_copy["1.b.c"] + + +def test_flatterdict_copy_nested_dict(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatterdict_dunder_equality.__name__, + test_flatterdict_dunder_getitem.__name__, + ], + scope="module", + ) + + # Values must be greater than 256 since python interns small integers, + # which would cause the copy to not be a deep copy. The string concatenation is to ensure + # that the string is not interned since string literals are also interned. + flat_dict = FlatterDict( + {"a": 100, "b": {"c": 200, "d": 300}, "e": [400, "foo" + "bar"]} + ) + + # Test that copying a FlatterDict creates a new FlatterDict + # with the same content but different instance (ie. a deep copy) + flat_dict_copy = flat_dict.copy() + + # Evaluate (Expected -> Actual) + assert flat_dict == flat_dict_copy + assert flat_dict is not flat_dict_copy + assert flat_dict["a"] == flat_dict_copy["a"] + assert flat_dict["b"] == flat_dict_copy["b"] + assert flat_dict["b"] is not flat_dict_copy["b"] + assert flat_dict["b"]["c"] == flat_dict_copy["b"]["c"] + assert flat_dict["b"]["d"] == flat_dict_copy["b"]["d"] + assert flat_dict["e"] == flat_dict_copy["e"] + assert flat_dict["e"] is not flat_dict_copy["e"] + assert flat_dict["e"][0] == flat_dict_copy["e"][0] + assert flat_dict["e"][1] == flat_dict_copy["e"][1] + + +def test_flatterdict_copy_empty(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatterdict_dunder_equality.__name__, + test_flatterdict_dunder_len.__name__, + ], + scope="module", + ) + + expected_length = 0 + flat_dict = FlatterDict() + flat_dict_copy = flat_dict.copy() + + assert flat_dict == flat_dict_copy + assert flat_dict is not flat_dict_copy + assert expected_length == len(flat_dict_copy) + + +def test_flatterdict_get(): + data = {"a": {"b": 1}, "c": (2, 3)} + flat_dict = FlatterDict(data, delimiter=".") + + # Test that get() returns the correct values for existing keys (meta & flattened keys) + assert data["a"] == flat_dict.get("a") + assert data["a"]["b"] == flat_dict.get("a.b") + assert data["c"] == flat_dict.get("c") + assert data["c"][0] == flat_dict.get("c.0") + assert data["c"][1] == flat_dict.get("c.1") + + # Test that get() returns None for non-existent keys without a default value + assert None is flat_dict.get("d") + assert None is flat_dict.get("a.c") + assert "default" == flat_dict.get("d", "default") + assert "default" == flat_dict.get("a.c", "default") + + # Test that get() works for a FlatterDict initialized from a Sequence + data = [{"a": 1}, {"b": 2}] + flat_dict = FlatterDict(data, delimiter=".") + + assert data[0] == flat_dict.get("0") + assert data[0]["a"] == flat_dict.get("0.a") + assert data[1] == flat_dict.get("1") + assert data[1]["b"] == flat_dict.get("1.b") + + assert None is flat_dict.get("2") + assert None is flat_dict.get("0.b") + assert "default" == flat_dict.get("2", "default") + assert "default" == flat_dict.get("0.b", "default") + + +@pytest.mark.order("first") +@pytest.mark.dependency +def test_flatterdict_inflate(): + # TODO: switch tuple to use a list when type casting of nested structures is implemented + expected = {"a": 100, "b": {"c": (200, "foo" + "bar")}} + flat_dict = FlatterDict(expected, delimiter=".") + + # Test that inflating the FlatterDict returns a dictionary equal to the original nested structure + inflated = cast("dict[str, Any]", flat_dict.inflate()) + + # Evaluate (Expected -> Actual) + assert expected["b"]["c"][0] == inflated["b"]["c"][0] + assert expected["b"]["c"][1] == inflated["b"]["c"][1] + assert expected["b"]["c"] == inflated["b"]["c"] + assert expected["b"]["c"] is not inflated["b"]["c"] + assert expected["b"] is not inflated["b"] + assert expected["b"] == inflated["b"] + assert expected["a"] == inflated["a"] + assert expected == inflated + assert expected is not inflated + + # Test that inflating a FlatterDict initialized from a Sequence + expected = list(expected.items()) + flat_dict = FlatterDict(expected, delimiter=".") + inflated = cast("list[tuple[str, Any]]", flat_dict.inflate()) + + assert expected == inflated + assert expected is not inflated + assert expected[0] == inflated[0] + assert expected[0] is not inflated[0] + assert expected[1] == inflated[1] + assert expected[1] is not inflated[1] + + +def test_flatterdict_inflate_set(): + # Test a FlatterDict starting with a set + expected = {"a", "b"} + flat_dict = FlatterDict(expected, delimiter=".") + inflated = flat_dict.inflate() + + assert expected == inflated + assert expected is not inflated + + +def test_flatterdict_inflate_empty(): + expected = {} + flat_dict = FlatterDict() + + # Test that inflating an empty FlatterDict returns an empty dictionary + inflated = flat_dict.inflate() + assert expected == inflated + assert expected is not inflated + + expected = [] + flat_dict = FlatterDict([]) + + # Test that inflating an empty FlatterDict initialized from a Sequence returns an empty list + inflated = flat_dict.inflate() + assert expected == inflated + assert expected is not inflated + + expected = set() + flat_dict = FlatterDict(expected) + + # Test that inflating an empty FlatterDict initialized from a Set returns an empty set + inflated = flat_dict.inflate() + assert expected == inflated + assert expected is not inflated + + expected = tuple() + flat_dict = FlatterDict(expected) + + # Test that inflating an empty FlatterDict initialized from a Tuple returns an empty tuple + inflated = flat_dict.inflate() + assert expected == inflated + + +def test_flatterdict_items(): + data = {"a": 100, "b": {"c": [200, "foo" + "bar"]}} + expected_items = [("a", 100), ("b.c.0", 200), ("b.c.1", "foobar")] + + # Test that the items method returns the correct set of key-value pairs + flat_dict = FlatterDict(data, delimiter=".") + items = list(flat_dict.items()) + assert expected_items == items + + data = [{"a": 100}, {"b": 200}] + expected_items = [("0.a", 100), ("1.b", 200)] + + # Test that the items method returns the correct set of key-value pairs for a FlatterDict initialized from a Sequence + flat_dict = FlatterDict(data, delimiter=".") + items = list(flat_dict.items()) + assert expected_items == items + + +def test_flatterdict_keys(): + data = {"a": 100, "b": {"c": [200, "foo" + "bar"]}} + expected_keys = {"a", "b.c.0", "b.c.1"} + + # Test that the keys method returns the correct set of keys + flat_dict = FlatterDict(data, delimiter=".") + keys = set(flat_dict.keys()) + assert expected_keys == keys + + data = [{"a": 100}, {"b": 200}] + expected_keys = {"0.a", "1.b"} + + # Test that the keys method returns the correct set of keys for a FlatterDict initialized from a Sequence + flat_dict = FlatterDict(data, delimiter=".") + keys = set(flat_dict.keys()) + assert expected_keys == keys + + +def test_flatterdict_pop(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatterdict_dunder_equality.__name__, + test_flatterdict_dunder_contains.__name__, + ], + scope="module", + ) + + data: dict[str, Any] = {"a": 100, "b": {"c": [200, "foo" + "bar"]}, "d": {"e": 300}} + flat_dict = FlatterDict(data, delimiter=".") + + # Test popping a nested list key + value = flat_dict.pop("b.c.1") + assert data["b"]["c"][1] == value + assert "b.c.1" not in flat_dict + + # Test popping a top-level key of a nested dictionary removing + # the entire dictionary + value = flat_dict.pop("d") + assert data["d"] == value + assert "d" not in flat_dict + + # Test popping a non-existent key without a default value + value = flat_dict.pop("d") + assert value is None + + # Test popping a non-existent key with a default value + value = flat_dict.pop("d", "default") + assert value == "default" + assert "d" not in flat_dict + + value = flat_dict.pop("b.c") + # TODO: Remove list() when FlatterDict handles type casting for nested structures + assert [data["b"]["c"][0]] == list(value) + assert "b.c" not in flat_dict + + value = flat_dict.pop("b") + empty_dict: dict[Any, Any] = {} + assert empty_dict == value + + # Test popping a top-level key + value = flat_dict.pop("a") + assert data["a"] == value + assert "a" not in flat_dict + + # Test that popping the last key results in an empty FlatterDict + assert flat_dict == {} + + data_list: list[dict[str, Any]] = [{"a": 100}, {"b": [200, "foo" + "bar"]}] + flat_dict = FlatterDict(data_list, delimiter=".") + + value = flat_dict.pop("0.a") + assert data_list[0]["a"] == value + assert "0.a" not in flat_dict + + # Test popping a starting list value from a nested list & the + # second value becomes the new starting list value + value = flat_dict.pop("1.b.0") + assert data_list[1]["b"][0] == value + assert data_list[1]["b"][1] == flat_dict["1.b.0"] + assert "1.b.1" not in flat_dict + + value = flat_dict.pop("1.b.0") + assert data_list[1]["b"][1] == value + assert "1.b.0" not in flat_dict + + value = flat_dict.pop("1.b") + empty_list: list[Any] = [] + # TODO: remove list() when FlatterDict handles type casting for nested structures + assert empty_list == list(value) + assert "1.b" not in flat_dict + + value = flat_dict.pop("1") + assert empty_dict == value + assert "1" not in flat_dict + + value = flat_dict.pop("0") + assert empty_dict == value + assert "0" not in flat_dict + + # Test that popping the last key results in an empty FlatterDict + assert empty_list == flat_dict + + +def test_flatterdict_setdefault(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatterdict_dunder_getitem.__name__], scope="module") + + expected_values = [100, 200, "foo" + "bar", 300] + data = { + "a": expected_values[0], + "b": {"c": [expected_values[1], expected_values[2]]}, + "d": {"e": expected_values[3]}, + } + flat_dict = FlatterDict(data, delimiter=".") + + # Test that setdefault returns the existing value for an existing key + value = flat_dict.setdefault("a", "default") + assert expected_values[0] == value + assert expected_values[0] == flat_dict["a"] + + # Test that setdefault returns the default value for a non-existent key and sets it in a dict + value = flat_dict.setdefault("f", "default") + assert "default" == value + assert "default" == flat_dict["f"] + + value = flat_dict.setdefault("b.c.2", "default") + assert "default" == value + assert "default" == flat_dict["b.c.2"] + # TODO: Remove list() when FlatterDict handles type casting for nested structures + assert [expected_values[1], expected_values[2], "default"] == list(flat_dict["b.c"]) + + # Test that setdefault does not overwrite an existing nested key + value = flat_dict.setdefault("d.e", "default") + assert expected_values[3] == value + assert expected_values[3] == flat_dict["d.e"] + + +def test_flatterdict_set_delimiter(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatterdict_dunder_getitem.__name__], scope="module") + + data: dict[str, Any] = {"a": 100, "b": {"c": [200, "foo" + "bar"]}, "d": {"e": 300}} + flat_dict = FlatterDict(data, delimiter=".") + + # Test that changing the delimiter updates the internal structure + # and allows access with the new delimiter + flat_dict.delimiter = "/" + assert data["a"] == flat_dict["a"] + assert data["b"]["c"][0] == flat_dict["b/c/0"] + assert data["d"]["e"] == flat_dict["d/e"] + + with pytest.raises(KeyError, match="Key 'b.c' not found in 'FlatterDict'"): + assert flat_dict["b.c"] + + with pytest.raises(ValueError, match="Delimiter cannot be an empty string"): + flat_dict.set_delimiter("") + + +def test_flatterdict_update_merge(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatterdict_dunder_getitem.__name__], scope="module") + + data: dict[str, Any] = {"a": 1, "b": {"c": [2]}} + flat_dict = FlatterDict(data, delimiter=".") + + # Test that updating the FlatterDict with a new dictionary correctly updates the content + new_data: dict[str, Any] = {"b": {"d": 3}, "e": 4} + flat_dict.update(new_data) + + # Old data is still accessible and unchanged + assert data["a"] == flat_dict["a"] + assert data["b"]["c"][0] == flat_dict["b.c.0"] + # TODO: Remove list() when FlatterDict handles type casting for nested structures + assert data["b"]["c"] == list(flat_dict["b.c"]) + + # New data is accessible + assert new_data["b"]["d"] == flat_dict["b.d"] + assert new_data["e"] == flat_dict["e"] + + # New data is merged + # TODO: switch this back to using the original data["b"] when FlatterDict handles type casting for nested structures + # expected_merge: dict[str, Any] = {**data["b"], **new_data["b"]} + expected_merge: dict[str, Any] = {**{"c": tuple(data["b"]["c"])}, **new_data["b"]} + assert expected_merge == flat_dict["b"] + + # Test updating the FlatterDict with kwargs + new_data = {"f": 5, "g": {"h": 6}} + flat_dict.update(f=new_data["f"], g=new_data["g"]) + assert new_data["f"] == flat_dict["f"] + assert new_data["g"]["h"] == flat_dict["g.h"] + assert new_data["g"] == flat_dict["g"] + + # Test updating the FlatterDict with iterable of key-value pairs + new_data_list: list[tuple[str, Any]] = [("i", 7), ("j", {"k": 8})] + flat_dict.update(new_data_list) + assert new_data_list[0][1] == flat_dict[new_data_list[0][0]] + assert new_data_list[1][1]["k"] == flat_dict["j.k"] + + data_list: list[dict[str, Any]] = [{"a": 1}, {"b": {"c": [2]}}] + flat_dict = FlatterDict(data_list, delimiter=".") + + with pytest.raises( + ValueError, + match="Cannot update a Set-initialized or Sequence-initialized FlatterDict with non-integer keys", + ): + new_data_list = [("b", {"d": 3}), ("e", 4)] + flat_dict.update(new_data_list) + + new_data_list = [("1", {"b": {"d": 3}}), ("2", {"e": 4})] + flat_dict.update(new_data_list) + assert data_list[0]["a"] == flat_dict["0.a"] + assert data_list[1]["b"]["c"][0] == flat_dict["1.b.c.0"] + # TODO: Remove list() when FlatterDict handles type casting for nested structures + assert data_list[1]["b"]["c"] == list(flat_dict["1.b.c"]) + assert new_data_list[0][1]["b"]["d"] == flat_dict["1.b.d"] + assert new_data_list[1][1]["e"] == flat_dict["2.e"] + + # TODO: simplify this + expected_merge = { + **{"c": tuple(data_list[1]["b"]["c"])}, + **new_data_list[0][1]["b"], + } + assert expected_merge == flat_dict["1.b"] + + +def test_flatterdict_update_overwrite(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatterdict_dunder_getitem.__name__], scope="module") + + data: dict[str, Any] = {"a": 1, "b": {"c": [2]}} + flat_dict = FlatterDict(data, delimiter=".") + + # Test that updating the FlatterDict with a new dictionary correctly overwrites existing keys + new_data: dict[str, Any] = {"a": 3, "b": {"c": [4]}} + flat_dict.update(new_data) + assert new_data["a"] == flat_dict["a"] + assert new_data["b"]["c"][0] == flat_dict["b.c.0"] + # TODO: Remove list() when FlatterDict handles type casting for nested structures + assert new_data["b"]["c"] == list(flat_dict["b.c"]) + # assert new_data["b"] == flat_dict["b"] + + # Initialize a FlatterDict from a Sequence + data_list: list[tuple[str, Any]] = [("a", 1), ("b", {"c": [2]})] + flat_dict = FlatterDict(data_list, delimiter=".") + + # Test that updating the FlatterDict with a new sequence correctly overwrites existing + # keys in a Sequence-initialized FlatterDict + new_data_list: list[tuple[str, Any]] = [("0", {"a": 3}), ("1", {"b": {"c": [4]}})] + flat_dict.update(new_data_list) + assert new_data_list[0][1]["a"] == flat_dict["0.a"] + assert new_data_list[1][1]["b"]["c"][0] == flat_dict["1.b.c.0"] + assert new_data_list[1][1]["b"]["c"] == list(flat_dict["1.b.c"]) + + # TODO: simplify this + expected_merge = {**{"c": tuple(new_data_list[1][1]["b"]["c"])}} + assert expected_merge == flat_dict["1.b"] + + +def test_flatterdict_update_restructure(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatterdict_dunder_getitem.__name__], scope="module") + + data: dict[str, Any] = {"a": 1, "b": {"c": [2]}} + flat_dict = FlatterDict(data, delimiter=".") + + # Test that updating the FlatterDict with a new dictionary correctly + # restructures the content + new_data: dict[str, Any] = {"b": 3} + flat_dict.update(new_data) + assert data["a"] == flat_dict["a"] + assert new_data["b"] == flat_dict["b"] + with pytest.raises(KeyError, match="Key 'b.c' not found in 'FlatterDict'"): + assert flat_dict["b.c"] + + # Test that updating the FlatterDict with a new dictionary correctly + # restructures the content again + new_data = {"b": {"d": 4}} + flat_dict.update(new_data) + assert data["a"] == flat_dict["a"] + assert new_data["b"]["d"] == flat_dict["b.d"] + with pytest.raises(KeyError, match="Key 'b.c' not found in 'FlatterDict'"): + assert flat_dict["b.c"] + + # Test that updating the FlatterDict with a new dictionary correctly + # restructures the content again with a sequence value + new_data = {"b": {"d": [5]}} + flat_dict.update(new_data) + assert data["a"] == flat_dict["a"] + assert new_data["b"]["d"][0] == flat_dict["b.d.0"] + # TODO: Remove list() when FlatterDict handles type casting for nested structures + assert new_data["b"]["d"] == list(flat_dict["b.d"]) + with pytest.raises(KeyError, match="Key 'b.d.1' not found in 'FlatterDict'"): + assert flat_dict["b.d.1"] + + +def test_flatterdict_update_empty(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatterdict_dunder_getitem.__name__], scope="module") + + data: dict[str, Any] = { + "a": 100, + "b": {"c": 200, "d": 300}, + "e": [400, "foo" + "bar"], + } + flat_dict = FlatterDict(data, delimiter=".") + + # Test that updating the FlatterDict with an empty dictionary does not change the content + flat_dict.update({}) + assert data["a"] == flat_dict["a"] + assert data["b"]["c"] == flat_dict["b.c"] + assert data["b"]["d"] == flat_dict["b.d"] + assert data["e"][0] == flat_dict["e.0"] + assert data["e"][1] == flat_dict["e.1"] + # TODO: remove list() after type casting fix + assert data["e"] == list(flat_dict["e"]) + + # Initialize a FlatterDict from a Sequence + data_list: list[dict[str, Any]] = [{"a": 100}, {"b": {"c": 200}}] + flat_dict = FlatterDict(data_list, delimiter=".") + + # Test a merge an empty list into a Sequence-initialized FlatterDict does not change the content + flat_dict.update([]) + assert data_list[0]["a"] == flat_dict["0.a"] + assert data_list[1]["b"]["c"] == flat_dict["1.b.c"] + assert data_list[1]["b"] == flat_dict["1.b"] + assert data_list[0] == flat_dict["0"] + assert data_list[1] == flat_dict["1"] + + # Test a merge an empty dictionary into a Sequence-initialized FlatterDict does not change the content + flat_dict.update({}) + assert data_list[0]["a"] == flat_dict["0.a"] + assert data_list[1]["b"]["c"] == flat_dict["1.b.c"] + assert data_list[1]["b"] == flat_dict["1.b"] + assert data_list[0] == flat_dict["0"] + assert data_list[1] == flat_dict["1"] + + +@pytest.mark.order("third") +@pytest.mark.dependency +def test_flatterdict_values(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends(request, [test_flatterdict_dunder_setitem.__name__], scope="module") + + initial_values = [1, 2, 3] + data = {"a": initial_values[0], "b": {"c": (initial_values[1], initial_values[2])}} + flat_dict = FlatterDict(data, delimiter=".") + + # Test that the values method returns the correct set of values + # corresponding to the flattened keys + values = flat_dict.values() + actual_values = list(values) + assert initial_values == actual_values + + # Test use of ValuesViewer when the FlatterDict is modified + # after calling values() + flat_dict["d"] = 3 # requires __setitem__ + actual_values = list(values) + expected = [*initial_values, 3] + assert expected == actual_values + + +@pytest.mark.order("first") +@pytest.mark.dependency +def test_flatterdict_dunder_contains(): + data = {"a": 1, "b": {"c": [2, 3]}, "d": [{"e": 4}]} + flat_dict = FlatterDict(data, delimiter=".") + + # Test that the __contains__ method correctly identifies existing keys + assert "a" in flat_dict + assert "b.c" in flat_dict + # sequence type keys + assert "d.0.e" in flat_dict + assert "b.c.0" in flat_dict + + # Test that the __contains__ method correctly identifies non-existent keys + assert "f" not in flat_dict + assert "b.d" not in flat_dict + assert "a.c" not in flat_dict + assert "d.1" not in flat_dict + assert "d.0.f" not in flat_dict + + # Test that the __contains__ method correctly identifies meta keys + # substrings of existing keys (higher level dictionary & sequence keys) + assert "b" in flat_dict + assert "d" in flat_dict + assert "d.0" in flat_dict + assert "b.c" in flat_dict + + # Start with Sequence data + data = [{"a": 1}, {"b": 2}] + flat_dict = FlatterDict(data, delimiter=".") + + # Test that the __contains__ method correctly identifies existing keys + assert "0.a" in flat_dict + assert "1.b" in flat_dict + + # Test that the __contains__ method correctly identifies non-existent keys + assert "0.b" not in flat_dict + assert "1.a" not in flat_dict + assert "2" not in flat_dict + + # Test that the __contains__ method correctly identifies meta keys + assert "0" in flat_dict + assert "1" in flat_dict + + # Start with Set data + data = {"a", "b"} + flat_dict = FlatterDict(data, delimiter=".") + + # Test that the __contains__ method correctly identifies existing keys + assert "0" in flat_dict + assert "1" in flat_dict + + # Test that the __contains__ method correctly identifies non-existent keys + assert "2" not in flat_dict + + +def test_flatterdict_dunder_delitem(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatterdict_dunder_contains.__name__, + test_flatterdict_dunder_getitem.__name__, + ], + scope="module", + ) + + data: dict[str, Any] = { + "a": 100, + "b": {"c": 200, "d": 300}, + "e": [400, "foo" + "bar"], + } + flat_dict = FlatterDict(data, delimiter=".") + + # Test that the __delitem__ method correctly deletes existing keys + del flat_dict["b"] + assert "b.c.d" not in flat_dict + assert "b.c" not in flat_dict + + del flat_dict["e.0"] + assert "e.0" in flat_dict + assert "e.1" not in flat_dict + assert data["e"][1] == flat_dict["e.0"] + + # Test that the __delitem__ method raises KeyError for non-existent keys + with pytest.raises(KeyError, match="Key 'f' not found in 'FlatterDict'"): + del flat_dict["f"] + + with pytest.raises(KeyError, match="Key 'b.e' not found in 'FlatterDict'"): + del flat_dict["b.e"] + + with pytest.raises(KeyError, match="Key 'a.c' not found in 'FlatterDict'"): + del flat_dict["a.c"] + + +@pytest.mark.order("first") +@pytest.mark.dependency +def test_flatterdict_dunder_equality(): + data = {"a": 1, "b": {"c": (2, 3)}, "d": ({"e": 4},)} + other_data = tuple(data.items()) + flat_dict1 = FlatterDict(data, delimiter=".") + flat_dict2 = FlatterDict(data, delimiter=".") + flat_dict3 = FlatterDict(other_data, delimiter=".") + flat_dict4 = FlatterDict(other_data, delimiter=".") + flat_dict5 = FlatterDict(data, delimiter="/") + + # Test that two FlatterDict instances with the same content are equal + assert flat_dict1 == flat_dict2 + assert flat_dict1 is not flat_dict2 + + # Test that a FlatterDict instance is equal to a regular dict with the same content + assert flat_dict1 == data + + # Test that two FlatterDict instances with different content are not equal + assert bool(flat_dict1 == flat_dict3) is False + assert flat_dict1 != flat_dict3 + + # Test that a FlatterDict instance is not equal to a regular dict with different content + assert bool(flat_dict1 == other_data) is False + assert flat_dict1 != other_data + + # Test that two FlatterDict instances that start as Sequences with the same content are equal + assert flat_dict3 == flat_dict4 + assert flat_dict3 is not flat_dict4 + + # Test that a FlatterDict instance that starts as a Sequence is equal to the same sequence + assert flat_dict3 == other_data + + # Test that two FlatterDict instances with the same content but different delimiters are not equal + assert bool(flat_dict1 == flat_dict5) is False + assert flat_dict1 != flat_dict5 + + # Test that a comparison with a non-dict or non-sequence type throws a TypeError + with pytest.raises(TypeError): + assert flat_dict1 == 1 + + +@pytest.mark.order("first") +@pytest.mark.dependency +def test_flatterdict_dunder_getitem(): + data = {"a": 1, "b": {"c": (2, 3)}, "d": ({"e": 4},)} + flat_dict = FlatterDict(data, delimiter=".") + + # Test that the __getitem__ method correctly retrieves existing values from keys + assert data["a"] == flat_dict["a"] + assert data["b"]["c"][0] == flat_dict["b.c.0"] + assert data["d"][0]["e"] == flat_dict["d.0.e"] + + # Test that the __getitem__ method correctly retrieves existing values from meta keys + assert data["b"]["c"] == flat_dict["b.c"] + assert data["d"] == flat_dict["d"] + assert data["d"][0] == flat_dict["d.0"] + + # Test that the __getitem__ method raises KeyError for non-existent keys + with pytest.raises(KeyError, match="Key 'e' not found in 'FlatterDict'"): + flat_dict["e"] + + with pytest.raises(KeyError, match="Key 'b.e' not found in 'FlatterDict'"): + flat_dict["b.e"] + + with pytest.raises(KeyError, match="Key 'a.c' not found in 'FlatterDict'"): + flat_dict["a.c"] + + data = tuple(data.items()) + flat_dict = FlatterDict(data, delimiter=".") + + # Test that the __getitem__ method correctly retrieves existing keys for a FlatterDict initialized from a Sequence + assert data[0][1] == flat_dict["0.1"] + + # Test that the __getitem__ method correctly retrieves existing values from meta keys for a FlatterDict initialized from a Sequence + assert data[1][1]["c"] == flat_dict["1.1.c"] + assert data[2][1][0]["e"] == flat_dict["2.1.0.e"] + + +def test_flatterdict_dunder_iter(): + data = {"a": 100, "b": {"c": 200, "d": 300}, "e": [400, "foo" + "bar"]} + flat_dict = FlatterDict(data, delimiter=".") + + # Test that the __iter__ method returns an iterator over the flattened keys + expected_key_order = ["a", "b.c", "b.d", "e.0", "e.1"] + actual_key_order = [] + for key in iter(flat_dict): + actual_key_order.append(key) + + assert expected_key_order == actual_key_order + + # Test __iter__() for a FlatterDict initialized from a Sequence + data = [{"a": 100}, {"b": 200}] + flat_dict = FlatterDict(data, delimiter=".") + + expected_key_order = ["0.a", "1.b"] + actual_key_order = [] + for key in iter(flat_dict): + actual_key_order.append(key) + + assert expected_key_order == actual_key_order + + +@pytest.mark.order("first") +@pytest.mark.dependency +def test_flatterdict_dunder_len(): + data = {"a": 1, "b": {"c": (2, 3)}, "d": ({"e": 4},)} + expected_length = 4 # "a", "b.c.0", "b.c.1", "d.0.e" + + flat_dict = FlatterDict(data, delimiter=".") + assert expected_length == len(flat_dict) + + +def test_flatterdict_dunder_repr(): + data = {"a": 100, "b": {"c": 200, "d": 300}, "e": [400, "foo" + "bar"]} + flat_dict = FlatterDict(data, delimiter=".") + + # Test that the __repr__ method returns a string representation of the FlatterDict + expected_repr = f"" + assert expected_repr == repr(flat_dict) + + +@pytest.mark.order("second") +@pytest.mark.dependency +def test_flatterdict_dunder_setitem(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatterdict_dunder_equality.__name__, + test_flatterdict_dunder_getitem.__name__, + ], + scope="module", + ) + + data: dict[str, Any] = {"a": 1, "b": {"c": (2, 3)}, "d": ({"e": 4},), "f": {"g": 5}} + + # starts with empty FlatterDict to test assignment of new keys + flat_dict = FlatterDict(delimiter=".") + + # Test setting values for new keys + # Top level key-value + flat_dict["a"] = 3 + assert flat_dict["a"] == 3 + + # Nested dictionary key-value + flat_dict["f.g"] = 10 + assert flat_dict["f.g"] == 10 + + # Insert Internal key value of a dict that is of an internal sequence type (building types as needed) + flat_dict["d.0.e"] = 8 + assert flat_dict["d.0.e"] == 8 + + # key-value with sequence value + flat_dict["b.c"] = (1, 2, 3) + assert flat_dict["b.c"] == (1, 2, 3) + + # Test updating values for existing keys + # Overwrites existing top level key-value + flat_dict["a"] = data["a"] + assert flat_dict["a"] == data["a"] + + # Overwrites existing nested key-value + flat_dict["f.g"] = data["f"]["g"] + assert flat_dict["f.g"] == data["f"]["g"] + + # Overwrites existing internal sequence key-value + flat_dict["d.0.e"] = data["d"][0]["e"] + assert flat_dict["d.0.e"] == data["d"][0]["e"] + + # Overwrites existing key-value with sequence value + flat_dict["b.c"] = data["b"]["c"] + assert flat_dict["b.c"] == data["b"]["c"] + + # Insertions built expected data structure + assert flat_dict == data + + # Type Change Tests + # ----------------- + + # Test assignment over existing non-dict keys + flat_dict["f"] = 5 + assert flat_dict["f"] == 5 + + # Test dict assignment over existing non-dict keys + flat_dict["f"] = data["f"] + assert flat_dict["f.g"] == data["f"]["g"] + + # Test handling of non-dict assignment into existing sequence + flat_dict["d.0"] = 7 + assert flat_dict["d.0"] == 7 + + # Test handling of dict assignment into existing sequence + flat_dict["d.0"] = data["d"][0] + assert flat_dict["d.0.e"] == data["d"][0]["e"] + + # Test handling of non-sequence assignment to replace existing sequence + flat_dict["b.c"] = 9 + assert flat_dict["b.c"] == 9 + + # Test handling of sequence assignment to replace existing non-sequence + flat_dict["b.c"] = data["b"]["c"] + assert flat_dict["b.c"] == data["b"]["c"] + + # Modifications changed and returned expected data structure + assert flat_dict == data + + # Test insertions to ending of existing sequence + flat_dict[f"b.c.{len(data['b']['c'])}"] = 10 + assert flat_dict["b.c"] == (*data["b"]["c"], 10) + + # Test insertions to beyond the end of existing sequence + flat_dict[f"b.c.{len(data['b']['c']) + 2}"] = 12 + assert flat_dict["b.c"] == (*data["b"]["c"], 10, None, 12) + + # Test insertions into existing empty sequence + flat_dict["h"] = [] + flat_dict["h.0"] = 1 + assert flat_dict["h.0"] == 1 + + with pytest.raises( + KeyError, match="Cannot set key on sequence with non-integer key" + ): + flat_dict["h.a"] = 2 + + # Test __setitem__ with a FlatterDict value + flat_dict["j"] = FlatterDict({"k": 100}) + assert flat_dict["j.k"] == 100 + + # Working with a Sequence-based FlatterDict + # ----------------------------------------- + data_list: list[dict[str, Any]] = [{"a": 1}, {"b": 2}] + flat_dict = FlatterDict([], delimiter=".") + + # Test setting values for new keys in a FlatterDict initialized from a Sequence + flat_dict["0.a"] = 3 + assert flat_dict["0.a"] == 3 + + # Test appending to an existing sequence via key + flat_dict["1"] = {"b": 10} + assert flat_dict["1.b"] == 10 + + # Test overwriting an existing sequence with a non-sequence value + flat_dict["1"] = 5 + assert flat_dict["1"] == 5 + + # Test overwriting an existing non-sequence value with a sequence + flat_dict["1"] = data_list[1] + assert flat_dict["1.b"] == data_list[1]["b"] + + # Test modifying into an existing dictionary value with a sequence assignment + flat_dict["0.a.c"] = 10 + assert flat_dict["0.a.c"] == 10 + assert flat_dict["0.a"] == {"c": 10} + + # Test modifying into an existing dictionary value with a non-sequence assignment + flat_dict["0.a"] = data_list[0]["a"] + assert flat_dict["0.a"] == data_list[0]["a"] + + # Test that modifications to a FlatterDict initialized from a Sequence built the expected data structure + assert flat_dict == data_list + + with pytest.raises( + KeyError, match="Cannot set key on sequence with non-integer key" + ): + flat_dict["a"] = 2 + + +def test_flatterdict_dunder_str(): + data = {"a": 100, "b": {"c": 200, "d": 300}, "e": [400, "foo" + "bar"]} + flat_dict = FlatterDict(data, delimiter=".") + + # Test that the __str__ method returns a string representation of the FlatterDict + expected_str = "{'a': 100, 'b.c': 200, 'b.d': 300, 'e.0': 400, 'e.1': 'foobar'}" + assert expected_str == str(flat_dict) + + +def test_flatterdict_pickle(request: pytest.FixtureRequest): + if not str(request.config.getoption("-k")): + depends( + request, + [ + test_flatterdict_dunder_equality.__name__, + test_flatterdict_dunder_getitem.__name__, + ], + scope="module", + ) + + import pickle + + data = {"a": 100, "b": {"c": 200, "d": 300}, "e": [400, "foo" + "bar"]} + flat_dict = FlatterDict(data) + + # Test that a FlatterDict instance can be pickled and unpickled correctly + pickled = pickle.dumps(flat_dict) + unpickled = pickle.loads(pickled) + + # Evaluate (Expected -> Actual) + assert flat_dict == unpickled + assert flat_dict is not unpickled + assert flat_dict["a"] == unpickled["a"] + assert flat_dict["b"] == unpickled["b"] + assert flat_dict["b"] is not unpickled["b"] + assert flat_dict["b"]["c"] == unpickled["b"]["c"] + assert flat_dict["b"]["d"] == unpickled["b"]["d"] + assert flat_dict["e"] == unpickled["e"] + assert flat_dict["e"] is not unpickled["e"] + assert flat_dict["e"][0] == unpickled["e"][0] + assert flat_dict["e"][1] == unpickled["e"][1]