update:charmed-kubernetes-657
This commit is contained in:
parent
114deb7b24
commit
11144404cd
|
|
@ -0,0 +1,92 @@
|
|||
name: Test Suite for CoreDNS
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
lint-and-unit-tests:
|
||||
name: Lint & Unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.8
|
||||
- name: Install Tox
|
||||
run: pip install tox
|
||||
- name: Run lint & unit tests
|
||||
run: tox
|
||||
|
||||
func-test:
|
||||
name: Functional test with MicroK8s
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.8
|
||||
- name: Fix global gitconfig for confined snap
|
||||
run: |
|
||||
# GH automatically includes the git-lfs plugin and configures it in
|
||||
# /etc/gitconfig. However, the confinement of the charmcraft snap
|
||||
# means that it can see that this file exists but cannot read it, even
|
||||
# if the file permissions should allow it; this breaks git usage within
|
||||
# the snap. To get around this, we move it from the global gitconfig to
|
||||
# the user's .gitconfig file.
|
||||
cat /etc/gitconfig >> $HOME/.gitconfig
|
||||
sudo rm /etc/gitconfig
|
||||
- name: Install MicroK8s
|
||||
uses: balchua/microk8s-actions@v0.1.3
|
||||
with:
|
||||
rbac: 'true'
|
||||
storage: 'true'
|
||||
dns: 'true' # required for juju, will adjust later
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
pip install tox
|
||||
sudo snap install juju --classic
|
||||
sudo snap install juju-wait --classic
|
||||
sudo usermod -aG microk8s $USER
|
||||
sudo snap install charmcraft --beta
|
||||
sudo snap install yq
|
||||
- name: Build charm
|
||||
run: |
|
||||
if ! charmcraft build; then
|
||||
echo Build failed, full log:
|
||||
cat "$(ls -1t "$HOME"/snap/charmcraft/common/charmcraft-log-* | head -n1)"
|
||||
exit 1
|
||||
fi
|
||||
- name: Bootstrap MicroK8s with Juju
|
||||
run: sg microk8s 'juju bootstrap microk8s microk8s'
|
||||
- name: Add model
|
||||
run: juju add-model coredns microk8s
|
||||
- name: Deploy CoreDNS
|
||||
run: |
|
||||
upstream_image=$(yq eval '.resources.coredns-image.upstream-source' metadata.yaml)
|
||||
juju deploy ./coredns.charm --resource coredns-image=$upstream_image --config forward=8.8.8.8
|
||||
- name: Wait for stable environment
|
||||
run: juju wait -wv
|
||||
- name: Tell MicroK8s to use CoreDNS charm
|
||||
run: |
|
||||
cluster_ip=$(sudo microk8s.kubectl get svc -n coredns coredns -o jsonpath='{..spec.clusterIP}')
|
||||
sudo sed -i -e "s/--cluster-dns=.*/--cluster-dns=$cluster_ip/" /var/snap/microk8s/current/args/kubelet
|
||||
sudo systemctl restart snap.microk8s.daemon-kubelet
|
||||
- name: Run functional test
|
||||
run: tox -e func
|
||||
- name: Juju Status
|
||||
if: failure()
|
||||
run: sudo juju status
|
||||
- name: Juju Log
|
||||
if: failure()
|
||||
run: sudo juju debug-log --replay --no-tail -i coredns
|
||||
- name: Microk8s Status
|
||||
if: failure()
|
||||
run: sudo microk8s.kubectl get all -A
|
||||
- name: Microk8s Pod Log
|
||||
if: failure()
|
||||
run: sudo microk8s.kubectl logs -n coredns -l juju-app=coredns
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.tox/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
placeholders/
|
||||
*.charm
|
||||
build/
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# Contributor Guide
|
||||
|
||||
This Juju charm is open source ([Apache License 2.0](./LICENSE)) and we actively seek any community contibutions
|
||||
for code, suggestions and documentation.
|
||||
This page details a few notes, workflows and suggestions for how to make contributions most effective and help us
|
||||
all build a better charm - please give them a read before working on any contributions.
|
||||
|
||||
## Licensing
|
||||
|
||||
This charm has been created under the [Apache License 2.0](./LICENSE), which will cover any contributions you may
|
||||
make to this project. Please familiarise yourself with the terms of the license.
|
||||
|
||||
Additionally, this charm uses the Harmony CLA agreement. It’s the easiest way for you to give us permission to
|
||||
use your contributions.
|
||||
In effect, you’re giving us a license, but you still own the copyright — so you retain the right to modify your
|
||||
code and use it in other projects. Please [sign the CLA here](https://ubuntu.com/legal/contributors/agreement) before
|
||||
making any contributions.
|
||||
|
||||
## Code of conduct
|
||||
|
||||
We have adopted the Ubuntu code of Conduct. You can read this in full [here](https://ubuntu.com/community/code-of-conduct).
|
||||
|
||||
## Contributing code
|
||||
|
||||
To contribute code to this project, please use the following workflow:
|
||||
|
||||
1. [Submit a bug](https://bugs.launchpad.net/charm-coredns/+filebug) to explain the need for and track the change.
|
||||
2. Create a branch on your fork of the repo with your changes, including a unit test covering the new or modified code.
|
||||
3. Submit a PR. The PR description should include a link to the bug on Launchpad.
|
||||
4. Update the Launchpad bug to include a link to the PR and the `review-needed` tag.
|
||||
5. Once reviewed and merged, the change will become available on the edge channel and assigned to an appropriate milestone
|
||||
for further release according to priority.
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
[[source]]
|
||||
name = "pypi"
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
|
||||
[dev-packages]
|
||||
pytest = "*"
|
||||
flake8 = "*"
|
||||
ipdb = "*"
|
||||
|
||||
[packages]
|
||||
ops = "*"
|
||||
oci-image = {git = "https://github.com/juju-solutions/resource-oci-image/"}
|
||||
|
||||
[requires]
|
||||
python_version = "3.8"
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "3a93ef1bf6ad71dacc9efebae3e194bb569d6bf8728161b19e95dbd7c407aa22"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.8"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"oci-image": {
|
||||
"git": "https://github.com/juju-solutions/resource-oci-image/",
|
||||
"ref": "c5778285d332edf3d9a538f9d0c06154b7ec1b0b"
|
||||
},
|
||||
"ops": {
|
||||
"hashes": [
|
||||
"sha256:23556db47b2c97a1bb72845b7c8ec88aa7a3e27717402903b5fea7b659616ab8",
|
||||
"sha256:d102359496584617a00f6f42525a01d1b60269a3d41788cf025738cbe3348c99"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.10.0"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:02c78d77281d8f8d07a255e57abdbf43b02257f59f50cc6b636937d68efa5dd0",
|
||||
"sha256:0dc9f2eb2e3c97640928dec63fd8dc1dd91e6b6ed236bd5ac00332b99b5c2ff9",
|
||||
"sha256:124fd7c7bc1e95b1eafc60825f2daf67c73ce7b33f1194731240d24b0d1bf628",
|
||||
"sha256:26fcb33776857f4072601502d93e1a619f166c9c00befb52826e7b774efaa9db",
|
||||
"sha256:31ba07c54ef4a897758563e3a0fcc60077698df10180abe4b8165d9895c00ebf",
|
||||
"sha256:3c49e39ac034fd64fd576d63bb4db53cda89b362768a67f07749d55f128ac18a",
|
||||
"sha256:52bf0930903818e600ae6c2901f748bc4869c0c406056f679ab9614e5d21a166",
|
||||
"sha256:5a3f345acff76cad4aa9cb171ee76c590f37394186325d53d1aa25318b0d4a09",
|
||||
"sha256:5e7ac4e0e79a53451dc2814f6876c2fa6f71452de1498bbe29c0b54b69a986f4",
|
||||
"sha256:7242790ab6c20316b8e7bb545be48d7ed36e26bbe279fd56f2c4a12510e60b4b",
|
||||
"sha256:737bd70e454a284d456aa1fa71a0b429dd527bcbf52c5c33f7c8eee81ac16b89",
|
||||
"sha256:8635d53223b1f561b081ff4adecb828fd484b8efffe542edcfdff471997f7c39",
|
||||
"sha256:8b818b6c5a920cbe4203b5a6b14256f0e5244338244560da89b7b0f1313ea4b6",
|
||||
"sha256:8bf38641b4713d77da19e91f8b5296b832e4db87338d6aeffe422d42f1ca896d",
|
||||
"sha256:a36a48a51e5471513a5aea920cdad84cbd56d70a5057cca3499a637496ea379c",
|
||||
"sha256:b2243dd033fd02c01212ad5c601dafb44fbb293065f430b0d3dbf03f3254d615",
|
||||
"sha256:cc547d3ead3754712223abb7b403f0a184e4c3eae18c9bb7fd15adef1597cc4b",
|
||||
"sha256:cc552b6434b90d9dbed6a4f13339625dc466fd82597119897e9489c953acbc22",
|
||||
"sha256:f3790156c606299ff499ec44db422f66f05a7363b39eb9d5b064f17bd7d7c47b",
|
||||
"sha256:f7a21e3d99aa3095ef0553e7ceba36fb693998fbb1226f1392ce33681047465f",
|
||||
"sha256:fdc6b2cb4b19e431994f25a9160695cc59a4e861710cc6fc97161c5e845fc579"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==5.4"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
|
||||
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
|
||||
],
|
||||
"version": "==20.3.0"
|
||||
},
|
||||
"backcall": {
|
||||
"hashes": [
|
||||
"sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e",
|
||||
"sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"
|
||||
],
|
||||
"version": "==0.2.0"
|
||||
},
|
||||
"decorator": {
|
||||
"hashes": [
|
||||
"sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760",
|
||||
"sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"
|
||||
],
|
||||
"version": "==4.4.2"
|
||||
},
|
||||
"flake8": {
|
||||
"hashes": [
|
||||
"sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c",
|
||||
"sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.8.3"
|
||||
},
|
||||
"iniconfig": {
|
||||
"hashes": [
|
||||
"sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3",
|
||||
"sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"
|
||||
],
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"ipdb": {
|
||||
"hashes": [
|
||||
"sha256:d6f46d261c45a65e65a2f7ec69288a1c511e16206edb2875e7ec6b2f66997e78"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.13.3"
|
||||
},
|
||||
"ipython": {
|
||||
"hashes": [
|
||||
"sha256:04323f72d5b85b606330b6d7e2dc8d2683ad46c3905e955aa96ecc7a99388e70",
|
||||
"sha256:34207ffb2f653bced2bc8e3756c1db86e7d93e44ed049daae9814fed66d408ec"
|
||||
],
|
||||
"version": "==7.21.0"
|
||||
},
|
||||
"ipython-genutils": {
|
||||
"hashes": [
|
||||
"sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8",
|
||||
"sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"
|
||||
],
|
||||
"version": "==0.2.0"
|
||||
},
|
||||
"jedi": {
|
||||
"hashes": [
|
||||
"sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93",
|
||||
"sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707"
|
||||
],
|
||||
"version": "==0.18.0"
|
||||
},
|
||||
"mccabe": {
|
||||
"hashes": [
|
||||
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
|
||||
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
|
||||
],
|
||||
"version": "==0.6.1"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5",
|
||||
"sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"
|
||||
],
|
||||
"version": "==20.9"
|
||||
},
|
||||
"parso": {
|
||||
"hashes": [
|
||||
"sha256:15b00182f472319383252c18d5913b69269590616c947747bc50bf4ac768f410",
|
||||
"sha256:8519430ad07087d4c997fda3a7918f7cfa27cb58972a8c89c2a0295a1c940e9e"
|
||||
],
|
||||
"version": "==0.8.1"
|
||||
},
|
||||
"pexpect": {
|
||||
"hashes": [
|
||||
"sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937",
|
||||
"sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"
|
||||
],
|
||||
"markers": "sys_platform != 'win32'",
|
||||
"version": "==4.8.0"
|
||||
},
|
||||
"pickleshare": {
|
||||
"hashes": [
|
||||
"sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca",
|
||||
"sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"
|
||||
],
|
||||
"version": "==0.7.5"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
|
||||
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
|
||||
],
|
||||
"version": "==0.13.1"
|
||||
},
|
||||
"prompt-toolkit": {
|
||||
"hashes": [
|
||||
"sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04",
|
||||
"sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc"
|
||||
],
|
||||
"version": "==3.0.18"
|
||||
},
|
||||
"ptyprocess": {
|
||||
"hashes": [
|
||||
"sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35",
|
||||
"sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"
|
||||
],
|
||||
"version": "==0.7.0"
|
||||
},
|
||||
"py": {
|
||||
"hashes": [
|
||||
"sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3",
|
||||
"sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"
|
||||
],
|
||||
"version": "==1.10.0"
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
|
||||
"sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
|
||||
],
|
||||
"version": "==2.6.0"
|
||||
},
|
||||
"pyflakes": {
|
||||
"hashes": [
|
||||
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
|
||||
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
|
||||
],
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94",
|
||||
"sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"
|
||||
],
|
||||
"version": "==2.8.1"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
|
||||
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
|
||||
],
|
||||
"version": "==2.4.7"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:1cd09785c0a50f9af72220dd12aa78cfa49cbffc356c61eab009ca189e018a33",
|
||||
"sha256:d010e24666435b39a4cf48740b039885642b6c273a3f77be3e7e03554d2806b7"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==6.1.0"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
|
||||
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
|
||||
],
|
||||
"version": "==0.10.2"
|
||||
},
|
||||
"traitlets": {
|
||||
"hashes": [
|
||||
"sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396",
|
||||
"sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426"
|
||||
],
|
||||
"version": "==5.0.5"
|
||||
},
|
||||
"wcwidth": {
|
||||
"hashes": [
|
||||
"sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784",
|
||||
"sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"
|
||||
],
|
||||
"version": "==0.2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# CoreDNS Operator
|
||||
|
||||
[CoreDNS][] is a flexible, plugin-based DNS server, and is the recommended
|
||||
solution for providing DNS to Kubernetes services within the cluster.
|
||||
This operator enables integration with [Charmed Kubernetes][] via a
|
||||
cross-model relation and allows for more customization than provided by the
|
||||
deployment of CoreDNS provided by default by Charmed Kubernetes.
|
||||
|
||||
More information on using this operator with Charmed Kubernetes can be found
|
||||
[here](https://ubuntu.com/kubernetes/docs/cdk-addons#coredns), and bugs should
|
||||
be filed [here](https://bugs.launchpad.net/charmed-kubernetes).
|
||||
|
||||
|
||||
[CoreDNS]: https://coredns.io/
|
||||
[Charmed Kubernetes]: https://ubuntu.com/kubernetes/docs
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
type: charm
|
||||
parts:
|
||||
charm:
|
||||
build-packages: [git]
|
||||
prime:
|
||||
- ./files/*
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
options:
|
||||
domain:
|
||||
description: The local domain for cluster DNS.
|
||||
type: string
|
||||
default: cluster.local
|
||||
forward:
|
||||
description: Where to forward non-cluster addresses.
|
||||
type: string
|
||||
default: /etc/resolv.conf
|
||||
extra_servers:
|
||||
description: Any additional servers to add to the Corefile.
|
||||
type: string
|
||||
default: ''
|
||||
corefile:
|
||||
description: >-
|
||||
Configuration file to use for CoreDNS. This is interpreted as a Python
|
||||
string. Template which will be given the `domain` and `forward` configs as
|
||||
its context.
|
||||
type: string
|
||||
default: |
|
||||
.:53 {
|
||||
errors
|
||||
health {
|
||||
lameduck 5s
|
||||
}
|
||||
ready
|
||||
kubernetes ${domain} in-addr.arpa ip6.arpa {
|
||||
fallthrough in-addr.arpa ip6.arpa
|
||||
pods insecure
|
||||
}
|
||||
prometheus :9153
|
||||
forward . ${forward}
|
||||
cache 30
|
||||
loop
|
||||
reload
|
||||
loadbalance
|
||||
}
|
||||
${extra_servers}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
|
||||
JUJU_DISPATCH_PATH="${JUJU_DISPATCH_PATH:-$0}" PYTHONPATH=lib:venv ./src/charm.py
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
|
||||
JUJU_DISPATCH_PATH="${JUJU_DISPATCH_PATH:-$0}" PYTHONPATH=lib:venv ./src/charm.py
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
|
||||
JUJU_DISPATCH_PATH="${JUJU_DISPATCH_PATH:-$0}" PYTHONPATH=lib:venv ./src/charm.py
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
|
||||
JUJU_DISPATCH_PATH="${JUJU_DISPATCH_PATH:-$0}" PYTHONPATH=lib:venv ./src/charm.py
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.7 KiB |
|
|
@ -0,0 +1,21 @@
|
|||
name: coredns
|
||||
summary: CoreDNS
|
||||
maintainers:
|
||||
- Cory Johns <cory.johns@canonical.com>
|
||||
description: |
|
||||
CoreDNS provides DNS resolution for Kubernetes.
|
||||
tags:
|
||||
- networking
|
||||
series:
|
||||
- kubernetes
|
||||
provides:
|
||||
dns-provider:
|
||||
interface: kube-dns
|
||||
requires: {}
|
||||
peers: {}
|
||||
resources:
|
||||
coredns-image:
|
||||
type: oci-image
|
||||
description: 'CoreDNS image'
|
||||
upstream-source: coredns/coredns:1.6.7
|
||||
min-juju-version: 2.8.2
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-i https://pypi.org/simple
|
||||
git+https://github.com/juju-solutions/resource-oci-image/@c5778285d332edf3d9a538f9d0c06154b7ec1b0b#egg=oci-image
|
||||
ops==0.10.0
|
||||
pyyaml==5.3.1
|
||||
|
|
@ -0,0 +1 @@
|
|||
0
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import logging
|
||||
from string import Template
|
||||
|
||||
from ops.charm import CharmBase
|
||||
from ops.main import main
|
||||
from ops.model import ActiveStatus, MaintenanceStatus, WaitingStatus
|
||||
|
||||
from oci_image import OCIImageResource, OCIImageResourceError
|
||||
|
||||
|
||||
class CoreDNSCharm(CharmBase):
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
if not self.unit.is_leader():
|
||||
# We can't do anything useful when not the leader, so do nothing.
|
||||
self.model.unit.status = WaitingStatus('Waiting for leadership')
|
||||
return
|
||||
self.log = logging.getLogger(__name__)
|
||||
self.image = OCIImageResource(self, 'coredns-image')
|
||||
for event in [self.on.install,
|
||||
self.on.leader_elected,
|
||||
self.on.upgrade_charm,
|
||||
self.on.config_changed]:
|
||||
self.framework.observe(event, self.main)
|
||||
self.framework.observe(self.on.dns_provider_relation_joined, self.provide_dns)
|
||||
|
||||
def main(self, event):
|
||||
try:
|
||||
image_details = self.image.fetch()
|
||||
except OCIImageResourceError as e:
|
||||
self.model.unit.status = e.status
|
||||
return
|
||||
|
||||
self.model.unit.status = MaintenanceStatus('Setting pod spec')
|
||||
|
||||
corefile = Template(self.model.config['corefile'])
|
||||
corefile = corefile.safe_substitute(self.model.config)
|
||||
|
||||
# Adapted from coredns.yaml.sed in https://github.com/coredns/ at 75a1cad
|
||||
self.model.pod.set_spec({
|
||||
'version': 3,
|
||||
'service': {
|
||||
'updateStrategy': {
|
||||
'type': 'RollingUpdate',
|
||||
'rollingUpdate': {'maxUnavailable': 1},
|
||||
},
|
||||
'annotations': {
|
||||
'prometheus.io/port': "9153",
|
||||
'prometheus.io/scrape': "true",
|
||||
},
|
||||
},
|
||||
# Dropped by a regression; see:
|
||||
# https://bugs.launchpad.net/juju/+bug/1895886
|
||||
# 'priorityClassName': 'system-cluster-critical',
|
||||
'containers': [{
|
||||
'name': 'coredns',
|
||||
'imageDetails': image_details,
|
||||
'imagePullPolicy': 'IfNotPresent',
|
||||
'args': ['-conf', '/etc/coredns/Corefile'],
|
||||
'volumeConfig': [{
|
||||
'name': 'config-volume',
|
||||
'mountPath': '/etc/coredns',
|
||||
# Not supported
|
||||
# 'readOnly': True,
|
||||
'files': [{
|
||||
'path': 'Corefile',
|
||||
'mode': 0o444,
|
||||
'content': corefile,
|
||||
}],
|
||||
}],
|
||||
'ports': [
|
||||
{
|
||||
'name': 'dns',
|
||||
'containerPort': 53,
|
||||
'protocol': 'UDP',
|
||||
},
|
||||
{
|
||||
'name': 'dns-tcp',
|
||||
'containerPort': 53,
|
||||
'protocol': 'TCP',
|
||||
},
|
||||
{
|
||||
'name': 'metrics',
|
||||
'containerPort': 9153,
|
||||
'protocol': 'TCP',
|
||||
},
|
||||
],
|
||||
# Can't be specified by the charm yet; see:
|
||||
# https://bugs.launchpad.net/juju/+bug/1893123
|
||||
# 'resources': {
|
||||
# 'limits': {'memory': '170Mi'},
|
||||
# 'requests': {'cpu': '100m', 'memory': '70Mi'},
|
||||
# },
|
||||
'kubernetes': {
|
||||
'securityContext': {
|
||||
'allowPrivilegeEscalation': False,
|
||||
'capabilities': {
|
||||
'add': ['NET_BIND_SERVICE'],
|
||||
'drop': ['all'],
|
||||
},
|
||||
'readOnlyRootFilesystem': True,
|
||||
},
|
||||
'livenessProbe': {
|
||||
'httpGet': {
|
||||
'path': '/health',
|
||||
'port': 8080,
|
||||
'scheme': 'HTTP',
|
||||
},
|
||||
'initialDelaySeconds': 60,
|
||||
'timeoutSeconds': 5,
|
||||
'successThreshold': 1,
|
||||
'failureThreshold': 5,
|
||||
},
|
||||
'readinessProbe': {
|
||||
'httpGet': {
|
||||
'path': '/ready',
|
||||
'port': 8181,
|
||||
'scheme': 'HTTP',
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
'serviceAccount': {
|
||||
'roles': [{
|
||||
'global': True,
|
||||
'rules': [
|
||||
{
|
||||
'apigroups': ['discovery.k8s.io'],
|
||||
'resources': [
|
||||
'endpointslices',
|
||||
],
|
||||
'verbs': ['list', 'watch'],
|
||||
},
|
||||
{
|
||||
'apigroups': [''],
|
||||
'resources': [
|
||||
'endpoints',
|
||||
'services',
|
||||
'pods',
|
||||
'namespaces',
|
||||
],
|
||||
'verbs': ['list', 'watch'],
|
||||
},
|
||||
{
|
||||
'apigroups': [''],
|
||||
'resources': ['nodes'],
|
||||
'verbs': ['get'],
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
'kubernetesResources': {
|
||||
'pod': {
|
||||
'dnsPolicy': 'Default',
|
||||
# Not yet supported by Juju; see:
|
||||
# https://bugs.launchpad.net/juju/+bug/1895887
|
||||
# 'tolerations': [{
|
||||
# 'key': 'CriticalAddonsOnly',
|
||||
# 'operator': 'Exists',
|
||||
# }],
|
||||
# 'affinity': {
|
||||
# 'podAntiAffinity': {
|
||||
# 'preferredDuringScheduling' +
|
||||
# 'IgnoredDuringExecution': [{
|
||||
# 'weight': 100,
|
||||
# 'podAffinityTerm': {
|
||||
# 'labelSelector': {
|
||||
# 'matchExpressions': [{
|
||||
# 'key': 'k8s-app',
|
||||
# 'operator': 'In',
|
||||
# 'values': ["kube-dns"],
|
||||
# }],
|
||||
# },
|
||||
# 'topologyKey': 'kubernetes.io/hostname',
|
||||
# },
|
||||
# }],
|
||||
# },
|
||||
# },
|
||||
# Can be done by the operator via placement (--to), but can't
|
||||
# be specified by the charm yet, per same bug as above.
|
||||
# 'nodeSelector': {
|
||||
# 'kubernetes.io/os': 'linux',
|
||||
# },
|
||||
}
|
||||
}
|
||||
})
|
||||
self.model.unit.status = ActiveStatus()
|
||||
|
||||
def provide_dns(self, event):
|
||||
provided_data = event.relation.data[self.unit]
|
||||
if not provided_data.get('ingress-address'):
|
||||
event.defer()
|
||||
return
|
||||
provided_data.update({
|
||||
'domain': self.model.config['domain'],
|
||||
'sdn-ip': str(provided_data['ingress-address']),
|
||||
'port': "53",
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(CoreDNSCharm)
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
CHARM_DIR = Path(__file__).parent.parent.parent.resolve()
|
||||
SPEC_FILE = Path(__file__).parent / 'validate-dns-spec.yaml'
|
||||
|
||||
|
||||
def test_charm():
|
||||
model = run('juju', 'switch').split('/')[-1]
|
||||
coredns_ready = run(
|
||||
'kubectl', 'get', 'pod', '-n', model, '-l', 'juju-app=coredns',
|
||||
'-o', 'jsonpath={..status.containerStatuses[0].ready}')
|
||||
assert coredns_ready == 'true'
|
||||
run('kubectl', 'apply', '-f', SPEC_FILE)
|
||||
try:
|
||||
wait_for_output('kubectl', 'get', 'pod/validate-dns',
|
||||
expected='Running')
|
||||
for name in ("www.ubuntu.com", "kubernetes.default.svc.cluster.local"):
|
||||
run('kubectl', 'exec', 'validate-dns', '--', 'nslookup', name)
|
||||
finally:
|
||||
run('kubectl', 'delete', '-f', SPEC_FILE)
|
||||
|
||||
|
||||
def run(*args):
|
||||
args = [str(a) for a in args]
|
||||
try:
|
||||
res = subprocess.run(args,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
return res.stdout.decode('utf8').strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
pytest.fail(f'Command {args} failed ({e.returncode}):\n'
|
||||
f'stdout:\n{e.stdout.decode("utf8")}\n'
|
||||
f'stderr:\n{e.stderr.decode("utf8")}\n')
|
||||
|
||||
|
||||
def wait_for_output(*args, expected='', timeout=3 * 60):
|
||||
args = [str(a) for a in args]
|
||||
output = None
|
||||
for attempt in range(int(timeout / 5)):
|
||||
output = run(*args)
|
||||
if expected in output:
|
||||
break
|
||||
sleep(5)
|
||||
else:
|
||||
pytest.fail(f'Timed out waiting for "{expected}" from {args}:\n{output}')
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: validate-dns
|
||||
spec:
|
||||
containers:
|
||||
- name: busybox
|
||||
image: busybox
|
||||
imagePullPolicy: IfNotPresent
|
||||
args: ['sleep', '3600']
|
||||
restartPolicy: Always
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import pytest
|
||||
|
||||
from ops.model import ActiveStatus, BlockedStatus, WaitingStatus
|
||||
from ops.testing import Harness
|
||||
import yaml
|
||||
|
||||
from charm import CoreDNSCharm
|
||||
|
||||
|
||||
if yaml.__with_libyaml__:
|
||||
_DefaultDumper = yaml.CSafeDumper
|
||||
else:
|
||||
_DefaultDumper = yaml.SafeDumper
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness():
|
||||
return Harness(CoreDNSCharm)
|
||||
|
||||
|
||||
def test_not_leader(harness):
|
||||
harness.begin()
|
||||
assert isinstance(harness.charm.model.unit.status, WaitingStatus)
|
||||
|
||||
|
||||
def test_missing_image(harness):
|
||||
harness.set_leader(True)
|
||||
harness.begin_with_initial_hooks()
|
||||
assert isinstance(harness.charm.model.unit.status, BlockedStatus)
|
||||
|
||||
|
||||
def test_main(harness):
|
||||
harness.set_leader(True)
|
||||
harness.add_oci_resource('coredns-image', {
|
||||
'registrypath': 'coredns/coredns:1.6.7',
|
||||
'username': '',
|
||||
'password': '',
|
||||
})
|
||||
harness.begin_with_initial_hooks()
|
||||
assert isinstance(harness.charm.model.unit.status, ActiveStatus)
|
||||
# confirm that we can serialize the pod spec
|
||||
yaml.dump(harness.get_pod_spec(), Dumper=_DefaultDumper)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
[flake8]
|
||||
max-line-length = 88
|
||||
|
||||
[tox]
|
||||
skipsdist = True
|
||||
envlist = lint,unit
|
||||
|
||||
[testenv]
|
||||
basepython = python3
|
||||
setenv =
|
||||
PYTHONPATH={toxinidir}/src
|
||||
PYTHONBREAKPOINT=ipdb.set_trace
|
||||
passenv = HOME
|
||||
deps = pipenv
|
||||
commands =
|
||||
pipenv install --dev --ignore-pipfile
|
||||
pipenv run pytest --tb native -s {posargs:tests/unit}
|
||||
|
||||
[testenv:lint]
|
||||
commands =
|
||||
pipenv install --dev --ignore-pipfile
|
||||
pipenv run flake8 {toxinidir}/src {toxinidir}/tests
|
||||
|
||||
[testenv:func]
|
||||
commands =
|
||||
pipenv install --dev --ignore-pipfile
|
||||
pipenv run pytest --tb native -s {posargs:tests/func}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pip
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
Copyright (c) 2017-2020 Ingy döt Net
|
||||
Copyright (c) 2006-2016 Kirill Simonov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
Metadata-Version: 2.1
|
||||
Name: PyYAML
|
||||
Version: 5.3.1
|
||||
Summary: YAML parser and emitter for Python
|
||||
Home-page: https://github.com/yaml/pyyaml
|
||||
Author: Kirill Simonov
|
||||
Author-email: xi@resolvent.net
|
||||
License: MIT
|
||||
Download-URL: https://pypi.org/project/PyYAML/
|
||||
Platform: Any
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Operating System :: OS Independent
|
||||
Classifier: Programming Language :: Cython
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 2
|
||||
Classifier: Programming Language :: Python :: 2.7
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3.5
|
||||
Classifier: Programming Language :: Python :: 3.6
|
||||
Classifier: Programming Language :: Python :: 3.7
|
||||
Classifier: Programming Language :: Python :: 3.8
|
||||
Classifier: Programming Language :: Python :: Implementation :: CPython
|
||||
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
||||
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
||||
Classifier: Topic :: Text Processing :: Markup
|
||||
Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*
|
||||
|
||||
YAML is a data serialization format designed for human readability
|
||||
and interaction with scripting languages. PyYAML is a YAML parser
|
||||
and emitter for Python.
|
||||
|
||||
PyYAML features a complete YAML 1.1 parser, Unicode support, pickle
|
||||
support, capable extension API, and sensible error messages. PyYAML
|
||||
supports standard YAML tags and provides Python-specific tags that
|
||||
allow to represent an arbitrary Python object.
|
||||
|
||||
PyYAML is applicable for a broad range of tasks from complex
|
||||
configuration files to object serialization and persistence.
|
||||
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
PyYAML-5.3.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
PyYAML-5.3.1.dist-info/LICENSE,sha256=xAESRJ8lS5dTBFklJIMT6ScO-jbSJrItgtTMbEPFfyk,1101
|
||||
PyYAML-5.3.1.dist-info/METADATA,sha256=xTsZFjd8T4M-5rC2M3BHgx_KTTpEPy5vFDIXrbzRXPQ,1758
|
||||
PyYAML-5.3.1.dist-info/RECORD,,
|
||||
PyYAML-5.3.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
PyYAML-5.3.1.dist-info/WHEEL,sha256=hzx2-39jWfx-No5BPGm7YN661ryRYBuLP8gZdbxDo8I,103
|
||||
PyYAML-5.3.1.dist-info/top_level.txt,sha256=rpj0IVMTisAjh_1vG3Ccf9v5jpCQwAz6cD1IVU5ZdhQ,11
|
||||
yaml/__init__.py,sha256=XFUNbKTg4afAd0BETjGQ1mKQ97_g5jbE1C0WoKc74dc,13170
|
||||
yaml/__pycache__/__init__.cpython-38.pyc,,
|
||||
yaml/__pycache__/composer.cpython-38.pyc,,
|
||||
yaml/__pycache__/constructor.cpython-38.pyc,,
|
||||
yaml/__pycache__/cyaml.cpython-38.pyc,,
|
||||
yaml/__pycache__/dumper.cpython-38.pyc,,
|
||||
yaml/__pycache__/emitter.cpython-38.pyc,,
|
||||
yaml/__pycache__/error.cpython-38.pyc,,
|
||||
yaml/__pycache__/events.cpython-38.pyc,,
|
||||
yaml/__pycache__/loader.cpython-38.pyc,,
|
||||
yaml/__pycache__/nodes.cpython-38.pyc,,
|
||||
yaml/__pycache__/parser.cpython-38.pyc,,
|
||||
yaml/__pycache__/reader.cpython-38.pyc,,
|
||||
yaml/__pycache__/representer.cpython-38.pyc,,
|
||||
yaml/__pycache__/resolver.cpython-38.pyc,,
|
||||
yaml/__pycache__/scanner.cpython-38.pyc,,
|
||||
yaml/__pycache__/serializer.cpython-38.pyc,,
|
||||
yaml/__pycache__/tokens.cpython-38.pyc,,
|
||||
yaml/composer.py,sha256=_Ko30Wr6eDWUeUpauUGT3Lcg9QPBnOPVlTnIMRGJ9FM,4883
|
||||
yaml/constructor.py,sha256=O3Uaf0_J_5GQBoeI9ZNhpJAhtdagr_X2HzDgGbZOMnw,28627
|
||||
yaml/cyaml.py,sha256=LiMkvchNonfoy1F6ec9L2BiUz3r0bwF4hympASJX1Ic,3846
|
||||
yaml/dumper.py,sha256=PLctZlYwZLp7XmeUdwRuv4nYOZ2UBnDIUy8-lKfLF-o,2837
|
||||
yaml/emitter.py,sha256=jghtaU7eFwg31bG0B7RZea_29Adi9CKmXq_QjgQpCkQ,43006
|
||||
yaml/error.py,sha256=Ah9z-toHJUbE9j-M8YpxgSRM5CgLCcwVzJgLLRF2Fxo,2533
|
||||
yaml/events.py,sha256=50_TksgQiE4up-lKo_V-nBy-tAIxkIPQxY5qDhKCeHw,2445
|
||||
yaml/loader.py,sha256=UVa-zIqmkFSCIYq_PgSGm4NSJttHY2Rf_zQ4_b1fHN0,2061
|
||||
yaml/nodes.py,sha256=gPKNj8pKCdh2d4gr3gIYINnPOaOxGhJAUiYhGRnPE84,1440
|
||||
yaml/parser.py,sha256=ilWp5vvgoHFGzvOZDItFoGjD6D42nhlZrZyjAwa0oJo,25495
|
||||
yaml/reader.py,sha256=0dmzirOiDG4Xo41RnuQS7K9rkY3xjHiVasfDMNTqCNw,6794
|
||||
yaml/representer.py,sha256=82UM3ZxUQKqsKAF4ltWOxCS6jGPIFtXpGs7mvqyv4Xs,14184
|
||||
yaml/resolver.py,sha256=DJCjpQr8YQCEYYjKEYqTl0GrsZil2H4aFOI9b0Oe-U4,8970
|
||||
yaml/scanner.py,sha256=KeQIKGNlSyPE8QDwionHxy9CgbqE5teJEz05FR9-nAg,51277
|
||||
yaml/serializer.py,sha256=ChuFgmhU01hj4xgI8GaKv6vfM2Bujwa9i7d2FAHj7cA,4165
|
||||
yaml/tokens.py,sha256=lTQIzSVw8Mg9wv459-TjiOQe6wVziqaRlqX2_89rp54,2573
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
Wheel-Version: 1.0
|
||||
Generator: bdist_wheel (0.36.2)
|
||||
Root-Is-Purelib: false
|
||||
Tag: cp38-cp38-linux_x86_64
|
||||
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
_yaml
|
||||
yaml
|
||||
|
|
@ -0,0 +1 @@
|
|||
pip
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
Metadata-Version: 2.1
|
||||
Name: oci-image
|
||||
Version: 1.0.0
|
||||
Summary: Helper for dealing with OCI Image resources in the charm operator framework
|
||||
Home-page: https://github.com/juju-solutions/resource-oci-image
|
||||
Author: Cory Johns
|
||||
Author-email: johnsca@gmail.com
|
||||
License: Apache License 2.0
|
||||
Platform: UNKNOWN
|
||||
|
||||
# OCI Image Resource helper
|
||||
|
||||
This is a helper for working with OCI image resources in the charm operator
|
||||
framework.
|
||||
|
||||
## Installation
|
||||
|
||||
Add it to your `requirements.txt`. Since it's not in PyPI, you'll need to use
|
||||
the GitHub archive URL (or `git+` URL, if you want to pin to a specific commit):
|
||||
|
||||
```
|
||||
https://github.com/juju-solutions/resource-oci-image/archive/master.zip
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The `OCIImageResource` class will wrap the framework resource for the given
|
||||
resource name, and calling `fetch` on it will either return the image info
|
||||
or raise an `OCIImageResourceError` if it can't fetch or parse the image
|
||||
info. The exception will have a `status` attribute you can use directly,
|
||||
or a `status_message` attribute if you just want that.
|
||||
|
||||
Example usage:
|
||||
|
||||
```python
|
||||
from ops.charm import CharmBase
|
||||
from ops.main import main
|
||||
from oci_image import OCIImageResource, OCIImageResourceError
|
||||
|
||||
class MyCharm(CharmBase):
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
self.image = OCIImageResource(self, 'resource-name')
|
||||
self.framework.observe(self.on.start, self.on_start)
|
||||
|
||||
def on_start(self, event):
|
||||
try:
|
||||
image_info = self.image.fetch()
|
||||
except OCIImageResourceError as e:
|
||||
self.model.unit.status = e.status
|
||||
event.defer()
|
||||
return
|
||||
|
||||
self.model.pod.set_spec({'containers': [{
|
||||
'name': 'my-charm',
|
||||
'imageDetails': image_info,
|
||||
}]})
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(MyCharm)
|
||||
```
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
__pycache__/oci_image.cpython-38.pyc,,
|
||||
oci_image-1.0.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
oci_image-1.0.0.dist-info/METADATA,sha256=QIpPa4JcSPa_Ci0n-DaCNp4PkKovZudFW8FnpnauJnQ,1808
|
||||
oci_image-1.0.0.dist-info/RECORD,,
|
||||
oci_image-1.0.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
oci_image-1.0.0.dist-info/WHEEL,sha256=OqRkF0eY5GHssMorFjlbTIq072vpHpF60fIQA6lS9xA,92
|
||||
oci_image-1.0.0.dist-info/direct_url.json,sha256=sUsaIeKXs7oqCE-NdmqTsNJ8rmr97YMi0wuRNVObj0Y,215
|
||||
oci_image-1.0.0.dist-info/top_level.txt,sha256=M4dLaObLx7irI4EO-A4_VJP_b-A6dDD7hB5QyVKdHOY,10
|
||||
oci_image.py,sha256=c75VR2vSmOp9pPTP2cnsxo23CqhhFbRtnIOtMjzDyXY,1794
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
Wheel-Version: 1.0
|
||||
Generator: bdist_wheel (0.36.2)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"url": "https://github.com/juju-solutions/resource-oci-image/", "vcs_info": {"commit_id": "c5778285d332edf3d9a538f9d0c06154b7ec1b0b", "requested_revision": "c5778285d332edf3d9a538f9d0c06154b7ec1b0b", "vcs": "git"}}
|
||||
|
|
@ -0,0 +1 @@
|
|||
oci_image
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from ops.framework import Object
|
||||
from ops.model import BlockedStatus, ModelError
|
||||
|
||||
|
||||
class OCIImageResource(Object):
|
||||
def __init__(self, charm, resource_name):
|
||||
super().__init__(charm, resource_name)
|
||||
self.resource_name = resource_name
|
||||
|
||||
def fetch(self):
|
||||
try:
|
||||
resource_path = self.model.resources.fetch(self.resource_name)
|
||||
except ModelError as e:
|
||||
raise MissingResourceError(self.resource_name) from e
|
||||
if not resource_path.exists():
|
||||
raise MissingResourceError(self.resource_name)
|
||||
resource_text = Path(resource_path).read_text()
|
||||
if not resource_text:
|
||||
raise MissingResourceError(self.resource_name)
|
||||
try:
|
||||
resource_data = yaml.safe_load(resource_text)
|
||||
except yaml.YAMLError as e:
|
||||
raise InvalidResourceError(self.resource_name) from e
|
||||
else:
|
||||
# Translate the data from the format used by the charm store to the
|
||||
# format used by the Juju K8s pod spec, since that is how this is
|
||||
# typically used.
|
||||
return {
|
||||
'imagePath': resource_data['registrypath'],
|
||||
'username': resource_data['username'],
|
||||
'password': resource_data['password'],
|
||||
}
|
||||
|
||||
|
||||
class OCIImageResourceError(ModelError):
|
||||
status_type = BlockedStatus
|
||||
status_message = 'Resource error'
|
||||
|
||||
def __init__(self, resource_name):
|
||||
super().__init__(resource_name)
|
||||
self.status = self.status_type(
|
||||
f'{self.status_message}: {resource_name}')
|
||||
|
||||
|
||||
class MissingResourceError(OCIImageResourceError):
|
||||
status_message = 'Missing resource'
|
||||
|
||||
|
||||
class InvalidResourceError(OCIImageResourceError):
|
||||
status_message = 'Invalid resource'
|
||||
|
|
@ -0,0 +1 @@
|
|||
pip
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
Metadata-Version: 2.1
|
||||
Name: ops
|
||||
Version: 0.10.0
|
||||
Summary: The Python library behind great charms
|
||||
Home-page: https://github.com/canonical/operator
|
||||
Author: The Charmcraft team at Canonical Ltd.
|
||||
Author-email: charmcraft@lists.launchpad.net
|
||||
License: Apache-2.0
|
||||
Platform: UNKNOWN
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: License :: OSI Approved :: Apache Software License
|
||||
Classifier: Development Status :: 4 - Beta
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: Intended Audience :: System Administrators
|
||||
Classifier: Operating System :: MacOS :: MacOS X
|
||||
Classifier: Operating System :: POSIX :: Linux
|
||||
Requires-Python: >=3.5
|
||||
Description-Content-Type: text/markdown
|
||||
Requires-Dist: PyYAML
|
||||
|
||||
# The Operator Framework
|
||||
|
||||
The Operator Framework provides a simple, lightweight, and powerful way of
|
||||
writing Juju charms, the best way to encapsulate operational experience in code.
|
||||
|
||||
The framework will help you to:
|
||||
|
||||
* model the integration of your services
|
||||
* manage the lifecycle of your application
|
||||
* create reusable and scalable components
|
||||
* keep your code simple and readable
|
||||
|
||||
## Getting Started
|
||||
|
||||
Charms written using the operator framework are just Python code. The intention
|
||||
is for it to feel very natural for somebody used to coding in Python, and
|
||||
reasonably easy to pick up for somebody who might be a domain expert but not
|
||||
necessarily a pythonista themselves.
|
||||
|
||||
The dependencies of the operator framework are kept as minimal as possible;
|
||||
currently that's Python 3.5 or greater, and `PyYAML` (both are included by
|
||||
default in Ubuntu's cloud images from 16.04 on).
|
||||
|
||||
<!--
|
||||
If you're new to the world of Juju and charms, you should probably dive into our
|
||||
[tutorial](/TBD).
|
||||
|
||||
If you know about Juju, and have written charms that didn't use the operator
|
||||
framework (be it with reactive or without), we have an [introduction to the
|
||||
operator framework](/TBD) just for you.
|
||||
|
||||
If you've gone through the above already and just want a refresher, or are
|
||||
really impatient and need to dive in, feel free to carry on down.
|
||||
-->
|
||||
## A Quick Introduction
|
||||
|
||||
Operator framework charms are just Python code. The entry point to your charm is
|
||||
a particular Python file. It could be anything that makes sense to your project,
|
||||
but let's assume this is `src/charm.py`. This file must be executable (and it
|
||||
must have the appropriate shebang line).
|
||||
|
||||
You need the usual `metadata.yaml` and (probably) `config.yaml` files, and a
|
||||
`requirements.txt` for any Python dependencies. In other words, your project
|
||||
might look like this:
|
||||
|
||||
```
|
||||
my-charm
|
||||
├── config.yaml
|
||||
├── metadata.yaml
|
||||
├── requirements.txt
|
||||
└── src/
|
||||
└── charm.py
|
||||
```
|
||||
|
||||
`src/charm.py` here is the entry point to your charm code. At a minimum, it
|
||||
needs to define a subclass of `CharmBase` and pass that into the framework's
|
||||
`main` function:
|
||||
|
||||
```python
|
||||
from ops.charm import CharmBase
|
||||
from ops.main import main
|
||||
|
||||
class MyCharm(CharmBase):
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
self.framework.observe(self.on.start, self.on_start)
|
||||
|
||||
def on_start(self, event):
|
||||
# Handle the start event here.
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(MyCharm)
|
||||
```
|
||||
|
||||
That should be enough for you to be able to run
|
||||
|
||||
```
|
||||
$ charmcraft build
|
||||
Done, charm left in 'my-charm.charm'
|
||||
$ juju deploy ./my-charm.charm
|
||||
```
|
||||
|
||||
> 🛈 More information on [`charmcraft`](https://pypi.org/project/charmcraft/) can
|
||||
> also be found on its [github page](https://github.com/canonical/charmcraft).
|
||||
|
||||
Happy charming!
|
||||
|
||||
## Testing your charms
|
||||
|
||||
The operator framework provides a testing harness, so that you can test that
|
||||
your charm does the right thing when presented with different scenarios, without
|
||||
having to have a full deployment to do so. `pydoc3 ops.testing` has the details
|
||||
for that, including this example:
|
||||
|
||||
```python
|
||||
harness = Harness(MyCharm)
|
||||
# Do initial setup here
|
||||
relation_id = harness.add_relation('db', 'postgresql')
|
||||
# Now instantiate the charm to see events as the model changes
|
||||
harness.begin()
|
||||
harness.add_relation_unit(relation_id, 'postgresql/0')
|
||||
harness.update_relation_data(relation_id, 'postgresql/0', {'key': 'val'})
|
||||
# Check that charm has properly handled the relation_joined event for postgresql/0
|
||||
self.assertEqual(harness.charm. ...)
|
||||
```
|
||||
|
||||
## Talk to us
|
||||
|
||||
If you need help, have ideas, or would just like to chat with us, reach out on
|
||||
IRC: we're in [#smooth-operator] on freenode (or try the [webchat]).
|
||||
|
||||
We also pay attention to Juju's [discourse]; most discussion at this
|
||||
stage is on IRC, however.
|
||||
|
||||
You can also deep dive into the [API docs] if that's your thing.
|
||||
|
||||
[webchat]: https://webchat.freenode.net/#smooth-operator
|
||||
[#smooth-operator]: irc://chat.freenode.net/%23smooth-operator
|
||||
[discourse]: https://discourse.juju.is/c/charming
|
||||
[API docs]: https://ops.rtfd.io/
|
||||
|
||||
## Operator Framework development
|
||||
|
||||
If you want to work in the framework *itself* you will need Python >= 3.5 and
|
||||
the dependencies declared in `requirements-dev.txt` installed in your system.
|
||||
Or you can use a virtualenv:
|
||||
|
||||
virtualenv --python=python3 env
|
||||
source env/bin/activate
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
Then you can try `./run_tests`, it should all go green.
|
||||
|
||||
If you see the error `yaml does not have libyaml extensions, using slower pure
|
||||
Python yaml`, you need to reinstall pyyaml with the correct extensions:
|
||||
|
||||
apt-get install libyaml-dev
|
||||
pip install --force-reinstall --no-cache-dir pyyaml
|
||||
|
||||
If you want to build the documentation you'll need the requirements from
|
||||
`docs/requirements.txt`, or in your virtualenv
|
||||
|
||||
pip install -r docs/requirements.txt
|
||||
|
||||
and then you can run `./build_docs`.
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
ops-0.10.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
ops-0.10.0.dist-info/LICENSE.txt,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
||||
ops-0.10.0.dist-info/METADATA,sha256=AI7mL-PWkkYQ4f_NCulM5VcIQrMskxPIYp108DZrOcA,5577
|
||||
ops-0.10.0.dist-info/RECORD,,
|
||||
ops-0.10.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
ops-0.10.0.dist-info/WHEEL,sha256=g4nMs7d-Xl9-xC9XovUrsDHGXt-FT0E17Yqo92DEfvY,92
|
||||
ops-0.10.0.dist-info/top_level.txt,sha256=enC05wWafSg8iDKIvj3gvtAtEP2kYCyN5Gmd689q-_I,4
|
||||
ops/__init__.py,sha256=WaHb0dfp1KEe6jFV8Pm_mcdJ3ModiWujnQ6xLjNzPNQ,819
|
||||
ops/__pycache__/__init__.cpython-38.pyc,,
|
||||
ops/__pycache__/charm.cpython-38.pyc,,
|
||||
ops/__pycache__/framework.cpython-38.pyc,,
|
||||
ops/__pycache__/jujuversion.cpython-38.pyc,,
|
||||
ops/__pycache__/log.cpython-38.pyc,,
|
||||
ops/__pycache__/main.cpython-38.pyc,,
|
||||
ops/__pycache__/model.cpython-38.pyc,,
|
||||
ops/__pycache__/storage.cpython-38.pyc,,
|
||||
ops/__pycache__/testing.cpython-38.pyc,,
|
||||
ops/__pycache__/version.cpython-38.pyc,,
|
||||
ops/charm.py,sha256=i1fcd-pMzRV6f9AfMy0S_Jr_rZso3s9Xi-5GZWEs3nc,22512
|
||||
ops/framework.py,sha256=T9PWR4FXBI6Yd3XGwwNO51rJlyMUeO5vPdd4GmEjdzY,38298
|
||||
ops/jujuversion.py,sha256=T5KafqBHbQiHJ1OVoVbseUnZz7og4gPUz7CayXcHddk,3845
|
||||
ops/lib/__init__.py,sha256=7i2EN1jCUkVZT5NCi_q_ilBBzpCkWaW9mnBc3vBYCns,9188
|
||||
ops/lib/__pycache__/__init__.cpython-38.pyc,,
|
||||
ops/log.py,sha256=7jNn71--WpFngrZIwnJoaTRiaVrNVkLHK2enVu_VRA8,1860
|
||||
ops/main.py,sha256=TcOAS3VE1nMt-jF9uUzoyDWGTNl-OoAkS7XqQraWH3c,15375
|
||||
ops/model.py,sha256=katD2gQc35VArVMfGdI2AjPobFegQjShmDqVCKeLXZc,46796
|
||||
ops/storage.py,sha256=dal0athxe35cnWE8ol9N7nEUQDMcphDgRrQrmyGQDoA,11859
|
||||
ops/testing.py,sha256=HRjgq2ikVijGRMjVN2g-HJr8oQJ0ul8QEUUZv9D2_go,34727
|
||||
ops/version.py,sha256=6wsm0bsNX30wL9YmCZai2X5ISKQZYBIFJAbgmBn2Ri4,47
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
Wheel-Version: 1.0
|
||||
Generator: bdist_wheel (0.34.2)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
ops
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Copyright 2020 Canonical Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""The Operator Framework."""
|
||||
|
||||
from .version import version as __version__ # noqa: F401 (imported but unused)
|
||||
|
||||
# Import here the bare minimum to break the circular import between modules
|
||||
from . import charm # noqa: F401 (imported but unused)
|
||||
|
|
@ -0,0 +1,575 @@
|
|||
# Copyright 2019-2020 Canonical Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import enum
|
||||
import os
|
||||
import pathlib
|
||||
import typing
|
||||
|
||||
import yaml
|
||||
|
||||
from ops.framework import Object, EventSource, EventBase, Framework, ObjectEvents
|
||||
from ops import model
|
||||
|
||||
|
||||
def _loadYaml(source):
|
||||
if yaml.__with_libyaml__:
|
||||
return yaml.load(source, Loader=yaml.CSafeLoader)
|
||||
return yaml.load(source, Loader=yaml.SafeLoader)
|
||||
|
||||
|
||||
class HookEvent(EventBase):
|
||||
"""A base class for events that trigger because of a Juju hook firing."""
|
||||
|
||||
|
||||
class ActionEvent(EventBase):
|
||||
"""A base class for events that trigger when a user asks for an Action to be run.
|
||||
|
||||
To read the parameters for the action, see the instance variable `params`.
|
||||
To respond with the result of the action, call `set_results`. To add progress
|
||||
messages that are visible as the action is progressing use `log`.
|
||||
|
||||
:ivar params: The parameters passed to the action (read by action-get)
|
||||
"""
|
||||
|
||||
def defer(self):
|
||||
"""Action events are not deferable like other events.
|
||||
|
||||
This is because an action runs synchronously and the user is waiting for the result.
|
||||
"""
|
||||
raise RuntimeError('cannot defer action events')
|
||||
|
||||
def restore(self, snapshot: dict) -> None:
|
||||
"""Used by the operator framework to record the action.
|
||||
|
||||
Not meant to be called directly by Charm code.
|
||||
"""
|
||||
env_action_name = os.environ.get('JUJU_ACTION_NAME')
|
||||
event_action_name = self.handle.kind[:-len('_action')].replace('_', '-')
|
||||
if event_action_name != env_action_name:
|
||||
# This could only happen if the dev manually emits the action, or from a bug.
|
||||
raise RuntimeError('action event kind does not match current action')
|
||||
# Params are loaded at restore rather than __init__ because
|
||||
# the model is not available in __init__.
|
||||
self.params = self.framework.model._backend.action_get()
|
||||
|
||||
def set_results(self, results: typing.Mapping) -> None:
|
||||
"""Report the result of the action.
|
||||
|
||||
Args:
|
||||
results: The result of the action as a Dict
|
||||
"""
|
||||
self.framework.model._backend.action_set(results)
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
"""Send a message that a user will see while the action is running.
|
||||
|
||||
Args:
|
||||
message: The message for the user.
|
||||
"""
|
||||
self.framework.model._backend.action_log(message)
|
||||
|
||||
def fail(self, message: str = '') -> None:
|
||||
"""Report that this action has failed.
|
||||
|
||||
Args:
|
||||
message: Optional message to record why it has failed.
|
||||
"""
|
||||
self.framework.model._backend.action_fail(message)
|
||||
|
||||
|
||||
class InstallEvent(HookEvent):
|
||||
"""Represents the `install` hook from Juju."""
|
||||
|
||||
|
||||
class StartEvent(HookEvent):
|
||||
"""Represents the `start` hook from Juju."""
|
||||
|
||||
|
||||
class StopEvent(HookEvent):
|
||||
"""Represents the `stop` hook from Juju."""
|
||||
|
||||
|
||||
class RemoveEvent(HookEvent):
|
||||
"""Represents the `remove` hook from Juju. """
|
||||
|
||||
|
||||
class ConfigChangedEvent(HookEvent):
|
||||
"""Represents the `config-changed` hook from Juju."""
|
||||
|
||||
|
||||
class UpdateStatusEvent(HookEvent):
|
||||
"""Represents the `update-status` hook from Juju."""
|
||||
|
||||
|
||||
class UpgradeCharmEvent(HookEvent):
|
||||
"""Represents the `upgrade-charm` hook from Juju.
|
||||
|
||||
This will be triggered when a user has run `juju upgrade-charm`. It is run after Juju
|
||||
has unpacked the upgraded charm code, and so this event will be handled with new code.
|
||||
"""
|
||||
|
||||
|
||||
class PreSeriesUpgradeEvent(HookEvent):
|
||||
"""Represents the `pre-series-upgrade` hook from Juju.
|
||||
|
||||
This happens when a user has run `juju upgrade-series MACHINE prepare` and
|
||||
will fire for each unit that is running on the machine, telling them that
|
||||
the user is preparing to upgrade the Machine's series (eg trusty->bionic).
|
||||
The charm should take actions to prepare for the upgrade (a database charm
|
||||
would want to write out a version-independent dump of the database, so that
|
||||
when a new version of the database is available in a new series, it can be
|
||||
used.)
|
||||
Once all units on a machine have run `pre-series-upgrade`, the user will
|
||||
initiate the steps to actually upgrade the machine (eg `do-release-upgrade`).
|
||||
When the upgrade has been completed, the :class:`PostSeriesUpgradeEvent` will fire.
|
||||
"""
|
||||
|
||||
|
||||
class PostSeriesUpgradeEvent(HookEvent):
|
||||
"""Represents the `post-series-upgrade` hook from Juju.
|
||||
|
||||
This is run after the user has done a distribution upgrade (or rolled back
|
||||
and kept the same series). It is called in response to
|
||||
`juju upgrade-series MACHINE complete`. Charms are expected to do whatever
|
||||
steps are necessary to reconfigure their applications for the new series.
|
||||
"""
|
||||
|
||||
|
||||
class LeaderElectedEvent(HookEvent):
|
||||
"""Represents the `leader-elected` hook from Juju.
|
||||
|
||||
Juju will trigger this when a new lead unit is chosen for a given application.
|
||||
This represents the leader of the charm information (not necessarily the primary
|
||||
of a running application). The main utility is that charm authors can know
|
||||
that only one unit will be a leader at any given time, so they can do
|
||||
configuration, etc, that would otherwise require coordination between units.
|
||||
(eg, selecting a password for a new relation)
|
||||
"""
|
||||
|
||||
|
||||
class LeaderSettingsChangedEvent(HookEvent):
|
||||
"""Represents the `leader-settings-changed` hook from Juju.
|
||||
|
||||
Deprecated. This represents when a lead unit would call `leader-set` to inform
|
||||
the other units of an application that they have new information to handle.
|
||||
This has been deprecated in favor of using a Peer relation, and having the
|
||||
leader set a value in the Application data bag for that peer relation.
|
||||
(see :class:`RelationChangedEvent`).
|
||||
"""
|
||||
|
||||
|
||||
class CollectMetricsEvent(HookEvent):
|
||||
"""Represents the `collect-metrics` hook from Juju.
|
||||
|
||||
Note that events firing during a CollectMetricsEvent are currently
|
||||
sandboxed in how they can interact with Juju. To report metrics
|
||||
use :meth:`.add_metrics`.
|
||||
"""
|
||||
|
||||
def add_metrics(self, metrics: typing.Mapping, labels: typing.Mapping = None) -> None:
|
||||
"""Record metrics that have been gathered by the charm for this unit.
|
||||
|
||||
Args:
|
||||
metrics: A collection of {key: float} pairs that contains the
|
||||
metrics that have been gathered
|
||||
labels: {key:value} strings that can be applied to the
|
||||
metrics that are being gathered
|
||||
"""
|
||||
self.framework.model._backend.add_metrics(metrics, labels)
|
||||
|
||||
|
||||
class RelationEvent(HookEvent):
|
||||
"""A base class representing the various relation lifecycle events.
|
||||
|
||||
Charmers should not be creating RelationEvents directly. The events will be
|
||||
generated by the framework from Juju related events. Users can observe them
|
||||
from the various `CharmBase.on[relation_name].relation_*` events.
|
||||
|
||||
Attributes:
|
||||
relation: The Relation involved in this event
|
||||
app: The remote application that has triggered this event
|
||||
unit: The remote unit that has triggered this event. This may be None
|
||||
if the relation event was triggered as an Application level event
|
||||
"""
|
||||
|
||||
def __init__(self, handle, relation, app=None, unit=None):
|
||||
super().__init__(handle)
|
||||
|
||||
if unit is not None and unit.app != app:
|
||||
raise RuntimeError(
|
||||
'cannot create RelationEvent with application {} and unit {}'.format(app, unit))
|
||||
|
||||
self.relation = relation
|
||||
self.app = app
|
||||
self.unit = unit
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
"""Used by the framework to serialize the event to disk.
|
||||
|
||||
Not meant to be called by Charm code.
|
||||
"""
|
||||
snapshot = {
|
||||
'relation_name': self.relation.name,
|
||||
'relation_id': self.relation.id,
|
||||
}
|
||||
if self.app:
|
||||
snapshot['app_name'] = self.app.name
|
||||
if self.unit:
|
||||
snapshot['unit_name'] = self.unit.name
|
||||
return snapshot
|
||||
|
||||
def restore(self, snapshot: dict) -> None:
|
||||
"""Used by the framework to deserialize the event from disk.
|
||||
|
||||
Not meant to be called by Charm code.
|
||||
"""
|
||||
self.relation = self.framework.model.get_relation(
|
||||
snapshot['relation_name'], snapshot['relation_id'])
|
||||
|
||||
app_name = snapshot.get('app_name')
|
||||
if app_name:
|
||||
self.app = self.framework.model.get_app(app_name)
|
||||
else:
|
||||
self.app = None
|
||||
|
||||
unit_name = snapshot.get('unit_name')
|
||||
if unit_name:
|
||||
self.unit = self.framework.model.get_unit(unit_name)
|
||||
else:
|
||||
self.unit = None
|
||||
|
||||
|
||||
class RelationCreatedEvent(RelationEvent):
|
||||
"""Represents the `relation-created` hook from Juju.
|
||||
|
||||
This is triggered when a new relation to another app is added in Juju. This
|
||||
can occur before units for those applications have started. All existing
|
||||
relations should be established before start.
|
||||
"""
|
||||
|
||||
|
||||
class RelationJoinedEvent(RelationEvent):
|
||||
"""Represents the `relation-joined` hook from Juju.
|
||||
|
||||
This is triggered whenever a new unit of a related application joins the relation.
|
||||
(eg, a unit was added to an existing related app, or a new relation was established
|
||||
with an application that already had units.)
|
||||
"""
|
||||
|
||||
|
||||
class RelationChangedEvent(RelationEvent):
|
||||
"""Represents the `relation-changed` hook from Juju.
|
||||
|
||||
This is triggered whenever there is a change to the data bucket for a related
|
||||
application or unit. Look at `event.relation.data[event.unit/app]` to see the
|
||||
new information.
|
||||
"""
|
||||
|
||||
|
||||
class RelationDepartedEvent(RelationEvent):
|
||||
"""Represents the `relation-departed` hook from Juju.
|
||||
|
||||
This is the inverse of the RelationJoinedEvent, representing when a unit
|
||||
is leaving the relation (the unit is being removed, the app is being removed,
|
||||
the relation is being removed). It is fired once for each unit that is
|
||||
going away.
|
||||
"""
|
||||
|
||||
|
||||
class RelationBrokenEvent(RelationEvent):
|
||||
"""Represents the `relation-broken` hook from Juju.
|
||||
|
||||
If a relation is being removed (`juju remove-relation` or `juju remove-application`),
|
||||
once all the units have been removed, RelationBrokenEvent will fire to signal
|
||||
that the relationship has been fully terminated.
|
||||
"""
|
||||
|
||||
|
||||
class StorageEvent(HookEvent):
|
||||
"""Base class representing Storage related events."""
|
||||
|
||||
|
||||
class StorageAttachedEvent(StorageEvent):
|
||||
"""Represents the `storage-attached` hook from Juju.
|
||||
|
||||
Called when new storage is available for the charm to use.
|
||||
"""
|
||||
|
||||
|
||||
class StorageDetachingEvent(StorageEvent):
|
||||
"""Represents the `storage-detaching` hook from Juju.
|
||||
|
||||
Called when storage a charm has been using is going away.
|
||||
"""
|
||||
|
||||
|
||||
class CharmEvents(ObjectEvents):
|
||||
"""The events that are generated by Juju in response to the lifecycle of an application."""
|
||||
|
||||
install = EventSource(InstallEvent)
|
||||
start = EventSource(StartEvent)
|
||||
stop = EventSource(StopEvent)
|
||||
remove = EventSource(RemoveEvent)
|
||||
update_status = EventSource(UpdateStatusEvent)
|
||||
config_changed = EventSource(ConfigChangedEvent)
|
||||
upgrade_charm = EventSource(UpgradeCharmEvent)
|
||||
pre_series_upgrade = EventSource(PreSeriesUpgradeEvent)
|
||||
post_series_upgrade = EventSource(PostSeriesUpgradeEvent)
|
||||
leader_elected = EventSource(LeaderElectedEvent)
|
||||
leader_settings_changed = EventSource(LeaderSettingsChangedEvent)
|
||||
collect_metrics = EventSource(CollectMetricsEvent)
|
||||
|
||||
|
||||
class CharmBase(Object):
|
||||
"""Base class that represents the Charm overall.
|
||||
|
||||
Usually this initialization is done by ops.main.main() rather than Charm authors
|
||||
directly instantiating a Charm.
|
||||
|
||||
Args:
|
||||
framework: The framework responsible for managing the Model and events for this
|
||||
Charm.
|
||||
key: Ignored; will remove after deprecation period of the signature change.
|
||||
"""
|
||||
|
||||
on = CharmEvents()
|
||||
|
||||
def __init__(self, framework: Framework, key: typing.Optional = None):
|
||||
super().__init__(framework, None)
|
||||
|
||||
for relation_name in self.framework.meta.relations:
|
||||
relation_name = relation_name.replace('-', '_')
|
||||
self.on.define_event(relation_name + '_relation_created', RelationCreatedEvent)
|
||||
self.on.define_event(relation_name + '_relation_joined', RelationJoinedEvent)
|
||||
self.on.define_event(relation_name + '_relation_changed', RelationChangedEvent)
|
||||
self.on.define_event(relation_name + '_relation_departed', RelationDepartedEvent)
|
||||
self.on.define_event(relation_name + '_relation_broken', RelationBrokenEvent)
|
||||
|
||||
for storage_name in self.framework.meta.storages:
|
||||
storage_name = storage_name.replace('-', '_')
|
||||
self.on.define_event(storage_name + '_storage_attached', StorageAttachedEvent)
|
||||
self.on.define_event(storage_name + '_storage_detaching', StorageDetachingEvent)
|
||||
|
||||
for action_name in self.framework.meta.actions:
|
||||
action_name = action_name.replace('-', '_')
|
||||
self.on.define_event(action_name + '_action', ActionEvent)
|
||||
|
||||
@property
|
||||
def app(self) -> model.Application:
|
||||
"""Application that this unit is part of."""
|
||||
return self.framework.model.app
|
||||
|
||||
@property
|
||||
def unit(self) -> model.Unit:
|
||||
"""Unit that this execution is responsible for."""
|
||||
return self.framework.model.unit
|
||||
|
||||
@property
|
||||
def meta(self) -> 'CharmMeta':
|
||||
"""CharmMeta of this charm.
|
||||
"""
|
||||
return self.framework.meta
|
||||
|
||||
@property
|
||||
def charm_dir(self) -> pathlib.Path:
|
||||
"""Root directory of the Charm as it is running.
|
||||
"""
|
||||
return self.framework.charm_dir
|
||||
|
||||
|
||||
class CharmMeta:
|
||||
"""Object containing the metadata for the charm.
|
||||
|
||||
This is read from metadata.yaml and/or actions.yaml. Generally charms will
|
||||
define this information, rather than reading it at runtime. This class is
|
||||
mostly for the framework to understand what the charm has defined.
|
||||
|
||||
The maintainers, tags, terms, series, and extra_bindings attributes are all
|
||||
lists of strings. The requires, provides, peers, relations, storage,
|
||||
resources, and payloads attributes are all mappings of names to instances
|
||||
of the respective RelationMeta, StorageMeta, ResourceMeta, or PayloadMeta.
|
||||
|
||||
The relations attribute is a convenience accessor which includes all of the
|
||||
requires, provides, and peers RelationMeta items. If needed, the role of
|
||||
the relation definition can be obtained from its role attribute.
|
||||
|
||||
Attributes:
|
||||
name: The name of this charm
|
||||
summary: Short description of what this charm does
|
||||
description: Long description for this charm
|
||||
maintainers: A list of strings of the email addresses of the maintainers
|
||||
of this charm.
|
||||
tags: Charm store tag metadata for categories associated with this charm.
|
||||
terms: Charm store terms that should be agreed to before this charm can
|
||||
be deployed. (Used for things like licensing issues.)
|
||||
series: The list of supported OS series that this charm can support.
|
||||
The first entry in the list is the default series that will be
|
||||
used by deploy if no other series is requested by the user.
|
||||
subordinate: True/False whether this charm is intended to be used as a
|
||||
subordinate charm.
|
||||
min_juju_version: If supplied, indicates this charm needs features that
|
||||
are not available in older versions of Juju.
|
||||
requires: A dict of {name: :class:`RelationMeta` } for each 'requires' relation.
|
||||
provides: A dict of {name: :class:`RelationMeta` } for each 'provides' relation.
|
||||
peers: A dict of {name: :class:`RelationMeta` } for each 'peer' relation.
|
||||
relations: A dict containing all :class:`RelationMeta` attributes (merged from other
|
||||
sections)
|
||||
storages: A dict of {name: :class:`StorageMeta`} for each defined storage.
|
||||
resources: A dict of {name: :class:`ResourceMeta`} for each defined resource.
|
||||
payloads: A dict of {name: :class:`PayloadMeta`} for each defined payload.
|
||||
extra_bindings: A dict of additional named bindings that a charm can use
|
||||
for network configuration.
|
||||
actions: A dict of {name: :class:`ActionMeta`} for actions that the charm has defined.
|
||||
Args:
|
||||
raw: a mapping containing the contents of metadata.yaml
|
||||
actions_raw: a mapping containing the contents of actions.yaml
|
||||
"""
|
||||
|
||||
def __init__(self, raw: dict = {}, actions_raw: dict = {}):
|
||||
self.name = raw.get('name', '')
|
||||
self.summary = raw.get('summary', '')
|
||||
self.description = raw.get('description', '')
|
||||
self.maintainers = []
|
||||
if 'maintainer' in raw:
|
||||
self.maintainers.append(raw['maintainer'])
|
||||
if 'maintainers' in raw:
|
||||
self.maintainers.extend(raw['maintainers'])
|
||||
self.tags = raw.get('tags', [])
|
||||
self.terms = raw.get('terms', [])
|
||||
self.series = raw.get('series', [])
|
||||
self.subordinate = raw.get('subordinate', False)
|
||||
self.min_juju_version = raw.get('min-juju-version')
|
||||
self.requires = {name: RelationMeta(RelationRole.requires, name, rel)
|
||||
for name, rel in raw.get('requires', {}).items()}
|
||||
self.provides = {name: RelationMeta(RelationRole.provides, name, rel)
|
||||
for name, rel in raw.get('provides', {}).items()}
|
||||
self.peers = {name: RelationMeta(RelationRole.peer, name, rel)
|
||||
for name, rel in raw.get('peers', {}).items()}
|
||||
self.relations = {}
|
||||
self.relations.update(self.requires)
|
||||
self.relations.update(self.provides)
|
||||
self.relations.update(self.peers)
|
||||
self.storages = {name: StorageMeta(name, storage)
|
||||
for name, storage in raw.get('storage', {}).items()}
|
||||
self.resources = {name: ResourceMeta(name, res)
|
||||
for name, res in raw.get('resources', {}).items()}
|
||||
self.payloads = {name: PayloadMeta(name, payload)
|
||||
for name, payload in raw.get('payloads', {}).items()}
|
||||
self.extra_bindings = raw.get('extra-bindings', {})
|
||||
self.actions = {name: ActionMeta(name, action) for name, action in actions_raw.items()}
|
||||
|
||||
@classmethod
|
||||
def from_yaml(
|
||||
cls, metadata: typing.Union[str, typing.TextIO],
|
||||
actions: typing.Optional[typing.Union[str, typing.TextIO]] = None):
|
||||
"""Instantiate a CharmMeta from a YAML description of metadata.yaml.
|
||||
|
||||
Args:
|
||||
metadata: A YAML description of charm metadata (name, relations, etc.)
|
||||
This can be a simple string, or a file-like object. (passed to `yaml.safe_load`).
|
||||
actions: YAML description of Actions for this charm (eg actions.yaml)
|
||||
"""
|
||||
meta = _loadYaml(metadata)
|
||||
raw_actions = {}
|
||||
if actions is not None:
|
||||
raw_actions = _loadYaml(actions)
|
||||
return cls(meta, raw_actions)
|
||||
|
||||
|
||||
class RelationRole(enum.Enum):
|
||||
peer = 'peer'
|
||||
requires = 'requires'
|
||||
provides = 'provides'
|
||||
|
||||
def is_peer(self) -> bool:
|
||||
"""Return whether the current role is peer.
|
||||
|
||||
A convenience to avoid having to import charm.
|
||||
"""
|
||||
return self is RelationRole.peer
|
||||
|
||||
|
||||
class RelationMeta:
|
||||
"""Object containing metadata about a relation definition.
|
||||
|
||||
Should not be constructed directly by Charm code. Is gotten from one of
|
||||
:attr:`CharmMeta.peers`, :attr:`CharmMeta.requires`, :attr:`CharmMeta.provides`,
|
||||
or :attr:`CharmMeta.relations`.
|
||||
|
||||
Attributes:
|
||||
role: This is one of peer/requires/provides
|
||||
relation_name: Name of this relation from metadata.yaml
|
||||
interface_name: Optional definition of the interface protocol.
|
||||
scope: "global" or "container" scope based on how the relation should be used.
|
||||
"""
|
||||
|
||||
def __init__(self, role: RelationRole, relation_name: str, raw: dict):
|
||||
if not isinstance(role, RelationRole):
|
||||
raise TypeError("role should be a Role, not {!r}".format(role))
|
||||
self.role = role
|
||||
self.relation_name = relation_name
|
||||
self.interface_name = raw['interface']
|
||||
self.scope = raw.get('scope')
|
||||
|
||||
|
||||
class StorageMeta:
|
||||
"""Object containing metadata about a storage definition."""
|
||||
|
||||
def __init__(self, name, raw):
|
||||
self.storage_name = name
|
||||
self.type = raw['type']
|
||||
self.description = raw.get('description', '')
|
||||
self.shared = raw.get('shared', False)
|
||||
self.read_only = raw.get('read-only', False)
|
||||
self.minimum_size = raw.get('minimum-size')
|
||||
self.location = raw.get('location')
|
||||
self.multiple_range = None
|
||||
if 'multiple' in raw:
|
||||
range = raw['multiple']['range']
|
||||
if '-' not in range:
|
||||
self.multiple_range = (int(range), int(range))
|
||||
else:
|
||||
range = range.split('-')
|
||||
self.multiple_range = (int(range[0]), int(range[1]) if range[1] else None)
|
||||
|
||||
|
||||
class ResourceMeta:
|
||||
"""Object containing metadata about a resource definition."""
|
||||
|
||||
def __init__(self, name, raw):
|
||||
self.resource_name = name
|
||||
self.type = raw['type']
|
||||
self.filename = raw.get('filename', None)
|
||||
self.description = raw.get('description', '')
|
||||
|
||||
|
||||
class PayloadMeta:
|
||||
"""Object containing metadata about a payload definition."""
|
||||
|
||||
def __init__(self, name, raw):
|
||||
self.payload_name = name
|
||||
self.type = raw['type']
|
||||
|
||||
|
||||
class ActionMeta:
|
||||
"""Object containing metadata about an action's definition."""
|
||||
|
||||
def __init__(self, name, raw=None):
|
||||
raw = raw or {}
|
||||
self.name = name
|
||||
self.title = raw.get('title', '')
|
||||
self.description = raw.get('description', '')
|
||||
self.parameters = raw.get('params', {}) # {<parameter name>: <JSON Schema definition>}
|
||||
self.required = raw.get('required', []) # [<parameter name>, ...]
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,106 @@
|
|||
# Copyright 2020 Canonical Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
import re
|
||||
from functools import total_ordering
|
||||
|
||||
|
||||
@total_ordering
|
||||
class JujuVersion:
|
||||
|
||||
PATTERN = r'''^
|
||||
(?P<major>\d{1,9})\.(?P<minor>\d{1,9}) # <major> and <minor> numbers are always there
|
||||
((?:\.|-(?P<tag>[a-z]+))(?P<patch>\d{1,9}))? # sometimes with .<patch> or -<tag><patch>
|
||||
(\.(?P<build>\d{1,9}))?$ # and sometimes with a <build> number.
|
||||
'''
|
||||
|
||||
def __init__(self, version):
|
||||
m = re.match(self.PATTERN, version, re.VERBOSE)
|
||||
if not m:
|
||||
raise RuntimeError('"{}" is not a valid Juju version string'.format(version))
|
||||
|
||||
d = m.groupdict()
|
||||
self.major = int(m.group('major'))
|
||||
self.minor = int(m.group('minor'))
|
||||
self.tag = d['tag'] or ''
|
||||
self.patch = int(d['patch'] or 0)
|
||||
self.build = int(d['build'] or 0)
|
||||
|
||||
def __repr__(self):
|
||||
if self.tag:
|
||||
s = '{}.{}-{}{}'.format(self.major, self.minor, self.tag, self.patch)
|
||||
else:
|
||||
s = '{}.{}.{}'.format(self.major, self.minor, self.patch)
|
||||
if self.build > 0:
|
||||
s += '.{}'.format(self.build)
|
||||
return s
|
||||
|
||||
def __eq__(self, other):
|
||||
if self is other:
|
||||
return True
|
||||
if isinstance(other, str):
|
||||
other = type(self)(other)
|
||||
elif not isinstance(other, JujuVersion):
|
||||
raise RuntimeError('cannot compare Juju version "{}" with "{}"'.format(self, other))
|
||||
return (
|
||||
self.major == other.major
|
||||
and self.minor == other.minor
|
||||
and self.tag == other.tag
|
||||
and self.build == other.build
|
||||
and self.patch == other.patch)
|
||||
|
||||
def __lt__(self, other):
|
||||
if self is other:
|
||||
return False
|
||||
if isinstance(other, str):
|
||||
other = type(self)(other)
|
||||
elif not isinstance(other, JujuVersion):
|
||||
raise RuntimeError('cannot compare Juju version "{}" with "{}"'.format(self, other))
|
||||
|
||||
if self.major != other.major:
|
||||
return self.major < other.major
|
||||
elif self.minor != other.minor:
|
||||
return self.minor < other.minor
|
||||
elif self.tag != other.tag:
|
||||
if not self.tag:
|
||||
return False
|
||||
elif not other.tag:
|
||||
return True
|
||||
return self.tag < other.tag
|
||||
elif self.patch != other.patch:
|
||||
return self.patch < other.patch
|
||||
elif self.build != other.build:
|
||||
return self.build < other.build
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def from_environ(cls) -> 'JujuVersion':
|
||||
"""Build a JujuVersion from JUJU_VERSION."""
|
||||
v = os.environ.get('JUJU_VERSION')
|
||||
if v is None:
|
||||
v = '0.0.0'
|
||||
return cls(v)
|
||||
|
||||
def has_app_data(self) -> bool:
|
||||
"""Determine whether this juju version knows about app data."""
|
||||
return (self.major, self.minor, self.patch) >= (2, 7, 0)
|
||||
|
||||
def is_dispatch_aware(self) -> bool:
|
||||
"""Determine whether this juju version knows about dispatch."""
|
||||
return (self.major, self.minor, self.patch) >= (2, 8, 0)
|
||||
|
||||
def has_controller_storage(self) -> bool:
|
||||
"""Determine whether this juju version supports controller-side storage."""
|
||||
return (self.major, self.minor, self.patch) >= (2, 8, 0)
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
# Copyright 2020 Canonical Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from ast import literal_eval
|
||||
from importlib.util import module_from_spec
|
||||
from importlib.machinery import ModuleSpec
|
||||
from pkgutil import get_importer
|
||||
from types import ModuleType
|
||||
from typing import List
|
||||
|
||||
__all__ = ('use', 'autoimport')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_libraries = None
|
||||
|
||||
_libline_re = re.compile(r'''^LIB([A-Z]+)\s*=\s*([0-9]+|['"][a-zA-Z0-9_.\-@]+['"])''')
|
||||
_libname_re = re.compile(r'''^[a-z][a-z0-9]+$''')
|
||||
|
||||
# Not perfect, but should do for now.
|
||||
_libauthor_re = re.compile(r'''^[A-Za-z0-9_+.-]+@[a-z0-9_-]+(?:\.[a-z0-9_-]+)*\.[a-z]{2,3}$''')
|
||||
|
||||
|
||||
def use(name: str, api: int, author: str) -> ModuleType:
|
||||
"""Use a library from the ops libraries.
|
||||
|
||||
Args:
|
||||
name: the name of the library requested.
|
||||
api: the API version of the library.
|
||||
author: the author of the library. If not given, requests the
|
||||
one in the standard library.
|
||||
Raises:
|
||||
ImportError: if the library cannot be found.
|
||||
TypeError: if the name, api, or author are the wrong type.
|
||||
ValueError: if the name, api, or author are invalid.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
raise TypeError("invalid library name: {!r} (must be a str)".format(name))
|
||||
if not isinstance(author, str):
|
||||
raise TypeError("invalid library author: {!r} (must be a str)".format(author))
|
||||
if not isinstance(api, int):
|
||||
raise TypeError("invalid library API: {!r} (must be an int)".format(api))
|
||||
if api < 0:
|
||||
raise ValueError('invalid library api: {} (must be ≥0)'.format(api))
|
||||
if not _libname_re.match(name):
|
||||
raise ValueError("invalid library name: {!r} (chars and digits only)".format(name))
|
||||
if not _libauthor_re.match(author):
|
||||
raise ValueError("invalid library author email: {!r}".format(author))
|
||||
|
||||
if _libraries is None:
|
||||
autoimport()
|
||||
|
||||
versions = _libraries.get((name, author), ())
|
||||
for lib in versions:
|
||||
if lib.api == api:
|
||||
return lib.import_module()
|
||||
|
||||
others = ', '.join(str(lib.api) for lib in versions)
|
||||
if others:
|
||||
msg = 'cannot find "{}" from "{}" with API version {} (have {})'.format(
|
||||
name, author, api, others)
|
||||
else:
|
||||
msg = 'cannot find library "{}" from "{}"'.format(name, author)
|
||||
|
||||
raise ImportError(msg, name=name)
|
||||
|
||||
|
||||
def autoimport():
|
||||
"""Find all libs in the path and enable use of them.
|
||||
|
||||
You only need to call this if you've installed a package or
|
||||
otherwise changed sys.path in the current run, and need to see the
|
||||
changes. Otherwise libraries are found on first call of `use`.
|
||||
"""
|
||||
global _libraries
|
||||
_libraries = {}
|
||||
for spec in _find_all_specs(sys.path):
|
||||
lib = _parse_lib(spec)
|
||||
if lib is None:
|
||||
continue
|
||||
|
||||
versions = _libraries.setdefault((lib.name, lib.author), [])
|
||||
versions.append(lib)
|
||||
versions.sort(reverse=True)
|
||||
|
||||
|
||||
def _find_all_specs(path):
|
||||
for sys_dir in path:
|
||||
if sys_dir == "":
|
||||
sys_dir = "."
|
||||
try:
|
||||
top_dirs = os.listdir(sys_dir)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
continue
|
||||
except OSError as e:
|
||||
logger.debug("Tried to look for ops.lib packages under '%s': %s", sys_dir, e)
|
||||
continue
|
||||
logger.debug("Looking for ops.lib packages under '%s'", sys_dir)
|
||||
for top_dir in top_dirs:
|
||||
opslib = os.path.join(sys_dir, top_dir, 'opslib')
|
||||
try:
|
||||
lib_dirs = os.listdir(opslib)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
continue
|
||||
except OSError as e:
|
||||
logger.debug(" Tried '%s': %s", opslib, e) # *lots* of things checked here
|
||||
continue
|
||||
else:
|
||||
logger.debug(" Trying '%s'", opslib)
|
||||
finder = get_importer(opslib)
|
||||
if finder is None:
|
||||
logger.debug(" Finder for '%s' is None", opslib)
|
||||
continue
|
||||
if not hasattr(finder, 'find_spec'):
|
||||
logger.debug(" Finder for '%s' has no find_spec", opslib)
|
||||
continue
|
||||
for lib_dir in lib_dirs:
|
||||
spec_name = "{}.opslib.{}".format(top_dir, lib_dir)
|
||||
spec = finder.find_spec(spec_name)
|
||||
if spec is None:
|
||||
logger.debug(" No spec for %r", spec_name)
|
||||
continue
|
||||
if spec.loader is None:
|
||||
# a namespace package; not supported
|
||||
logger.debug(" No loader for %r (probably a namespace package)", spec_name)
|
||||
continue
|
||||
|
||||
logger.debug(" Found %r", spec_name)
|
||||
yield spec
|
||||
|
||||
|
||||
# only the first this many lines of a file are looked at for the LIB* constants
|
||||
_MAX_LIB_LINES = 99
|
||||
# these keys, with these types, are needed to have an opslib
|
||||
_NEEDED_KEYS = {'NAME': str, 'AUTHOR': str, 'API': int, 'PATCH': int}
|
||||
|
||||
|
||||
def _join_and(keys: List[str]) -> str:
|
||||
if len(keys) == 0:
|
||||
return ""
|
||||
if len(keys) == 1:
|
||||
return keys[0]
|
||||
return ", ".join(keys[:-1]) + ", and " + keys[-1]
|
||||
|
||||
|
||||
class _Missing:
|
||||
"""A silly little helper to only work out the difference between
|
||||
what was found and what was needed when logging"""
|
||||
|
||||
def __init__(self, found):
|
||||
self._found = found
|
||||
|
||||
def __str__(self):
|
||||
exp = set(_NEEDED_KEYS)
|
||||
got = set(self._found)
|
||||
if len(got) == 0:
|
||||
return "missing {}".format(_join_and(sorted(exp)))
|
||||
return "got {}, but missing {}".format(
|
||||
_join_and(sorted(got)),
|
||||
_join_and(sorted(exp - got)))
|
||||
|
||||
|
||||
def _parse_lib(spec):
|
||||
if spec.origin is None:
|
||||
# "can't happen"
|
||||
logger.warning("No origin for %r (no idea why; please report)", spec.name)
|
||||
return None
|
||||
|
||||
logger.debug(" Parsing %r", spec.name)
|
||||
|
||||
try:
|
||||
with open(spec.origin, 'rt', encoding='utf-8') as f:
|
||||
libinfo = {}
|
||||
for n, line in enumerate(f):
|
||||
if len(libinfo) == len(_NEEDED_KEYS):
|
||||
break
|
||||
if n > _MAX_LIB_LINES:
|
||||
logger.debug(
|
||||
" Missing opslib metadata after reading to line %d: %s",
|
||||
_MAX_LIB_LINES, _Missing(libinfo))
|
||||
return None
|
||||
m = _libline_re.match(line)
|
||||
if m is None:
|
||||
continue
|
||||
key, value = m.groups()
|
||||
if key in _NEEDED_KEYS:
|
||||
value = literal_eval(value)
|
||||
if not isinstance(value, _NEEDED_KEYS[key]):
|
||||
logger.debug(
|
||||
" Bad type for %s: expected %s, got %s",
|
||||
key, _NEEDED_KEYS[key].__name__, type(value).__name__)
|
||||
return None
|
||||
libinfo[key] = value
|
||||
else:
|
||||
if len(libinfo) != len(_NEEDED_KEYS):
|
||||
logger.debug(
|
||||
" Missing opslib metadata after reading to end of file: %s",
|
||||
_Missing(libinfo))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(" Failed: %s", e)
|
||||
return None
|
||||
|
||||
lib = _Lib(spec, libinfo['NAME'], libinfo['AUTHOR'], libinfo['API'], libinfo['PATCH'])
|
||||
logger.debug(" Success: found library %s", lib)
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
class _Lib:
|
||||
|
||||
def __init__(self, spec: ModuleSpec, name: str, author: str, api: int, patch: int):
|
||||
self.spec = spec
|
||||
self.name = name
|
||||
self.author = author
|
||||
self.api = api
|
||||
self.patch = patch
|
||||
|
||||
self._module = None
|
||||
|
||||
def __repr__(self):
|
||||
return "<_Lib {}>".format(self)
|
||||
|
||||
def __str__(self):
|
||||
return "{0.name} by {0.author}, API {0.api}, patch {0.patch}".format(self)
|
||||
|
||||
def import_module(self) -> ModuleType:
|
||||
if self._module is None:
|
||||
module = module_from_spec(self.spec)
|
||||
self.spec.loader.exec_module(module)
|
||||
self._module = module
|
||||
return self._module
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, _Lib):
|
||||
return NotImplemented
|
||||
a = (self.name, self.author, self.api, self.patch)
|
||||
b = (other.name, other.author, other.api, other.patch)
|
||||
return a == b
|
||||
|
||||
def __lt__(self, other):
|
||||
if not isinstance(other, _Lib):
|
||||
return NotImplemented
|
||||
a = (self.name, self.author, self.api, self.patch)
|
||||
b = (other.name, other.author, other.api, other.patch)
|
||||
return a < b
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Copyright 2020 Canonical Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import sys
|
||||
import logging
|
||||
|
||||
|
||||
class JujuLogHandler(logging.Handler):
|
||||
"""A handler for sending logs to Juju via juju-log."""
|
||||
|
||||
def __init__(self, model_backend, level=logging.DEBUG):
|
||||
super().__init__(level)
|
||||
self.model_backend = model_backend
|
||||
|
||||
def emit(self, record):
|
||||
self.model_backend.juju_log(record.levelname, self.format(record))
|
||||
|
||||
|
||||
def setup_root_logging(model_backend, debug=False):
|
||||
"""Setup python logging to forward messages to juju-log.
|
||||
|
||||
By default, logging is set to DEBUG level, and messages will be filtered by Juju.
|
||||
Charmers can also set their own default log level with::
|
||||
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
|
||||
model_backend -- a ModelBackend to use for juju-log
|
||||
debug -- if True, write logs to stderr as well as to juju-log.
|
||||
"""
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.addHandler(JujuLogHandler(model_backend))
|
||||
if debug:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
sys.excepthook = lambda etype, value, tb: logger.error(
|
||||
"Uncaught exception while in charm code:", exc_info=(etype, value, tb))
|
||||
|
|
@ -0,0 +1,404 @@
|
|||
# Copyright 2019-2020 Canonical Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import typing
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
import ops.charm
|
||||
import ops.framework
|
||||
import ops.model
|
||||
import ops.storage
|
||||
|
||||
from ops.log import setup_root_logging
|
||||
from ops.jujuversion import JujuVersion
|
||||
|
||||
CHARM_STATE_FILE = '.unit-state.db'
|
||||
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
def _exe_path(path: Path) -> typing.Optional[Path]:
|
||||
"""Find and return the full path to the given binary.
|
||||
|
||||
Here path is the absolute path to a binary, but might be missing an extension.
|
||||
"""
|
||||
p = shutil.which(path.name, mode=os.F_OK, path=str(path.parent))
|
||||
if p is None:
|
||||
return None
|
||||
return Path(p)
|
||||
|
||||
|
||||
def _get_charm_dir():
|
||||
charm_dir = os.environ.get("JUJU_CHARM_DIR")
|
||||
if charm_dir is None:
|
||||
# Assume $JUJU_CHARM_DIR/lib/op/main.py structure.
|
||||
charm_dir = Path('{}/../../..'.format(__file__)).resolve()
|
||||
else:
|
||||
charm_dir = Path(charm_dir).resolve()
|
||||
return charm_dir
|
||||
|
||||
|
||||
def _create_event_link(charm, bound_event, link_to):
|
||||
"""Create a symlink for a particular event.
|
||||
|
||||
charm -- A charm object.
|
||||
bound_event -- An event for which to create a symlink.
|
||||
link_to -- What the event link should point to
|
||||
"""
|
||||
if issubclass(bound_event.event_type, ops.charm.HookEvent):
|
||||
event_dir = charm.framework.charm_dir / 'hooks'
|
||||
event_path = event_dir / bound_event.event_kind.replace('_', '-')
|
||||
elif issubclass(bound_event.event_type, ops.charm.ActionEvent):
|
||||
if not bound_event.event_kind.endswith("_action"):
|
||||
raise RuntimeError(
|
||||
'action event name {} needs _action suffix'.format(bound_event.event_kind))
|
||||
event_dir = charm.framework.charm_dir / 'actions'
|
||||
# The event_kind is suffixed with "_action" while the executable is not.
|
||||
event_path = event_dir / bound_event.event_kind[:-len('_action')].replace('_', '-')
|
||||
else:
|
||||
raise RuntimeError(
|
||||
'cannot create a symlink: unsupported event type {}'.format(bound_event.event_type))
|
||||
|
||||
event_dir.mkdir(exist_ok=True)
|
||||
if not event_path.exists():
|
||||
target_path = os.path.relpath(link_to, str(event_dir))
|
||||
|
||||
# Ignore the non-symlink files or directories
|
||||
# assuming the charm author knows what they are doing.
|
||||
logger.debug(
|
||||
'Creating a new relative symlink at %s pointing to %s',
|
||||
event_path, target_path)
|
||||
event_path.symlink_to(target_path)
|
||||
|
||||
|
||||
def _setup_event_links(charm_dir, charm):
|
||||
"""Set up links for supported events that originate from Juju.
|
||||
|
||||
Whether a charm can handle an event or not can be determined by
|
||||
introspecting which events are defined on it.
|
||||
|
||||
Hooks or actions are created as symlinks to the charm code file
|
||||
which is determined by inspecting symlinks provided by the charm
|
||||
author at hooks/install or hooks/start.
|
||||
|
||||
charm_dir -- A root directory of the charm.
|
||||
charm -- An instance of the Charm class.
|
||||
|
||||
"""
|
||||
# XXX: on windows this function does not accomplish what it wants to:
|
||||
# it creates symlinks with no extension pointing to a .py
|
||||
# and juju only knows how to handle .exe, .bat, .cmd, and .ps1
|
||||
# so it does its job, but does not accomplish anything as the
|
||||
# hooks aren't 'callable'.
|
||||
link_to = os.path.realpath(os.environ.get("JUJU_DISPATCH_PATH", sys.argv[0]))
|
||||
for bound_event in charm.on.events().values():
|
||||
# Only events that originate from Juju need symlinks.
|
||||
if issubclass(bound_event.event_type, (ops.charm.HookEvent, ops.charm.ActionEvent)):
|
||||
_create_event_link(charm, bound_event, link_to)
|
||||
|
||||
|
||||
def _emit_charm_event(charm, event_name):
|
||||
"""Emits a charm event based on a Juju event name.
|
||||
|
||||
charm -- A charm instance to emit an event from.
|
||||
event_name -- A Juju event name to emit on a charm.
|
||||
"""
|
||||
event_to_emit = None
|
||||
try:
|
||||
event_to_emit = getattr(charm.on, event_name)
|
||||
except AttributeError:
|
||||
logger.debug("Event %s not defined for %s.", event_name, charm)
|
||||
|
||||
# If the event is not supported by the charm implementation, do
|
||||
# not error out or try to emit it. This is to support rollbacks.
|
||||
if event_to_emit is not None:
|
||||
args, kwargs = _get_event_args(charm, event_to_emit)
|
||||
logger.debug('Emitting Juju event %s.', event_name)
|
||||
event_to_emit.emit(*args, **kwargs)
|
||||
|
||||
|
||||
def _get_event_args(charm, bound_event):
|
||||
event_type = bound_event.event_type
|
||||
model = charm.framework.model
|
||||
|
||||
if issubclass(event_type, ops.charm.RelationEvent):
|
||||
relation_name = os.environ['JUJU_RELATION']
|
||||
relation_id = int(os.environ['JUJU_RELATION_ID'].split(':')[-1])
|
||||
relation = model.get_relation(relation_name, relation_id)
|
||||
else:
|
||||
relation = None
|
||||
|
||||
remote_app_name = os.environ.get('JUJU_REMOTE_APP', '')
|
||||
remote_unit_name = os.environ.get('JUJU_REMOTE_UNIT', '')
|
||||
if remote_app_name or remote_unit_name:
|
||||
if not remote_app_name:
|
||||
if '/' not in remote_unit_name:
|
||||
raise RuntimeError('invalid remote unit name: {}'.format(remote_unit_name))
|
||||
remote_app_name = remote_unit_name.split('/')[0]
|
||||
args = [relation, model.get_app(remote_app_name)]
|
||||
if remote_unit_name:
|
||||
args.append(model.get_unit(remote_unit_name))
|
||||
return args, {}
|
||||
elif relation:
|
||||
return [relation], {}
|
||||
return [], {}
|
||||
|
||||
|
||||
class _Dispatcher:
|
||||
"""Encapsulate how to figure out what event Juju wants us to run.
|
||||
|
||||
Also knows how to run “legacy” hooks when Juju called us via a top-level
|
||||
``dispatch`` binary.
|
||||
|
||||
Args:
|
||||
charm_dir: the toplevel directory of the charm
|
||||
|
||||
Attributes:
|
||||
event_name: the name of the event to run
|
||||
is_dispatch_aware: are we running under a Juju that knows about the
|
||||
dispatch binary, and is that binary present?
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, charm_dir: Path):
|
||||
self._charm_dir = charm_dir
|
||||
self._exec_path = Path(os.environ.get('JUJU_DISPATCH_PATH', sys.argv[0]))
|
||||
|
||||
dispatch = charm_dir / 'dispatch'
|
||||
if JujuVersion.from_environ().is_dispatch_aware() and _exe_path(dispatch) is not None:
|
||||
self._init_dispatch()
|
||||
else:
|
||||
self._init_legacy()
|
||||
|
||||
def ensure_event_links(self, charm):
|
||||
"""Make sure necessary symlinks are present on disk"""
|
||||
|
||||
if self.is_dispatch_aware:
|
||||
# links aren't needed
|
||||
return
|
||||
|
||||
# When a charm is force-upgraded and a unit is in an error state Juju
|
||||
# does not run upgrade-charm and instead runs the failed hook followed
|
||||
# by config-changed. Given the nature of force-upgrading the hook setup
|
||||
# code is not triggered on config-changed.
|
||||
#
|
||||
# 'start' event is included as Juju does not fire the install event for
|
||||
# K8s charms (see LP: #1854635).
|
||||
if (self.event_name in ('install', 'start', 'upgrade_charm')
|
||||
or self.event_name.endswith('_storage_attached')):
|
||||
_setup_event_links(self._charm_dir, charm)
|
||||
|
||||
def run_any_legacy_hook(self):
|
||||
"""Run any extant legacy hook.
|
||||
|
||||
If there is both a dispatch file and a legacy hook for the
|
||||
current event, run the wanted legacy hook.
|
||||
"""
|
||||
|
||||
if not self.is_dispatch_aware:
|
||||
# we *are* the legacy hook
|
||||
return
|
||||
|
||||
dispatch_path = _exe_path(self._charm_dir / self._dispatch_path)
|
||||
if dispatch_path is None:
|
||||
logger.debug("Legacy %s does not exist.", self._dispatch_path)
|
||||
return
|
||||
|
||||
# super strange that there isn't an is_executable
|
||||
if not os.access(str(dispatch_path), os.X_OK):
|
||||
logger.warning("Legacy %s exists but is not executable.", self._dispatch_path)
|
||||
return
|
||||
|
||||
if dispatch_path.resolve() == Path(sys.argv[0]).resolve():
|
||||
logger.debug("Legacy %s is just a link to ourselves.", self._dispatch_path)
|
||||
return
|
||||
|
||||
argv = sys.argv.copy()
|
||||
argv[0] = str(dispatch_path)
|
||||
logger.info("Running legacy %s.", self._dispatch_path)
|
||||
try:
|
||||
subprocess.run(argv, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("Legacy %s exited with status %d.", self._dispatch_path, e.returncode)
|
||||
sys.exit(e.returncode)
|
||||
except OSError as e:
|
||||
logger.warning("Unable to run legacy %s: %s", self._dispatch_path, e)
|
||||
sys.exit(1)
|
||||
else:
|
||||
logger.debug("Legacy %s exited with status 0.", self._dispatch_path)
|
||||
|
||||
def _set_name_from_path(self, path: Path):
|
||||
"""Sets the name attribute to that which can be inferred from the given path."""
|
||||
name = path.name.replace('-', '_')
|
||||
if path.parent.name == 'actions':
|
||||
name = '{}_action'.format(name)
|
||||
self.event_name = name
|
||||
|
||||
def _init_legacy(self):
|
||||
"""Set up the 'legacy' dispatcher.
|
||||
|
||||
The current Juju doesn't know about 'dispatch' and calls hooks
|
||||
explicitly.
|
||||
"""
|
||||
self.is_dispatch_aware = False
|
||||
self._set_name_from_path(self._exec_path)
|
||||
|
||||
def _init_dispatch(self):
|
||||
"""Set up the new 'dispatch' dispatcher.
|
||||
|
||||
The current Juju will run 'dispatch' if it exists, and otherwise fall
|
||||
back to the old behaviour.
|
||||
|
||||
JUJU_DISPATCH_PATH will be set to the wanted hook, e.g. hooks/install,
|
||||
in both cases.
|
||||
"""
|
||||
self._dispatch_path = Path(os.environ['JUJU_DISPATCH_PATH'])
|
||||
|
||||
if 'OPERATOR_DISPATCH' in os.environ:
|
||||
logger.debug("Charm called itself via %s.", self._dispatch_path)
|
||||
sys.exit(0)
|
||||
os.environ['OPERATOR_DISPATCH'] = '1'
|
||||
|
||||
self.is_dispatch_aware = True
|
||||
self._set_name_from_path(self._dispatch_path)
|
||||
|
||||
def is_restricted_context(self):
|
||||
""""Return True if we are running in a restricted Juju context.
|
||||
|
||||
When in a restricted context, most commands (relation-get, config-get,
|
||||
state-get) are not available. As such, we change how we interact with
|
||||
Juju.
|
||||
"""
|
||||
return self.event_name in ('collect_metrics',)
|
||||
|
||||
|
||||
def _should_use_controller_storage(db_path: Path, meta: ops.charm.CharmMeta) -> bool:
|
||||
"""Figure out whether we want to use controller storage or not."""
|
||||
# if you've previously used local state, carry on using that
|
||||
if db_path.exists():
|
||||
logger.debug("Using local storage: %s already exists", db_path)
|
||||
return False
|
||||
|
||||
# if you're not in k8s you don't need controller storage
|
||||
if 'kubernetes' not in meta.series:
|
||||
logger.debug("Using local storage: not a kubernetes charm")
|
||||
return False
|
||||
|
||||
# are we in a new enough Juju?
|
||||
cur_version = JujuVersion.from_environ()
|
||||
|
||||
if cur_version.has_controller_storage():
|
||||
logger.debug("Using controller storage: JUJU_VERSION=%s", cur_version)
|
||||
return True
|
||||
else:
|
||||
logger.debug("Using local storage: JUJU_VERSION=%s", cur_version)
|
||||
return False
|
||||
|
||||
|
||||
def main(charm_class: ops.charm.CharmBase, use_juju_for_storage: bool = None):
|
||||
"""Setup the charm and dispatch the observed event.
|
||||
|
||||
The event name is based on the way this executable was called (argv[0]).
|
||||
|
||||
Args:
|
||||
charm_class: your charm class.
|
||||
use_juju_for_storage: whether to use controller-side storage. If not specified
|
||||
then kubernetes charms that haven't previously used local storage and that
|
||||
are running on a new enough Juju default to controller-side storage,
|
||||
otherwise local storage is used.
|
||||
"""
|
||||
charm_dir = _get_charm_dir()
|
||||
|
||||
model_backend = ops.model._ModelBackend()
|
||||
debug = ('JUJU_DEBUG' in os.environ)
|
||||
setup_root_logging(model_backend, debug=debug)
|
||||
logger.debug("Operator Framework %s up and running.", ops.__version__)
|
||||
|
||||
dispatcher = _Dispatcher(charm_dir)
|
||||
dispatcher.run_any_legacy_hook()
|
||||
|
||||
metadata = (charm_dir / 'metadata.yaml').read_text()
|
||||
actions_meta = charm_dir / 'actions.yaml'
|
||||
if actions_meta.exists():
|
||||
actions_metadata = actions_meta.read_text()
|
||||
else:
|
||||
actions_metadata = None
|
||||
|
||||
if not yaml.__with_libyaml__:
|
||||
logger.debug('yaml does not have libyaml extensions, using slower pure Python yaml loader')
|
||||
meta = ops.charm.CharmMeta.from_yaml(metadata, actions_metadata)
|
||||
model = ops.model.Model(meta, model_backend)
|
||||
|
||||
charm_state_path = charm_dir / CHARM_STATE_FILE
|
||||
|
||||
if use_juju_for_storage and not ops.storage.juju_backend_available():
|
||||
# raise an exception; the charm is broken and needs fixing.
|
||||
msg = 'charm set use_juju_for_storage=True, but Juju version {} does not support it'
|
||||
raise RuntimeError(msg.format(JujuVersion.from_environ()))
|
||||
|
||||
if use_juju_for_storage is None:
|
||||
use_juju_for_storage = _should_use_controller_storage(charm_state_path, meta)
|
||||
|
||||
if use_juju_for_storage:
|
||||
if dispatcher.is_restricted_context():
|
||||
# TODO: jam 2020-06-30 This unconditionally avoids running a collect metrics event
|
||||
# Though we eventually expect that juju will run collect-metrics in a
|
||||
# non-restricted context. Once we can determine that we are running collect-metrics
|
||||
# in a non-restricted context, we should fire the event as normal.
|
||||
logger.debug('"%s" is not supported when using Juju for storage\n'
|
||||
'see: https://github.com/canonical/operator/issues/348',
|
||||
dispatcher.event_name)
|
||||
# Note that we don't exit nonzero, because that would cause Juju to rerun the hook
|
||||
return
|
||||
store = ops.storage.JujuStorage()
|
||||
else:
|
||||
store = ops.storage.SQLiteStorage(charm_state_path)
|
||||
framework = ops.framework.Framework(store, charm_dir, meta, model)
|
||||
try:
|
||||
sig = inspect.signature(charm_class)
|
||||
try:
|
||||
sig.bind(framework)
|
||||
except TypeError:
|
||||
msg = (
|
||||
"the second argument, 'key', has been deprecated and will be "
|
||||
"removed after the 0.7 release")
|
||||
warnings.warn(msg, DeprecationWarning)
|
||||
charm = charm_class(framework, None)
|
||||
else:
|
||||
charm = charm_class(framework)
|
||||
dispatcher.ensure_event_links(charm)
|
||||
|
||||
# TODO: Remove the collect_metrics check below as soon as the relevant
|
||||
# Juju changes are made.
|
||||
#
|
||||
# Skip reemission of deferred events for collect-metrics events because
|
||||
# they do not have the full access to all hook tools.
|
||||
if not dispatcher.is_restricted_context():
|
||||
framework.reemit()
|
||||
|
||||
_emit_charm_event(charm, dispatcher.event_name)
|
||||
|
||||
framework.commit()
|
||||
finally:
|
||||
framework.close()
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,318 @@
|
|||
# Copyright 2019-2020 Canonical Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from datetime import timedelta
|
||||
import pickle
|
||||
import shutil
|
||||
import subprocess
|
||||
import sqlite3
|
||||
import typing
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def _run(args, **kw):
|
||||
cmd = shutil.which(args[0])
|
||||
if cmd is None:
|
||||
raise FileNotFoundError(args[0])
|
||||
return subprocess.run([cmd, *args[1:]], **kw)
|
||||
|
||||
|
||||
class SQLiteStorage:
|
||||
|
||||
DB_LOCK_TIMEOUT = timedelta(hours=1)
|
||||
|
||||
def __init__(self, filename):
|
||||
# The isolation_level argument is set to None such that the implicit
|
||||
# transaction management behavior of the sqlite3 module is disabled.
|
||||
self._db = sqlite3.connect(str(filename),
|
||||
isolation_level=None,
|
||||
timeout=self.DB_LOCK_TIMEOUT.total_seconds())
|
||||
self._setup()
|
||||
|
||||
def _setup(self):
|
||||
# Make sure that the database is locked until the connection is closed,
|
||||
# not until the transaction ends.
|
||||
self._db.execute("PRAGMA locking_mode=EXCLUSIVE")
|
||||
c = self._db.execute("BEGIN")
|
||||
c.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='snapshot'")
|
||||
if c.fetchone()[0] == 0:
|
||||
# Keep in mind what might happen if the process dies somewhere below.
|
||||
# The system must not be rendered permanently broken by that.
|
||||
self._db.execute("CREATE TABLE snapshot (handle TEXT PRIMARY KEY, data BLOB)")
|
||||
self._db.execute('''
|
||||
CREATE TABLE notice (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_path TEXT,
|
||||
observer_path TEXT,
|
||||
method_name TEXT)
|
||||
''')
|
||||
self._db.commit()
|
||||
|
||||
def close(self):
|
||||
self._db.close()
|
||||
|
||||
def commit(self):
|
||||
self._db.commit()
|
||||
|
||||
# There's commit but no rollback. For abort to be supported, we'll need logic that
|
||||
# can rollback decisions made by third-party code in terms of the internal state
|
||||
# of objects that have been snapshotted, and hooks to let them know about it and
|
||||
# take the needed actions to undo their logic until the last snapshot.
|
||||
# This is doable but will increase significantly the chances for mistakes.
|
||||
|
||||
def save_snapshot(self, handle_path: str, snapshot_data: typing.Any) -> None:
|
||||
"""Part of the Storage API, persist a snapshot data under the given handle.
|
||||
|
||||
Args:
|
||||
handle_path: The string identifying the snapshot.
|
||||
snapshot_data: The data to be persisted. (as returned by Object.snapshot()). This
|
||||
might be a dict/tuple/int, but must only contain 'simple' python types.
|
||||
"""
|
||||
# Use pickle for serialization, so the value remains portable.
|
||||
raw_data = pickle.dumps(snapshot_data)
|
||||
self._db.execute("REPLACE INTO snapshot VALUES (?, ?)", (handle_path, raw_data))
|
||||
|
||||
def load_snapshot(self, handle_path: str) -> typing.Any:
|
||||
"""Part of the Storage API, retrieve a snapshot that was previously saved.
|
||||
|
||||
Args:
|
||||
handle_path: The string identifying the snapshot.
|
||||
Raises:
|
||||
NoSnapshotError: if there is no snapshot for the given handle_path.
|
||||
"""
|
||||
c = self._db.cursor()
|
||||
c.execute("SELECT data FROM snapshot WHERE handle=?", (handle_path,))
|
||||
row = c.fetchone()
|
||||
if row:
|
||||
return pickle.loads(row[0])
|
||||
raise NoSnapshotError(handle_path)
|
||||
|
||||
def drop_snapshot(self, handle_path: str):
|
||||
"""Part of the Storage API, remove a snapshot that was previously saved.
|
||||
|
||||
Dropping a snapshot that doesn't exist is treated as a no-op.
|
||||
"""
|
||||
self._db.execute("DELETE FROM snapshot WHERE handle=?", (handle_path,))
|
||||
|
||||
def list_snapshots(self) -> typing.Generator[str, None, None]:
|
||||
"""Return the name of all snapshots that are currently saved."""
|
||||
c = self._db.cursor()
|
||||
c.execute("SELECT handle FROM snapshot")
|
||||
while True:
|
||||
rows = c.fetchmany()
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
yield row[0]
|
||||
|
||||
def save_notice(self, event_path: str, observer_path: str, method_name: str) -> None:
|
||||
"""Part of the Storage API, record an notice (event and observer)"""
|
||||
self._db.execute('INSERT INTO notice VALUES (NULL, ?, ?, ?)',
|
||||
(event_path, observer_path, method_name))
|
||||
|
||||
def drop_notice(self, event_path: str, observer_path: str, method_name: str) -> None:
|
||||
"""Part of the Storage API, remove a notice that was previously recorded."""
|
||||
self._db.execute('''
|
||||
DELETE FROM notice
|
||||
WHERE event_path=?
|
||||
AND observer_path=?
|
||||
AND method_name=?
|
||||
''', (event_path, observer_path, method_name))
|
||||
|
||||
def notices(self, event_path: typing.Optional[str]) ->\
|
||||
typing.Generator[typing.Tuple[str, str, str], None, None]:
|
||||
"""Part of the Storage API, return all notices that begin with event_path.
|
||||
|
||||
Args:
|
||||
event_path: If supplied, will only yield events that match event_path. If not
|
||||
supplied (or None/'') will return all events.
|
||||
Returns:
|
||||
Iterable of (event_path, observer_path, method_name) tuples
|
||||
"""
|
||||
if event_path:
|
||||
c = self._db.execute('''
|
||||
SELECT event_path, observer_path, method_name
|
||||
FROM notice
|
||||
WHERE event_path=?
|
||||
ORDER BY sequence
|
||||
''', (event_path,))
|
||||
else:
|
||||
c = self._db.execute('''
|
||||
SELECT event_path, observer_path, method_name
|
||||
FROM notice
|
||||
ORDER BY sequence
|
||||
''')
|
||||
while True:
|
||||
rows = c.fetchmany()
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
yield tuple(row)
|
||||
|
||||
|
||||
class JujuStorage:
|
||||
""""Storing the content tracked by the Framework in Juju.
|
||||
|
||||
This uses :class:`_JujuStorageBackend` to interact with state-get/state-set
|
||||
as the way to store state for the framework and for components.
|
||||
"""
|
||||
|
||||
NOTICE_KEY = "#notices#"
|
||||
|
||||
def __init__(self, backend: '_JujuStorageBackend' = None):
|
||||
self._backend = backend
|
||||
if backend is None:
|
||||
self._backend = _JujuStorageBackend()
|
||||
|
||||
def close(self):
|
||||
return
|
||||
|
||||
def commit(self):
|
||||
return
|
||||
|
||||
def save_snapshot(self, handle_path: str, snapshot_data: typing.Any) -> None:
|
||||
self._backend.set(handle_path, snapshot_data)
|
||||
|
||||
def load_snapshot(self, handle_path):
|
||||
try:
|
||||
content = self._backend.get(handle_path)
|
||||
except KeyError:
|
||||
raise NoSnapshotError(handle_path)
|
||||
return content
|
||||
|
||||
def drop_snapshot(self, handle_path):
|
||||
self._backend.delete(handle_path)
|
||||
|
||||
def save_notice(self, event_path: str, observer_path: str, method_name: str):
|
||||
notice_list = self._load_notice_list()
|
||||
notice_list.append([event_path, observer_path, method_name])
|
||||
self._save_notice_list(notice_list)
|
||||
|
||||
def drop_notice(self, event_path: str, observer_path: str, method_name: str):
|
||||
notice_list = self._load_notice_list()
|
||||
notice_list.remove([event_path, observer_path, method_name])
|
||||
self._save_notice_list(notice_list)
|
||||
|
||||
def notices(self, event_path: str):
|
||||
notice_list = self._load_notice_list()
|
||||
for row in notice_list:
|
||||
if row[0] != event_path:
|
||||
continue
|
||||
yield tuple(row)
|
||||
|
||||
def _load_notice_list(self) -> typing.List[typing.Tuple[str]]:
|
||||
try:
|
||||
notice_list = self._backend.get(self.NOTICE_KEY)
|
||||
except KeyError:
|
||||
return []
|
||||
if notice_list is None:
|
||||
return []
|
||||
return notice_list
|
||||
|
||||
def _save_notice_list(self, notices: typing.List[typing.Tuple[str]]) -> None:
|
||||
self._backend.set(self.NOTICE_KEY, notices)
|
||||
|
||||
|
||||
class _SimpleLoader(getattr(yaml, 'CSafeLoader', yaml.SafeLoader)):
|
||||
"""Handle a couple basic python types.
|
||||
|
||||
yaml.SafeLoader can handle all the basic int/float/dict/set/etc that we want. The only one
|
||||
that it *doesn't* handle is tuples. We don't want to support arbitrary types, so we just
|
||||
subclass SafeLoader and add tuples back in.
|
||||
"""
|
||||
# Taken from the example at:
|
||||
# https://stackoverflow.com/questions/9169025/how-can-i-add-a-python-tuple-to-a-yaml-file-using-pyyaml
|
||||
|
||||
construct_python_tuple = yaml.Loader.construct_python_tuple
|
||||
|
||||
|
||||
_SimpleLoader.add_constructor(
|
||||
u'tag:yaml.org,2002:python/tuple',
|
||||
_SimpleLoader.construct_python_tuple)
|
||||
|
||||
|
||||
class _SimpleDumper(getattr(yaml, 'CSafeDumper', yaml.SafeDumper)):
|
||||
"""Add types supported by 'marshal'
|
||||
|
||||
YAML can support arbitrary types, but that is generally considered unsafe (like pickle). So
|
||||
we want to only support dumping out types that are safe to load.
|
||||
"""
|
||||
|
||||
|
||||
_SimpleDumper.represent_tuple = yaml.Dumper.represent_tuple
|
||||
_SimpleDumper.add_representer(tuple, _SimpleDumper.represent_tuple)
|
||||
|
||||
|
||||
def juju_backend_available() -> bool:
|
||||
"""Check if Juju state storage is available."""
|
||||
p = shutil.which('state-get')
|
||||
return p is not None
|
||||
|
||||
|
||||
class _JujuStorageBackend:
|
||||
"""Implements the interface from the Operator framework to Juju's state-get/set/etc."""
|
||||
|
||||
def set(self, key: str, value: typing.Any) -> None:
|
||||
"""Set a key to a given value.
|
||||
|
||||
Args:
|
||||
key: The string key that will be used to find the value later
|
||||
value: Arbitrary content that will be returned by get().
|
||||
Raises:
|
||||
CalledProcessError: if 'state-set' returns an error code.
|
||||
"""
|
||||
# default_flow_style=None means that it can use Block for
|
||||
# complex types (types that have nested types) but use flow
|
||||
# for simple types (like an array). Not all versions of PyYAML
|
||||
# have the same default style.
|
||||
encoded_value = yaml.dump(value, Dumper=_SimpleDumper, default_flow_style=None)
|
||||
content = yaml.dump(
|
||||
{key: encoded_value}, encoding='utf8', default_style='|',
|
||||
default_flow_style=False,
|
||||
Dumper=_SimpleDumper)
|
||||
_run(["state-set", "--file", "-"], input=content, check=True)
|
||||
|
||||
def get(self, key: str) -> typing.Any:
|
||||
"""Get the bytes value associated with a given key.
|
||||
|
||||
Args:
|
||||
key: The string key that will be used to find the value
|
||||
Raises:
|
||||
CalledProcessError: if 'state-get' returns an error code.
|
||||
"""
|
||||
# We don't capture stderr here so it can end up in debug logs.
|
||||
p = _run(["state-get", key], stdout=subprocess.PIPE, check=True, universal_newlines=True)
|
||||
if p.stdout == '' or p.stdout == '\n':
|
||||
raise KeyError(key)
|
||||
return yaml.load(p.stdout, Loader=_SimpleLoader)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Remove a key from being tracked.
|
||||
|
||||
Args:
|
||||
key: The key to stop storing
|
||||
Raises:
|
||||
CalledProcessError: if 'state-delete' returns an error code.
|
||||
"""
|
||||
_run(["state-delete", key], check=True)
|
||||
|
||||
|
||||
class NoSnapshotError(Exception):
|
||||
|
||||
def __init__(self, handle_path):
|
||||
self.handle_path = handle_path
|
||||
|
||||
def __str__(self):
|
||||
return 'no snapshot data found for {} object'.format(self.handle_path)
|
||||
|
|
@ -0,0 +1,818 @@
|
|||
# Copyright 2020 Canonical Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import inspect
|
||||
import pathlib
|
||||
import random
|
||||
import tempfile
|
||||
import typing
|
||||
import yaml
|
||||
from contextlib import contextmanager
|
||||
from textwrap import dedent
|
||||
|
||||
from ops import (
|
||||
charm,
|
||||
framework,
|
||||
model,
|
||||
storage,
|
||||
)
|
||||
|
||||
|
||||
# OptionalYAML is something like metadata.yaml or actions.yaml. You can
|
||||
# pass in a file-like object or the string directly.
|
||||
OptionalYAML = typing.Optional[typing.Union[str, typing.TextIO]]
|
||||
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
class Harness:
|
||||
"""This class represents a way to build up the model that will drive a test suite.
|
||||
|
||||
The model that is created is from the viewpoint of the charm that you are testing.
|
||||
|
||||
Example::
|
||||
|
||||
harness = Harness(MyCharm)
|
||||
# Do initial setup here
|
||||
relation_id = harness.add_relation('db', 'postgresql')
|
||||
# Now instantiate the charm to see events as the model changes
|
||||
harness.begin()
|
||||
harness.add_relation_unit(relation_id, 'postgresql/0')
|
||||
harness.update_relation_data(relation_id, 'postgresql/0', {'key': 'val'})
|
||||
# Check that charm has properly handled the relation_joined event for postgresql/0
|
||||
self.assertEqual(harness.charm. ...)
|
||||
|
||||
Args:
|
||||
charm_cls: The Charm class that you'll be testing.
|
||||
meta: charm.CharmBase is a A string or file-like object containing the contents of
|
||||
metadata.yaml. If not supplied, we will look for a 'metadata.yaml' file in the
|
||||
parent directory of the Charm, and if not found fall back to a trivial
|
||||
'name: test-charm' metadata.
|
||||
actions: A string or file-like object containing the contents of
|
||||
actions.yaml. If not supplied, we will look for a 'actions.yaml' file in the
|
||||
parent directory of the Charm.
|
||||
config: A string or file-like object containing the contents of
|
||||
config.yaml. If not supplied, we will look for a 'config.yaml' file in the
|
||||
parent directory of the Charm.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
charm_cls: typing.Type[charm.CharmBase],
|
||||
*,
|
||||
meta: OptionalYAML = None,
|
||||
actions: OptionalYAML = None,
|
||||
config: OptionalYAML = None):
|
||||
self._charm_cls = charm_cls
|
||||
self._charm = None
|
||||
self._charm_dir = 'no-disk-path' # this may be updated by _create_meta
|
||||
self._meta = self._create_meta(meta, actions)
|
||||
self._unit_name = self._meta.name + '/0'
|
||||
self._framework = None
|
||||
self._hooks_enabled = True
|
||||
self._relation_id_counter = 0
|
||||
self._backend = _TestingModelBackend(self._unit_name, self._meta)
|
||||
self._model = model.Model(self._meta, self._backend)
|
||||
self._storage = storage.SQLiteStorage(':memory:')
|
||||
self._oci_resources = {}
|
||||
self._framework = framework.Framework(
|
||||
self._storage, self._charm_dir, self._meta, self._model)
|
||||
self._update_config(key_values=self._load_config_defaults(config))
|
||||
|
||||
@property
|
||||
def charm(self) -> charm.CharmBase:
|
||||
"""Return the instance of the charm class that was passed to __init__.
|
||||
|
||||
Note that the Charm is not instantiated until you have called
|
||||
:meth:`.begin()`.
|
||||
"""
|
||||
return self._charm
|
||||
|
||||
@property
|
||||
def model(self) -> model.Model:
|
||||
"""Return the :class:`~ops.model.Model` that is being driven by this Harness."""
|
||||
return self._model
|
||||
|
||||
@property
|
||||
def framework(self) -> framework.Framework:
|
||||
"""Return the Framework that is being driven by this Harness."""
|
||||
return self._framework
|
||||
|
||||
def begin(self) -> None:
|
||||
"""Instantiate the Charm and start handling events.
|
||||
|
||||
Before calling :meth:`.begin`(), there is no Charm instance, so changes to the Model won't
|
||||
emit events. You must call :meth:`.begin` before :attr:`.charm` is valid.
|
||||
"""
|
||||
if self._charm is not None:
|
||||
raise RuntimeError('cannot call the begin method on the harness more than once')
|
||||
|
||||
# The Framework adds attributes to class objects for events, etc. As such, we can't re-use
|
||||
# the original class against multiple Frameworks. So create a locally defined class
|
||||
# and register it.
|
||||
# TODO: jam 2020-03-16 We are looking to changes this to Instance attributes instead of
|
||||
# Class attributes which should clean up this ugliness. The API can stay the same
|
||||
class TestEvents(self._charm_cls.on.__class__):
|
||||
pass
|
||||
|
||||
TestEvents.__name__ = self._charm_cls.on.__class__.__name__
|
||||
|
||||
class TestCharm(self._charm_cls):
|
||||
on = TestEvents()
|
||||
|
||||
# Note: jam 2020-03-01 This is so that errors in testing say MyCharm has no attribute foo,
|
||||
# rather than TestCharm has no attribute foo.
|
||||
TestCharm.__name__ = self._charm_cls.__name__
|
||||
self._charm = TestCharm(self._framework)
|
||||
|
||||
def begin_with_initial_hooks(self) -> None:
|
||||
"""Called when you want the Harness to fire the same hooks that Juju would fire at startup.
|
||||
|
||||
This triggers install, relation-created, config-changed, start, and any relation-joined
|
||||
hooks. Based on what relations have been defined before you called begin().
|
||||
Note that all of these are fired before returning control to the test suite, so if you
|
||||
want to introspect what happens at each step, you need to fire them directly
|
||||
(eg Charm.on.install.emit()).
|
||||
|
||||
To use this with all the normal hooks, you should instantiate the harness, setup any
|
||||
relations that you want active when the charm starts, and then call this method.
|
||||
|
||||
Example::
|
||||
|
||||
harness = Harness(MyCharm)
|
||||
# Do initial setup here
|
||||
relation_id = harness.add_relation('db', 'postgresql')
|
||||
harness.add_relation_unit(relation_id, 'postgresql/0')
|
||||
harness.update_relation_data(relation_id, 'postgresql/0', {'key': 'val'})
|
||||
harness.set_leader(True)
|
||||
harness.update_config({'initial': 'config'})
|
||||
harness.begin_with_initial_hooks()
|
||||
# This will cause
|
||||
# install, db-relation-created('postgresql'), leader-elected, config-changed, start
|
||||
# db-relation-joined('postrgesql/0'), db-relation-changed('postgresql/0')
|
||||
# To be fired.
|
||||
"""
|
||||
self.begin()
|
||||
# TODO: jam 2020-08-03 This should also handle storage-attached hooks once we have support
|
||||
# for dealing with storage.
|
||||
self._charm.on.install.emit()
|
||||
# Juju itself iterates what relation to fire based on a map[int]relation, so it doesn't
|
||||
# guarantee a stable ordering between relation events. It *does* give a stable ordering
|
||||
# of joined units for a given relation.
|
||||
items = list(self._meta.relations.items())
|
||||
random.shuffle(items)
|
||||
this_app_name = self._meta.name
|
||||
for relname, rel_meta in items:
|
||||
if rel_meta.role == charm.RelationRole.peer:
|
||||
# If the user has directly added a relation, leave it be, but otherwise ensure
|
||||
# that peer relations are always established at before leader-elected.
|
||||
rel_ids = self._backend._relation_ids_map.get(relname)
|
||||
if rel_ids is None:
|
||||
self.add_relation(relname, self._meta.name)
|
||||
else:
|
||||
random.shuffle(rel_ids)
|
||||
for rel_id in rel_ids:
|
||||
self._emit_relation_created(relname, rel_id, this_app_name)
|
||||
else:
|
||||
rel_ids = self._backend._relation_ids_map.get(relname, [])
|
||||
random.shuffle(rel_ids)
|
||||
for rel_id in rel_ids:
|
||||
app_name = self._backend._relation_app_and_units[rel_id]["app"]
|
||||
self._emit_relation_created(relname, rel_id, app_name)
|
||||
if self._backend._is_leader:
|
||||
self._charm.on.leader_elected.emit()
|
||||
else:
|
||||
self._charm.on.leader_settings_changed.emit()
|
||||
self._charm.on.config_changed.emit()
|
||||
self._charm.on.start.emit()
|
||||
all_ids = list(self._backend._relation_names.items())
|
||||
random.shuffle(all_ids)
|
||||
for rel_id, rel_name in all_ids:
|
||||
rel_app_and_units = self._backend._relation_app_and_units[rel_id]
|
||||
app_name = rel_app_and_units["app"]
|
||||
# Note: Juju *does* fire relation events for a given relation in the sorted order of
|
||||
# the unit names. It also always fires relation-changed immediately after
|
||||
# relation-joined for the same unit.
|
||||
# Juju only fires relation-changed (app) if there is data for the related application
|
||||
relation = self._model.get_relation(rel_name, rel_id)
|
||||
if self._backend._relation_data[rel_id].get(app_name):
|
||||
app = self._model.get_app(app_name)
|
||||
self._charm.on[rel_name].relation_changed.emit(
|
||||
relation, app, None)
|
||||
for unit_name in sorted(rel_app_and_units["units"]):
|
||||
remote_unit = self._model.get_unit(unit_name)
|
||||
self._charm.on[rel_name].relation_joined.emit(
|
||||
relation, remote_unit.app, remote_unit)
|
||||
self._charm.on[rel_name].relation_changed.emit(
|
||||
relation, remote_unit.app, remote_unit)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Called by your test infrastructure to cleanup any temporary directories/files/etc.
|
||||
|
||||
Currently this only needs to be called if you test with resources. But it is reasonable
|
||||
to always include a `testcase.addCleanup(harness.cleanup)` just in case.
|
||||
"""
|
||||
self._backend._cleanup()
|
||||
|
||||
def _create_meta(self, charm_metadata, action_metadata):
|
||||
"""Create a CharmMeta object.
|
||||
|
||||
Handle the cases where a user doesn't supply explicit metadata snippets.
|
||||
"""
|
||||
filename = inspect.getfile(self._charm_cls)
|
||||
charm_dir = pathlib.Path(filename).parents[1]
|
||||
|
||||
if charm_metadata is None:
|
||||
metadata_path = charm_dir / 'metadata.yaml'
|
||||
if metadata_path.is_file():
|
||||
charm_metadata = metadata_path.read_text()
|
||||
self._charm_dir = charm_dir
|
||||
else:
|
||||
# The simplest of metadata that the framework can support
|
||||
charm_metadata = 'name: test-charm'
|
||||
elif isinstance(charm_metadata, str):
|
||||
charm_metadata = dedent(charm_metadata)
|
||||
|
||||
if action_metadata is None:
|
||||
actions_path = charm_dir / 'actions.yaml'
|
||||
if actions_path.is_file():
|
||||
action_metadata = actions_path.read_text()
|
||||
self._charm_dir = charm_dir
|
||||
elif isinstance(action_metadata, str):
|
||||
action_metadata = dedent(action_metadata)
|
||||
|
||||
return charm.CharmMeta.from_yaml(charm_metadata, action_metadata)
|
||||
|
||||
def _load_config_defaults(self, charm_config):
|
||||
"""Load default values from config.yaml
|
||||
|
||||
Handle the case where a user doesn't supply explicit config snippets.
|
||||
"""
|
||||
filename = inspect.getfile(self._charm_cls)
|
||||
charm_dir = pathlib.Path(filename).parents[1]
|
||||
|
||||
if charm_config is None:
|
||||
config_path = charm_dir / 'config.yaml'
|
||||
if config_path.is_file():
|
||||
charm_config = config_path.read_text()
|
||||
self._charm_dir = charm_dir
|
||||
else:
|
||||
# The simplest of config that the framework can support
|
||||
charm_config = '{}'
|
||||
elif isinstance(charm_config, str):
|
||||
charm_config = dedent(charm_config)
|
||||
charm_config = yaml.load(charm_config, Loader=yaml.SafeLoader)
|
||||
charm_config = charm_config.get('options', {})
|
||||
return {key: value['default'] for key, value in charm_config.items()
|
||||
if 'default' in value}
|
||||
|
||||
def add_oci_resource(self, resource_name: str,
|
||||
contents: typing.Mapping[str, str] = None) -> None:
|
||||
"""Add oci resources to the backend.
|
||||
|
||||
This will register an oci resource and create a temporary file for processing metadata
|
||||
about the resource. A default set of values will be used for all the file contents
|
||||
unless a specific contents dict is provided.
|
||||
|
||||
Args:
|
||||
resource_name: Name of the resource to add custom contents to.
|
||||
contents: Optional custom dict to write for the named resource.
|
||||
"""
|
||||
if not contents:
|
||||
contents = {'registrypath': 'registrypath',
|
||||
'username': 'username',
|
||||
'password': 'password',
|
||||
}
|
||||
if resource_name not in self._meta.resources.keys():
|
||||
raise RuntimeError('Resource {} is not a defined resources'.format(resource_name))
|
||||
if self._meta.resources[resource_name].type != "oci-image":
|
||||
raise RuntimeError('Resource {} is not an OCI Image'.format(resource_name))
|
||||
|
||||
as_yaml = yaml.dump(contents, Dumper=yaml.SafeDumper)
|
||||
self._backend._resources_map[resource_name] = ('contents.yaml', as_yaml)
|
||||
|
||||
def add_resource(self, resource_name: str, content: typing.AnyStr) -> None:
|
||||
"""Add content for a resource to the backend.
|
||||
|
||||
This will register the content, so that a call to `Model.resources.fetch(resource_name)`
|
||||
will return a path to a file containing that content.
|
||||
|
||||
Args:
|
||||
resource_name: The name of the resource being added
|
||||
contents: Either string or bytes content, which will be the content of the filename
|
||||
returned by resource-get. If contents is a string, it will be encoded in utf-8
|
||||
"""
|
||||
if resource_name not in self._meta.resources.keys():
|
||||
raise RuntimeError('Resource {} is not a defined resources'.format(resource_name))
|
||||
record = self._meta.resources[resource_name]
|
||||
if record.type != "file":
|
||||
raise RuntimeError(
|
||||
'Resource {} is not a file, but actually {}'.format(resource_name, record.type))
|
||||
filename = record.filename
|
||||
if filename is None:
|
||||
filename = resource_name
|
||||
|
||||
self._backend._resources_map[resource_name] = (filename, content)
|
||||
|
||||
def populate_oci_resources(self) -> None:
|
||||
"""Populate all OCI resources."""
|
||||
for name, data in self._meta.resources.items():
|
||||
if data.type == "oci-image":
|
||||
self.add_oci_resource(name)
|
||||
|
||||
def disable_hooks(self) -> None:
|
||||
"""Stop emitting hook events when the model changes.
|
||||
|
||||
This can be used by developers to stop changes to the model from emitting events that
|
||||
the charm will react to. Call :meth:`.enable_hooks`
|
||||
to re-enable them.
|
||||
"""
|
||||
self._hooks_enabled = False
|
||||
|
||||
def enable_hooks(self) -> None:
|
||||
"""Re-enable hook events from charm.on when the model is changed.
|
||||
|
||||
By default hook events are enabled once you call :meth:`.begin`,
|
||||
but if you have used :meth:`.disable_hooks`, this can be used to
|
||||
enable them again.
|
||||
"""
|
||||
self._hooks_enabled = True
|
||||
|
||||
@contextmanager
|
||||
def hooks_disabled(self):
|
||||
"""A context manager to run code with hooks disabled.
|
||||
|
||||
Example::
|
||||
|
||||
with harness.hooks_disabled():
|
||||
# things in here don't fire events
|
||||
harness.set_leader(True)
|
||||
harness.update_config(unset=['foo', 'bar'])
|
||||
# things here will again fire events
|
||||
"""
|
||||
self.disable_hooks()
|
||||
try:
|
||||
yield None
|
||||
finally:
|
||||
self.enable_hooks()
|
||||
|
||||
def _next_relation_id(self):
|
||||
rel_id = self._relation_id_counter
|
||||
self._relation_id_counter += 1
|
||||
return rel_id
|
||||
|
||||
def add_relation(self, relation_name: str, remote_app: str) -> int:
|
||||
"""Declare that there is a new relation between this app and `remote_app`.
|
||||
|
||||
Args:
|
||||
relation_name: The relation on Charm that is being related to
|
||||
remote_app: The name of the application that is being related to
|
||||
|
||||
Return:
|
||||
The relation_id created by this add_relation.
|
||||
"""
|
||||
rel_id = self._next_relation_id()
|
||||
self._backend._relation_ids_map.setdefault(relation_name, []).append(rel_id)
|
||||
self._backend._relation_names[rel_id] = relation_name
|
||||
self._backend._relation_list_map[rel_id] = []
|
||||
self._backend._relation_data[rel_id] = {
|
||||
remote_app: {},
|
||||
self._backend.unit_name: {},
|
||||
self._backend.app_name: {},
|
||||
}
|
||||
self._backend._relation_app_and_units[rel_id] = {
|
||||
"app": remote_app,
|
||||
"units": [],
|
||||
}
|
||||
# Reload the relation_ids list
|
||||
if self._model is not None:
|
||||
self._model.relations._invalidate(relation_name)
|
||||
self._emit_relation_created(relation_name, rel_id, remote_app)
|
||||
return rel_id
|
||||
|
||||
def _emit_relation_created(self, relation_name: str, relation_id: int,
|
||||
remote_app: str) -> None:
|
||||
"""Trigger relation-created for a given relation with a given remote application."""
|
||||
if self._charm is None or not self._hooks_enabled:
|
||||
return
|
||||
if self._charm is None or not self._hooks_enabled:
|
||||
return
|
||||
relation = self._model.get_relation(relation_name, relation_id)
|
||||
app = self._model.get_app(remote_app)
|
||||
self._charm.on[relation_name].relation_created.emit(
|
||||
relation, app)
|
||||
|
||||
def add_relation_unit(self, relation_id: int, remote_unit_name: str) -> None:
|
||||
"""Add a new unit to a relation.
|
||||
|
||||
Example::
|
||||
|
||||
rel_id = harness.add_relation('db', 'postgresql')
|
||||
harness.add_relation_unit(rel_id, 'postgresql/0')
|
||||
|
||||
This will trigger a `relation_joined` event. This would naturally be
|
||||
followed by a `relation_changed` event, which you can trigger with
|
||||
:meth:`.update_relation_data`. This separation is artificial in the
|
||||
sense that Juju will always fire the two, but is intended to make
|
||||
testing relations and their data bags slightly more natural.
|
||||
|
||||
Args:
|
||||
relation_id: The integer relation identifier (as returned by add_relation).
|
||||
remote_unit_name: A string representing the remote unit that is being added.
|
||||
Return:
|
||||
None
|
||||
"""
|
||||
self._backend._relation_list_map[relation_id].append(remote_unit_name)
|
||||
self._backend._relation_data[relation_id][remote_unit_name] = {}
|
||||
# TODO: jam 2020-08-03 This is where we could assert that the unit name matches the
|
||||
# application name (eg you don't have a relation to 'foo' but add units of 'bar/0'
|
||||
self._backend._relation_app_and_units[relation_id]["units"].append(remote_unit_name)
|
||||
relation_name = self._backend._relation_names[relation_id]
|
||||
# Make sure that the Model reloads the relation_list for this relation_id, as well as
|
||||
# reloading the relation data for this unit.
|
||||
if self._model is not None:
|
||||
remote_unit = self._model.get_unit(remote_unit_name)
|
||||
relation = self._model.get_relation(relation_name, relation_id)
|
||||
unit_cache = relation.data.get(remote_unit, None)
|
||||
if unit_cache is not None:
|
||||
unit_cache._invalidate()
|
||||
self._model.relations._invalidate(relation_name)
|
||||
if self._charm is None or not self._hooks_enabled:
|
||||
return
|
||||
self._charm.on[relation_name].relation_joined.emit(
|
||||
relation, remote_unit.app, remote_unit)
|
||||
|
||||
def get_relation_data(self, relation_id: int, app_or_unit: str) -> typing.Mapping:
|
||||
"""Get the relation data bucket for a single app or unit in a given relation.
|
||||
|
||||
This ignores all of the safety checks of who can and can't see data in relations (eg,
|
||||
non-leaders can't read their own application's relation data because there are no events
|
||||
that keep that data up-to-date for the unit).
|
||||
|
||||
Args:
|
||||
relation_id: The relation whose content we want to look at.
|
||||
app_or_unit: The name of the application or unit whose data we want to read
|
||||
Return:
|
||||
a dict containing the relation data for `app_or_unit` or None.
|
||||
Raises:
|
||||
KeyError: if relation_id doesn't exist
|
||||
"""
|
||||
return self._backend._relation_data[relation_id].get(app_or_unit, None)
|
||||
|
||||
def get_pod_spec(self) -> (typing.Mapping, typing.Mapping):
|
||||
"""Return the content of the pod spec as last set by the charm.
|
||||
|
||||
This returns both the pod spec and any k8s_resources that were supplied.
|
||||
See the signature of Model.pod.set_spec
|
||||
"""
|
||||
return self._backend._pod_spec
|
||||
|
||||
def get_workload_version(self) -> str:
|
||||
"""Read the workload version that was set by the unit."""
|
||||
return self._backend._workload_version
|
||||
|
||||
def set_model_name(self, name: str) -> None:
|
||||
"""Set the name of the Model that this is representing.
|
||||
|
||||
This cannot be called once begin() has been called. But it lets you set the value that
|
||||
will be returned by Model.name.
|
||||
"""
|
||||
if self._charm is not None:
|
||||
raise RuntimeError('cannot set the Model name after begin()')
|
||||
self._backend.model_name = name
|
||||
|
||||
def update_relation_data(
|
||||
self,
|
||||
relation_id: int,
|
||||
app_or_unit: str,
|
||||
key_values: typing.Mapping,
|
||||
) -> None:
|
||||
"""Update the relation data for a given unit or application in a given relation.
|
||||
|
||||
This also triggers the `relation_changed` event for this relation_id.
|
||||
|
||||
Args:
|
||||
relation_id: The integer relation_id representing this relation.
|
||||
app_or_unit: The unit or application name that is being updated.
|
||||
This can be the local or remote application.
|
||||
key_values: Each key/value will be updated in the relation data.
|
||||
"""
|
||||
relation_name = self._backend._relation_names[relation_id]
|
||||
relation = self._model.get_relation(relation_name, relation_id)
|
||||
if '/' in app_or_unit:
|
||||
entity = self._model.get_unit(app_or_unit)
|
||||
else:
|
||||
entity = self._model.get_app(app_or_unit)
|
||||
rel_data = relation.data.get(entity, None)
|
||||
if rel_data is not None:
|
||||
# rel_data may have cached now-stale data, so _invalidate() it.
|
||||
# Note, this won't cause the data to be loaded if it wasn't already.
|
||||
rel_data._invalidate()
|
||||
|
||||
new_values = self._backend._relation_data[relation_id][app_or_unit].copy()
|
||||
for k, v in key_values.items():
|
||||
if v == '':
|
||||
new_values.pop(k, None)
|
||||
else:
|
||||
new_values[k] = v
|
||||
self._backend._relation_data[relation_id][app_or_unit] = new_values
|
||||
|
||||
if app_or_unit == self._model.unit.name:
|
||||
# No events for our own unit
|
||||
return
|
||||
if app_or_unit == self._model.app.name:
|
||||
# updating our own app only generates an event if it is a peer relation and we
|
||||
# aren't the leader
|
||||
is_peer = self._meta.relations[relation_name].role.is_peer()
|
||||
if not is_peer:
|
||||
return
|
||||
if self._model.unit.is_leader():
|
||||
return
|
||||
self._emit_relation_changed(relation_id, app_or_unit)
|
||||
|
||||
def _emit_relation_changed(self, relation_id, app_or_unit):
|
||||
if self._charm is None or not self._hooks_enabled:
|
||||
return
|
||||
rel_name = self._backend._relation_names[relation_id]
|
||||
relation = self.model.get_relation(rel_name, relation_id)
|
||||
if '/' in app_or_unit:
|
||||
app_name = app_or_unit.split('/')[0]
|
||||
unit_name = app_or_unit
|
||||
app = self.model.get_app(app_name)
|
||||
unit = self.model.get_unit(unit_name)
|
||||
args = (relation, app, unit)
|
||||
else:
|
||||
app_name = app_or_unit
|
||||
app = self.model.get_app(app_name)
|
||||
args = (relation, app)
|
||||
self._charm.on[rel_name].relation_changed.emit(*args)
|
||||
|
||||
def _update_config(
|
||||
self,
|
||||
key_values: typing.Mapping[str, str] = None,
|
||||
unset: typing.Iterable[str] = (),
|
||||
) -> None:
|
||||
"""Update the config as seen by the charm.
|
||||
|
||||
This will *not* trigger a `config_changed` event, and is intended for internal use.
|
||||
|
||||
Note that the `key_values` mapping will only add or update configuration items.
|
||||
To remove existing ones, see the `unset` parameter.
|
||||
|
||||
Args:
|
||||
key_values: A Mapping of key:value pairs to update in config.
|
||||
unset: An iterable of keys to remove from Config. (Note that this does
|
||||
not currently reset the config values to the default defined in config.yaml.)
|
||||
"""
|
||||
# NOTE: jam 2020-03-01 Note that this sort of works "by accident". Config
|
||||
# is a LazyMapping, but its _load returns a dict and this method mutates
|
||||
# the dict that Config is caching. Arguably we should be doing some sort
|
||||
# of charm.framework.model.config._invalidate()
|
||||
config = self._backend._config
|
||||
if key_values is not None:
|
||||
for key, value in key_values.items():
|
||||
config[key] = value
|
||||
for key in unset:
|
||||
config.pop(key, None)
|
||||
|
||||
def update_config(
|
||||
self,
|
||||
key_values: typing.Mapping[str, str] = None,
|
||||
unset: typing.Iterable[str] = (),
|
||||
) -> None:
|
||||
"""Update the config as seen by the charm.
|
||||
|
||||
This will trigger a `config_changed` event.
|
||||
|
||||
Note that the `key_values` mapping will only add or update configuration items.
|
||||
To remove existing ones, see the `unset` parameter.
|
||||
|
||||
Args:
|
||||
key_values: A Mapping of key:value pairs to update in config.
|
||||
unset: An iterable of keys to remove from Config. (Note that this does
|
||||
not currently reset the config values to the default defined in config.yaml.)
|
||||
"""
|
||||
self._update_config(key_values, unset)
|
||||
if self._charm is None or not self._hooks_enabled:
|
||||
return
|
||||
self._charm.on.config_changed.emit()
|
||||
|
||||
def set_leader(self, is_leader: bool = True) -> None:
|
||||
"""Set whether this unit is the leader or not.
|
||||
|
||||
If this charm becomes a leader then `leader_elected` will be triggered.
|
||||
|
||||
Args:
|
||||
is_leader: True/False as to whether this unit is the leader.
|
||||
"""
|
||||
was_leader = self._backend._is_leader
|
||||
self._backend._is_leader = is_leader
|
||||
# Note: jam 2020-03-01 currently is_leader is cached at the ModelBackend level, not in
|
||||
# the Model objects, so this automatically gets noticed.
|
||||
if is_leader and not was_leader and self._charm is not None and self._hooks_enabled:
|
||||
self._charm.on.leader_elected.emit()
|
||||
|
||||
def _get_backend_calls(self, reset: bool = True) -> list:
|
||||
"""Return the calls that we have made to the TestingModelBackend.
|
||||
|
||||
This is useful mostly for testing the framework itself, so that we can assert that we
|
||||
do/don't trigger extra calls.
|
||||
|
||||
Args:
|
||||
reset: If True, reset the calls list back to empty, if false, the call list is
|
||||
preserved.
|
||||
Return:
|
||||
``[(call1, args...), (call2, args...)]``
|
||||
"""
|
||||
calls = self._backend._calls.copy()
|
||||
if reset:
|
||||
self._backend._calls.clear()
|
||||
return calls
|
||||
|
||||
|
||||
def _record_calls(cls):
|
||||
"""Replace methods on cls with methods that record that they have been called.
|
||||
|
||||
Iterate all attributes of cls, and for public methods, replace them with a wrapped method
|
||||
that records the method called along with the arguments and keyword arguments.
|
||||
"""
|
||||
for meth_name, orig_method in cls.__dict__.items():
|
||||
if meth_name.startswith('_'):
|
||||
continue
|
||||
|
||||
def decorator(orig_method):
|
||||
def wrapped(self, *args, **kwargs):
|
||||
full_args = (orig_method.__name__,) + args
|
||||
if kwargs:
|
||||
full_args = full_args + (kwargs,)
|
||||
self._calls.append(full_args)
|
||||
return orig_method(self, *args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
setattr(cls, meth_name, decorator(orig_method))
|
||||
return cls
|
||||
|
||||
|
||||
class _ResourceEntry:
|
||||
"""Tracks the contents of a Resource."""
|
||||
|
||||
def __init__(self, resource_name):
|
||||
self.name = resource_name
|
||||
|
||||
|
||||
@_record_calls
|
||||
class _TestingModelBackend:
|
||||
"""This conforms to the interface for ModelBackend but provides canned data.
|
||||
|
||||
DO NOT use this class directly, it is used by `Harness`_ to drive the model.
|
||||
`Harness`_ is responsible for maintaining the internal consistency of the values here,
|
||||
as the only public methods of this type are for implementing ModelBackend.
|
||||
"""
|
||||
|
||||
def __init__(self, unit_name, meta):
|
||||
self.unit_name = unit_name
|
||||
self.app_name = self.unit_name.split('/')[0]
|
||||
self.model_name = None
|
||||
self._calls = []
|
||||
self._meta = meta
|
||||
self._is_leader = None
|
||||
self._relation_ids_map = {} # relation name to [relation_ids,...]
|
||||
self._relation_names = {} # reverse map from relation_id to relation_name
|
||||
self._relation_list_map = {} # relation_id: [unit_name,...]
|
||||
self._relation_data = {} # {relation_id: {name: data}}
|
||||
# {relation_id: {"app": app_name, "units": ["app/0",...]}
|
||||
self._relation_app_and_units = {}
|
||||
self._config = {}
|
||||
self._is_leader = False
|
||||
self._resources_map = {} # {resource_name: resource_content}
|
||||
self._pod_spec = None
|
||||
self._app_status = {'status': 'unknown', 'message': ''}
|
||||
self._unit_status = {'status': 'maintenance', 'message': ''}
|
||||
self._workload_version = None
|
||||
self._resource_dir = None
|
||||
|
||||
def _cleanup(self):
|
||||
if self._resource_dir is not None:
|
||||
self._resource_dir.cleanup()
|
||||
self._resource_dir = None
|
||||
|
||||
def _get_resource_dir(self) -> pathlib.Path:
|
||||
if self._resource_dir is None:
|
||||
# In actual Juju, the resource path for a charm's resource is
|
||||
# $AGENT_DIR/resources/$RESOURCE_NAME/$RESOURCE_FILENAME
|
||||
# However, charms shouldn't depend on this.
|
||||
self._resource_dir = tempfile.TemporaryDirectory(prefix='tmp-ops-test-resource-')
|
||||
return pathlib.Path(self._resource_dir.name)
|
||||
|
||||
def relation_ids(self, relation_name):
|
||||
try:
|
||||
return self._relation_ids_map[relation_name]
|
||||
except KeyError as e:
|
||||
if relation_name not in self._meta.relations:
|
||||
raise model.ModelError('{} is not a known relation'.format(relation_name)) from e
|
||||
return []
|
||||
|
||||
def relation_list(self, relation_id):
|
||||
try:
|
||||
return self._relation_list_map[relation_id]
|
||||
except KeyError as e:
|
||||
raise model.RelationNotFoundError from e
|
||||
|
||||
def relation_get(self, relation_id, member_name, is_app):
|
||||
if is_app and '/' in member_name:
|
||||
member_name = member_name.split('/')[0]
|
||||
if relation_id not in self._relation_data:
|
||||
raise model.RelationNotFoundError()
|
||||
return self._relation_data[relation_id][member_name].copy()
|
||||
|
||||
def relation_set(self, relation_id, key, value, is_app):
|
||||
relation = self._relation_data[relation_id]
|
||||
if is_app:
|
||||
bucket_key = self.app_name
|
||||
else:
|
||||
bucket_key = self.unit_name
|
||||
if bucket_key not in relation:
|
||||
relation[bucket_key] = {}
|
||||
bucket = relation[bucket_key]
|
||||
if value == '':
|
||||
bucket.pop(key, None)
|
||||
else:
|
||||
bucket[key] = value
|
||||
|
||||
def config_get(self):
|
||||
return self._config
|
||||
|
||||
def is_leader(self):
|
||||
return self._is_leader
|
||||
|
||||
def application_version_set(self, version):
|
||||
self._workload_version = version
|
||||
|
||||
def resource_get(self, resource_name):
|
||||
if resource_name not in self._resources_map:
|
||||
raise model.ModelError(
|
||||
"ERROR could not download resource: HTTP request failed: "
|
||||
"Get https://.../units/unit-{}/resources/{}: resource#{}/{} not found".format(
|
||||
self.unit_name.replace('/', '-'), resource_name, self.app_name, resource_name
|
||||
))
|
||||
filename, contents = self._resources_map[resource_name]
|
||||
resource_dir = self._get_resource_dir()
|
||||
resource_filename = resource_dir / resource_name / filename
|
||||
if not resource_filename.exists():
|
||||
if isinstance(contents, bytes):
|
||||
mode = 'wb'
|
||||
else:
|
||||
mode = 'wt'
|
||||
resource_filename.parent.mkdir(exist_ok=True)
|
||||
with resource_filename.open(mode=mode) as resource_file:
|
||||
resource_file.write(contents)
|
||||
return resource_filename
|
||||
|
||||
def pod_spec_set(self, spec, k8s_resources):
|
||||
self._pod_spec = (spec, k8s_resources)
|
||||
|
||||
def status_get(self, *, is_app=False):
|
||||
if is_app:
|
||||
return self._app_status
|
||||
else:
|
||||
return self._unit_status
|
||||
|
||||
def status_set(self, status, message='', *, is_app=False):
|
||||
if is_app:
|
||||
self._app_status = {'status': status, 'message': message}
|
||||
else:
|
||||
self._unit_status = {'status': status, 'message': message}
|
||||
|
||||
def storage_list(self, name):
|
||||
raise NotImplementedError(self.storage_list)
|
||||
|
||||
def storage_get(self, storage_name_id, attribute):
|
||||
raise NotImplementedError(self.storage_get)
|
||||
|
||||
def storage_add(self, name, count=1):
|
||||
raise NotImplementedError(self.storage_add)
|
||||
|
||||
def action_get(self):
|
||||
raise NotImplementedError(self.action_get)
|
||||
|
||||
def action_set(self, results):
|
||||
raise NotImplementedError(self.action_set)
|
||||
|
||||
def action_log(self, message):
|
||||
raise NotImplementedError(self.action_log)
|
||||
|
||||
def action_fail(self, message=''):
|
||||
raise NotImplementedError(self.action_fail)
|
||||
|
||||
def network_get(self, endpoint_name, relation_id=None):
|
||||
raise NotImplementedError(self.network_get)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# this is a generated file
|
||||
|
||||
version = '0.10.0'
|
||||
|
|
@ -0,0 +1,427 @@
|
|||
|
||||
from .error import *
|
||||
|
||||
from .tokens import *
|
||||
from .events import *
|
||||
from .nodes import *
|
||||
|
||||
from .loader import *
|
||||
from .dumper import *
|
||||
|
||||
__version__ = '5.3.1'
|
||||
try:
|
||||
from .cyaml import *
|
||||
__with_libyaml__ = True
|
||||
except ImportError:
|
||||
__with_libyaml__ = False
|
||||
|
||||
import io
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# Warnings control
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
# 'Global' warnings state:
|
||||
_warnings_enabled = {
|
||||
'YAMLLoadWarning': True,
|
||||
}
|
||||
|
||||
# Get or set global warnings' state
|
||||
def warnings(settings=None):
|
||||
if settings is None:
|
||||
return _warnings_enabled
|
||||
|
||||
if type(settings) is dict:
|
||||
for key in settings:
|
||||
if key in _warnings_enabled:
|
||||
_warnings_enabled[key] = settings[key]
|
||||
|
||||
# Warn when load() is called without Loader=...
|
||||
class YAMLLoadWarning(RuntimeWarning):
|
||||
pass
|
||||
|
||||
def load_warning(method):
|
||||
if _warnings_enabled['YAMLLoadWarning'] is False:
|
||||
return
|
||||
|
||||
import warnings
|
||||
|
||||
message = (
|
||||
"calling yaml.%s() without Loader=... is deprecated, as the "
|
||||
"default Loader is unsafe. Please read "
|
||||
"https://msg.pyyaml.org/load for full details."
|
||||
) % method
|
||||
|
||||
warnings.warn(message, YAMLLoadWarning, stacklevel=3)
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
def scan(stream, Loader=Loader):
|
||||
"""
|
||||
Scan a YAML stream and produce scanning tokens.
|
||||
"""
|
||||
loader = Loader(stream)
|
||||
try:
|
||||
while loader.check_token():
|
||||
yield loader.get_token()
|
||||
finally:
|
||||
loader.dispose()
|
||||
|
||||
def parse(stream, Loader=Loader):
|
||||
"""
|
||||
Parse a YAML stream and produce parsing events.
|
||||
"""
|
||||
loader = Loader(stream)
|
||||
try:
|
||||
while loader.check_event():
|
||||
yield loader.get_event()
|
||||
finally:
|
||||
loader.dispose()
|
||||
|
||||
def compose(stream, Loader=Loader):
|
||||
"""
|
||||
Parse the first YAML document in a stream
|
||||
and produce the corresponding representation tree.
|
||||
"""
|
||||
loader = Loader(stream)
|
||||
try:
|
||||
return loader.get_single_node()
|
||||
finally:
|
||||
loader.dispose()
|
||||
|
||||
def compose_all(stream, Loader=Loader):
|
||||
"""
|
||||
Parse all YAML documents in a stream
|
||||
and produce corresponding representation trees.
|
||||
"""
|
||||
loader = Loader(stream)
|
||||
try:
|
||||
while loader.check_node():
|
||||
yield loader.get_node()
|
||||
finally:
|
||||
loader.dispose()
|
||||
|
||||
def load(stream, Loader=None):
|
||||
"""
|
||||
Parse the first YAML document in a stream
|
||||
and produce the corresponding Python object.
|
||||
"""
|
||||
if Loader is None:
|
||||
load_warning('load')
|
||||
Loader = FullLoader
|
||||
|
||||
loader = Loader(stream)
|
||||
try:
|
||||
return loader.get_single_data()
|
||||
finally:
|
||||
loader.dispose()
|
||||
|
||||
def load_all(stream, Loader=None):
|
||||
"""
|
||||
Parse all YAML documents in a stream
|
||||
and produce corresponding Python objects.
|
||||
"""
|
||||
if Loader is None:
|
||||
load_warning('load_all')
|
||||
Loader = FullLoader
|
||||
|
||||
loader = Loader(stream)
|
||||
try:
|
||||
while loader.check_data():
|
||||
yield loader.get_data()
|
||||
finally:
|
||||
loader.dispose()
|
||||
|
||||
def full_load(stream):
|
||||
"""
|
||||
Parse the first YAML document in a stream
|
||||
and produce the corresponding Python object.
|
||||
|
||||
Resolve all tags except those known to be
|
||||
unsafe on untrusted input.
|
||||
"""
|
||||
return load(stream, FullLoader)
|
||||
|
||||
def full_load_all(stream):
|
||||
"""
|
||||
Parse all YAML documents in a stream
|
||||
and produce corresponding Python objects.
|
||||
|
||||
Resolve all tags except those known to be
|
||||
unsafe on untrusted input.
|
||||
"""
|
||||
return load_all(stream, FullLoader)
|
||||
|
||||
def safe_load(stream):
|
||||
"""
|
||||
Parse the first YAML document in a stream
|
||||
and produce the corresponding Python object.
|
||||
|
||||
Resolve only basic YAML tags. This is known
|
||||
to be safe for untrusted input.
|
||||
"""
|
||||
return load(stream, SafeLoader)
|
||||
|
||||
def safe_load_all(stream):
|
||||
"""
|
||||
Parse all YAML documents in a stream
|
||||
and produce corresponding Python objects.
|
||||
|
||||
Resolve only basic YAML tags. This is known
|
||||
to be safe for untrusted input.
|
||||
"""
|
||||
return load_all(stream, SafeLoader)
|
||||
|
||||
def unsafe_load(stream):
|
||||
"""
|
||||
Parse the first YAML document in a stream
|
||||
and produce the corresponding Python object.
|
||||
|
||||
Resolve all tags, even those known to be
|
||||
unsafe on untrusted input.
|
||||
"""
|
||||
return load(stream, UnsafeLoader)
|
||||
|
||||
def unsafe_load_all(stream):
|
||||
"""
|
||||
Parse all YAML documents in a stream
|
||||
and produce corresponding Python objects.
|
||||
|
||||
Resolve all tags, even those known to be
|
||||
unsafe on untrusted input.
|
||||
"""
|
||||
return load_all(stream, UnsafeLoader)
|
||||
|
||||
def emit(events, stream=None, Dumper=Dumper,
|
||||
canonical=None, indent=None, width=None,
|
||||
allow_unicode=None, line_break=None):
|
||||
"""
|
||||
Emit YAML parsing events into a stream.
|
||||
If stream is None, return the produced string instead.
|
||||
"""
|
||||
getvalue = None
|
||||
if stream is None:
|
||||
stream = io.StringIO()
|
||||
getvalue = stream.getvalue
|
||||
dumper = Dumper(stream, canonical=canonical, indent=indent, width=width,
|
||||
allow_unicode=allow_unicode, line_break=line_break)
|
||||
try:
|
||||
for event in events:
|
||||
dumper.emit(event)
|
||||
finally:
|
||||
dumper.dispose()
|
||||
if getvalue:
|
||||
return getvalue()
|
||||
|
||||
def serialize_all(nodes, stream=None, Dumper=Dumper,
|
||||
canonical=None, indent=None, width=None,
|
||||
allow_unicode=None, line_break=None,
|
||||
encoding=None, explicit_start=None, explicit_end=None,
|
||||
version=None, tags=None):
|
||||
"""
|
||||
Serialize a sequence of representation trees into a YAML stream.
|
||||
If stream is None, return the produced string instead.
|
||||
"""
|
||||
getvalue = None
|
||||
if stream is None:
|
||||
if encoding is None:
|
||||
stream = io.StringIO()
|
||||
else:
|
||||
stream = io.BytesIO()
|
||||
getvalue = stream.getvalue
|
||||
dumper = Dumper(stream, canonical=canonical, indent=indent, width=width,
|
||||
allow_unicode=allow_unicode, line_break=line_break,
|
||||
encoding=encoding, version=version, tags=tags,
|
||||
explicit_start=explicit_start, explicit_end=explicit_end)
|
||||
try:
|
||||
dumper.open()
|
||||
for node in nodes:
|
||||
dumper.serialize(node)
|
||||
dumper.close()
|
||||
finally:
|
||||
dumper.dispose()
|
||||
if getvalue:
|
||||
return getvalue()
|
||||
|
||||
def serialize(node, stream=None, Dumper=Dumper, **kwds):
|
||||
"""
|
||||
Serialize a representation tree into a YAML stream.
|
||||
If stream is None, return the produced string instead.
|
||||
"""
|
||||
return serialize_all([node], stream, Dumper=Dumper, **kwds)
|
||||
|
||||
def dump_all(documents, stream=None, Dumper=Dumper,
|
||||
default_style=None, default_flow_style=False,
|
||||
canonical=None, indent=None, width=None,
|
||||
allow_unicode=None, line_break=None,
|
||||
encoding=None, explicit_start=None, explicit_end=None,
|
||||
version=None, tags=None, sort_keys=True):
|
||||
"""
|
||||
Serialize a sequence of Python objects into a YAML stream.
|
||||
If stream is None, return the produced string instead.
|
||||
"""
|
||||
getvalue = None
|
||||
if stream is None:
|
||||
if encoding is None:
|
||||
stream = io.StringIO()
|
||||
else:
|
||||
stream = io.BytesIO()
|
||||
getvalue = stream.getvalue
|
||||
dumper = Dumper(stream, default_style=default_style,
|
||||
default_flow_style=default_flow_style,
|
||||
canonical=canonical, indent=indent, width=width,
|
||||
allow_unicode=allow_unicode, line_break=line_break,
|
||||
encoding=encoding, version=version, tags=tags,
|
||||
explicit_start=explicit_start, explicit_end=explicit_end, sort_keys=sort_keys)
|
||||
try:
|
||||
dumper.open()
|
||||
for data in documents:
|
||||
dumper.represent(data)
|
||||
dumper.close()
|
||||
finally:
|
||||
dumper.dispose()
|
||||
if getvalue:
|
||||
return getvalue()
|
||||
|
||||
def dump(data, stream=None, Dumper=Dumper, **kwds):
|
||||
"""
|
||||
Serialize a Python object into a YAML stream.
|
||||
If stream is None, return the produced string instead.
|
||||
"""
|
||||
return dump_all([data], stream, Dumper=Dumper, **kwds)
|
||||
|
||||
def safe_dump_all(documents, stream=None, **kwds):
|
||||
"""
|
||||
Serialize a sequence of Python objects into a YAML stream.
|
||||
Produce only basic YAML tags.
|
||||
If stream is None, return the produced string instead.
|
||||
"""
|
||||
return dump_all(documents, stream, Dumper=SafeDumper, **kwds)
|
||||
|
||||
def safe_dump(data, stream=None, **kwds):
|
||||
"""
|
||||
Serialize a Python object into a YAML stream.
|
||||
Produce only basic YAML tags.
|
||||
If stream is None, return the produced string instead.
|
||||
"""
|
||||
return dump_all([data], stream, Dumper=SafeDumper, **kwds)
|
||||
|
||||
def add_implicit_resolver(tag, regexp, first=None,
|
||||
Loader=None, Dumper=Dumper):
|
||||
"""
|
||||
Add an implicit scalar detector.
|
||||
If an implicit scalar value matches the given regexp,
|
||||
the corresponding tag is assigned to the scalar.
|
||||
first is a sequence of possible initial characters or None.
|
||||
"""
|
||||
if Loader is None:
|
||||
loader.Loader.add_implicit_resolver(tag, regexp, first)
|
||||
loader.FullLoader.add_implicit_resolver(tag, regexp, first)
|
||||
loader.UnsafeLoader.add_implicit_resolver(tag, regexp, first)
|
||||
else:
|
||||
Loader.add_implicit_resolver(tag, regexp, first)
|
||||
Dumper.add_implicit_resolver(tag, regexp, first)
|
||||
|
||||
def add_path_resolver(tag, path, kind=None, Loader=None, Dumper=Dumper):
|
||||
"""
|
||||
Add a path based resolver for the given tag.
|
||||
A path is a list of keys that forms a path
|
||||
to a node in the representation tree.
|
||||
Keys can be string values, integers, or None.
|
||||
"""
|
||||
if Loader is None:
|
||||
loader.Loader.add_path_resolver(tag, path, kind)
|
||||
loader.FullLoader.add_path_resolver(tag, path, kind)
|
||||
loader.UnsafeLoader.add_path_resolver(tag, path, kind)
|
||||
else:
|
||||
Loader.add_path_resolver(tag, path, kind)
|
||||
Dumper.add_path_resolver(tag, path, kind)
|
||||
|
||||
def add_constructor(tag, constructor, Loader=None):
|
||||
"""
|
||||
Add a constructor for the given tag.
|
||||
Constructor is a function that accepts a Loader instance
|
||||
and a node object and produces the corresponding Python object.
|
||||
"""
|
||||
if Loader is None:
|
||||
loader.Loader.add_constructor(tag, constructor)
|
||||
loader.FullLoader.add_constructor(tag, constructor)
|
||||
loader.UnsafeLoader.add_constructor(tag, constructor)
|
||||
else:
|
||||
Loader.add_constructor(tag, constructor)
|
||||
|
||||
def add_multi_constructor(tag_prefix, multi_constructor, Loader=None):
|
||||
"""
|
||||
Add a multi-constructor for the given tag prefix.
|
||||
Multi-constructor is called for a node if its tag starts with tag_prefix.
|
||||
Multi-constructor accepts a Loader instance, a tag suffix,
|
||||
and a node object and produces the corresponding Python object.
|
||||
"""
|
||||
if Loader is None:
|
||||
loader.Loader.add_multi_constructor(tag_prefix, multi_constructor)
|
||||
loader.FullLoader.add_multi_constructor(tag_prefix, multi_constructor)
|
||||
loader.UnsafeLoader.add_multi_constructor(tag_prefix, multi_constructor)
|
||||
else:
|
||||
Loader.add_multi_constructor(tag_prefix, multi_constructor)
|
||||
|
||||
def add_representer(data_type, representer, Dumper=Dumper):
|
||||
"""
|
||||
Add a representer for the given type.
|
||||
Representer is a function accepting a Dumper instance
|
||||
and an instance of the given data type
|
||||
and producing the corresponding representation node.
|
||||
"""
|
||||
Dumper.add_representer(data_type, representer)
|
||||
|
||||
def add_multi_representer(data_type, multi_representer, Dumper=Dumper):
|
||||
"""
|
||||
Add a representer for the given type.
|
||||
Multi-representer is a function accepting a Dumper instance
|
||||
and an instance of the given data type or subtype
|
||||
and producing the corresponding representation node.
|
||||
"""
|
||||
Dumper.add_multi_representer(data_type, multi_representer)
|
||||
|
||||
class YAMLObjectMetaclass(type):
|
||||
"""
|
||||
The metaclass for YAMLObject.
|
||||
"""
|
||||
def __init__(cls, name, bases, kwds):
|
||||
super(YAMLObjectMetaclass, cls).__init__(name, bases, kwds)
|
||||
if 'yaml_tag' in kwds and kwds['yaml_tag'] is not None:
|
||||
if isinstance(cls.yaml_loader, list):
|
||||
for loader in cls.yaml_loader:
|
||||
loader.add_constructor(cls.yaml_tag, cls.from_yaml)
|
||||
else:
|
||||
cls.yaml_loader.add_constructor(cls.yaml_tag, cls.from_yaml)
|
||||
|
||||
cls.yaml_dumper.add_representer(cls, cls.to_yaml)
|
||||
|
||||
class YAMLObject(metaclass=YAMLObjectMetaclass):
|
||||
"""
|
||||
An object that can dump itself to a YAML stream
|
||||
and load itself from a YAML stream.
|
||||
"""
|
||||
|
||||
__slots__ = () # no direct instantiation, so allow immutable subclasses
|
||||
|
||||
yaml_loader = [Loader, FullLoader, UnsafeLoader]
|
||||
yaml_dumper = Dumper
|
||||
|
||||
yaml_tag = None
|
||||
yaml_flow_style = None
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, loader, node):
|
||||
"""
|
||||
Convert a representation node to a Python object.
|
||||
"""
|
||||
return loader.construct_yaml_object(node, cls)
|
||||
|
||||
@classmethod
|
||||
def to_yaml(cls, dumper, data):
|
||||
"""
|
||||
Convert a Python object to a representation node.
|
||||
"""
|
||||
return dumper.represent_yaml_object(cls.yaml_tag, data, cls,
|
||||
flow_style=cls.yaml_flow_style)
|
||||
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
|
||||
__all__ = ['Composer', 'ComposerError']
|
||||
|
||||
from .error import MarkedYAMLError
|
||||
from .events import *
|
||||
from .nodes import *
|
||||
|
||||
class ComposerError(MarkedYAMLError):
|
||||
pass
|
||||
|
||||
class Composer:
|
||||
|
||||
def __init__(self):
|
||||
self.anchors = {}
|
||||
|
||||
def check_node(self):
|
||||
# Drop the STREAM-START event.
|
||||
if self.check_event(StreamStartEvent):
|
||||
self.get_event()
|
||||
|
||||
# If there are more documents available?
|
||||
return not self.check_event(StreamEndEvent)
|
||||
|
||||
def get_node(self):
|
||||
# Get the root node of the next document.
|
||||
if not self.check_event(StreamEndEvent):
|
||||
return self.compose_document()
|
||||
|
||||
def get_single_node(self):
|
||||
# Drop the STREAM-START event.
|
||||
self.get_event()
|
||||
|
||||
# Compose a document if the stream is not empty.
|
||||
document = None
|
||||
if not self.check_event(StreamEndEvent):
|
||||
document = self.compose_document()
|
||||
|
||||
# Ensure that the stream contains no more documents.
|
||||
if not self.check_event(StreamEndEvent):
|
||||
event = self.get_event()
|
||||
raise ComposerError("expected a single document in the stream",
|
||||
document.start_mark, "but found another document",
|
||||
event.start_mark)
|
||||
|
||||
# Drop the STREAM-END event.
|
||||
self.get_event()
|
||||
|
||||
return document
|
||||
|
||||
def compose_document(self):
|
||||
# Drop the DOCUMENT-START event.
|
||||
self.get_event()
|
||||
|
||||
# Compose the root node.
|
||||
node = self.compose_node(None, None)
|
||||
|
||||
# Drop the DOCUMENT-END event.
|
||||
self.get_event()
|
||||
|
||||
self.anchors = {}
|
||||
return node
|
||||
|
||||
def compose_node(self, parent, index):
|
||||
if self.check_event(AliasEvent):
|
||||
event = self.get_event()
|
||||
anchor = event.anchor
|
||||
if anchor not in self.anchors:
|
||||
raise ComposerError(None, None, "found undefined alias %r"
|
||||
% anchor, event.start_mark)
|
||||
return self.anchors[anchor]
|
||||
event = self.peek_event()
|
||||
anchor = event.anchor
|
||||
if anchor is not None:
|
||||
if anchor in self.anchors:
|
||||
raise ComposerError("found duplicate anchor %r; first occurrence"
|
||||
% anchor, self.anchors[anchor].start_mark,
|
||||
"second occurrence", event.start_mark)
|
||||
self.descend_resolver(parent, index)
|
||||
if self.check_event(ScalarEvent):
|
||||
node = self.compose_scalar_node(anchor)
|
||||
elif self.check_event(SequenceStartEvent):
|
||||
node = self.compose_sequence_node(anchor)
|
||||
elif self.check_event(MappingStartEvent):
|
||||
node = self.compose_mapping_node(anchor)
|
||||
self.ascend_resolver()
|
||||
return node
|
||||
|
||||
def compose_scalar_node(self, anchor):
|
||||
event = self.get_event()
|
||||
tag = event.tag
|
||||
if tag is None or tag == '!':
|
||||
tag = self.resolve(ScalarNode, event.value, event.implicit)
|
||||
node = ScalarNode(tag, event.value,
|
||||
event.start_mark, event.end_mark, style=event.style)
|
||||
if anchor is not None:
|
||||
self.anchors[anchor] = node
|
||||
return node
|
||||
|
||||
def compose_sequence_node(self, anchor):
|
||||
start_event = self.get_event()
|
||||
tag = start_event.tag
|
||||
if tag is None or tag == '!':
|
||||
tag = self.resolve(SequenceNode, None, start_event.implicit)
|
||||
node = SequenceNode(tag, [],
|
||||
start_event.start_mark, None,
|
||||
flow_style=start_event.flow_style)
|
||||
if anchor is not None:
|
||||
self.anchors[anchor] = node
|
||||
index = 0
|
||||
while not self.check_event(SequenceEndEvent):
|
||||
node.value.append(self.compose_node(node, index))
|
||||
index += 1
|
||||
end_event = self.get_event()
|
||||
node.end_mark = end_event.end_mark
|
||||
return node
|
||||
|
||||
def compose_mapping_node(self, anchor):
|
||||
start_event = self.get_event()
|
||||
tag = start_event.tag
|
||||
if tag is None or tag == '!':
|
||||
tag = self.resolve(MappingNode, None, start_event.implicit)
|
||||
node = MappingNode(tag, [],
|
||||
start_event.start_mark, None,
|
||||
flow_style=start_event.flow_style)
|
||||
if anchor is not None:
|
||||
self.anchors[anchor] = node
|
||||
while not self.check_event(MappingEndEvent):
|
||||
#key_event = self.peek_event()
|
||||
item_key = self.compose_node(node, None)
|
||||
#if item_key in node.value:
|
||||
# raise ComposerError("while composing a mapping", start_event.start_mark,
|
||||
# "found duplicate key", key_event.start_mark)
|
||||
item_value = self.compose_node(node, item_key)
|
||||
#node.value[item_key] = item_value
|
||||
node.value.append((item_key, item_value))
|
||||
end_event = self.get_event()
|
||||
node.end_mark = end_event.end_mark
|
||||
return node
|
||||
|
||||
|
|
@ -0,0 +1,748 @@
|
|||
|
||||
__all__ = [
|
||||
'BaseConstructor',
|
||||
'SafeConstructor',
|
||||
'FullConstructor',
|
||||
'UnsafeConstructor',
|
||||
'Constructor',
|
||||
'ConstructorError'
|
||||
]
|
||||
|
||||
from .error import *
|
||||
from .nodes import *
|
||||
|
||||
import collections.abc, datetime, base64, binascii, re, sys, types
|
||||
|
||||
class ConstructorError(MarkedYAMLError):
|
||||
pass
|
||||
|
||||
class BaseConstructor:
|
||||
|
||||
yaml_constructors = {}
|
||||
yaml_multi_constructors = {}
|
||||
|
||||
def __init__(self):
|
||||
self.constructed_objects = {}
|
||||
self.recursive_objects = {}
|
||||
self.state_generators = []
|
||||
self.deep_construct = False
|
||||
|
||||
def check_data(self):
|
||||
# If there are more documents available?
|
||||
return self.check_node()
|
||||
|
||||
def check_state_key(self, key):
|
||||
"""Block special attributes/methods from being set in a newly created
|
||||
object, to prevent user-controlled methods from being called during
|
||||
deserialization"""
|
||||
if self.get_state_keys_blacklist_regexp().match(key):
|
||||
raise ConstructorError(None, None,
|
||||
"blacklisted key '%s' in instance state found" % (key,), None)
|
||||
|
||||
def get_data(self):
|
||||
# Construct and return the next document.
|
||||
if self.check_node():
|
||||
return self.construct_document(self.get_node())
|
||||
|
||||
def get_single_data(self):
|
||||
# Ensure that the stream contains a single document and construct it.
|
||||
node = self.get_single_node()
|
||||
if node is not None:
|
||||
return self.construct_document(node)
|
||||
return None
|
||||
|
||||
def construct_document(self, node):
|
||||
data = self.construct_object(node)
|
||||
while self.state_generators:
|
||||
state_generators = self.state_generators
|
||||
self.state_generators = []
|
||||
for generator in state_generators:
|
||||
for dummy in generator:
|
||||
pass
|
||||
self.constructed_objects = {}
|
||||
self.recursive_objects = {}
|
||||
self.deep_construct = False
|
||||
return data
|
||||
|
||||
def construct_object(self, node, deep=False):
|
||||
if node in self.constructed_objects:
|
||||
return self.constructed_objects[node]
|
||||
if deep:
|
||||
old_deep = self.deep_construct
|
||||
self.deep_construct = True
|
||||
if node in self.recursive_objects:
|
||||
raise ConstructorError(None, None,
|
||||
"found unconstructable recursive node", node.start_mark)
|
||||
self.recursive_objects[node] = None
|
||||
constructor = None
|
||||
tag_suffix = None
|
||||
if node.tag in self.yaml_constructors:
|
||||
constructor = self.yaml_constructors[node.tag]
|
||||
else:
|
||||
for tag_prefix in self.yaml_multi_constructors:
|
||||
if tag_prefix is not None and node.tag.startswith(tag_prefix):
|
||||
tag_suffix = node.tag[len(tag_prefix):]
|
||||
constructor = self.yaml_multi_constructors[tag_prefix]
|
||||
break
|
||||
else:
|
||||
if None in self.yaml_multi_constructors:
|
||||
tag_suffix = node.tag
|
||||
constructor = self.yaml_multi_constructors[None]
|
||||
elif None in self.yaml_constructors:
|
||||
constructor = self.yaml_constructors[None]
|
||||
elif isinstance(node, ScalarNode):
|
||||
constructor = self.__class__.construct_scalar
|
||||
elif isinstance(node, SequenceNode):
|
||||
constructor = self.__class__.construct_sequence
|
||||
elif isinstance(node, MappingNode):
|
||||
constructor = self.__class__.construct_mapping
|
||||
if tag_suffix is None:
|
||||
data = constructor(self, node)
|
||||
else:
|
||||
data = constructor(self, tag_suffix, node)
|
||||
if isinstance(data, types.GeneratorType):
|
||||
generator = data
|
||||
data = next(generator)
|
||||
if self.deep_construct:
|
||||
for dummy in generator:
|
||||
pass
|
||||
else:
|
||||
self.state_generators.append(generator)
|
||||
self.constructed_objects[node] = data
|
||||
del self.recursive_objects[node]
|
||||
if deep:
|
||||
self.deep_construct = old_deep
|
||||
return data
|
||||
|
||||
def construct_scalar(self, node):
|
||||
if not isinstance(node, ScalarNode):
|
||||
raise ConstructorError(None, None,
|
||||
"expected a scalar node, but found %s" % node.id,
|
||||
node.start_mark)
|
||||
return node.value
|
||||
|
||||
def construct_sequence(self, node, deep=False):
|
||||
if not isinstance(node, SequenceNode):
|
||||
raise ConstructorError(None, None,
|
||||
"expected a sequence node, but found %s" % node.id,
|
||||
node.start_mark)
|
||||
return [self.construct_object(child, deep=deep)
|
||||
for child in node.value]
|
||||
|
||||
def construct_mapping(self, node, deep=False):
|
||||
if not isinstance(node, MappingNode):
|
||||
raise ConstructorError(None, None,
|
||||
"expected a mapping node, but found %s" % node.id,
|
||||
node.start_mark)
|
||||
mapping = {}
|
||||
for key_node, value_node in node.value:
|
||||
key = self.construct_object(key_node, deep=deep)
|
||||
if not isinstance(key, collections.abc.Hashable):
|
||||
raise ConstructorError("while constructing a mapping", node.start_mark,
|
||||
"found unhashable key", key_node.start_mark)
|
||||
value = self.construct_object(value_node, deep=deep)
|
||||
mapping[key] = value
|
||||
return mapping
|
||||
|
||||
def construct_pairs(self, node, deep=False):
|
||||
if not isinstance(node, MappingNode):
|
||||
raise ConstructorError(None, None,
|
||||
"expected a mapping node, but found %s" % node.id,
|
||||
node.start_mark)
|
||||
pairs = []
|
||||
for key_node, value_node in node.value:
|
||||
key = self.construct_object(key_node, deep=deep)
|
||||
value = self.construct_object(value_node, deep=deep)
|
||||
pairs.append((key, value))
|
||||
return pairs
|
||||
|
||||
@classmethod
|
||||
def add_constructor(cls, tag, constructor):
|
||||
if not 'yaml_constructors' in cls.__dict__:
|
||||
cls.yaml_constructors = cls.yaml_constructors.copy()
|
||||
cls.yaml_constructors[tag] = constructor
|
||||
|
||||
@classmethod
|
||||
def add_multi_constructor(cls, tag_prefix, multi_constructor):
|
||||
if not 'yaml_multi_constructors' in cls.__dict__:
|
||||
cls.yaml_multi_constructors = cls.yaml_multi_constructors.copy()
|
||||
cls.yaml_multi_constructors[tag_prefix] = multi_constructor
|
||||
|
||||
class SafeConstructor(BaseConstructor):
|
||||
|
||||
def construct_scalar(self, node):
|
||||
if isinstance(node, MappingNode):
|
||||
for key_node, value_node in node.value:
|
||||
if key_node.tag == 'tag:yaml.org,2002:value':
|
||||
return self.construct_scalar(value_node)
|
||||
return super().construct_scalar(node)
|
||||
|
||||
def flatten_mapping(self, node):
|
||||
merge = []
|
||||
index = 0
|
||||
while index < len(node.value):
|
||||
key_node, value_node = node.value[index]
|
||||
if key_node.tag == 'tag:yaml.org,2002:merge':
|
||||
del node.value[index]
|
||||
if isinstance(value_node, MappingNode):
|
||||
self.flatten_mapping(value_node)
|
||||
merge.extend(value_node.value)
|
||||
elif isinstance(value_node, SequenceNode):
|
||||
submerge = []
|
||||
for subnode in value_node.value:
|
||||
if not isinstance(subnode, MappingNode):
|
||||
raise ConstructorError("while constructing a mapping",
|
||||
node.start_mark,
|
||||
"expected a mapping for merging, but found %s"
|
||||
% subnode.id, subnode.start_mark)
|
||||
self.flatten_mapping(subnode)
|
||||
submerge.append(subnode.value)
|
||||
submerge.reverse()
|
||||
for value in submerge:
|
||||
merge.extend(value)
|
||||
else:
|
||||
raise ConstructorError("while constructing a mapping", node.start_mark,
|
||||
"expected a mapping or list of mappings for merging, but found %s"
|
||||
% value_node.id, value_node.start_mark)
|
||||
elif key_node.tag == 'tag:yaml.org,2002:value':
|
||||
key_node.tag = 'tag:yaml.org,2002:str'
|
||||
index += 1
|
||||
else:
|
||||
index += 1
|
||||
if merge:
|
||||
node.value = merge + node.value
|
||||
|
||||
def construct_mapping(self, node, deep=False):
|
||||
if isinstance(node, MappingNode):
|
||||
self.flatten_mapping(node)
|
||||
return super().construct_mapping(node, deep=deep)
|
||||
|
||||
def construct_yaml_null(self, node):
|
||||
self.construct_scalar(node)
|
||||
return None
|
||||
|
||||
bool_values = {
|
||||
'yes': True,
|
||||
'no': False,
|
||||
'true': True,
|
||||
'false': False,
|
||||
'on': True,
|
||||
'off': False,
|
||||
}
|
||||
|
||||
def construct_yaml_bool(self, node):
|
||||
value = self.construct_scalar(node)
|
||||
return self.bool_values[value.lower()]
|
||||
|
||||
def construct_yaml_int(self, node):
|
||||
value = self.construct_scalar(node)
|
||||
value = value.replace('_', '')
|
||||
sign = +1
|
||||
if value[0] == '-':
|
||||
sign = -1
|
||||
if value[0] in '+-':
|
||||
value = value[1:]
|
||||
if value == '0':
|
||||
return 0
|
||||
elif value.startswith('0b'):
|
||||
return sign*int(value[2:], 2)
|
||||
elif value.startswith('0x'):
|
||||
return sign*int(value[2:], 16)
|
||||
elif value[0] == '0':
|
||||
return sign*int(value, 8)
|
||||
elif ':' in value:
|
||||
digits = [int(part) for part in value.split(':')]
|
||||
digits.reverse()
|
||||
base = 1
|
||||
value = 0
|
||||
for digit in digits:
|
||||
value += digit*base
|
||||
base *= 60
|
||||
return sign*value
|
||||
else:
|
||||
return sign*int(value)
|
||||
|
||||
inf_value = 1e300
|
||||
while inf_value != inf_value*inf_value:
|
||||
inf_value *= inf_value
|
||||
nan_value = -inf_value/inf_value # Trying to make a quiet NaN (like C99).
|
||||
|
||||
def construct_yaml_float(self, node):
|
||||
value = self.construct_scalar(node)
|
||||
value = value.replace('_', '').lower()
|
||||
sign = +1
|
||||
if value[0] == '-':
|
||||
sign = -1
|
||||
if value[0] in '+-':
|
||||
value = value[1:]
|
||||
if value == '.inf':
|
||||
return sign*self.inf_value
|
||||
elif value == '.nan':
|
||||
return self.nan_value
|
||||
elif ':' in value:
|
||||
digits = [float(part) for part in value.split(':')]
|
||||
digits.reverse()
|
||||
base = 1
|
||||
value = 0.0
|
||||
for digit in digits:
|
||||
value += digit*base
|
||||
base *= 60
|
||||
return sign*value
|
||||
else:
|
||||
return sign*float(value)
|
||||
|
||||
def construct_yaml_binary(self, node):
|
||||
try:
|
||||
value = self.construct_scalar(node).encode('ascii')
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ConstructorError(None, None,
|
||||
"failed to convert base64 data into ascii: %s" % exc,
|
||||
node.start_mark)
|
||||
try:
|
||||
if hasattr(base64, 'decodebytes'):
|
||||
return base64.decodebytes(value)
|
||||
else:
|
||||
return base64.decodestring(value)
|
||||
except binascii.Error as exc:
|
||||
raise ConstructorError(None, None,
|
||||
"failed to decode base64 data: %s" % exc, node.start_mark)
|
||||
|
||||
timestamp_regexp = re.compile(
|
||||
r'''^(?P<year>[0-9][0-9][0-9][0-9])
|
||||
-(?P<month>[0-9][0-9]?)
|
||||
-(?P<day>[0-9][0-9]?)
|
||||
(?:(?:[Tt]|[ \t]+)
|
||||
(?P<hour>[0-9][0-9]?)
|
||||
:(?P<minute>[0-9][0-9])
|
||||
:(?P<second>[0-9][0-9])
|
||||
(?:\.(?P<fraction>[0-9]*))?
|
||||
(?:[ \t]*(?P<tz>Z|(?P<tz_sign>[-+])(?P<tz_hour>[0-9][0-9]?)
|
||||
(?::(?P<tz_minute>[0-9][0-9]))?))?)?$''', re.X)
|
||||
|
||||
def construct_yaml_timestamp(self, node):
|
||||
value = self.construct_scalar(node)
|
||||
match = self.timestamp_regexp.match(node.value)
|
||||
values = match.groupdict()
|
||||
year = int(values['year'])
|
||||
month = int(values['month'])
|
||||
day = int(values['day'])
|
||||
if not values['hour']:
|
||||
return datetime.date(year, month, day)
|
||||
hour = int(values['hour'])
|
||||
minute = int(values['minute'])
|
||||
second = int(values['second'])
|
||||
fraction = 0
|
||||
tzinfo = None
|
||||
if values['fraction']:
|
||||
fraction = values['fraction'][:6]
|
||||
while len(fraction) < 6:
|
||||
fraction += '0'
|
||||
fraction = int(fraction)
|
||||
if values['tz_sign']:
|
||||
tz_hour = int(values['tz_hour'])
|
||||
tz_minute = int(values['tz_minute'] or 0)
|
||||
delta = datetime.timedelta(hours=tz_hour, minutes=tz_minute)
|
||||
if values['tz_sign'] == '-':
|
||||
delta = -delta
|
||||
tzinfo = datetime.timezone(delta)
|
||||
elif values['tz']:
|
||||
tzinfo = datetime.timezone.utc
|
||||
return datetime.datetime(year, month, day, hour, minute, second, fraction,
|
||||
tzinfo=tzinfo)
|
||||
|
||||
def construct_yaml_omap(self, node):
|
||||
# Note: we do not check for duplicate keys, because it's too
|
||||
# CPU-expensive.
|
||||
omap = []
|
||||
yield omap
|
||||
if not isinstance(node, SequenceNode):
|
||||
raise ConstructorError("while constructing an ordered map", node.start_mark,
|
||||
"expected a sequence, but found %s" % node.id, node.start_mark)
|
||||
for subnode in node.value:
|
||||
if not isinstance(subnode, MappingNode):
|
||||
raise ConstructorError("while constructing an ordered map", node.start_mark,
|
||||
"expected a mapping of length 1, but found %s" % subnode.id,
|
||||
subnode.start_mark)
|
||||
if len(subnode.value) != 1:
|
||||
raise ConstructorError("while constructing an ordered map", node.start_mark,
|
||||
"expected a single mapping item, but found %d items" % len(subnode.value),
|
||||
subnode.start_mark)
|
||||
key_node, value_node = subnode.value[0]
|
||||
key = self.construct_object(key_node)
|
||||
value = self.construct_object(value_node)
|
||||
omap.append((key, value))
|
||||
|
||||
def construct_yaml_pairs(self, node):
|
||||
# Note: the same code as `construct_yaml_omap`.
|
||||
pairs = []
|
||||
yield pairs
|
||||
if not isinstance(node, SequenceNode):
|
||||
raise ConstructorError("while constructing pairs", node.start_mark,
|
||||
"expected a sequence, but found %s" % node.id, node.start_mark)
|
||||
for subnode in node.value:
|
||||
if not isinstance(subnode, MappingNode):
|
||||
raise ConstructorError("while constructing pairs", node.start_mark,
|
||||
"expected a mapping of length 1, but found %s" % subnode.id,
|
||||
subnode.start_mark)
|
||||
if len(subnode.value) != 1:
|
||||
raise ConstructorError("while constructing pairs", node.start_mark,
|
||||
"expected a single mapping item, but found %d items" % len(subnode.value),
|
||||
subnode.start_mark)
|
||||
key_node, value_node = subnode.value[0]
|
||||
key = self.construct_object(key_node)
|
||||
value = self.construct_object(value_node)
|
||||
pairs.append((key, value))
|
||||
|
||||
def construct_yaml_set(self, node):
|
||||
data = set()
|
||||
yield data
|
||||
value = self.construct_mapping(node)
|
||||
data.update(value)
|
||||
|
||||
def construct_yaml_str(self, node):
|
||||
return self.construct_scalar(node)
|
||||
|
||||
def construct_yaml_seq(self, node):
|
||||
data = []
|
||||
yield data
|
||||
data.extend(self.construct_sequence(node))
|
||||
|
||||
def construct_yaml_map(self, node):
|
||||
data = {}
|
||||
yield data
|
||||
value = self.construct_mapping(node)
|
||||
data.update(value)
|
||||
|
||||
def construct_yaml_object(self, node, cls):
|
||||
data = cls.__new__(cls)
|
||||
yield data
|
||||
if hasattr(data, '__setstate__'):
|
||||
state = self.construct_mapping(node, deep=True)
|
||||
data.__setstate__(state)
|
||||
else:
|
||||
state = self.construct_mapping(node)
|
||||
data.__dict__.update(state)
|
||||
|
||||
def construct_undefined(self, node):
|
||||
raise ConstructorError(None, None,
|
||||
"could not determine a constructor for the tag %r" % node.tag,
|
||||
node.start_mark)
|
||||
|
||||
SafeConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:null',
|
||||
SafeConstructor.construct_yaml_null)
|
||||
|
||||
SafeConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:bool',
|
||||
SafeConstructor.construct_yaml_bool)
|
||||
|
||||
SafeConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:int',
|
||||
SafeConstructor.construct_yaml_int)
|
||||
|
||||
SafeConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:float',
|
||||
SafeConstructor.construct_yaml_float)
|
||||
|
||||
SafeConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:binary',
|
||||
SafeConstructor.construct_yaml_binary)
|
||||
|
||||
SafeConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:timestamp',
|
||||
SafeConstructor.construct_yaml_timestamp)
|
||||
|
||||
SafeConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:omap',
|
||||
SafeConstructor.construct_yaml_omap)
|
||||
|
||||
SafeConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:pairs',
|
||||
SafeConstructor.construct_yaml_pairs)
|
||||
|
||||
SafeConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:set',
|
||||
SafeConstructor.construct_yaml_set)
|
||||
|
||||
SafeConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:str',
|
||||
SafeConstructor.construct_yaml_str)
|
||||
|
||||
SafeConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:seq',
|
||||
SafeConstructor.construct_yaml_seq)
|
||||
|
||||
SafeConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:map',
|
||||
SafeConstructor.construct_yaml_map)
|
||||
|
||||
SafeConstructor.add_constructor(None,
|
||||
SafeConstructor.construct_undefined)
|
||||
|
||||
class FullConstructor(SafeConstructor):
|
||||
# 'extend' is blacklisted because it is used by
|
||||
# construct_python_object_apply to add `listitems` to a newly generate
|
||||
# python instance
|
||||
def get_state_keys_blacklist(self):
|
||||
return ['^extend$', '^__.*__$']
|
||||
|
||||
def get_state_keys_blacklist_regexp(self):
|
||||
if not hasattr(self, 'state_keys_blacklist_regexp'):
|
||||
self.state_keys_blacklist_regexp = re.compile('(' + '|'.join(self.get_state_keys_blacklist()) + ')')
|
||||
return self.state_keys_blacklist_regexp
|
||||
|
||||
def construct_python_str(self, node):
|
||||
return self.construct_scalar(node)
|
||||
|
||||
def construct_python_unicode(self, node):
|
||||
return self.construct_scalar(node)
|
||||
|
||||
def construct_python_bytes(self, node):
|
||||
try:
|
||||
value = self.construct_scalar(node).encode('ascii')
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ConstructorError(None, None,
|
||||
"failed to convert base64 data into ascii: %s" % exc,
|
||||
node.start_mark)
|
||||
try:
|
||||
if hasattr(base64, 'decodebytes'):
|
||||
return base64.decodebytes(value)
|
||||
else:
|
||||
return base64.decodestring(value)
|
||||
except binascii.Error as exc:
|
||||
raise ConstructorError(None, None,
|
||||
"failed to decode base64 data: %s" % exc, node.start_mark)
|
||||
|
||||
def construct_python_long(self, node):
|
||||
return self.construct_yaml_int(node)
|
||||
|
||||
def construct_python_complex(self, node):
|
||||
return complex(self.construct_scalar(node))
|
||||
|
||||
def construct_python_tuple(self, node):
|
||||
return tuple(self.construct_sequence(node))
|
||||
|
||||
def find_python_module(self, name, mark, unsafe=False):
|
||||
if not name:
|
||||
raise ConstructorError("while constructing a Python module", mark,
|
||||
"expected non-empty name appended to the tag", mark)
|
||||
if unsafe:
|
||||
try:
|
||||
__import__(name)
|
||||
except ImportError as exc:
|
||||
raise ConstructorError("while constructing a Python module", mark,
|
||||
"cannot find module %r (%s)" % (name, exc), mark)
|
||||
if name not in sys.modules:
|
||||
raise ConstructorError("while constructing a Python module", mark,
|
||||
"module %r is not imported" % name, mark)
|
||||
return sys.modules[name]
|
||||
|
||||
def find_python_name(self, name, mark, unsafe=False):
|
||||
if not name:
|
||||
raise ConstructorError("while constructing a Python object", mark,
|
||||
"expected non-empty name appended to the tag", mark)
|
||||
if '.' in name:
|
||||
module_name, object_name = name.rsplit('.', 1)
|
||||
else:
|
||||
module_name = 'builtins'
|
||||
object_name = name
|
||||
if unsafe:
|
||||
try:
|
||||
__import__(module_name)
|
||||
except ImportError as exc:
|
||||
raise ConstructorError("while constructing a Python object", mark,
|
||||
"cannot find module %r (%s)" % (module_name, exc), mark)
|
||||
if module_name not in sys.modules:
|
||||
raise ConstructorError("while constructing a Python object", mark,
|
||||
"module %r is not imported" % module_name, mark)
|
||||
module = sys.modules[module_name]
|
||||
if not hasattr(module, object_name):
|
||||
raise ConstructorError("while constructing a Python object", mark,
|
||||
"cannot find %r in the module %r"
|
||||
% (object_name, module.__name__), mark)
|
||||
return getattr(module, object_name)
|
||||
|
||||
def construct_python_name(self, suffix, node):
|
||||
value = self.construct_scalar(node)
|
||||
if value:
|
||||
raise ConstructorError("while constructing a Python name", node.start_mark,
|
||||
"expected the empty value, but found %r" % value, node.start_mark)
|
||||
return self.find_python_name(suffix, node.start_mark)
|
||||
|
||||
def construct_python_module(self, suffix, node):
|
||||
value = self.construct_scalar(node)
|
||||
if value:
|
||||
raise ConstructorError("while constructing a Python module", node.start_mark,
|
||||
"expected the empty value, but found %r" % value, node.start_mark)
|
||||
return self.find_python_module(suffix, node.start_mark)
|
||||
|
||||
def make_python_instance(self, suffix, node,
|
||||
args=None, kwds=None, newobj=False, unsafe=False):
|
||||
if not args:
|
||||
args = []
|
||||
if not kwds:
|
||||
kwds = {}
|
||||
cls = self.find_python_name(suffix, node.start_mark)
|
||||
if not (unsafe or isinstance(cls, type)):
|
||||
raise ConstructorError("while constructing a Python instance", node.start_mark,
|
||||
"expected a class, but found %r" % type(cls),
|
||||
node.start_mark)
|
||||
if newobj and isinstance(cls, type):
|
||||
return cls.__new__(cls, *args, **kwds)
|
||||
else:
|
||||
return cls(*args, **kwds)
|
||||
|
||||
def set_python_instance_state(self, instance, state, unsafe=False):
|
||||
if hasattr(instance, '__setstate__'):
|
||||
instance.__setstate__(state)
|
||||
else:
|
||||
slotstate = {}
|
||||
if isinstance(state, tuple) and len(state) == 2:
|
||||
state, slotstate = state
|
||||
if hasattr(instance, '__dict__'):
|
||||
if not unsafe and state:
|
||||
for key in state.keys():
|
||||
self.check_state_key(key)
|
||||
instance.__dict__.update(state)
|
||||
elif state:
|
||||
slotstate.update(state)
|
||||
for key, value in slotstate.items():
|
||||
if not unsafe:
|
||||
self.check_state_key(key)
|
||||
setattr(instance, key, value)
|
||||
|
||||
def construct_python_object(self, suffix, node):
|
||||
# Format:
|
||||
# !!python/object:module.name { ... state ... }
|
||||
instance = self.make_python_instance(suffix, node, newobj=True)
|
||||
yield instance
|
||||
deep = hasattr(instance, '__setstate__')
|
||||
state = self.construct_mapping(node, deep=deep)
|
||||
self.set_python_instance_state(instance, state)
|
||||
|
||||
def construct_python_object_apply(self, suffix, node, newobj=False):
|
||||
# Format:
|
||||
# !!python/object/apply # (or !!python/object/new)
|
||||
# args: [ ... arguments ... ]
|
||||
# kwds: { ... keywords ... }
|
||||
# state: ... state ...
|
||||
# listitems: [ ... listitems ... ]
|
||||
# dictitems: { ... dictitems ... }
|
||||
# or short format:
|
||||
# !!python/object/apply [ ... arguments ... ]
|
||||
# The difference between !!python/object/apply and !!python/object/new
|
||||
# is how an object is created, check make_python_instance for details.
|
||||
if isinstance(node, SequenceNode):
|
||||
args = self.construct_sequence(node, deep=True)
|
||||
kwds = {}
|
||||
state = {}
|
||||
listitems = []
|
||||
dictitems = {}
|
||||
else:
|
||||
value = self.construct_mapping(node, deep=True)
|
||||
args = value.get('args', [])
|
||||
kwds = value.get('kwds', {})
|
||||
state = value.get('state', {})
|
||||
listitems = value.get('listitems', [])
|
||||
dictitems = value.get('dictitems', {})
|
||||
instance = self.make_python_instance(suffix, node, args, kwds, newobj)
|
||||
if state:
|
||||
self.set_python_instance_state(instance, state)
|
||||
if listitems:
|
||||
instance.extend(listitems)
|
||||
if dictitems:
|
||||
for key in dictitems:
|
||||
instance[key] = dictitems[key]
|
||||
return instance
|
||||
|
||||
def construct_python_object_new(self, suffix, node):
|
||||
return self.construct_python_object_apply(suffix, node, newobj=True)
|
||||
|
||||
FullConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:python/none',
|
||||
FullConstructor.construct_yaml_null)
|
||||
|
||||
FullConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:python/bool',
|
||||
FullConstructor.construct_yaml_bool)
|
||||
|
||||
FullConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:python/str',
|
||||
FullConstructor.construct_python_str)
|
||||
|
||||
FullConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:python/unicode',
|
||||
FullConstructor.construct_python_unicode)
|
||||
|
||||
FullConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:python/bytes',
|
||||
FullConstructor.construct_python_bytes)
|
||||
|
||||
FullConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:python/int',
|
||||
FullConstructor.construct_yaml_int)
|
||||
|
||||
FullConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:python/long',
|
||||
FullConstructor.construct_python_long)
|
||||
|
||||
FullConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:python/float',
|
||||
FullConstructor.construct_yaml_float)
|
||||
|
||||
FullConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:python/complex',
|
||||
FullConstructor.construct_python_complex)
|
||||
|
||||
FullConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:python/list',
|
||||
FullConstructor.construct_yaml_seq)
|
||||
|
||||
FullConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:python/tuple',
|
||||
FullConstructor.construct_python_tuple)
|
||||
|
||||
FullConstructor.add_constructor(
|
||||
'tag:yaml.org,2002:python/dict',
|
||||
FullConstructor.construct_yaml_map)
|
||||
|
||||
FullConstructor.add_multi_constructor(
|
||||
'tag:yaml.org,2002:python/name:',
|
||||
FullConstructor.construct_python_name)
|
||||
|
||||
FullConstructor.add_multi_constructor(
|
||||
'tag:yaml.org,2002:python/module:',
|
||||
FullConstructor.construct_python_module)
|
||||
|
||||
FullConstructor.add_multi_constructor(
|
||||
'tag:yaml.org,2002:python/object:',
|
||||
FullConstructor.construct_python_object)
|
||||
|
||||
FullConstructor.add_multi_constructor(
|
||||
'tag:yaml.org,2002:python/object/new:',
|
||||
FullConstructor.construct_python_object_new)
|
||||
|
||||
class UnsafeConstructor(FullConstructor):
|
||||
|
||||
def find_python_module(self, name, mark):
|
||||
return super(UnsafeConstructor, self).find_python_module(name, mark, unsafe=True)
|
||||
|
||||
def find_python_name(self, name, mark):
|
||||
return super(UnsafeConstructor, self).find_python_name(name, mark, unsafe=True)
|
||||
|
||||
def make_python_instance(self, suffix, node, args=None, kwds=None, newobj=False):
|
||||
return super(UnsafeConstructor, self).make_python_instance(
|
||||
suffix, node, args, kwds, newobj, unsafe=True)
|
||||
|
||||
def set_python_instance_state(self, instance, state):
|
||||
return super(UnsafeConstructor, self).set_python_instance_state(
|
||||
instance, state, unsafe=True)
|
||||
|
||||
UnsafeConstructor.add_multi_constructor(
|
||||
'tag:yaml.org,2002:python/object/apply:',
|
||||
UnsafeConstructor.construct_python_object_apply)
|
||||
|
||||
# Constructor is same as UnsafeConstructor. Need to leave this in place in case
|
||||
# people have extended it directly.
|
||||
class Constructor(UnsafeConstructor):
|
||||
pass
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
|
||||
__all__ = [
|
||||
'CBaseLoader', 'CSafeLoader', 'CFullLoader', 'CUnsafeLoader', 'CLoader',
|
||||
'CBaseDumper', 'CSafeDumper', 'CDumper'
|
||||
]
|
||||
|
||||
from _yaml import CParser, CEmitter
|
||||
|
||||
from .constructor import *
|
||||
|
||||
from .serializer import *
|
||||
from .representer import *
|
||||
|
||||
from .resolver import *
|
||||
|
||||
class CBaseLoader(CParser, BaseConstructor, BaseResolver):
|
||||
|
||||
def __init__(self, stream):
|
||||
CParser.__init__(self, stream)
|
||||
BaseConstructor.__init__(self)
|
||||
BaseResolver.__init__(self)
|
||||
|
||||
class CSafeLoader(CParser, SafeConstructor, Resolver):
|
||||
|
||||
def __init__(self, stream):
|
||||
CParser.__init__(self, stream)
|
||||
SafeConstructor.__init__(self)
|
||||
Resolver.__init__(self)
|
||||
|
||||
class CFullLoader(CParser, FullConstructor, Resolver):
|
||||
|
||||
def __init__(self, stream):
|
||||
CParser.__init__(self, stream)
|
||||
FullConstructor.__init__(self)
|
||||
Resolver.__init__(self)
|
||||
|
||||
class CUnsafeLoader(CParser, UnsafeConstructor, Resolver):
|
||||
|
||||
def __init__(self, stream):
|
||||
CParser.__init__(self, stream)
|
||||
UnsafeConstructor.__init__(self)
|
||||
Resolver.__init__(self)
|
||||
|
||||
class CLoader(CParser, Constructor, Resolver):
|
||||
|
||||
def __init__(self, stream):
|
||||
CParser.__init__(self, stream)
|
||||
Constructor.__init__(self)
|
||||
Resolver.__init__(self)
|
||||
|
||||
class CBaseDumper(CEmitter, BaseRepresenter, BaseResolver):
|
||||
|
||||
def __init__(self, stream,
|
||||
default_style=None, default_flow_style=False,
|
||||
canonical=None, indent=None, width=None,
|
||||
allow_unicode=None, line_break=None,
|
||||
encoding=None, explicit_start=None, explicit_end=None,
|
||||
version=None, tags=None, sort_keys=True):
|
||||
CEmitter.__init__(self, stream, canonical=canonical,
|
||||
indent=indent, width=width, encoding=encoding,
|
||||
allow_unicode=allow_unicode, line_break=line_break,
|
||||
explicit_start=explicit_start, explicit_end=explicit_end,
|
||||
version=version, tags=tags)
|
||||
Representer.__init__(self, default_style=default_style,
|
||||
default_flow_style=default_flow_style, sort_keys=sort_keys)
|
||||
Resolver.__init__(self)
|
||||
|
||||
class CSafeDumper(CEmitter, SafeRepresenter, Resolver):
|
||||
|
||||
def __init__(self, stream,
|
||||
default_style=None, default_flow_style=False,
|
||||
canonical=None, indent=None, width=None,
|
||||
allow_unicode=None, line_break=None,
|
||||
encoding=None, explicit_start=None, explicit_end=None,
|
||||
version=None, tags=None, sort_keys=True):
|
||||
CEmitter.__init__(self, stream, canonical=canonical,
|
||||
indent=indent, width=width, encoding=encoding,
|
||||
allow_unicode=allow_unicode, line_break=line_break,
|
||||
explicit_start=explicit_start, explicit_end=explicit_end,
|
||||
version=version, tags=tags)
|
||||
SafeRepresenter.__init__(self, default_style=default_style,
|
||||
default_flow_style=default_flow_style, sort_keys=sort_keys)
|
||||
Resolver.__init__(self)
|
||||
|
||||
class CDumper(CEmitter, Serializer, Representer, Resolver):
|
||||
|
||||
def __init__(self, stream,
|
||||
default_style=None, default_flow_style=False,
|
||||
canonical=None, indent=None, width=None,
|
||||
allow_unicode=None, line_break=None,
|
||||
encoding=None, explicit_start=None, explicit_end=None,
|
||||
version=None, tags=None, sort_keys=True):
|
||||
CEmitter.__init__(self, stream, canonical=canonical,
|
||||
indent=indent, width=width, encoding=encoding,
|
||||
allow_unicode=allow_unicode, line_break=line_break,
|
||||
explicit_start=explicit_start, explicit_end=explicit_end,
|
||||
version=version, tags=tags)
|
||||
Representer.__init__(self, default_style=default_style,
|
||||
default_flow_style=default_flow_style, sort_keys=sort_keys)
|
||||
Resolver.__init__(self)
|
||||
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
|
||||
__all__ = ['BaseDumper', 'SafeDumper', 'Dumper']
|
||||
|
||||
from .emitter import *
|
||||
from .serializer import *
|
||||
from .representer import *
|
||||
from .resolver import *
|
||||
|
||||
class BaseDumper(Emitter, Serializer, BaseRepresenter, BaseResolver):
|
||||
|
||||
def __init__(self, stream,
|
||||
default_style=None, default_flow_style=False,
|
||||
canonical=None, indent=None, width=None,
|
||||
allow_unicode=None, line_break=None,
|
||||
encoding=None, explicit_start=None, explicit_end=None,
|
||||
version=None, tags=None, sort_keys=True):
|
||||
Emitter.__init__(self, stream, canonical=canonical,
|
||||
indent=indent, width=width,
|
||||
allow_unicode=allow_unicode, line_break=line_break)
|
||||
Serializer.__init__(self, encoding=encoding,
|
||||
explicit_start=explicit_start, explicit_end=explicit_end,
|
||||
version=version, tags=tags)
|
||||
Representer.__init__(self, default_style=default_style,
|
||||
default_flow_style=default_flow_style, sort_keys=sort_keys)
|
||||
Resolver.__init__(self)
|
||||
|
||||
class SafeDumper(Emitter, Serializer, SafeRepresenter, Resolver):
|
||||
|
||||
def __init__(self, stream,
|
||||
default_style=None, default_flow_style=False,
|
||||
canonical=None, indent=None, width=None,
|
||||
allow_unicode=None, line_break=None,
|
||||
encoding=None, explicit_start=None, explicit_end=None,
|
||||
version=None, tags=None, sort_keys=True):
|
||||
Emitter.__init__(self, stream, canonical=canonical,
|
||||
indent=indent, width=width,
|
||||
allow_unicode=allow_unicode, line_break=line_break)
|
||||
Serializer.__init__(self, encoding=encoding,
|
||||
explicit_start=explicit_start, explicit_end=explicit_end,
|
||||
version=version, tags=tags)
|
||||
SafeRepresenter.__init__(self, default_style=default_style,
|
||||
default_flow_style=default_flow_style, sort_keys=sort_keys)
|
||||
Resolver.__init__(self)
|
||||
|
||||
class Dumper(Emitter, Serializer, Representer, Resolver):
|
||||
|
||||
def __init__(self, stream,
|
||||
default_style=None, default_flow_style=False,
|
||||
canonical=None, indent=None, width=None,
|
||||
allow_unicode=None, line_break=None,
|
||||
encoding=None, explicit_start=None, explicit_end=None,
|
||||
version=None, tags=None, sort_keys=True):
|
||||
Emitter.__init__(self, stream, canonical=canonical,
|
||||
indent=indent, width=width,
|
||||
allow_unicode=allow_unicode, line_break=line_break)
|
||||
Serializer.__init__(self, encoding=encoding,
|
||||
explicit_start=explicit_start, explicit_end=explicit_end,
|
||||
version=version, tags=tags)
|
||||
Representer.__init__(self, default_style=default_style,
|
||||
default_flow_style=default_flow_style, sort_keys=sort_keys)
|
||||
Resolver.__init__(self)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,75 @@
|
|||
|
||||
__all__ = ['Mark', 'YAMLError', 'MarkedYAMLError']
|
||||
|
||||
class Mark:
|
||||
|
||||
def __init__(self, name, index, line, column, buffer, pointer):
|
||||
self.name = name
|
||||
self.index = index
|
||||
self.line = line
|
||||
self.column = column
|
||||
self.buffer = buffer
|
||||
self.pointer = pointer
|
||||
|
||||
def get_snippet(self, indent=4, max_length=75):
|
||||
if self.buffer is None:
|
||||
return None
|
||||
head = ''
|
||||
start = self.pointer
|
||||
while start > 0 and self.buffer[start-1] not in '\0\r\n\x85\u2028\u2029':
|
||||
start -= 1
|
||||
if self.pointer-start > max_length/2-1:
|
||||
head = ' ... '
|
||||
start += 5
|
||||
break
|
||||
tail = ''
|
||||
end = self.pointer
|
||||
while end < len(self.buffer) and self.buffer[end] not in '\0\r\n\x85\u2028\u2029':
|
||||
end += 1
|
||||
if end-self.pointer > max_length/2-1:
|
||||
tail = ' ... '
|
||||
end -= 5
|
||||
break
|
||||
snippet = self.buffer[start:end]
|
||||
return ' '*indent + head + snippet + tail + '\n' \
|
||||
+ ' '*(indent+self.pointer-start+len(head)) + '^'
|
||||
|
||||
def __str__(self):
|
||||
snippet = self.get_snippet()
|
||||
where = " in \"%s\", line %d, column %d" \
|
||||
% (self.name, self.line+1, self.column+1)
|
||||
if snippet is not None:
|
||||
where += ":\n"+snippet
|
||||
return where
|
||||
|
||||
class YAMLError(Exception):
|
||||
pass
|
||||
|
||||
class MarkedYAMLError(YAMLError):
|
||||
|
||||
def __init__(self, context=None, context_mark=None,
|
||||
problem=None, problem_mark=None, note=None):
|
||||
self.context = context
|
||||
self.context_mark = context_mark
|
||||
self.problem = problem
|
||||
self.problem_mark = problem_mark
|
||||
self.note = note
|
||||
|
||||
def __str__(self):
|
||||
lines = []
|
||||
if self.context is not None:
|
||||
lines.append(self.context)
|
||||
if self.context_mark is not None \
|
||||
and (self.problem is None or self.problem_mark is None
|
||||
or self.context_mark.name != self.problem_mark.name
|
||||
or self.context_mark.line != self.problem_mark.line
|
||||
or self.context_mark.column != self.problem_mark.column):
|
||||
lines.append(str(self.context_mark))
|
||||
if self.problem is not None:
|
||||
lines.append(self.problem)
|
||||
if self.problem_mark is not None:
|
||||
lines.append(str(self.problem_mark))
|
||||
if self.note is not None:
|
||||
lines.append(self.note)
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
|
||||
# Abstract classes.
|
||||
|
||||
class Event(object):
|
||||
def __init__(self, start_mark=None, end_mark=None):
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
def __repr__(self):
|
||||
attributes = [key for key in ['anchor', 'tag', 'implicit', 'value']
|
||||
if hasattr(self, key)]
|
||||
arguments = ', '.join(['%s=%r' % (key, getattr(self, key))
|
||||
for key in attributes])
|
||||
return '%s(%s)' % (self.__class__.__name__, arguments)
|
||||
|
||||
class NodeEvent(Event):
|
||||
def __init__(self, anchor, start_mark=None, end_mark=None):
|
||||
self.anchor = anchor
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
|
||||
class CollectionStartEvent(NodeEvent):
|
||||
def __init__(self, anchor, tag, implicit, start_mark=None, end_mark=None,
|
||||
flow_style=None):
|
||||
self.anchor = anchor
|
||||
self.tag = tag
|
||||
self.implicit = implicit
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
self.flow_style = flow_style
|
||||
|
||||
class CollectionEndEvent(Event):
|
||||
pass
|
||||
|
||||
# Implementations.
|
||||
|
||||
class StreamStartEvent(Event):
|
||||
def __init__(self, start_mark=None, end_mark=None, encoding=None):
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
self.encoding = encoding
|
||||
|
||||
class StreamEndEvent(Event):
|
||||
pass
|
||||
|
||||
class DocumentStartEvent(Event):
|
||||
def __init__(self, start_mark=None, end_mark=None,
|
||||
explicit=None, version=None, tags=None):
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
self.explicit = explicit
|
||||
self.version = version
|
||||
self.tags = tags
|
||||
|
||||
class DocumentEndEvent(Event):
|
||||
def __init__(self, start_mark=None, end_mark=None,
|
||||
explicit=None):
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
self.explicit = explicit
|
||||
|
||||
class AliasEvent(NodeEvent):
|
||||
pass
|
||||
|
||||
class ScalarEvent(NodeEvent):
|
||||
def __init__(self, anchor, tag, implicit, value,
|
||||
start_mark=None, end_mark=None, style=None):
|
||||
self.anchor = anchor
|
||||
self.tag = tag
|
||||
self.implicit = implicit
|
||||
self.value = value
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
self.style = style
|
||||
|
||||
class SequenceStartEvent(CollectionStartEvent):
|
||||
pass
|
||||
|
||||
class SequenceEndEvent(CollectionEndEvent):
|
||||
pass
|
||||
|
||||
class MappingStartEvent(CollectionStartEvent):
|
||||
pass
|
||||
|
||||
class MappingEndEvent(CollectionEndEvent):
|
||||
pass
|
||||
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
|
||||
__all__ = ['BaseLoader', 'FullLoader', 'SafeLoader', 'Loader', 'UnsafeLoader']
|
||||
|
||||
from .reader import *
|
||||
from .scanner import *
|
||||
from .parser import *
|
||||
from .composer import *
|
||||
from .constructor import *
|
||||
from .resolver import *
|
||||
|
||||
class BaseLoader(Reader, Scanner, Parser, Composer, BaseConstructor, BaseResolver):
|
||||
|
||||
def __init__(self, stream):
|
||||
Reader.__init__(self, stream)
|
||||
Scanner.__init__(self)
|
||||
Parser.__init__(self)
|
||||
Composer.__init__(self)
|
||||
BaseConstructor.__init__(self)
|
||||
BaseResolver.__init__(self)
|
||||
|
||||
class FullLoader(Reader, Scanner, Parser, Composer, FullConstructor, Resolver):
|
||||
|
||||
def __init__(self, stream):
|
||||
Reader.__init__(self, stream)
|
||||
Scanner.__init__(self)
|
||||
Parser.__init__(self)
|
||||
Composer.__init__(self)
|
||||
FullConstructor.__init__(self)
|
||||
Resolver.__init__(self)
|
||||
|
||||
class SafeLoader(Reader, Scanner, Parser, Composer, SafeConstructor, Resolver):
|
||||
|
||||
def __init__(self, stream):
|
||||
Reader.__init__(self, stream)
|
||||
Scanner.__init__(self)
|
||||
Parser.__init__(self)
|
||||
Composer.__init__(self)
|
||||
SafeConstructor.__init__(self)
|
||||
Resolver.__init__(self)
|
||||
|
||||
class Loader(Reader, Scanner, Parser, Composer, Constructor, Resolver):
|
||||
|
||||
def __init__(self, stream):
|
||||
Reader.__init__(self, stream)
|
||||
Scanner.__init__(self)
|
||||
Parser.__init__(self)
|
||||
Composer.__init__(self)
|
||||
Constructor.__init__(self)
|
||||
Resolver.__init__(self)
|
||||
|
||||
# UnsafeLoader is the same as Loader (which is and was always unsafe on
|
||||
# untrusted input). Use of either Loader or UnsafeLoader should be rare, since
|
||||
# FullLoad should be able to load almost all YAML safely. Loader is left intact
|
||||
# to ensure backwards compatibility.
|
||||
class UnsafeLoader(Reader, Scanner, Parser, Composer, Constructor, Resolver):
|
||||
|
||||
def __init__(self, stream):
|
||||
Reader.__init__(self, stream)
|
||||
Scanner.__init__(self)
|
||||
Parser.__init__(self)
|
||||
Composer.__init__(self)
|
||||
Constructor.__init__(self)
|
||||
Resolver.__init__(self)
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
|
||||
class Node(object):
|
||||
def __init__(self, tag, value, start_mark, end_mark):
|
||||
self.tag = tag
|
||||
self.value = value
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
def __repr__(self):
|
||||
value = self.value
|
||||
#if isinstance(value, list):
|
||||
# if len(value) == 0:
|
||||
# value = '<empty>'
|
||||
# elif len(value) == 1:
|
||||
# value = '<1 item>'
|
||||
# else:
|
||||
# value = '<%d items>' % len(value)
|
||||
#else:
|
||||
# if len(value) > 75:
|
||||
# value = repr(value[:70]+u' ... ')
|
||||
# else:
|
||||
# value = repr(value)
|
||||
value = repr(value)
|
||||
return '%s(tag=%r, value=%s)' % (self.__class__.__name__, self.tag, value)
|
||||
|
||||
class ScalarNode(Node):
|
||||
id = 'scalar'
|
||||
def __init__(self, tag, value,
|
||||
start_mark=None, end_mark=None, style=None):
|
||||
self.tag = tag
|
||||
self.value = value
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
self.style = style
|
||||
|
||||
class CollectionNode(Node):
|
||||
def __init__(self, tag, value,
|
||||
start_mark=None, end_mark=None, flow_style=None):
|
||||
self.tag = tag
|
||||
self.value = value
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
self.flow_style = flow_style
|
||||
|
||||
class SequenceNode(CollectionNode):
|
||||
id = 'sequence'
|
||||
|
||||
class MappingNode(CollectionNode):
|
||||
id = 'mapping'
|
||||
|
||||
|
|
@ -0,0 +1,589 @@
|
|||
|
||||
# The following YAML grammar is LL(1) and is parsed by a recursive descent
|
||||
# parser.
|
||||
#
|
||||
# stream ::= STREAM-START implicit_document? explicit_document* STREAM-END
|
||||
# implicit_document ::= block_node DOCUMENT-END*
|
||||
# explicit_document ::= DIRECTIVE* DOCUMENT-START block_node? DOCUMENT-END*
|
||||
# block_node_or_indentless_sequence ::=
|
||||
# ALIAS
|
||||
# | properties (block_content | indentless_block_sequence)?
|
||||
# | block_content
|
||||
# | indentless_block_sequence
|
||||
# block_node ::= ALIAS
|
||||
# | properties block_content?
|
||||
# | block_content
|
||||
# flow_node ::= ALIAS
|
||||
# | properties flow_content?
|
||||
# | flow_content
|
||||
# properties ::= TAG ANCHOR? | ANCHOR TAG?
|
||||
# block_content ::= block_collection | flow_collection | SCALAR
|
||||
# flow_content ::= flow_collection | SCALAR
|
||||
# block_collection ::= block_sequence | block_mapping
|
||||
# flow_collection ::= flow_sequence | flow_mapping
|
||||
# block_sequence ::= BLOCK-SEQUENCE-START (BLOCK-ENTRY block_node?)* BLOCK-END
|
||||
# indentless_sequence ::= (BLOCK-ENTRY block_node?)+
|
||||
# block_mapping ::= BLOCK-MAPPING_START
|
||||
# ((KEY block_node_or_indentless_sequence?)?
|
||||
# (VALUE block_node_or_indentless_sequence?)?)*
|
||||
# BLOCK-END
|
||||
# flow_sequence ::= FLOW-SEQUENCE-START
|
||||
# (flow_sequence_entry FLOW-ENTRY)*
|
||||
# flow_sequence_entry?
|
||||
# FLOW-SEQUENCE-END
|
||||
# flow_sequence_entry ::= flow_node | KEY flow_node? (VALUE flow_node?)?
|
||||
# flow_mapping ::= FLOW-MAPPING-START
|
||||
# (flow_mapping_entry FLOW-ENTRY)*
|
||||
# flow_mapping_entry?
|
||||
# FLOW-MAPPING-END
|
||||
# flow_mapping_entry ::= flow_node | KEY flow_node? (VALUE flow_node?)?
|
||||
#
|
||||
# FIRST sets:
|
||||
#
|
||||
# stream: { STREAM-START }
|
||||
# explicit_document: { DIRECTIVE DOCUMENT-START }
|
||||
# implicit_document: FIRST(block_node)
|
||||
# block_node: { ALIAS TAG ANCHOR SCALAR BLOCK-SEQUENCE-START BLOCK-MAPPING-START FLOW-SEQUENCE-START FLOW-MAPPING-START }
|
||||
# flow_node: { ALIAS ANCHOR TAG SCALAR FLOW-SEQUENCE-START FLOW-MAPPING-START }
|
||||
# block_content: { BLOCK-SEQUENCE-START BLOCK-MAPPING-START FLOW-SEQUENCE-START FLOW-MAPPING-START SCALAR }
|
||||
# flow_content: { FLOW-SEQUENCE-START FLOW-MAPPING-START SCALAR }
|
||||
# block_collection: { BLOCK-SEQUENCE-START BLOCK-MAPPING-START }
|
||||
# flow_collection: { FLOW-SEQUENCE-START FLOW-MAPPING-START }
|
||||
# block_sequence: { BLOCK-SEQUENCE-START }
|
||||
# block_mapping: { BLOCK-MAPPING-START }
|
||||
# block_node_or_indentless_sequence: { ALIAS ANCHOR TAG SCALAR BLOCK-SEQUENCE-START BLOCK-MAPPING-START FLOW-SEQUENCE-START FLOW-MAPPING-START BLOCK-ENTRY }
|
||||
# indentless_sequence: { ENTRY }
|
||||
# flow_collection: { FLOW-SEQUENCE-START FLOW-MAPPING-START }
|
||||
# flow_sequence: { FLOW-SEQUENCE-START }
|
||||
# flow_mapping: { FLOW-MAPPING-START }
|
||||
# flow_sequence_entry: { ALIAS ANCHOR TAG SCALAR FLOW-SEQUENCE-START FLOW-MAPPING-START KEY }
|
||||
# flow_mapping_entry: { ALIAS ANCHOR TAG SCALAR FLOW-SEQUENCE-START FLOW-MAPPING-START KEY }
|
||||
|
||||
__all__ = ['Parser', 'ParserError']
|
||||
|
||||
from .error import MarkedYAMLError
|
||||
from .tokens import *
|
||||
from .events import *
|
||||
from .scanner import *
|
||||
|
||||
class ParserError(MarkedYAMLError):
|
||||
pass
|
||||
|
||||
class Parser:
|
||||
# Since writing a recursive-descendant parser is a straightforward task, we
|
||||
# do not give many comments here.
|
||||
|
||||
DEFAULT_TAGS = {
|
||||
'!': '!',
|
||||
'!!': 'tag:yaml.org,2002:',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.current_event = None
|
||||
self.yaml_version = None
|
||||
self.tag_handles = {}
|
||||
self.states = []
|
||||
self.marks = []
|
||||
self.state = self.parse_stream_start
|
||||
|
||||
def dispose(self):
|
||||
# Reset the state attributes (to clear self-references)
|
||||
self.states = []
|
||||
self.state = None
|
||||
|
||||
def check_event(self, *choices):
|
||||
# Check the type of the next event.
|
||||
if self.current_event is None:
|
||||
if self.state:
|
||||
self.current_event = self.state()
|
||||
if self.current_event is not None:
|
||||
if not choices:
|
||||
return True
|
||||
for choice in choices:
|
||||
if isinstance(self.current_event, choice):
|
||||
return True
|
||||
return False
|
||||
|
||||
def peek_event(self):
|
||||
# Get the next event.
|
||||
if self.current_event is None:
|
||||
if self.state:
|
||||
self.current_event = self.state()
|
||||
return self.current_event
|
||||
|
||||
def get_event(self):
|
||||
# Get the next event and proceed further.
|
||||
if self.current_event is None:
|
||||
if self.state:
|
||||
self.current_event = self.state()
|
||||
value = self.current_event
|
||||
self.current_event = None
|
||||
return value
|
||||
|
||||
# stream ::= STREAM-START implicit_document? explicit_document* STREAM-END
|
||||
# implicit_document ::= block_node DOCUMENT-END*
|
||||
# explicit_document ::= DIRECTIVE* DOCUMENT-START block_node? DOCUMENT-END*
|
||||
|
||||
def parse_stream_start(self):
|
||||
|
||||
# Parse the stream start.
|
||||
token = self.get_token()
|
||||
event = StreamStartEvent(token.start_mark, token.end_mark,
|
||||
encoding=token.encoding)
|
||||
|
||||
# Prepare the next state.
|
||||
self.state = self.parse_implicit_document_start
|
||||
|
||||
return event
|
||||
|
||||
def parse_implicit_document_start(self):
|
||||
|
||||
# Parse an implicit document.
|
||||
if not self.check_token(DirectiveToken, DocumentStartToken,
|
||||
StreamEndToken):
|
||||
self.tag_handles = self.DEFAULT_TAGS
|
||||
token = self.peek_token()
|
||||
start_mark = end_mark = token.start_mark
|
||||
event = DocumentStartEvent(start_mark, end_mark,
|
||||
explicit=False)
|
||||
|
||||
# Prepare the next state.
|
||||
self.states.append(self.parse_document_end)
|
||||
self.state = self.parse_block_node
|
||||
|
||||
return event
|
||||
|
||||
else:
|
||||
return self.parse_document_start()
|
||||
|
||||
def parse_document_start(self):
|
||||
|
||||
# Parse any extra document end indicators.
|
||||
while self.check_token(DocumentEndToken):
|
||||
self.get_token()
|
||||
|
||||
# Parse an explicit document.
|
||||
if not self.check_token(StreamEndToken):
|
||||
token = self.peek_token()
|
||||
start_mark = token.start_mark
|
||||
version, tags = self.process_directives()
|
||||
if not self.check_token(DocumentStartToken):
|
||||
raise ParserError(None, None,
|
||||
"expected '<document start>', but found %r"
|
||||
% self.peek_token().id,
|
||||
self.peek_token().start_mark)
|
||||
token = self.get_token()
|
||||
end_mark = token.end_mark
|
||||
event = DocumentStartEvent(start_mark, end_mark,
|
||||
explicit=True, version=version, tags=tags)
|
||||
self.states.append(self.parse_document_end)
|
||||
self.state = self.parse_document_content
|
||||
else:
|
||||
# Parse the end of the stream.
|
||||
token = self.get_token()
|
||||
event = StreamEndEvent(token.start_mark, token.end_mark)
|
||||
assert not self.states
|
||||
assert not self.marks
|
||||
self.state = None
|
||||
return event
|
||||
|
||||
def parse_document_end(self):
|
||||
|
||||
# Parse the document end.
|
||||
token = self.peek_token()
|
||||
start_mark = end_mark = token.start_mark
|
||||
explicit = False
|
||||
if self.check_token(DocumentEndToken):
|
||||
token = self.get_token()
|
||||
end_mark = token.end_mark
|
||||
explicit = True
|
||||
event = DocumentEndEvent(start_mark, end_mark,
|
||||
explicit=explicit)
|
||||
|
||||
# Prepare the next state.
|
||||
self.state = self.parse_document_start
|
||||
|
||||
return event
|
||||
|
||||
def parse_document_content(self):
|
||||
if self.check_token(DirectiveToken,
|
||||
DocumentStartToken, DocumentEndToken, StreamEndToken):
|
||||
event = self.process_empty_scalar(self.peek_token().start_mark)
|
||||
self.state = self.states.pop()
|
||||
return event
|
||||
else:
|
||||
return self.parse_block_node()
|
||||
|
||||
def process_directives(self):
|
||||
self.yaml_version = None
|
||||
self.tag_handles = {}
|
||||
while self.check_token(DirectiveToken):
|
||||
token = self.get_token()
|
||||
if token.name == 'YAML':
|
||||
if self.yaml_version is not None:
|
||||
raise ParserError(None, None,
|
||||
"found duplicate YAML directive", token.start_mark)
|
||||
major, minor = token.value
|
||||
if major != 1:
|
||||
raise ParserError(None, None,
|
||||
"found incompatible YAML document (version 1.* is required)",
|
||||
token.start_mark)
|
||||
self.yaml_version = token.value
|
||||
elif token.name == 'TAG':
|
||||
handle, prefix = token.value
|
||||
if handle in self.tag_handles:
|
||||
raise ParserError(None, None,
|
||||
"duplicate tag handle %r" % handle,
|
||||
token.start_mark)
|
||||
self.tag_handles[handle] = prefix
|
||||
if self.tag_handles:
|
||||
value = self.yaml_version, self.tag_handles.copy()
|
||||
else:
|
||||
value = self.yaml_version, None
|
||||
for key in self.DEFAULT_TAGS:
|
||||
if key not in self.tag_handles:
|
||||
self.tag_handles[key] = self.DEFAULT_TAGS[key]
|
||||
return value
|
||||
|
||||
# block_node_or_indentless_sequence ::= ALIAS
|
||||
# | properties (block_content | indentless_block_sequence)?
|
||||
# | block_content
|
||||
# | indentless_block_sequence
|
||||
# block_node ::= ALIAS
|
||||
# | properties block_content?
|
||||
# | block_content
|
||||
# flow_node ::= ALIAS
|
||||
# | properties flow_content?
|
||||
# | flow_content
|
||||
# properties ::= TAG ANCHOR? | ANCHOR TAG?
|
||||
# block_content ::= block_collection | flow_collection | SCALAR
|
||||
# flow_content ::= flow_collection | SCALAR
|
||||
# block_collection ::= block_sequence | block_mapping
|
||||
# flow_collection ::= flow_sequence | flow_mapping
|
||||
|
||||
def parse_block_node(self):
|
||||
return self.parse_node(block=True)
|
||||
|
||||
def parse_flow_node(self):
|
||||
return self.parse_node()
|
||||
|
||||
def parse_block_node_or_indentless_sequence(self):
|
||||
return self.parse_node(block=True, indentless_sequence=True)
|
||||
|
||||
def parse_node(self, block=False, indentless_sequence=False):
|
||||
if self.check_token(AliasToken):
|
||||
token = self.get_token()
|
||||
event = AliasEvent(token.value, token.start_mark, token.end_mark)
|
||||
self.state = self.states.pop()
|
||||
else:
|
||||
anchor = None
|
||||
tag = None
|
||||
start_mark = end_mark = tag_mark = None
|
||||
if self.check_token(AnchorToken):
|
||||
token = self.get_token()
|
||||
start_mark = token.start_mark
|
||||
end_mark = token.end_mark
|
||||
anchor = token.value
|
||||
if self.check_token(TagToken):
|
||||
token = self.get_token()
|
||||
tag_mark = token.start_mark
|
||||
end_mark = token.end_mark
|
||||
tag = token.value
|
||||
elif self.check_token(TagToken):
|
||||
token = self.get_token()
|
||||
start_mark = tag_mark = token.start_mark
|
||||
end_mark = token.end_mark
|
||||
tag = token.value
|
||||
if self.check_token(AnchorToken):
|
||||
token = self.get_token()
|
||||
end_mark = token.end_mark
|
||||
anchor = token.value
|
||||
if tag is not None:
|
||||
handle, suffix = tag
|
||||
if handle is not None:
|
||||
if handle not in self.tag_handles:
|
||||
raise ParserError("while parsing a node", start_mark,
|
||||
"found undefined tag handle %r" % handle,
|
||||
tag_mark)
|
||||
tag = self.tag_handles[handle]+suffix
|
||||
else:
|
||||
tag = suffix
|
||||
#if tag == '!':
|
||||
# raise ParserError("while parsing a node", start_mark,
|
||||
# "found non-specific tag '!'", tag_mark,
|
||||
# "Please check 'http://pyyaml.org/wiki/YAMLNonSpecificTag' and share your opinion.")
|
||||
if start_mark is None:
|
||||
start_mark = end_mark = self.peek_token().start_mark
|
||||
event = None
|
||||
implicit = (tag is None or tag == '!')
|
||||
if indentless_sequence and self.check_token(BlockEntryToken):
|
||||
end_mark = self.peek_token().end_mark
|
||||
event = SequenceStartEvent(anchor, tag, implicit,
|
||||
start_mark, end_mark)
|
||||
self.state = self.parse_indentless_sequence_entry
|
||||
else:
|
||||
if self.check_token(ScalarToken):
|
||||
token = self.get_token()
|
||||
end_mark = token.end_mark
|
||||
if (token.plain and tag is None) or tag == '!':
|
||||
implicit = (True, False)
|
||||
elif tag is None:
|
||||
implicit = (False, True)
|
||||
else:
|
||||
implicit = (False, False)
|
||||
event = ScalarEvent(anchor, tag, implicit, token.value,
|
||||
start_mark, end_mark, style=token.style)
|
||||
self.state = self.states.pop()
|
||||
elif self.check_token(FlowSequenceStartToken):
|
||||
end_mark = self.peek_token().end_mark
|
||||
event = SequenceStartEvent(anchor, tag, implicit,
|
||||
start_mark, end_mark, flow_style=True)
|
||||
self.state = self.parse_flow_sequence_first_entry
|
||||
elif self.check_token(FlowMappingStartToken):
|
||||
end_mark = self.peek_token().end_mark
|
||||
event = MappingStartEvent(anchor, tag, implicit,
|
||||
start_mark, end_mark, flow_style=True)
|
||||
self.state = self.parse_flow_mapping_first_key
|
||||
elif block and self.check_token(BlockSequenceStartToken):
|
||||
end_mark = self.peek_token().start_mark
|
||||
event = SequenceStartEvent(anchor, tag, implicit,
|
||||
start_mark, end_mark, flow_style=False)
|
||||
self.state = self.parse_block_sequence_first_entry
|
||||
elif block and self.check_token(BlockMappingStartToken):
|
||||
end_mark = self.peek_token().start_mark
|
||||
event = MappingStartEvent(anchor, tag, implicit,
|
||||
start_mark, end_mark, flow_style=False)
|
||||
self.state = self.parse_block_mapping_first_key
|
||||
elif anchor is not None or tag is not None:
|
||||
# Empty scalars are allowed even if a tag or an anchor is
|
||||
# specified.
|
||||
event = ScalarEvent(anchor, tag, (implicit, False), '',
|
||||
start_mark, end_mark)
|
||||
self.state = self.states.pop()
|
||||
else:
|
||||
if block:
|
||||
node = 'block'
|
||||
else:
|
||||
node = 'flow'
|
||||
token = self.peek_token()
|
||||
raise ParserError("while parsing a %s node" % node, start_mark,
|
||||
"expected the node content, but found %r" % token.id,
|
||||
token.start_mark)
|
||||
return event
|
||||
|
||||
# block_sequence ::= BLOCK-SEQUENCE-START (BLOCK-ENTRY block_node?)* BLOCK-END
|
||||
|
||||
def parse_block_sequence_first_entry(self):
|
||||
token = self.get_token()
|
||||
self.marks.append(token.start_mark)
|
||||
return self.parse_block_sequence_entry()
|
||||
|
||||
def parse_block_sequence_entry(self):
|
||||
if self.check_token(BlockEntryToken):
|
||||
token = self.get_token()
|
||||
if not self.check_token(BlockEntryToken, BlockEndToken):
|
||||
self.states.append(self.parse_block_sequence_entry)
|
||||
return self.parse_block_node()
|
||||
else:
|
||||
self.state = self.parse_block_sequence_entry
|
||||
return self.process_empty_scalar(token.end_mark)
|
||||
if not self.check_token(BlockEndToken):
|
||||
token = self.peek_token()
|
||||
raise ParserError("while parsing a block collection", self.marks[-1],
|
||||
"expected <block end>, but found %r" % token.id, token.start_mark)
|
||||
token = self.get_token()
|
||||
event = SequenceEndEvent(token.start_mark, token.end_mark)
|
||||
self.state = self.states.pop()
|
||||
self.marks.pop()
|
||||
return event
|
||||
|
||||
# indentless_sequence ::= (BLOCK-ENTRY block_node?)+
|
||||
|
||||
def parse_indentless_sequence_entry(self):
|
||||
if self.check_token(BlockEntryToken):
|
||||
token = self.get_token()
|
||||
if not self.check_token(BlockEntryToken,
|
||||
KeyToken, ValueToken, BlockEndToken):
|
||||
self.states.append(self.parse_indentless_sequence_entry)
|
||||
return self.parse_block_node()
|
||||
else:
|
||||
self.state = self.parse_indentless_sequence_entry
|
||||
return self.process_empty_scalar(token.end_mark)
|
||||
token = self.peek_token()
|
||||
event = SequenceEndEvent(token.start_mark, token.start_mark)
|
||||
self.state = self.states.pop()
|
||||
return event
|
||||
|
||||
# block_mapping ::= BLOCK-MAPPING_START
|
||||
# ((KEY block_node_or_indentless_sequence?)?
|
||||
# (VALUE block_node_or_indentless_sequence?)?)*
|
||||
# BLOCK-END
|
||||
|
||||
def parse_block_mapping_first_key(self):
|
||||
token = self.get_token()
|
||||
self.marks.append(token.start_mark)
|
||||
return self.parse_block_mapping_key()
|
||||
|
||||
def parse_block_mapping_key(self):
|
||||
if self.check_token(KeyToken):
|
||||
token = self.get_token()
|
||||
if not self.check_token(KeyToken, ValueToken, BlockEndToken):
|
||||
self.states.append(self.parse_block_mapping_value)
|
||||
return self.parse_block_node_or_indentless_sequence()
|
||||
else:
|
||||
self.state = self.parse_block_mapping_value
|
||||
return self.process_empty_scalar(token.end_mark)
|
||||
if not self.check_token(BlockEndToken):
|
||||
token = self.peek_token()
|
||||
raise ParserError("while parsing a block mapping", self.marks[-1],
|
||||
"expected <block end>, but found %r" % token.id, token.start_mark)
|
||||
token = self.get_token()
|
||||
event = MappingEndEvent(token.start_mark, token.end_mark)
|
||||
self.state = self.states.pop()
|
||||
self.marks.pop()
|
||||
return event
|
||||
|
||||
def parse_block_mapping_value(self):
|
||||
if self.check_token(ValueToken):
|
||||
token = self.get_token()
|
||||
if not self.check_token(KeyToken, ValueToken, BlockEndToken):
|
||||
self.states.append(self.parse_block_mapping_key)
|
||||
return self.parse_block_node_or_indentless_sequence()
|
||||
else:
|
||||
self.state = self.parse_block_mapping_key
|
||||
return self.process_empty_scalar(token.end_mark)
|
||||
else:
|
||||
self.state = self.parse_block_mapping_key
|
||||
token = self.peek_token()
|
||||
return self.process_empty_scalar(token.start_mark)
|
||||
|
||||
# flow_sequence ::= FLOW-SEQUENCE-START
|
||||
# (flow_sequence_entry FLOW-ENTRY)*
|
||||
# flow_sequence_entry?
|
||||
# FLOW-SEQUENCE-END
|
||||
# flow_sequence_entry ::= flow_node | KEY flow_node? (VALUE flow_node?)?
|
||||
#
|
||||
# Note that while production rules for both flow_sequence_entry and
|
||||
# flow_mapping_entry are equal, their interpretations are different.
|
||||
# For `flow_sequence_entry`, the part `KEY flow_node? (VALUE flow_node?)?`
|
||||
# generate an inline mapping (set syntax).
|
||||
|
||||
def parse_flow_sequence_first_entry(self):
|
||||
token = self.get_token()
|
||||
self.marks.append(token.start_mark)
|
||||
return self.parse_flow_sequence_entry(first=True)
|
||||
|
||||
def parse_flow_sequence_entry(self, first=False):
|
||||
if not self.check_token(FlowSequenceEndToken):
|
||||
if not first:
|
||||
if self.check_token(FlowEntryToken):
|
||||
self.get_token()
|
||||
else:
|
||||
token = self.peek_token()
|
||||
raise ParserError("while parsing a flow sequence", self.marks[-1],
|
||||
"expected ',' or ']', but got %r" % token.id, token.start_mark)
|
||||
|
||||
if self.check_token(KeyToken):
|
||||
token = self.peek_token()
|
||||
event = MappingStartEvent(None, None, True,
|
||||
token.start_mark, token.end_mark,
|
||||
flow_style=True)
|
||||
self.state = self.parse_flow_sequence_entry_mapping_key
|
||||
return event
|
||||
elif not self.check_token(FlowSequenceEndToken):
|
||||
self.states.append(self.parse_flow_sequence_entry)
|
||||
return self.parse_flow_node()
|
||||
token = self.get_token()
|
||||
event = SequenceEndEvent(token.start_mark, token.end_mark)
|
||||
self.state = self.states.pop()
|
||||
self.marks.pop()
|
||||
return event
|
||||
|
||||
def parse_flow_sequence_entry_mapping_key(self):
|
||||
token = self.get_token()
|
||||
if not self.check_token(ValueToken,
|
||||
FlowEntryToken, FlowSequenceEndToken):
|
||||
self.states.append(self.parse_flow_sequence_entry_mapping_value)
|
||||
return self.parse_flow_node()
|
||||
else:
|
||||
self.state = self.parse_flow_sequence_entry_mapping_value
|
||||
return self.process_empty_scalar(token.end_mark)
|
||||
|
||||
def parse_flow_sequence_entry_mapping_value(self):
|
||||
if self.check_token(ValueToken):
|
||||
token = self.get_token()
|
||||
if not self.check_token(FlowEntryToken, FlowSequenceEndToken):
|
||||
self.states.append(self.parse_flow_sequence_entry_mapping_end)
|
||||
return self.parse_flow_node()
|
||||
else:
|
||||
self.state = self.parse_flow_sequence_entry_mapping_end
|
||||
return self.process_empty_scalar(token.end_mark)
|
||||
else:
|
||||
self.state = self.parse_flow_sequence_entry_mapping_end
|
||||
token = self.peek_token()
|
||||
return self.process_empty_scalar(token.start_mark)
|
||||
|
||||
def parse_flow_sequence_entry_mapping_end(self):
|
||||
self.state = self.parse_flow_sequence_entry
|
||||
token = self.peek_token()
|
||||
return MappingEndEvent(token.start_mark, token.start_mark)
|
||||
|
||||
# flow_mapping ::= FLOW-MAPPING-START
|
||||
# (flow_mapping_entry FLOW-ENTRY)*
|
||||
# flow_mapping_entry?
|
||||
# FLOW-MAPPING-END
|
||||
# flow_mapping_entry ::= flow_node | KEY flow_node? (VALUE flow_node?)?
|
||||
|
||||
def parse_flow_mapping_first_key(self):
|
||||
token = self.get_token()
|
||||
self.marks.append(token.start_mark)
|
||||
return self.parse_flow_mapping_key(first=True)
|
||||
|
||||
def parse_flow_mapping_key(self, first=False):
|
||||
if not self.check_token(FlowMappingEndToken):
|
||||
if not first:
|
||||
if self.check_token(FlowEntryToken):
|
||||
self.get_token()
|
||||
else:
|
||||
token = self.peek_token()
|
||||
raise ParserError("while parsing a flow mapping", self.marks[-1],
|
||||
"expected ',' or '}', but got %r" % token.id, token.start_mark)
|
||||
if self.check_token(KeyToken):
|
||||
token = self.get_token()
|
||||
if not self.check_token(ValueToken,
|
||||
FlowEntryToken, FlowMappingEndToken):
|
||||
self.states.append(self.parse_flow_mapping_value)
|
||||
return self.parse_flow_node()
|
||||
else:
|
||||
self.state = self.parse_flow_mapping_value
|
||||
return self.process_empty_scalar(token.end_mark)
|
||||
elif not self.check_token(FlowMappingEndToken):
|
||||
self.states.append(self.parse_flow_mapping_empty_value)
|
||||
return self.parse_flow_node()
|
||||
token = self.get_token()
|
||||
event = MappingEndEvent(token.start_mark, token.end_mark)
|
||||
self.state = self.states.pop()
|
||||
self.marks.pop()
|
||||
return event
|
||||
|
||||
def parse_flow_mapping_value(self):
|
||||
if self.check_token(ValueToken):
|
||||
token = self.get_token()
|
||||
if not self.check_token(FlowEntryToken, FlowMappingEndToken):
|
||||
self.states.append(self.parse_flow_mapping_key)
|
||||
return self.parse_flow_node()
|
||||
else:
|
||||
self.state = self.parse_flow_mapping_key
|
||||
return self.process_empty_scalar(token.end_mark)
|
||||
else:
|
||||
self.state = self.parse_flow_mapping_key
|
||||
token = self.peek_token()
|
||||
return self.process_empty_scalar(token.start_mark)
|
||||
|
||||
def parse_flow_mapping_empty_value(self):
|
||||
self.state = self.parse_flow_mapping_key
|
||||
return self.process_empty_scalar(self.peek_token().start_mark)
|
||||
|
||||
def process_empty_scalar(self, mark):
|
||||
return ScalarEvent(None, None, (True, False), '', mark, mark)
|
||||
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
# This module contains abstractions for the input stream. You don't have to
|
||||
# looks further, there are no pretty code.
|
||||
#
|
||||
# We define two classes here.
|
||||
#
|
||||
# Mark(source, line, column)
|
||||
# It's just a record and its only use is producing nice error messages.
|
||||
# Parser does not use it for any other purposes.
|
||||
#
|
||||
# Reader(source, data)
|
||||
# Reader determines the encoding of `data` and converts it to unicode.
|
||||
# Reader provides the following methods and attributes:
|
||||
# reader.peek(length=1) - return the next `length` characters
|
||||
# reader.forward(length=1) - move the current position to `length` characters.
|
||||
# reader.index - the number of the current character.
|
||||
# reader.line, stream.column - the line and the column of the current character.
|
||||
|
||||
__all__ = ['Reader', 'ReaderError']
|
||||
|
||||
from .error import YAMLError, Mark
|
||||
|
||||
import codecs, re
|
||||
|
||||
class ReaderError(YAMLError):
|
||||
|
||||
def __init__(self, name, position, character, encoding, reason):
|
||||
self.name = name
|
||||
self.character = character
|
||||
self.position = position
|
||||
self.encoding = encoding
|
||||
self.reason = reason
|
||||
|
||||
def __str__(self):
|
||||
if isinstance(self.character, bytes):
|
||||
return "'%s' codec can't decode byte #x%02x: %s\n" \
|
||||
" in \"%s\", position %d" \
|
||||
% (self.encoding, ord(self.character), self.reason,
|
||||
self.name, self.position)
|
||||
else:
|
||||
return "unacceptable character #x%04x: %s\n" \
|
||||
" in \"%s\", position %d" \
|
||||
% (self.character, self.reason,
|
||||
self.name, self.position)
|
||||
|
||||
class Reader(object):
|
||||
# Reader:
|
||||
# - determines the data encoding and converts it to a unicode string,
|
||||
# - checks if characters are in allowed range,
|
||||
# - adds '\0' to the end.
|
||||
|
||||
# Reader accepts
|
||||
# - a `bytes` object,
|
||||
# - a `str` object,
|
||||
# - a file-like object with its `read` method returning `str`,
|
||||
# - a file-like object with its `read` method returning `unicode`.
|
||||
|
||||
# Yeah, it's ugly and slow.
|
||||
|
||||
def __init__(self, stream):
|
||||
self.name = None
|
||||
self.stream = None
|
||||
self.stream_pointer = 0
|
||||
self.eof = True
|
||||
self.buffer = ''
|
||||
self.pointer = 0
|
||||
self.raw_buffer = None
|
||||
self.raw_decode = None
|
||||
self.encoding = None
|
||||
self.index = 0
|
||||
self.line = 0
|
||||
self.column = 0
|
||||
if isinstance(stream, str):
|
||||
self.name = "<unicode string>"
|
||||
self.check_printable(stream)
|
||||
self.buffer = stream+'\0'
|
||||
elif isinstance(stream, bytes):
|
||||
self.name = "<byte string>"
|
||||
self.raw_buffer = stream
|
||||
self.determine_encoding()
|
||||
else:
|
||||
self.stream = stream
|
||||
self.name = getattr(stream, 'name', "<file>")
|
||||
self.eof = False
|
||||
self.raw_buffer = None
|
||||
self.determine_encoding()
|
||||
|
||||
def peek(self, index=0):
|
||||
try:
|
||||
return self.buffer[self.pointer+index]
|
||||
except IndexError:
|
||||
self.update(index+1)
|
||||
return self.buffer[self.pointer+index]
|
||||
|
||||
def prefix(self, length=1):
|
||||
if self.pointer+length >= len(self.buffer):
|
||||
self.update(length)
|
||||
return self.buffer[self.pointer:self.pointer+length]
|
||||
|
||||
def forward(self, length=1):
|
||||
if self.pointer+length+1 >= len(self.buffer):
|
||||
self.update(length+1)
|
||||
while length:
|
||||
ch = self.buffer[self.pointer]
|
||||
self.pointer += 1
|
||||
self.index += 1
|
||||
if ch in '\n\x85\u2028\u2029' \
|
||||
or (ch == '\r' and self.buffer[self.pointer] != '\n'):
|
||||
self.line += 1
|
||||
self.column = 0
|
||||
elif ch != '\uFEFF':
|
||||
self.column += 1
|
||||
length -= 1
|
||||
|
||||
def get_mark(self):
|
||||
if self.stream is None:
|
||||
return Mark(self.name, self.index, self.line, self.column,
|
||||
self.buffer, self.pointer)
|
||||
else:
|
||||
return Mark(self.name, self.index, self.line, self.column,
|
||||
None, None)
|
||||
|
||||
def determine_encoding(self):
|
||||
while not self.eof and (self.raw_buffer is None or len(self.raw_buffer) < 2):
|
||||
self.update_raw()
|
||||
if isinstance(self.raw_buffer, bytes):
|
||||
if self.raw_buffer.startswith(codecs.BOM_UTF16_LE):
|
||||
self.raw_decode = codecs.utf_16_le_decode
|
||||
self.encoding = 'utf-16-le'
|
||||
elif self.raw_buffer.startswith(codecs.BOM_UTF16_BE):
|
||||
self.raw_decode = codecs.utf_16_be_decode
|
||||
self.encoding = 'utf-16-be'
|
||||
else:
|
||||
self.raw_decode = codecs.utf_8_decode
|
||||
self.encoding = 'utf-8'
|
||||
self.update(1)
|
||||
|
||||
NON_PRINTABLE = re.compile('[^\x09\x0A\x0D\x20-\x7E\x85\xA0-\uD7FF\uE000-\uFFFD\U00010000-\U0010ffff]')
|
||||
def check_printable(self, data):
|
||||
match = self.NON_PRINTABLE.search(data)
|
||||
if match:
|
||||
character = match.group()
|
||||
position = self.index+(len(self.buffer)-self.pointer)+match.start()
|
||||
raise ReaderError(self.name, position, ord(character),
|
||||
'unicode', "special characters are not allowed")
|
||||
|
||||
def update(self, length):
|
||||
if self.raw_buffer is None:
|
||||
return
|
||||
self.buffer = self.buffer[self.pointer:]
|
||||
self.pointer = 0
|
||||
while len(self.buffer) < length:
|
||||
if not self.eof:
|
||||
self.update_raw()
|
||||
if self.raw_decode is not None:
|
||||
try:
|
||||
data, converted = self.raw_decode(self.raw_buffer,
|
||||
'strict', self.eof)
|
||||
except UnicodeDecodeError as exc:
|
||||
character = self.raw_buffer[exc.start]
|
||||
if self.stream is not None:
|
||||
position = self.stream_pointer-len(self.raw_buffer)+exc.start
|
||||
else:
|
||||
position = exc.start
|
||||
raise ReaderError(self.name, position, character,
|
||||
exc.encoding, exc.reason)
|
||||
else:
|
||||
data = self.raw_buffer
|
||||
converted = len(data)
|
||||
self.check_printable(data)
|
||||
self.buffer += data
|
||||
self.raw_buffer = self.raw_buffer[converted:]
|
||||
if self.eof:
|
||||
self.buffer += '\0'
|
||||
self.raw_buffer = None
|
||||
break
|
||||
|
||||
def update_raw(self, size=4096):
|
||||
data = self.stream.read(size)
|
||||
if self.raw_buffer is None:
|
||||
self.raw_buffer = data
|
||||
else:
|
||||
self.raw_buffer += data
|
||||
self.stream_pointer += len(data)
|
||||
if not data:
|
||||
self.eof = True
|
||||
|
|
@ -0,0 +1,389 @@
|
|||
|
||||
__all__ = ['BaseRepresenter', 'SafeRepresenter', 'Representer',
|
||||
'RepresenterError']
|
||||
|
||||
from .error import *
|
||||
from .nodes import *
|
||||
|
||||
import datetime, copyreg, types, base64, collections
|
||||
|
||||
class RepresenterError(YAMLError):
|
||||
pass
|
||||
|
||||
class BaseRepresenter:
|
||||
|
||||
yaml_representers = {}
|
||||
yaml_multi_representers = {}
|
||||
|
||||
def __init__(self, default_style=None, default_flow_style=False, sort_keys=True):
|
||||
self.default_style = default_style
|
||||
self.sort_keys = sort_keys
|
||||
self.default_flow_style = default_flow_style
|
||||
self.represented_objects = {}
|
||||
self.object_keeper = []
|
||||
self.alias_key = None
|
||||
|
||||
def represent(self, data):
|
||||
node = self.represent_data(data)
|
||||
self.serialize(node)
|
||||
self.represented_objects = {}
|
||||
self.object_keeper = []
|
||||
self.alias_key = None
|
||||
|
||||
def represent_data(self, data):
|
||||
if self.ignore_aliases(data):
|
||||
self.alias_key = None
|
||||
else:
|
||||
self.alias_key = id(data)
|
||||
if self.alias_key is not None:
|
||||
if self.alias_key in self.represented_objects:
|
||||
node = self.represented_objects[self.alias_key]
|
||||
#if node is None:
|
||||
# raise RepresenterError("recursive objects are not allowed: %r" % data)
|
||||
return node
|
||||
#self.represented_objects[alias_key] = None
|
||||
self.object_keeper.append(data)
|
||||
data_types = type(data).__mro__
|
||||
if data_types[0] in self.yaml_representers:
|
||||
node = self.yaml_representers[data_types[0]](self, data)
|
||||
else:
|
||||
for data_type in data_types:
|
||||
if data_type in self.yaml_multi_representers:
|
||||
node = self.yaml_multi_representers[data_type](self, data)
|
||||
break
|
||||
else:
|
||||
if None in self.yaml_multi_representers:
|
||||
node = self.yaml_multi_representers[None](self, data)
|
||||
elif None in self.yaml_representers:
|
||||
node = self.yaml_representers[None](self, data)
|
||||
else:
|
||||
node = ScalarNode(None, str(data))
|
||||
#if alias_key is not None:
|
||||
# self.represented_objects[alias_key] = node
|
||||
return node
|
||||
|
||||
@classmethod
|
||||
def add_representer(cls, data_type, representer):
|
||||
if not 'yaml_representers' in cls.__dict__:
|
||||
cls.yaml_representers = cls.yaml_representers.copy()
|
||||
cls.yaml_representers[data_type] = representer
|
||||
|
||||
@classmethod
|
||||
def add_multi_representer(cls, data_type, representer):
|
||||
if not 'yaml_multi_representers' in cls.__dict__:
|
||||
cls.yaml_multi_representers = cls.yaml_multi_representers.copy()
|
||||
cls.yaml_multi_representers[data_type] = representer
|
||||
|
||||
def represent_scalar(self, tag, value, style=None):
|
||||
if style is None:
|
||||
style = self.default_style
|
||||
node = ScalarNode(tag, value, style=style)
|
||||
if self.alias_key is not None:
|
||||
self.represented_objects[self.alias_key] = node
|
||||
return node
|
||||
|
||||
def represent_sequence(self, tag, sequence, flow_style=None):
|
||||
value = []
|
||||
node = SequenceNode(tag, value, flow_style=flow_style)
|
||||
if self.alias_key is not None:
|
||||
self.represented_objects[self.alias_key] = node
|
||||
best_style = True
|
||||
for item in sequence:
|
||||
node_item = self.represent_data(item)
|
||||
if not (isinstance(node_item, ScalarNode) and not node_item.style):
|
||||
best_style = False
|
||||
value.append(node_item)
|
||||
if flow_style is None:
|
||||
if self.default_flow_style is not None:
|
||||
node.flow_style = self.default_flow_style
|
||||
else:
|
||||
node.flow_style = best_style
|
||||
return node
|
||||
|
||||
def represent_mapping(self, tag, mapping, flow_style=None):
|
||||
value = []
|
||||
node = MappingNode(tag, value, flow_style=flow_style)
|
||||
if self.alias_key is not None:
|
||||
self.represented_objects[self.alias_key] = node
|
||||
best_style = True
|
||||
if hasattr(mapping, 'items'):
|
||||
mapping = list(mapping.items())
|
||||
if self.sort_keys:
|
||||
try:
|
||||
mapping = sorted(mapping)
|
||||
except TypeError:
|
||||
pass
|
||||
for item_key, item_value in mapping:
|
||||
node_key = self.represent_data(item_key)
|
||||
node_value = self.represent_data(item_value)
|
||||
if not (isinstance(node_key, ScalarNode) and not node_key.style):
|
||||
best_style = False
|
||||
if not (isinstance(node_value, ScalarNode) and not node_value.style):
|
||||
best_style = False
|
||||
value.append((node_key, node_value))
|
||||
if flow_style is None:
|
||||
if self.default_flow_style is not None:
|
||||
node.flow_style = self.default_flow_style
|
||||
else:
|
||||
node.flow_style = best_style
|
||||
return node
|
||||
|
||||
def ignore_aliases(self, data):
|
||||
return False
|
||||
|
||||
class SafeRepresenter(BaseRepresenter):
|
||||
|
||||
def ignore_aliases(self, data):
|
||||
if data is None:
|
||||
return True
|
||||
if isinstance(data, tuple) and data == ():
|
||||
return True
|
||||
if isinstance(data, (str, bytes, bool, int, float)):
|
||||
return True
|
||||
|
||||
def represent_none(self, data):
|
||||
return self.represent_scalar('tag:yaml.org,2002:null', 'null')
|
||||
|
||||
def represent_str(self, data):
|
||||
return self.represent_scalar('tag:yaml.org,2002:str', data)
|
||||
|
||||
def represent_binary(self, data):
|
||||
if hasattr(base64, 'encodebytes'):
|
||||
data = base64.encodebytes(data).decode('ascii')
|
||||
else:
|
||||
data = base64.encodestring(data).decode('ascii')
|
||||
return self.represent_scalar('tag:yaml.org,2002:binary', data, style='|')
|
||||
|
||||
def represent_bool(self, data):
|
||||
if data:
|
||||
value = 'true'
|
||||
else:
|
||||
value = 'false'
|
||||
return self.represent_scalar('tag:yaml.org,2002:bool', value)
|
||||
|
||||
def represent_int(self, data):
|
||||
return self.represent_scalar('tag:yaml.org,2002:int', str(data))
|
||||
|
||||
inf_value = 1e300
|
||||
while repr(inf_value) != repr(inf_value*inf_value):
|
||||
inf_value *= inf_value
|
||||
|
||||
def represent_float(self, data):
|
||||
if data != data or (data == 0.0 and data == 1.0):
|
||||
value = '.nan'
|
||||
elif data == self.inf_value:
|
||||
value = '.inf'
|
||||
elif data == -self.inf_value:
|
||||
value = '-.inf'
|
||||
else:
|
||||
value = repr(data).lower()
|
||||
# Note that in some cases `repr(data)` represents a float number
|
||||
# without the decimal parts. For instance:
|
||||
# >>> repr(1e17)
|
||||
# '1e17'
|
||||
# Unfortunately, this is not a valid float representation according
|
||||
# to the definition of the `!!float` tag. We fix this by adding
|
||||
# '.0' before the 'e' symbol.
|
||||
if '.' not in value and 'e' in value:
|
||||
value = value.replace('e', '.0e', 1)
|
||||
return self.represent_scalar('tag:yaml.org,2002:float', value)
|
||||
|
||||
def represent_list(self, data):
|
||||
#pairs = (len(data) > 0 and isinstance(data, list))
|
||||
#if pairs:
|
||||
# for item in data:
|
||||
# if not isinstance(item, tuple) or len(item) != 2:
|
||||
# pairs = False
|
||||
# break
|
||||
#if not pairs:
|
||||
return self.represent_sequence('tag:yaml.org,2002:seq', data)
|
||||
#value = []
|
||||
#for item_key, item_value in data:
|
||||
# value.append(self.represent_mapping(u'tag:yaml.org,2002:map',
|
||||
# [(item_key, item_value)]))
|
||||
#return SequenceNode(u'tag:yaml.org,2002:pairs', value)
|
||||
|
||||
def represent_dict(self, data):
|
||||
return self.represent_mapping('tag:yaml.org,2002:map', data)
|
||||
|
||||
def represent_set(self, data):
|
||||
value = {}
|
||||
for key in data:
|
||||
value[key] = None
|
||||
return self.represent_mapping('tag:yaml.org,2002:set', value)
|
||||
|
||||
def represent_date(self, data):
|
||||
value = data.isoformat()
|
||||
return self.represent_scalar('tag:yaml.org,2002:timestamp', value)
|
||||
|
||||
def represent_datetime(self, data):
|
||||
value = data.isoformat(' ')
|
||||
return self.represent_scalar('tag:yaml.org,2002:timestamp', value)
|
||||
|
||||
def represent_yaml_object(self, tag, data, cls, flow_style=None):
|
||||
if hasattr(data, '__getstate__'):
|
||||
state = data.__getstate__()
|
||||
else:
|
||||
state = data.__dict__.copy()
|
||||
return self.represent_mapping(tag, state, flow_style=flow_style)
|
||||
|
||||
def represent_undefined(self, data):
|
||||
raise RepresenterError("cannot represent an object", data)
|
||||
|
||||
SafeRepresenter.add_representer(type(None),
|
||||
SafeRepresenter.represent_none)
|
||||
|
||||
SafeRepresenter.add_representer(str,
|
||||
SafeRepresenter.represent_str)
|
||||
|
||||
SafeRepresenter.add_representer(bytes,
|
||||
SafeRepresenter.represent_binary)
|
||||
|
||||
SafeRepresenter.add_representer(bool,
|
||||
SafeRepresenter.represent_bool)
|
||||
|
||||
SafeRepresenter.add_representer(int,
|
||||
SafeRepresenter.represent_int)
|
||||
|
||||
SafeRepresenter.add_representer(float,
|
||||
SafeRepresenter.represent_float)
|
||||
|
||||
SafeRepresenter.add_representer(list,
|
||||
SafeRepresenter.represent_list)
|
||||
|
||||
SafeRepresenter.add_representer(tuple,
|
||||
SafeRepresenter.represent_list)
|
||||
|
||||
SafeRepresenter.add_representer(dict,
|
||||
SafeRepresenter.represent_dict)
|
||||
|
||||
SafeRepresenter.add_representer(set,
|
||||
SafeRepresenter.represent_set)
|
||||
|
||||
SafeRepresenter.add_representer(datetime.date,
|
||||
SafeRepresenter.represent_date)
|
||||
|
||||
SafeRepresenter.add_representer(datetime.datetime,
|
||||
SafeRepresenter.represent_datetime)
|
||||
|
||||
SafeRepresenter.add_representer(None,
|
||||
SafeRepresenter.represent_undefined)
|
||||
|
||||
class Representer(SafeRepresenter):
|
||||
|
||||
def represent_complex(self, data):
|
||||
if data.imag == 0.0:
|
||||
data = '%r' % data.real
|
||||
elif data.real == 0.0:
|
||||
data = '%rj' % data.imag
|
||||
elif data.imag > 0:
|
||||
data = '%r+%rj' % (data.real, data.imag)
|
||||
else:
|
||||
data = '%r%rj' % (data.real, data.imag)
|
||||
return self.represent_scalar('tag:yaml.org,2002:python/complex', data)
|
||||
|
||||
def represent_tuple(self, data):
|
||||
return self.represent_sequence('tag:yaml.org,2002:python/tuple', data)
|
||||
|
||||
def represent_name(self, data):
|
||||
name = '%s.%s' % (data.__module__, data.__name__)
|
||||
return self.represent_scalar('tag:yaml.org,2002:python/name:'+name, '')
|
||||
|
||||
def represent_module(self, data):
|
||||
return self.represent_scalar(
|
||||
'tag:yaml.org,2002:python/module:'+data.__name__, '')
|
||||
|
||||
def represent_object(self, data):
|
||||
# We use __reduce__ API to save the data. data.__reduce__ returns
|
||||
# a tuple of length 2-5:
|
||||
# (function, args, state, listitems, dictitems)
|
||||
|
||||
# For reconstructing, we calls function(*args), then set its state,
|
||||
# listitems, and dictitems if they are not None.
|
||||
|
||||
# A special case is when function.__name__ == '__newobj__'. In this
|
||||
# case we create the object with args[0].__new__(*args).
|
||||
|
||||
# Another special case is when __reduce__ returns a string - we don't
|
||||
# support it.
|
||||
|
||||
# We produce a !!python/object, !!python/object/new or
|
||||
# !!python/object/apply node.
|
||||
|
||||
cls = type(data)
|
||||
if cls in copyreg.dispatch_table:
|
||||
reduce = copyreg.dispatch_table[cls](data)
|
||||
elif hasattr(data, '__reduce_ex__'):
|
||||
reduce = data.__reduce_ex__(2)
|
||||
elif hasattr(data, '__reduce__'):
|
||||
reduce = data.__reduce__()
|
||||
else:
|
||||
raise RepresenterError("cannot represent an object", data)
|
||||
reduce = (list(reduce)+[None]*5)[:5]
|
||||
function, args, state, listitems, dictitems = reduce
|
||||
args = list(args)
|
||||
if state is None:
|
||||
state = {}
|
||||
if listitems is not None:
|
||||
listitems = list(listitems)
|
||||
if dictitems is not None:
|
||||
dictitems = dict(dictitems)
|
||||
if function.__name__ == '__newobj__':
|
||||
function = args[0]
|
||||
args = args[1:]
|
||||
tag = 'tag:yaml.org,2002:python/object/new:'
|
||||
newobj = True
|
||||
else:
|
||||
tag = 'tag:yaml.org,2002:python/object/apply:'
|
||||
newobj = False
|
||||
function_name = '%s.%s' % (function.__module__, function.__name__)
|
||||
if not args and not listitems and not dictitems \
|
||||
and isinstance(state, dict) and newobj:
|
||||
return self.represent_mapping(
|
||||
'tag:yaml.org,2002:python/object:'+function_name, state)
|
||||
if not listitems and not dictitems \
|
||||
and isinstance(state, dict) and not state:
|
||||
return self.represent_sequence(tag+function_name, args)
|
||||
value = {}
|
||||
if args:
|
||||
value['args'] = args
|
||||
if state or not isinstance(state, dict):
|
||||
value['state'] = state
|
||||
if listitems:
|
||||
value['listitems'] = listitems
|
||||
if dictitems:
|
||||
value['dictitems'] = dictitems
|
||||
return self.represent_mapping(tag+function_name, value)
|
||||
|
||||
def represent_ordered_dict(self, data):
|
||||
# Provide uniform representation across different Python versions.
|
||||
data_type = type(data)
|
||||
tag = 'tag:yaml.org,2002:python/object/apply:%s.%s' \
|
||||
% (data_type.__module__, data_type.__name__)
|
||||
items = [[key, value] for key, value in data.items()]
|
||||
return self.represent_sequence(tag, [items])
|
||||
|
||||
Representer.add_representer(complex,
|
||||
Representer.represent_complex)
|
||||
|
||||
Representer.add_representer(tuple,
|
||||
Representer.represent_tuple)
|
||||
|
||||
Representer.add_representer(type,
|
||||
Representer.represent_name)
|
||||
|
||||
Representer.add_representer(collections.OrderedDict,
|
||||
Representer.represent_ordered_dict)
|
||||
|
||||
Representer.add_representer(types.FunctionType,
|
||||
Representer.represent_name)
|
||||
|
||||
Representer.add_representer(types.BuiltinFunctionType,
|
||||
Representer.represent_name)
|
||||
|
||||
Representer.add_representer(types.ModuleType,
|
||||
Representer.represent_module)
|
||||
|
||||
Representer.add_multi_representer(object,
|
||||
Representer.represent_object)
|
||||
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
|
||||
__all__ = ['BaseResolver', 'Resolver']
|
||||
|
||||
from .error import *
|
||||
from .nodes import *
|
||||
|
||||
import re
|
||||
|
||||
class ResolverError(YAMLError):
|
||||
pass
|
||||
|
||||
class BaseResolver:
|
||||
|
||||
DEFAULT_SCALAR_TAG = 'tag:yaml.org,2002:str'
|
||||
DEFAULT_SEQUENCE_TAG = 'tag:yaml.org,2002:seq'
|
||||
DEFAULT_MAPPING_TAG = 'tag:yaml.org,2002:map'
|
||||
|
||||
yaml_implicit_resolvers = {}
|
||||
yaml_path_resolvers = {}
|
||||
|
||||
def __init__(self):
|
||||
self.resolver_exact_paths = []
|
||||
self.resolver_prefix_paths = []
|
||||
|
||||
@classmethod
|
||||
def add_implicit_resolver(cls, tag, regexp, first):
|
||||
if not 'yaml_implicit_resolvers' in cls.__dict__:
|
||||
implicit_resolvers = {}
|
||||
for key in cls.yaml_implicit_resolvers:
|
||||
implicit_resolvers[key] = cls.yaml_implicit_resolvers[key][:]
|
||||
cls.yaml_implicit_resolvers = implicit_resolvers
|
||||
if first is None:
|
||||
first = [None]
|
||||
for ch in first:
|
||||
cls.yaml_implicit_resolvers.setdefault(ch, []).append((tag, regexp))
|
||||
|
||||
@classmethod
|
||||
def add_path_resolver(cls, tag, path, kind=None):
|
||||
# Note: `add_path_resolver` is experimental. The API could be changed.
|
||||
# `new_path` is a pattern that is matched against the path from the
|
||||
# root to the node that is being considered. `node_path` elements are
|
||||
# tuples `(node_check, index_check)`. `node_check` is a node class:
|
||||
# `ScalarNode`, `SequenceNode`, `MappingNode` or `None`. `None`
|
||||
# matches any kind of a node. `index_check` could be `None`, a boolean
|
||||
# value, a string value, or a number. `None` and `False` match against
|
||||
# any _value_ of sequence and mapping nodes. `True` matches against
|
||||
# any _key_ of a mapping node. A string `index_check` matches against
|
||||
# a mapping value that corresponds to a scalar key which content is
|
||||
# equal to the `index_check` value. An integer `index_check` matches
|
||||
# against a sequence value with the index equal to `index_check`.
|
||||
if not 'yaml_path_resolvers' in cls.__dict__:
|
||||
cls.yaml_path_resolvers = cls.yaml_path_resolvers.copy()
|
||||
new_path = []
|
||||
for element in path:
|
||||
if isinstance(element, (list, tuple)):
|
||||
if len(element) == 2:
|
||||
node_check, index_check = element
|
||||
elif len(element) == 1:
|
||||
node_check = element[0]
|
||||
index_check = True
|
||||
else:
|
||||
raise ResolverError("Invalid path element: %s" % element)
|
||||
else:
|
||||
node_check = None
|
||||
index_check = element
|
||||
if node_check is str:
|
||||
node_check = ScalarNode
|
||||
elif node_check is list:
|
||||
node_check = SequenceNode
|
||||
elif node_check is dict:
|
||||
node_check = MappingNode
|
||||
elif node_check not in [ScalarNode, SequenceNode, MappingNode] \
|
||||
and not isinstance(node_check, str) \
|
||||
and node_check is not None:
|
||||
raise ResolverError("Invalid node checker: %s" % node_check)
|
||||
if not isinstance(index_check, (str, int)) \
|
||||
and index_check is not None:
|
||||
raise ResolverError("Invalid index checker: %s" % index_check)
|
||||
new_path.append((node_check, index_check))
|
||||
if kind is str:
|
||||
kind = ScalarNode
|
||||
elif kind is list:
|
||||
kind = SequenceNode
|
||||
elif kind is dict:
|
||||
kind = MappingNode
|
||||
elif kind not in [ScalarNode, SequenceNode, MappingNode] \
|
||||
and kind is not None:
|
||||
raise ResolverError("Invalid node kind: %s" % kind)
|
||||
cls.yaml_path_resolvers[tuple(new_path), kind] = tag
|
||||
|
||||
def descend_resolver(self, current_node, current_index):
|
||||
if not self.yaml_path_resolvers:
|
||||
return
|
||||
exact_paths = {}
|
||||
prefix_paths = []
|
||||
if current_node:
|
||||
depth = len(self.resolver_prefix_paths)
|
||||
for path, kind in self.resolver_prefix_paths[-1]:
|
||||
if self.check_resolver_prefix(depth, path, kind,
|
||||
current_node, current_index):
|
||||
if len(path) > depth:
|
||||
prefix_paths.append((path, kind))
|
||||
else:
|
||||
exact_paths[kind] = self.yaml_path_resolvers[path, kind]
|
||||
else:
|
||||
for path, kind in self.yaml_path_resolvers:
|
||||
if not path:
|
||||
exact_paths[kind] = self.yaml_path_resolvers[path, kind]
|
||||
else:
|
||||
prefix_paths.append((path, kind))
|
||||
self.resolver_exact_paths.append(exact_paths)
|
||||
self.resolver_prefix_paths.append(prefix_paths)
|
||||
|
||||
def ascend_resolver(self):
|
||||
if not self.yaml_path_resolvers:
|
||||
return
|
||||
self.resolver_exact_paths.pop()
|
||||
self.resolver_prefix_paths.pop()
|
||||
|
||||
def check_resolver_prefix(self, depth, path, kind,
|
||||
current_node, current_index):
|
||||
node_check, index_check = path[depth-1]
|
||||
if isinstance(node_check, str):
|
||||
if current_node.tag != node_check:
|
||||
return
|
||||
elif node_check is not None:
|
||||
if not isinstance(current_node, node_check):
|
||||
return
|
||||
if index_check is True and current_index is not None:
|
||||
return
|
||||
if (index_check is False or index_check is None) \
|
||||
and current_index is None:
|
||||
return
|
||||
if isinstance(index_check, str):
|
||||
if not (isinstance(current_index, ScalarNode)
|
||||
and index_check == current_index.value):
|
||||
return
|
||||
elif isinstance(index_check, int) and not isinstance(index_check, bool):
|
||||
if index_check != current_index:
|
||||
return
|
||||
return True
|
||||
|
||||
def resolve(self, kind, value, implicit):
|
||||
if kind is ScalarNode and implicit[0]:
|
||||
if value == '':
|
||||
resolvers = self.yaml_implicit_resolvers.get('', [])
|
||||
else:
|
||||
resolvers = self.yaml_implicit_resolvers.get(value[0], [])
|
||||
resolvers += self.yaml_implicit_resolvers.get(None, [])
|
||||
for tag, regexp in resolvers:
|
||||
if regexp.match(value):
|
||||
return tag
|
||||
implicit = implicit[1]
|
||||
if self.yaml_path_resolvers:
|
||||
exact_paths = self.resolver_exact_paths[-1]
|
||||
if kind in exact_paths:
|
||||
return exact_paths[kind]
|
||||
if None in exact_paths:
|
||||
return exact_paths[None]
|
||||
if kind is ScalarNode:
|
||||
return self.DEFAULT_SCALAR_TAG
|
||||
elif kind is SequenceNode:
|
||||
return self.DEFAULT_SEQUENCE_TAG
|
||||
elif kind is MappingNode:
|
||||
return self.DEFAULT_MAPPING_TAG
|
||||
|
||||
class Resolver(BaseResolver):
|
||||
pass
|
||||
|
||||
Resolver.add_implicit_resolver(
|
||||
'tag:yaml.org,2002:bool',
|
||||
re.compile(r'''^(?:yes|Yes|YES|no|No|NO
|
||||
|true|True|TRUE|false|False|FALSE
|
||||
|on|On|ON|off|Off|OFF)$''', re.X),
|
||||
list('yYnNtTfFoO'))
|
||||
|
||||
Resolver.add_implicit_resolver(
|
||||
'tag:yaml.org,2002:float',
|
||||
re.compile(r'''^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)?
|
||||
|\.[0-9_]+(?:[eE][-+][0-9]+)?
|
||||
|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*
|
||||
|[-+]?\.(?:inf|Inf|INF)
|
||||
|\.(?:nan|NaN|NAN))$''', re.X),
|
||||
list('-+0123456789.'))
|
||||
|
||||
Resolver.add_implicit_resolver(
|
||||
'tag:yaml.org,2002:int',
|
||||
re.compile(r'''^(?:[-+]?0b[0-1_]+
|
||||
|[-+]?0[0-7_]+
|
||||
|[-+]?(?:0|[1-9][0-9_]*)
|
||||
|[-+]?0x[0-9a-fA-F_]+
|
||||
|[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$''', re.X),
|
||||
list('-+0123456789'))
|
||||
|
||||
Resolver.add_implicit_resolver(
|
||||
'tag:yaml.org,2002:merge',
|
||||
re.compile(r'^(?:<<)$'),
|
||||
['<'])
|
||||
|
||||
Resolver.add_implicit_resolver(
|
||||
'tag:yaml.org,2002:null',
|
||||
re.compile(r'''^(?: ~
|
||||
|null|Null|NULL
|
||||
| )$''', re.X),
|
||||
['~', 'n', 'N', ''])
|
||||
|
||||
Resolver.add_implicit_resolver(
|
||||
'tag:yaml.org,2002:timestamp',
|
||||
re.compile(r'''^(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]
|
||||
|[0-9][0-9][0-9][0-9] -[0-9][0-9]? -[0-9][0-9]?
|
||||
(?:[Tt]|[ \t]+)[0-9][0-9]?
|
||||
:[0-9][0-9] :[0-9][0-9] (?:\.[0-9]*)?
|
||||
(?:[ \t]*(?:Z|[-+][0-9][0-9]?(?::[0-9][0-9])?))?)$''', re.X),
|
||||
list('0123456789'))
|
||||
|
||||
Resolver.add_implicit_resolver(
|
||||
'tag:yaml.org,2002:value',
|
||||
re.compile(r'^(?:=)$'),
|
||||
['='])
|
||||
|
||||
# The following resolver is only for documentation purposes. It cannot work
|
||||
# because plain scalars cannot start with '!', '&', or '*'.
|
||||
Resolver.add_implicit_resolver(
|
||||
'tag:yaml.org,2002:yaml',
|
||||
re.compile(r'^(?:!|&|\*)$'),
|
||||
list('!&*'))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,111 @@
|
|||
|
||||
__all__ = ['Serializer', 'SerializerError']
|
||||
|
||||
from .error import YAMLError
|
||||
from .events import *
|
||||
from .nodes import *
|
||||
|
||||
class SerializerError(YAMLError):
|
||||
pass
|
||||
|
||||
class Serializer:
|
||||
|
||||
ANCHOR_TEMPLATE = 'id%03d'
|
||||
|
||||
def __init__(self, encoding=None,
|
||||
explicit_start=None, explicit_end=None, version=None, tags=None):
|
||||
self.use_encoding = encoding
|
||||
self.use_explicit_start = explicit_start
|
||||
self.use_explicit_end = explicit_end
|
||||
self.use_version = version
|
||||
self.use_tags = tags
|
||||
self.serialized_nodes = {}
|
||||
self.anchors = {}
|
||||
self.last_anchor_id = 0
|
||||
self.closed = None
|
||||
|
||||
def open(self):
|
||||
if self.closed is None:
|
||||
self.emit(StreamStartEvent(encoding=self.use_encoding))
|
||||
self.closed = False
|
||||
elif self.closed:
|
||||
raise SerializerError("serializer is closed")
|
||||
else:
|
||||
raise SerializerError("serializer is already opened")
|
||||
|
||||
def close(self):
|
||||
if self.closed is None:
|
||||
raise SerializerError("serializer is not opened")
|
||||
elif not self.closed:
|
||||
self.emit(StreamEndEvent())
|
||||
self.closed = True
|
||||
|
||||
#def __del__(self):
|
||||
# self.close()
|
||||
|
||||
def serialize(self, node):
|
||||
if self.closed is None:
|
||||
raise SerializerError("serializer is not opened")
|
||||
elif self.closed:
|
||||
raise SerializerError("serializer is closed")
|
||||
self.emit(DocumentStartEvent(explicit=self.use_explicit_start,
|
||||
version=self.use_version, tags=self.use_tags))
|
||||
self.anchor_node(node)
|
||||
self.serialize_node(node, None, None)
|
||||
self.emit(DocumentEndEvent(explicit=self.use_explicit_end))
|
||||
self.serialized_nodes = {}
|
||||
self.anchors = {}
|
||||
self.last_anchor_id = 0
|
||||
|
||||
def anchor_node(self, node):
|
||||
if node in self.anchors:
|
||||
if self.anchors[node] is None:
|
||||
self.anchors[node] = self.generate_anchor(node)
|
||||
else:
|
||||
self.anchors[node] = None
|
||||
if isinstance(node, SequenceNode):
|
||||
for item in node.value:
|
||||
self.anchor_node(item)
|
||||
elif isinstance(node, MappingNode):
|
||||
for key, value in node.value:
|
||||
self.anchor_node(key)
|
||||
self.anchor_node(value)
|
||||
|
||||
def generate_anchor(self, node):
|
||||
self.last_anchor_id += 1
|
||||
return self.ANCHOR_TEMPLATE % self.last_anchor_id
|
||||
|
||||
def serialize_node(self, node, parent, index):
|
||||
alias = self.anchors[node]
|
||||
if node in self.serialized_nodes:
|
||||
self.emit(AliasEvent(alias))
|
||||
else:
|
||||
self.serialized_nodes[node] = True
|
||||
self.descend_resolver(parent, index)
|
||||
if isinstance(node, ScalarNode):
|
||||
detected_tag = self.resolve(ScalarNode, node.value, (True, False))
|
||||
default_tag = self.resolve(ScalarNode, node.value, (False, True))
|
||||
implicit = (node.tag == detected_tag), (node.tag == default_tag)
|
||||
self.emit(ScalarEvent(alias, node.tag, implicit, node.value,
|
||||
style=node.style))
|
||||
elif isinstance(node, SequenceNode):
|
||||
implicit = (node.tag
|
||||
== self.resolve(SequenceNode, node.value, True))
|
||||
self.emit(SequenceStartEvent(alias, node.tag, implicit,
|
||||
flow_style=node.flow_style))
|
||||
index = 0
|
||||
for item in node.value:
|
||||
self.serialize_node(item, node, index)
|
||||
index += 1
|
||||
self.emit(SequenceEndEvent())
|
||||
elif isinstance(node, MappingNode):
|
||||
implicit = (node.tag
|
||||
== self.resolve(MappingNode, node.value, True))
|
||||
self.emit(MappingStartEvent(alias, node.tag, implicit,
|
||||
flow_style=node.flow_style))
|
||||
for key, value in node.value:
|
||||
self.serialize_node(key, node, None)
|
||||
self.serialize_node(value, node, key)
|
||||
self.emit(MappingEndEvent())
|
||||
self.ascend_resolver()
|
||||
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
|
||||
class Token(object):
|
||||
def __init__(self, start_mark, end_mark):
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
def __repr__(self):
|
||||
attributes = [key for key in self.__dict__
|
||||
if not key.endswith('_mark')]
|
||||
attributes.sort()
|
||||
arguments = ', '.join(['%s=%r' % (key, getattr(self, key))
|
||||
for key in attributes])
|
||||
return '%s(%s)' % (self.__class__.__name__, arguments)
|
||||
|
||||
#class BOMToken(Token):
|
||||
# id = '<byte order mark>'
|
||||
|
||||
class DirectiveToken(Token):
|
||||
id = '<directive>'
|
||||
def __init__(self, name, value, start_mark, end_mark):
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
|
||||
class DocumentStartToken(Token):
|
||||
id = '<document start>'
|
||||
|
||||
class DocumentEndToken(Token):
|
||||
id = '<document end>'
|
||||
|
||||
class StreamStartToken(Token):
|
||||
id = '<stream start>'
|
||||
def __init__(self, start_mark=None, end_mark=None,
|
||||
encoding=None):
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
self.encoding = encoding
|
||||
|
||||
class StreamEndToken(Token):
|
||||
id = '<stream end>'
|
||||
|
||||
class BlockSequenceStartToken(Token):
|
||||
id = '<block sequence start>'
|
||||
|
||||
class BlockMappingStartToken(Token):
|
||||
id = '<block mapping start>'
|
||||
|
||||
class BlockEndToken(Token):
|
||||
id = '<block end>'
|
||||
|
||||
class FlowSequenceStartToken(Token):
|
||||
id = '['
|
||||
|
||||
class FlowMappingStartToken(Token):
|
||||
id = '{'
|
||||
|
||||
class FlowSequenceEndToken(Token):
|
||||
id = ']'
|
||||
|
||||
class FlowMappingEndToken(Token):
|
||||
id = '}'
|
||||
|
||||
class KeyToken(Token):
|
||||
id = '?'
|
||||
|
||||
class ValueToken(Token):
|
||||
id = ':'
|
||||
|
||||
class BlockEntryToken(Token):
|
||||
id = '-'
|
||||
|
||||
class FlowEntryToken(Token):
|
||||
id = ','
|
||||
|
||||
class AliasToken(Token):
|
||||
id = '<alias>'
|
||||
def __init__(self, value, start_mark, end_mark):
|
||||
self.value = value
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
|
||||
class AnchorToken(Token):
|
||||
id = '<anchor>'
|
||||
def __init__(self, value, start_mark, end_mark):
|
||||
self.value = value
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
|
||||
class TagToken(Token):
|
||||
id = '<tag>'
|
||||
def __init__(self, value, start_mark, end_mark):
|
||||
self.value = value
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
|
||||
class ScalarToken(Token):
|
||||
id = '<scalar>'
|
||||
def __init__(self, value, plain, start_mark, end_mark, style=None):
|
||||
self.value = value
|
||||
self.plain = plain
|
||||
self.start_mark = start_mark
|
||||
self.end_mark = end_mark
|
||||
self.style = style
|
||||
|
||||
Loading…
Reference in New Issue